Skip to content

fix(native): 按会话持有 tab 状态(#636 + #661 + 切回清零 + 状态归档) - #712

Closed
yanzhaohui1999 wants to merge 8 commits into
omdsh-dev:mainfrom
yanzhaohui1999:fix/tree-scroll-memory
Closed

yanzhaohui1999 wants to merge 8 commits into
omdsh-dev:mainfrom
yanzhaohui1999:fix/tree-scroll-memory

Conversation

@yanzhaohui1999

Copy link
Copy Markdown
Contributor

Closes #636、Closes #661

问题

宿主两条契约叠加本插件的单键记录表,造成三类状态丢失:

  1. [Bug] 切换对话后侧栏文件浏览器失灵:点文件夹/文件无任何反应,需重开该 tab(v0.19.0 / v0.19.1) #636 跨会话失灵:原生 tab id 每会话各自从 1 计数(dsh-client-ui-sidebar-right 的 createSurface() → counting(0)),切会话时进入方的 body 在 render 阶段接管同号记录,紧接着被离开方的卸载清理删掉;versionOf(缺失)=0 → 0→0 快照不变 → 不重渲染 → ensure 不再重建 → 此后点击全是静默 no-op(不发任何请求)。双向失灵。
  2. [Bug] 切换 tab 后插件侧状态被清空:文件树展开集/终端选中态丢失,merged 就地打开的文件内容消失 #661 同会话切 tab 丢状态:宿主同一 pane 内只挂当前 tab 的 body(dockkit 的 bodiesFor → .paneBody 内只渲染当前 tab),切走即卸载 → 卸载清理删记录 → 树展开集、就地打开的文件全丢。
  3. 切回会话时状态清零:即使修好前两项,单键记录遇到别的会话只能按原生种子重建(种子只有 kind/title/导航 params),而读者积累的状态只存在于记录里,重建等于丢失。
A after use   : {"expanded":["/work/src"],"path":"/work/notes.md"}
during B      : {"expanded":[]}
A after return: {"expanded":[]}      ← 切回 A,状态没了

修复

把被顶掉的会话的记录归档(键 (sessionId, tabId)),切回时取回;寿命改为活到宿主关 tab 为止(surface.close 是唯一出口,带会话校验)。

维度 修复前 现在
跨会话 同号异会话 → 接管/重建(状态清零) 离开方归档,切回取回
寿命 body 卸载即删记录 活到宿主关 tab
公开 API — 签名零改动(update/has/activate 仍按 tabId,扩展无需适配)

chip 也改为按会话读(records.peek(sessionId, id)):tab strip 渲染在 body 之前,切会话那一帧 live 槽里可能还是上一个会话的记录。

顺带补上同类丢失(都随 body 卸载一起丢):

  • 编辑器未保存的编辑(+预览/编辑模式)—— 真数据损失
  • 未提交的 commit message
  • git 当前选中的 worktree
  • 文件树滚动位置

另加归档驱逐(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.tsx 6、surface.ts 1、index.ts 1、native-surface.spec.ts 2 —— 两边的 ensure 主体互斥,只能留一个)。

#637 修好了「点击失灵」与「切 tab 丢状态」,但切回会话走的是「按原生种子重建」,状态清零(见上)。本方案是超集,并承接了 #637 两项不可或缺的产物:

  1. 设计文档(宿主契约 + 红/绿证据)→ 新文档 docs/plans/2026-09-17-native-tab-state-retention.md
  2. tests/e2e/mount.e2e.ts 的 side-chat 改动 —— 这一条是核心修复本身导致的:sidechat 的 chip 标题会跟随线程名被 updateTab 重写,旧写法按 getByRole('tab', { name: /Side Chat/ }) 匹配,只在「切 tab 丢记录 → chip 回退到打开时标题」时才成立;记录不再丢之后 chip 显示线程名,断言就匹配不到了。已一并移植,否则 mount lane 会挂。

未覆盖(诚实记录)

…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.
@yanzhaohui1999

Copy link
Copy Markdown
Contributor Author

这个 PR 我关掉了,改由 #776 取代。原因记在这里,方便后来查这条设计史的人。

为什么换方案

本 PR 的核心是 park/归档:单键记录表 + 一个 parked Map,离开的会话把记录归档、切回来再取回;组件态(文件树滚动、编辑器草稿、commit message、worktree 选择)各配一个 module-level 记忆层,在 body 卸载时存、重挂时取。

方案本身能解决 #636 / #661,但它建立在一条被上游推翻的前提上——本 PR 的设计文档 §不做第 3 条写着:

不做 keep-alive 式的隐藏而不卸载——宿主「一 pane 一 body」的挂载模型在宿主侧,插件无法单方面改变。

DSH 0.1.7 把这个开关交到了 tab 类型手里:SidebarRightTabDefinition.keepMounted(ui-sidebar-right/src/client/tab-registry.ts),语义就是「lazily keep a visited body mounted through hiding, Session changes and docking」。于是「保活」这一半由宿主实现,插件只剩身份与寿命两件事,四个记忆层全部不需要了。

本 PR 漏掉的一个根因

src/client/native/index.ts 的 sync() 用「描述符清单」比对 live 注册表,而 files 接管不是描述符(键是 FILES_KIND)——于是每一次 store/service 通知(会话切换、改设置、任何 state 变更)都会把它注销再重建:

[native] DISPOSE type dsh-better-sidebar:files
[native] register type dsh-better-sidebar:files

注销即卸载该 kind 的 body。所以本 PR 即使修好了记录,组件态仍会重建——这正是「记录活了但滚动还是丢」的原因。这一条在 #776 里是单独的第一个 commit。

本 PR 的 e2e 一直是假绿的

tests/e2e/tree-scroll.e2e.ts 按行号点「第二个会话」,但会话树的第一行是 workspace 行,且没有消息的种子会话根本不占一行——那次「A → B → A」实际是重选同一个会话,三条断言天然为真。#776 改为按 data-row-key="session:<id>" 认会话、用 rail 自己的 New session 建出真正的第二个会话,并断言「确实离开了 A」。

被保留下来的东西

谢谢 @HuanLinOTO 当时在 #621 那边一句 upstream 的提示——keepMounted 最终确实是走上游这条路,只是要等 0.1.7。

yanzhaohui1999 added a commit to yanzhaohui1999/DSH-better-sidebar that referenced this pull request Sep 28, 2026
…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.
yanzhaohui1999 added a commit to yanzhaohui1999/DSH-better-sidebar that referenced this pull request Sep 28, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant