Skip to content

fix(native): 按会话记账 + 宿主 keepMounted,原生 tab 状态不再跨切换丢失(#636 + #661) - #776

Open
yanzhaohui1999 wants to merge 5 commits into
omdsh-dev:mainfrom
yanzhaohui1999:fix/native-tab-state-retention
Open

yanzhaohui1999 wants to merge 5 commits into
omdsh-dev:mainfrom
yanzhaohui1999:fix/native-tab-state-retention

Conversation

@yanzhaohui1999

Copy link
Copy Markdown
Contributor

概述

原生右侧栏的 tab 插件侧状态在切换 tab / 切换会话时会丢。本 PR 用 DSH 0.1.7 的 keepMounted + 「按会话记账的记录表」修掉,不引入任何 module-level 记忆层。

Closes #636、Closes #661。取代 #712(park/归档方案)。

三个根因

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

外加一个 #712 未发现的独立根因(本 PR 的第一个 commit 单独修):sync() 的清理循环拿「描述符清单」比对 live 注册表,而 files 接管不是描述符(键是 FILES_KIND)——于是每一次 store/service 通知(会话切换、改设置、任何 state 变更)都会把它注销再重建:

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

注销即卸载该 kind 的 body —— 所以记录活下来、组件态(滚动/草稿所在的视图)仍会重建。这是「#712 修好了记录、切回会话还是丢」的真答案。

方案

维度 修复前 本 PR
身份 裸 tabId 单键 sessionId::tabId 复合键
同会话切 tab 保活 body 卸载即删记录 宿主 keepMounted:body 不卸载,组件态免费保留
记录寿命 随 body 卸载删除 活到 surface.close / 会话消失;卸载不删
组件态(滚动/草稿/commit message/worktree) 需要 4 处 module-level 记忆 不需要(body 一直挂着)
files 接管寿命 每次 store 脉冲重建 只由编辑器类型开关决定
公开写面 — update/has/activate 新增可选 sessionId;缺省按 sidebarRight.mounted 解析,多会话同名时拒绝而不是猜

SidebarSurface 只新增可选参数,不删既有签名——在屏会话下与旧行为完全一致,外部插件不传照旧。

验证

真机(scripts/e2e-mount.sh:npm 打包 → 全新 scratch profile → 真实 dsh web 钉 0.1.7-rc.1 → headless Chromium),tests/e2e/tree-scroll.e2e.ts 的 A → B → A 往返,断言完整状态向量:

  • 树展开集 / 滚动位置 / chip 标题跨会话往返复原;
  • B 不继承 A 的展开集与滚动(同号 tab 隔离);
  • 打开的文件仍开着,且 fs.read 不再重发——「状态还在」与「其实悄悄重载了一遍」是两回事,所以请求也计数了(路由是 POST,路径在 body 里);
  • 未保存的编辑器草稿仍在文档里(唯一无法靠重新查看恢复的损失);
  • 未保存的 commit message仍在输入框里;
  • 选中的 worktree仍是选中态(不重新从清单推导)。

8/8 通过。

单测:tests/native-surface.spec.ts +8(按会话隔离、按会话 patch 与 drop、retain 驱逐、显式 session / 缺省上屏 / 歧义拒绝、同屏双 seat 互不继承、files 接管不得被会话脉冲重建)。每条在缺对应修复时都真的红。

tsc --noEmit 0 错、eslint . 干净、全量单测 113 文件 / 1141 例通过、check-consumer-types 双档通过。

顺带修掉的 e2e 缺陷

#712 的 lane 一直没真的切过会话:它按行号点「第二个会话」,而会话树的第一行是 workspace 行,且没有消息的种子会话根本不占行——那次「A → B → A」实际是重选同一个会话,三条断言天然为真。现在按 data-row-key="session:<id>" 认会话、用 rail 自己的 New session 建出真正的第二个会话,并断言「确实离开了 A」。

风险与边界

  • 依赖宿主契约 keepMounted(0.1.7 新增的可选字段)与 ISidebarRight.mounted;上游若撤掉,退化为「切 tab 丢组件态」,记录层仍按会话正确。
  • 上游若改 sidebar-right 的挂载模型(重新变得只挂当前 tab),组件态会退回需要记忆层——files 接管那条修复与复合键记录是抗这种情况的底线。
  • 内存上界:keepMounted 的 body 与其记录活到 tab 关闭 / 会话消失,宿主没有 LRU 上限;访问过的会话越多常驻越高。属宿主策略,插件无法单方面设界,记为已知代价。
  • 不改 DSH 源码。

文档

  • docs/plans/2026-09-25-native-tab-state-retention-keepmounted.md(设计过程与实测记录)
  • AGENTS.md §3.4 新增「原生 tab 的状态保留」段(两条必须同时成立的事实)

@Menghuan1918

Copy link
Copy Markdown
Collaborator

Rebase 提示:FILES_KIND 的清理循环跳过已由 #777 落地

#777 已合并进 main(4099700),它在本 PR 同样改动的 sync() 清理循环里加了:

for (const [descriptorId, registration] of live) {
  if (descriptorId === FILES_KIND || wanted.has(descriptorId)) continue   // ← 与你的改动同一行
  disposeSafely(() => registration.dispose(), `native tab type "${descriptorId}"`)
  live.delete(descriptorId)
}

所以 rebase 到最新 main 时,请删掉你那份重复的 descriptorId === FILES_KIND 判断(保留 #777 的 disposeSafely 形态即可),其余部分(按会话记账 + keepMounted)与本改动没有语义冲突;另外 main 上新增了 tests/e2e/native-reload.e2e.ts 与 tests/native-registration.spec.ts,如果你的改动触及原生 tab 生命周期,跑一次 pnpm test + pnpm test:mount 即可确认两条门都没被踩到。

顺带说明本次的根因(与你此前在 #636/#661 里处理的状态丢失是不同问题):teardown 期间 service.notify() 会驱动 sync() 在 inactive ctx 上跑,清理循环误删 files 接管后又在同一轮重建 → tabs.register(宿主 ctx,仍活着)成功取走 id,而 ctx.slots.inject(插件 ctx)抛 cannot create effect on inactive context,disposer 丢失、id 永久占用。详见 docs/plans/2026-09-28-native-files-takeover-reload-leak.md。

`sync()`'s cleanup loop compared the live registrations against the
DESCRIPTOR list, which never contains the `files` takeover (its key is
`FILES_KIND`, not a descriptor id). Every store/service notification — a
Session switch is one — therefore disposed and immediately rebuilt it:

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

Disposal unmounts that kind's body, so the explorer's own component state
(its scroll offset) was rebuilt on every pulse even when the plugin-side
record survived. Its lifetime really belongs to the editor type's switch,
which the loop below already handles; skip it in the cleanup pass.

Pinned by "keeps the files takeover registered across a Session switch
pulse": reverting the guard turns it red (`expected 2 to be 1` — 2 is the
rebuilt registration count).

Rebased onto omdsh-dev#777 (`4099700`): the guard itself now lives on `main` — its
cleanup pass already skips `FILES_KIND` and releases through `disposeSafely`
— so what remains in this commit is the comment above that guard, recording
the second reason it is load-bearing (a re-registration on an already
inactive context orphaned the id), plus the regression spec below.
Native tab ids restart in every session (`tab1`, `tab2`, …), and DSH 0.1.7
keeps a visited tab body mounted across hiding, tab selection and Session
switches, so several sessions' same-named tabs are alive at once. The
plugin's registry was a single-keyed map, which made the entering session's
body adopt the leaving one's record — and the leaving body's unmount cleanup
then delete it. The visible result was omdsh-dev#636: after a conversation switch the
explorer stopped responding entirely (no request sent), in both directions.

Records are now filed under `sessionId::tabId`, and the seat session is
carried explicitly instead of inferred:

- `createNativeTabRecords` takes the seat session on `ensure`, and
  `get/has/update/drop/toggleExpanded/versionOf` all take it too;
  `retain(sessions)` reclaims the records of sessions that are gone;
- the title slot gets its own seat `sessionId` — the chip is drawn BEFORE
  the body, so it used to read whichever record the live slot happened to
  hold (another conversation's title on the switch frame);
- `SidebarSurface.update/has/activate` take an optional `sessionId`: with
  one, it names the session; without, the mounted seat answers, a unique
  match still resolves, and an id that names a tab in several sessions
  resolves to none rather than guessing (writing into another conversation
  is worse than refusing);
- `BetterSidebarService.updateTab` forwards it, and the six call sites that
  know their own scope (EditorHost, SideChatView, ChangesTab,
  tree-mutations) pass theirs.

Tests (native-surface.spec.ts, +8): per-seat-session identity (same-named
tabs stay apart, a patch/drop hits exactly one session, `retain` reclaims),
and surface resolution (explicit session wins, mounted seat is the
fallback, an ambiguous bare id is refused, close plus dead-session reclaim).
Each is red without this change.
…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.
…olds

"State survived" is not the same claim as "nothing was silently reloaded":
the retention lane asserted the visible vector (expansion, scroll, chip) but
never looked at the network, so a body that remounted and refetched
everything would have passed it.

The lane now counts the plugin's own reads. These routes are POSTs, so the
path being listed or read travels in the BODY — the counter parses it, which
is what lets one level or file be told apart from a fresh read. It then opens
a real file in conversation A and requires, on return:

- the file is still open as a tab, and selecting it shows the editor without
  a second `fs.read` of its bytes;
- no `fs.read` of that file happened anywhere in the round trip.

Measured against the real host (0.1.7-rc.1, headless Chromium): zero plugin
reads on returning to A — the seat lands on its previous tab and shows the
content it already holds.

One ordering fact this lane had to be taught: opening a file in merged mode
swaps THAT tab's content to the editor, so the tree component unmounts and
its scroll starts over by design (attributed with a probe: hide/show keeps
320, opening a file drops it to 0). The lane therefore re-builds the scroll
offset after the file step before measuring the conversation round trip,
which is the claim the offset is actually evidence for.
…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.
@yanzhaohui1999
yanzhaohui1999 force-pushed the fix/native-tab-state-retention branch from 007f846 to 77887e2 Compare September 28, 2026 03:36
@yanzhaohui1999

Copy link
Copy Markdown
Contributor Author

已按提示 rebase 到 69f43490(含 #777 的 4099700),三处重叠全部照你的建议收敛:

  1. sync() 清理循环:删掉本 PR 那份重复的 descriptorId === FILES_KIND 判断,保留 fix(native): 注册失败的 tab 类型必须释放,否则 id 永久占用(插件重载后文件树变空态) #777 的形态

    if (descriptorId === FILES_KIND || wanted.has(descriptorId)) continue
    disposeSafely(() => registration.dispose(), `native tab type "${descriptorId}"`)

    注释合成两条理由——它同时解释「每次通知重建 body 会丢组件态」与「inactive ctx 上重建会把 id 孤儿化」,后者正是你那次修复的根因。

  2. registerSlots():保留 fix(native): 注册失败的 tab 类型必须释放,否则 id 永久占用(插件重载后文件树变空态) #777 的 try/catch 与「逐条收集 disposer、失败时释放已建槽位」结构,把本 PR 需要的 title 槽 inject(sessionId) 并进去。原因不变:chip 早于 body 渲染,而各会话的 tab id 同号并存(每会话各自从 1 计数),chip 必须用自己 seat 的会话,否则会显示别的会话的标题。

  3. AGENTS.md §3.4 第 9 条:保留你写的两条注册账本不变量,把本 PR 的「原生 tab 的状态保留」作为同一段的补充(去掉与不变量 ① 重复的表述)。另外在设计文档 docs/plans/2026-09-25-native-tab-state-retention-keepmounted.md 里加了一条归属更正:那句守卫出自 fix(native): 注册失败的 tab 类型必须释放,否则 id 永久占用(插件重载后文件树变空态) #777,本 PR 只留设计与锁住它的回归 spec。

验证(都在 69f43490 之上):

  • pnpm typecheck 0 错、pnpm lint 干净
  • pnpm test:122 文件 / 1302 例通过
  • pnpm build && pnpm pack && pnpm test:mount:9/9 通过,含你新加的 tests/e2e/native-reload.e2e.ts 与本 PR 的 tests/e2e/tree-scroll.e2e.ts(A → B → A 的树展开集 / 滚动位置 / 未保存草稿 / commit message / worktree 全部复原,且 fs.read 不重发)

其余部分(按会话记账 + 宿主 keepMounted)未改动,公开签名仍只新增可选 sessionId。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants