fix(native): 按会话持有 tab 状态(#636 + #661 + 切回清零 + 状态归档) - #712
yanzhaohui1999 wants to merge 8 commits into
Conversation
…witches The host mounts ONE tab body per pane and mints native tab ids per session (every session's first tab is "tab1"), so a conversation switch renders the entering session's body over the SAME id while the leaving one unmounts. The record registry kept one record per id and deleted it on unmount, so: - a tab switch destroyed the tab's plugin-side state (tree expansion, the in-place opened file, the terminal selection) -- issue omdsh-dev#661; - a conversation switch handed the leaving session's record to the entering one and deleted it, leaving every later click a silent no-op -- issue omdsh-dev#636; - switching back re-minted from the native seed, so the reader's state was gone for good. The registry now parks a displaced session's record under (sessionId, tabId) and revives it when that session comes back, and nothing removes a record on body unmount -- the only exit is the host closing the tab (close -> drop(id, sessionId), session-checked). The tab chip reads through the session too: the strip renders before the body, so the live slot may still hold the session the reader just left. Component state that lived only in useState died the same way and is now parked per session + path on unmount: the unsaved editor document (with its preview/edit mode) and the unsaved commit message, plus the git lens's selected worktree. Every one is cleared when it is committed to disk. Tests: a real A -> B -> A round trip pins the record state (and the parked lookup/close semantics); the editor and commit-message drafts get red/green unit specs; the e2e lane was rewritten around a whole state vector (scroll, expansion, chip) instead of one scroll scalar, and fails on the unfixed baseline at the same-conversation tab switch.
…reproduction) The reported shape of omdsh-dev#636 is a conversation switch making the explorer unresponsive in BOTH directions, with no request on the wire. The registry specs covered record ownership, but nothing reproduced the host's commit semantics: the session-keyed seat renders the entering session's body and unmounts the leaving one in ONE commit, both carrying the same native id. This mounts NativeTabBody exactly that way (outer div keyed by sessionId, one `act` per switch) and requires a folder click to keep responding through A -> B -> A -> B -> A. On the pre-fix adapter it fails with "session-B: the entered session keeps a live record: expected false to be true" - the silent no-op the issue describes.
The record archive is released by switching back to its session or by the host closing that tab. A session the user DELETED has neither, so its parked state was retained for the life of the page: bounded per session (native ids restart per session and only grow) but linear in the number of sessions ever opened. Measured at 0.6 KB for a typical explorer tab and 8 KB for a heavily-expanded one. `createNativeSurface` already subscribes to the session list to replay queued opens; that same subscription now prunes archives whose session is absent from it, plus once at bind time. `retain` refuses an empty list on purpose: a session list that has not loaded yet is not evidence that every session is gone, and evicting on one would throw live state away.
…mdsh-dev#637's side-chat e2e fix Design doc for this branch: host contract (one body per pane, per-session tab ids), the three failure chains (omdsh-dev#636 / omdsh-dev#661 / the cleared-on-return path omdsh-dev#637's rebuild had), the decision table (archive-and-restore over rebuild), per-subsystem changes, red/green evidence, and the honest gaps (undo history, omdsh-dev#669's browser URL, the search box, host layout). It also records that omdsh-dev#637's mount-lane fix must come with this work: the fix itself invalidates the old assertion, because a side-chat chip is retitled by the thread and matching "Side Chat" only worked while a tab switch dropped the record. That change is ported here so the mount lane keeps passing.
…in it
The per-workspace worktree memory was written by an explicit pick but the
mount effect still reset `worktreeChosenByUser`, so the auto-select ran again
on every remount and overrode the restore: a clean primary checkout beside one
dirty linked checkout auto-picks the linked one, silently discarding the
reader's choice. A remembered value now sets the flag too, so the restore wins.
The new spec found this on its first run ("the chosen worktree survives the
remount: expected 'C:/repo/agent' to be 'C:/repo/main'"), and reverting just
the guard turns it red again.
The two pre-existing cases in this file shared one sessionId; with the memory
present that bled across them, so each case now uses its own key.
|
这个 PR 我关掉了,改由 #776 取代。原因记在这里,方便后来查这条设计史的人。 为什么换方案本 PR 的核心是 park/归档:单键记录表 + 一个 方案本身能解决 #636 / #661,但它建立在一条被上游推翻的前提上——本 PR 的设计文档 §不做第 3 条写着:
DSH 0.1.7 把这个开关交到了 tab 类型手里: 本 PR 漏掉的一个根因
注销即卸载该 kind 的 body。所以本 PR 即使修好了记录,组件态仍会重建——这正是「记录活了但滚动还是丢」的原因。这一条在 #776 里是单独的第一个 commit。 本 PR 的 e2e 一直是假绿的
被保留下来的东西
谢谢 @HuanLinOTO 当时在 #621 那边一句 |
…n end to end DSH 0.1.7 gives a tab TYPE the `keepMounted` switch: a marked body stays mounted through hiding, tab selection and Session switches. That is exactly what the plugin tried to fake with module-level memory, so declare it on every plugin type (the descriptor types and the `files` takeover) and let the host hold the component state — tree scroll, unsaved drafts, chip renames — with no plugin-side memory at all. With the bodies staying up, the per-session record registry from the previous commit is what keeps co-resident sessions apart; both halves are required, because a body may still be remounted by the host (type disposal, session teardown) and the records must outlive that. Verification on a real host (`scripts/e2e-mount.sh`: npm pack -> fresh scratch profile -> real `dsh web` pinned to 0.1.7-rc.1 -> headless Chromium), new lane `tests/e2e/tree-scroll.e2e.ts`: build state in conversation A, switch to B, come back, and compare the whole observable vector (tree expansion, scroll offset, chip title) — plus the negative half, that B does not inherit A's state. That lane also exposed a defect in the old one: it picked its "second conversation" by row index, but the session tree's first row is a WORKSPACE row and a seeded session with no messages takes no row at all, so the "A -> B -> A" round trip was re-selecting the same session and every assertion held vacuously. It now identifies sessions by `data-row-key="session:<id>"` and creates a real second session through the rail. `tests/e2e/mount.e2e.ts` reaches the side-chat tab through its guide entry now: with the record retained, the chip shows the thread's own title, so the old `/Side Chat/` chip match no longer names it. Docs: AGENTS.md records the two facts that must hold together, and docs/plans/2026-09-25-native-tab-state-retention-keepmounted.md replaces the park/archive design (PR omdsh-dev#712) with this one.
…prevent The comparison with PR omdsh-dev#712 left three of its cases uncovered here: omdsh-dev#712 parked an unsaved editor draft, an unsaved commit message and the chosen worktree in module-level memory and tested each by unmounting. This branch claims the host's `keepMounted` makes that memory unnecessary, but the claim was only ever exercised for tree expansion and scroll — the draft was explicitly recorded as unverified, and it is the one loss a reader cannot re-derive by looking again. All three are now measured on a real host, in the retention lane's A -> B -> A round trip: - an unsaved EDIT typed into the file's editor must still be in the document; - an unsaved COMMIT MESSAGE must still be in the changes tab's box; - the CHOSEN WORKTREE must still be selected, rather than re-derived from the inventory. The lane's workspace becomes a real git repo (idempotently: it persists across local runs) with one staged change — that is what renders a usable commit box — plus a linked checkout for the worktree choice. Two host details the lane has to respect, both recorded in comments: the worktree selector only renders when the inventory holds more than one entry, and paths are compared through `realpathSync` because macOS /var is a symlink to /private/var while git reports the resolved form. Measured: 8/8 on 0.1.7-rc.1 + headless Chromium, and the full unit suite (113 files / 1141 tests) stays green.
Closes #636、Closes #661
问题
宿主两条契约叠加本插件的单键记录表,造成三类状态丢失:
dsh-client-ui-sidebar-right的createSurface()→counting(0)),切会话时进入方的 body 在 render 阶段接管同号记录,紧接着被离开方的卸载清理删掉;versionOf(缺失)=0→ 0→0 快照不变 → 不重渲染 →ensure不再重建 → 此后点击全是静默 no-op(不发任何请求)。双向失灵。bodiesFor→.paneBody内只渲染当前 tab),切走即卸载 → 卸载清理删记录 → 树展开集、就地打开的文件全丢。修复
把被顶掉的会话的记录归档(键
(sessionId, tabId)),切回时取回;寿命改为活到宿主关 tab 为止(surface.close是唯一出口,带会话校验)。update/has/activate仍按 tabId,扩展无需适配)chip 也改为按会话读(
records.peek(sessionId, id)):tab strip 渲染在 body 之前,切会话那一帧 live 槽里可能还是上一个会话的记录。顺带补上同类丢失(都随 body 卸载一起丢):
另加归档驱逐(
retain(sessionIds)):会话被删除后释放其归档,否则死会话的归档会留到页面关闭。空列表刻意不清(会话列表未加载完不是"所有会话都没了"的证据)。测试
tests/native-surface.spec.ts:[Bug] 切换对话后侧栏文件浏览器失灵:点文件夹/文件无任何反应,需重开该 tab(v0.19.0 / v0.19.1) #636 组件级复现(照抄宿主形状:外层div以 sessionId 作 key、内层同一原生 tab id、一次act完成「进入 render + 离开 unmount」)、A→B→A 往返、peek/ 按会话drop/retain驱逐 + 接线。tests/editor-draft-memory.spec.tsx(新):编辑器草稿跨重挂载。tests/tree-scroll-memory.spec.tsx(新):树滚动 8 条(恢复 / 不继承 / 短树不夹到 0 / 每会话独立 / 只恢复一次 / 读者滚动优先 / 不可滚时不覆盖 / reveal 让位)。tests/changes-tab.spec.tsx:commit message。tests/git-view-worktree.spec.tsx:worktree —— 首次运行就抓到一个真实缺陷:恢复值没算作用户选择,auto-select 每次重挂载都会覆盖它。tests/e2e/tree-scroll.e2e.ts(新):真机状态向量(滚动 + 展开 + chip)跨 tab 切换与 A→B→A。在零改动的origin/main上跑同一条 lane 是红的(tree-scroll.e2e.ts:192:同会话切 tab 后展开集丢失)。每一项都做过「回退实现必红」。
pnpm typecheck/pnpm lint干净;pnpm test= 1333 passed / 9 skipped / 33 failed —— 33 条全在tests/agent-pty.spec.ts与tests/smoke.spec.ts(posix_spawnp failed,本机沙箱不允许 node-pty 起进程;零改动的origin/main上跑同样文件同样 33 条红)。与 #637 的关系
同一根因的另一套解法,语义冲突不能合并(merge 实测 4 文件 / 10 冲突块:
tab-adapter.tsx6、surface.ts1、index.ts1、native-surface.spec.ts2 —— 两边的ensure主体互斥,只能留一个)。#637 修好了「点击失灵」与「切 tab 丢状态」,但切回会话走的是「按原生种子重建」,状态清零(见上)。本方案是超集,并承接了 #637 两项不可或缺的产物:
docs/plans/2026-09-17-native-tab-state-retention.mdtests/e2e/mount.e2e.ts的 side-chat 改动 —— 这一条是核心修复本身导致的:sidechat 的 chip 标题会跟随线程名被updateTab重写,旧写法按getByRole('tab', { name: /Side Chat/ })匹配,只在「切 tab 丢记录 → chip 回退到打开时标题」时才成立;记录不再丢之后 chip 显示线程名,断言就匹配不到了。已一并移植,否则 mount lane 会挂。未覆盖(诚实记录)
EditorState不可序列化(含扩展与 facet 实例),要整体持有引用。实测单份保留开销约 73 / 109 / 257 KB(100 / 400 / 1600 行文档),撤销有minDepth: 100上界。暂缓 —— 收益只体现在「未保存编辑」的文件上,而隐含复杂度(主题 compartment 跨挂载复用、磁盘内容变更失效判定、state 驱逐)不低。BrowserView只读tab.path,URL 与 history/cursor 全在useState。已有 issue [Bug] 右侧边卡的「浏览器」标签不恢复 URL:重挂载后标题还在、地址栏与内容都是空的 #669 与在飞 PR fix(native): land a browser tab's URL seed inpath, not onlymeta.url#625 / fix(native): seed tab.path from params.url (#654) #658 / fix(native): map the browser url param onto tab.path #659 在做,本 PR 不重复修。revealed(Show in folder 高亮):state.ts注释写明 transient,本方案跟随。createSidebarRightStore的init: () => ({ bySession: {} }),按 sessionId 分槽),跨会话本就各自保留;宿主零落盘,刷新即回默认。本方案不动。