Преглед изворни кода

fix(sidebar): show full file paths and refine split and fullscreen controls

imccyu пре 4 дана
родитељ
комит
77690644b5
52 измењених фајлова са 466 додато и 119 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  11. 1 1
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  12. 1 1
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  13. 15 7
      apps/web/tests/seeded-history.e2e.ts
  14. 99 8
      apps/web/tests/sidebar-right.e2e.ts
  15. 2 2
      docs/subsystems/client-resources.i18n.yaml
  16. 1 1
      docs/subsystems/client-resources.md
  17. 1 1
      docs/subsystems/client-resources.zh.md
  18. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  19. 1 1
      docs/subsystems/sidebar-right.md
  20. 1 1
      docs/subsystems/sidebar-right.zh.md
  21. 2 2
      packages/api/workspace-files/README.i18n.yaml
  22. 1 1
      packages/api/workspace-files/README.md
  23. 1 1
      packages/api/workspace-files/README.zh.md
  24. 1 1
      packages/api/workspace-files/src/client/provider.ts
  25. 2 0
      packages/api/workspace-files/src/client/types.ts
  26. 6 6
      packages/api/workspace-files/tests/host-path.client.spec.ts
  27. 11 11
      packages/api/workspace-files/tests/provider.client.spec.ts
  28. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  29. 1 1
      packages/client/ui-dockkit/README.md
  30. 1 1
      packages/client/ui-dockkit/README.zh.md
  31. 4 1
      packages/client/ui-dockkit/src/components/DockSurface.tsx
  32. 17 15
      packages/client/ui-dockkit/src/components/TabPanel.tsx
  33. 2 0
      packages/client/ui-dockkit/src/components/render.ts
  34. 24 1
      packages/client/ui-dockkit/tests/components.client.spec.tsx
  35. 2 2
      packages/client/ui-layout/README.i18n.yaml
  36. 1 1
      packages/client/ui-layout/README.md
  37. 1 1
      packages/client/ui-layout/README.zh.md
  38. 6 0
      packages/client/ui-layout/src/client/AppFrame.module.css
  39. 1 0
      packages/client/ui-layout/src/client/AppFrame.tsx
  40. 16 0
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  41. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  42. 2 2
      packages/client/ui-sidebar-right/README.md
  43. 2 2
      packages/client/ui-sidebar-right/README.zh.md
  44. 32 11
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx
  45. 141 6
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  46. 2 2
      packages/client/ui-sidebar-textpreview/README.i18n.yaml
  47. 2 2
      packages/client/ui-sidebar-textpreview/README.md
  48. 2 2
      packages/client/ui-sidebar-textpreview/README.zh.md
  49. 2 1
      packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx
  50. 3 2
      packages/client/ui-sidebar-textpreview/tests/fixtures.client.ts
  51. 30 1
      packages/client/ui-sidebar-textpreview/tests/text-preview.client.spec.tsx
  52. 6 0
      snapshots/web/seeded-history/file-preview.expected.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md
-2026-09-05-client-resource-model.md: 6f626799b731fe49536593e9085c0a7c090f4b9f
-2026-09-05-client-resource-model.zh.md: 2dcd4752a941ec78c226eb0f592200a64882576b
+2026-09-05-client-resource-model.md: 75502ffc91af049bf89b7c36ec6ae3dc1339a5f8
+2026-09-05-client-resource-model.zh.md: d1430e88fc16b46a6ad32bbeacb1d59e0a7f6131

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md

@@ -51,7 +51,7 @@ type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnap
 
 ### Frames
 
-A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ version, bytes?, changed }`, and a consumer reads content itself, by page, through the [Workspace Files service](2026-09-05-workspace-files-service.md).
+A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads content itself, by page, through the [Workspace Files service](2026-09-05-workspace-files-service.md).
 
 ### Lifecycle
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md

@@ -51,7 +51,7 @@ type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnap
 
 ### 帧
 
-提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ version, bytes?, changed }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)按页读内容。
+提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)按页读内容。
 
 ### 生命周期
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
-2026-09-05-workspace-files-service.md: 13d31a4976f4aa2daf5559d611807ebf84c6c8ac
-2026-09-05-workspace-files-service.zh.md: e7c59bdf8682ed9236314a6af8311acec963e341
+2026-09-05-workspace-files-service.md: a95e083f8cea57b957a2c060fe1b7f2b76050153
+2026-09-05-workspace-files-service.zh.md: 59c638157f3e65efe3b89b220401434d1f2370b0

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md

@@ -98,7 +98,7 @@ It returns the bytes at `[offset, offset + length)`, shorter when the file ends
 
 The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The text-preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`.
 
-- **The value is metadata**, `WorkspaceFileResource { version, bytes?, changed }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and uses `version` and `changed` to know when they are stale.
+- **The value is metadata**, `WorkspaceFileResource { absolutePath, version, bytes?, changed }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and uses `version` and `changed` to know when they are stale.
 - **The address names the file; its scope selects the Session.** A `session` address's relative path reaches the Host unchanged for resolution and containment against that Session's workspace root; Client cwd is not a prerequisite. An `absolute` address reads through the current Session, failing with `workspace-file/unknown-workspace` when none is current. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream and make reload a no-op.
 - **The frames.** The first frame is a `stat` (`changed: false`) or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields `changed: true` with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata flagged `changed`, gone is a `not-found` frame with the previous value left for display. `reload(address)` stats again and yields `changed: false`. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file, or a reload, brings the resource live. Aborting the signal ends the stream silently.
 - **One `changes` subscription per Session.** The first follower opens `remote.$stream`, the last release disposes it, and successor streams and plugin teardown await pending closes. The Client starts its first `stat` only after accepting Host `ready`; sending a local WebSocket request is not Host acknowledgement. A follower registers by address, queues changes before its path is known, then filters queued and live frames by the successful stat's `absolutePath`, normalizing backslashes to slashes. Any Session write can trigger a re-stat before the first successful binding. Gateway supervision reconnects carrier loss; Host end or terminal failure ends followers and retains their last metadata until reopened.

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md

@@ -98,7 +98,7 @@ abstract readByteRange(target: FsTarget, range: { offset: number; length: number
 
 Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。文本预览包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。
 
-- **值是元数据**,`WorkspaceFileResource { version, bytes?, changed }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并以 `version` 与 `changed` 得知它们何时过时。
+- **值是元数据**,`WorkspaceFileResource { absolutePath, version, bytes?, changed }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并以 `version` 与 `changed` 得知它们何时过时。
 - **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对路径原样交给 Host,由 Host 按该会话的工作区根解析并检查包含关系,不要求 Client 持有 cwd。`absolute` 地址经当前会话读取,缺少当前会话时产生 `workspace-file/unknown-workspace`。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流,刷新无动作。
 - **帧。** 第一帧是 `stat`(`changed: false`)或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生 `changed: true`、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是标为 `changed` 的新元数据,不在则是保留上一个值供展示的 `not-found` 帧。`reload(address)` 再 stat 一次并产生 `changed: false`。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件或一次刷新会让资源恢复正常。中止 signal 则流静默结束。
 - **每会话一条 `changes` 订阅。** 首位跟随者打开 `remote.$stream`,最后一位离开时释放,后继流和插件拆除等待关闭完成。Client 接受 Host 的 `ready` 后才开始首次 `stat`;本地发出 WebSocket 请求不是 Host 确认。跟随者先按地址注册,缓冲路径未知期间的变更,成功 stat 后按返回的 `absolutePath` 过滤排队与实时帧,反斜杠归一为斜杠。尚未成功绑定时,Session 内任何写入均可触发重新 stat。载体掉线由 Gateway 监督器重连;Host 结束或终态失败会结束跟随者,并保留最近元数据,直到重新打开。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
-2026-09-07-sidebar-responsive-tab-info.md: 4e7e4b62d933fff104f0472c198470487761a9de
-2026-09-07-sidebar-responsive-tab-info.zh.md: fa3cefe83aedd91eebe139e51583093f10d769de
+2026-09-07-sidebar-responsive-tab-info.md: d06b8e523b11e04a747da1ecf22ca5cc898f882a
+2026-09-07-sidebar-responsive-tab-info.zh.md: f0e615191777d9450a04af1e580ef7ef7c512eb7

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md

@@ -14,7 +14,7 @@ The slot framework injects one `useTabInfo()` returning nested `sidebar`, `panel
 
 The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. Refresh restores defaults rather than persisting layout.
 
-Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities.
+Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities. A fullscreen entry completes its slide before reporting the underlying track; that covered width change is instantaneous, so neither entry nor returning to normal reveals a background reflow. At the two-pane budget the split control is hidden; a one-pane width refusal remains disabled.
 
 This decision supersedes the flat owner-props choice in [tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md) and the no-concession, overlay presentation and product pane limit in [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, record-lifetime, state ownership and engine-selection rationale remain active.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md

@@ -14,7 +14,7 @@ Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与
 
 框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。刷新恢复默认值,不持久化布局。
 
-全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。
+全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。 全屏入场先完成滑入,再报告底层轨道;被覆盖的宽度变化瞬间完成,因此入场及返回普通模式都不暴露底层重排。达到两格预算时隐藏分栏控件;单格宽度不足时仍显示禁用控件。
 
 本决策取代[标签类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md)的平铺 owner props 选择,以及[停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的无让步、覆盖模式和产品窗格上限。它们的注册、记录生命周期、状态所有权与引擎选型理由继续有效。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
-2026-09-05-sidebar-text-preview-and-file-tree.md: 017c637a2e4ee1fadc1f39ea2e163e8f04e6d53e
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 20a390189d0adb4616334aa538d28a92f4806934
+2026-09-05-sidebar-text-preview-and-file-tree.md: a21946c73302561a2cb8539ca314e0a5d4fb25b8
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: dc02c159e0e052a21f2f1c80ac2e6f30bf5cf416

+ 1 - 1
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md

@@ -30,7 +30,7 @@ A pane holds at most one guide, and the docking layer enforces it as product beh
 
 A tab's address is `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>` or `dsh-resource://file/absolute/<absolute path>` ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers in `dsh-util-workspace-path`). The preview never splits the string itself: `hostFileOf` in `rpc.ts` calls `parseFileAddress` and yields the `{ sessionId, path }` the endpoint takes — a `session` address reads under the session it names with the relative path the Host resolves, an `absolute` address reads under the session the slot was mounted for with the absolute path — and a malformed address throws, a programming error, because the registry routes every `file` address to this type and a caller building one is expected to use the helper.
 
-Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `{ version, bytes, changed }` from the `file` provider; the body reads `changed` and the resource's failed state. Content is the type's own business, read one page of lines at a time through `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` with no `limit`, so the page length is the Host's configured cap (`maxLines`, 5000 lines by default, and a page may not exceed `maxBytes`, 2 MB by default). The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next page until `eof`, disabled and reading `正在读取…` / `Reading…` while a read is in flight, and absent once the file has ended or a page failed. Pages are appended in file order with no separators and no line numbers, each carrying its line count (`lines`) so one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted and the walk restarts from the first page, so the body never shows two versions at once. The face keeps a request generation per tab: a reload bumps it, and a page settling from an older generation writes nothing. A tab switched away from and back reads nothing, because the pages live in the store, not the body.
+Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `{ absolutePath, version, bytes, changed }` from the `file` provider; the body reads `changed` and the resource's failed state. Content is the type's own business, read one page of lines at a time through `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` with no `limit`, so the page length is the Host's configured cap (`maxLines`, 5000 lines by default, and a page may not exceed `maxBytes`, 2 MB by default). The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next page until `eof`, disabled and reading `正在读取…` / `Reading…` while a read is in flight, and absent once the file has ended or a page failed. Pages are appended in file order with no separators and no line numbers, each carrying its line count (`lines`) so one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted and the walk restarts from the first page, so the body never shows two versions at once. The face keeps a request generation per tab: a reload bumps it, and a page settling from an older generation writes nothing. A tab switched away from and back reads nothing, because the pages live in the store, not the body.
 
 The store is Slot-standard: one exclusive instance per session, bucketed by tab id, holding `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`. Bucketing by tab, not by file, is deliberate — two tabs of one file scroll independently. The face (`loadPage`, `reloadPages`) is the only asynchronous half: it marks a read in flight, awaits the Remote result, and writes a page or a failure through the store's actions, writing nothing if the owner's `signal` has fired. The `signal` also ends the bucket: the face arms one abort listener per tab at the tab's first read, and that listener forgets the bucket — not the body, which mounts and unmounts as tabs switch; a tab that never read has no bucket and no listener, and a record can end while its body is unmounted behind another tab. Scroll offset, wrap, and the navigation already answered therefore outlive the body: a tab comes back where the reader left it rather than re-reading or jumping again. Nothing persists across a page reload.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md

@@ -30,7 +30,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、
 
 tab 的地址是 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<绝对路径>`([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md) 拥有这套语法及 `dsh-util-workspace-path` 里的 `fileAddressFor` / `parseFileAddress` 助手)。预览从不自己拆这个串:`rpc.ts` 里的 `hostFileOf` 调 `parseFileAddress` 得到端点所需的 `{ sessionId, path }`——`session` 地址在它命名的会话下以 Host 解析的相对路径读取,`absolute` 地址在坑位被挂载的会话下以绝对路径读取——畸形地址直接抛错,那是程序错误,因为注册表把每个 `file` 地址都路由给这个类型,而造地址的调用方本应使用助手。
 
-元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——从 `file` 提供者得到 `{ version, bytes, changed }`;体读 `changed` 与资源的失败态。内容是类型自己的事,经 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` 一次读一页行,不传 `limit`,因此页长就是 Host 配置的上限(`maxLines`,默认 5000 行;且一页不得超过 `maxBytes`,默认 2 MB)。首次挂载读第 1 页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`,读取进行中它禁用并显示 `正在读取…` / `Reading…`,文件读完或某页失败后消失。页按文件顺序追加,没有分隔也没有行号,每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用,从第一页重新走一遍,于是体永不同时显示两个版本。face 按 tab 记请求代次:重载递增它,旧代次结算的页什么也不写。切走再切回的 tab 什么都不读,因为页住在 store 里而不是体里。
+元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——从 `file` 提供者得到 `{ absolutePath, version, bytes, changed }`;体读 `changed` 与资源的失败态。内容是类型自己的事,经 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` 一次读一页行,不传 `limit`,因此页长就是 Host 配置的上限(`maxLines`,默认 5000 行;且一页不得超过 `maxBytes`,默认 2 MB)。首次挂载读第 1 页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`,读取进行中它禁用并显示 `正在读取…` / `Reading…`,文件读完或某页失败后消失。页按文件顺序追加,没有分隔也没有行号,每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用,从第一页重新走一遍,于是体永不同时显示两个版本。face 按 tab 记请求代次:重载递增它,旧代次结算的页什么也不写。切走再切回的 tab 什么都不读,因为页住在 store 里而不是体里。
 
 store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持有 `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`。按 tab 而非按文件分桶是有意的——同一文件的两个 tab 各自滚动。face(`loadPage`、`reloadPages`)是唯一的异步半边:它标记读取进行中,等待 Remote 结果,再经 store 的 action 写入一页或一次失败;若 owner 的 `signal` 已触发则什么也不写。`signal` 同时终结这个桶:face 在 tab 首次读取时挂一个 abort 监听器,由它忘掉桶——不是体,体随 tab 切换反复挂载卸载;从未读过的 tab 没有桶也没有监听器,而 tab 记录可能在其体被另一 tab 挡住而卸载时结束。因此滚动位置、换行与已答过的导航都活得比体久:tab 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。
 

+ 15 - 7
apps/web/tests/seeded-history.e2e.ts

@@ -38,6 +38,7 @@ const UI_EXPANDED_EXPECTED = fileURLToPath(
 // Command-row goldens over the same conversation after direct host commands.
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
+const FILE_PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'file-preview.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'seeded-history-web-e2e'
 
@@ -189,11 +190,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
-    // The workspace-aware flow runs sessions in <workspaceCwd>/workspace
-    // (the composer's default draft name); the read-tool targets must live in
-    // that session cwd. Pre-creating the directory is safe because the picker
-    // adopts an existing directory by path.
-    const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
+    // Composer recording uses a child workspace; seedSession owns the scaffold root.
+    const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
     await mkdir(sessionCwd, { recursive: true })
     await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
     await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
@@ -407,7 +405,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
   })
 
   it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log open the right Sidebar', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
+    onTestFailed(async () => {
+      await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/0907-2205-sidebar', import.meta.url)), { recursive: true })
+      await saveFailureShot(page, `screenshots/0907-2205-sidebar/seeded-toolrow-${process.pid}`)
+    })
     // Interaction over cold-resumed history: read summaries are file links
     // that open a text-preview tab in the right Sidebar (not expand-in-place).
     // Runs after the golden capture; still zero model calls.
@@ -422,6 +423,13 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2)
     // Path label survives from the recorded args (a.txt).
     await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
+    const path = column.locator('[data-textpreview-path]')
+    const absolutePath = join(scaffold.workspaceCwd, 'a.txt')
+    await expect.poll(() => path.textContent()).toBe(absolutePath)
+    expect(await path.getAttribute('title')).toBe(absolutePath)
+    await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
+    const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
     // Put the column back so the later goldens see the default frame.
     await column.locator('[data-sidebar-right-toggle]').click()
     await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
@@ -531,7 +539,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'command-row.expected.md', 'feedback-row.expected.md',
+      'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md',
       'session.v2.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
     ])
   })

+ 99 - 8
apps/web/tests/sidebar-right.e2e.ts

@@ -425,6 +425,100 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(tripwire.warnings).toEqual([])
     })
 
+    it('keeps the conversation still during fullscreen entry and installs the hidden track without animation', async () => {
+      onTestFailed(() => saveFailureShot(page, 'screenshots/0907-sidebar-rules/sidebar-right-fullscreen-entry'))
+      mkdirSync(SHOT_DIR, { recursive: true })
+      const column = await resetSidebar(page)
+      const frame = page.locator('[class*="frame"]').first()
+      const panel = column.locator('[data-sidebar-right-panel]')
+      const viewport = page.viewportSize()
+      if (viewport === null) throw new Error('expected a fixed viewport')
+      const geometry = () => frame.evaluate(node => ({
+        columns: getComputedStyle(node).gridTemplateColumns,
+        transition: getComputedStyle(node).transitionProperty,
+        handles: [...node.querySelectorAll('[data-side="sidebar"], [data-side="rightbar"]')]
+          .map(handle => getComputedStyle(handle).transitionProperty),
+        animatingGrid: node.getAnimations().some(animation =>
+          'transitionProperty' in animation && animation.transitionProperty === 'grid-template-columns'
+          && animation.playState !== 'finished' && animation.playState !== 'idle'),
+      }))
+      await frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) })
+      const normalColumns = (await geometry()).columns
+      await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
+      await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
+      await column.locator('[data-sidebar-right-toggle]').click()
+      await Promise.all([
+        panel.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
+        frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
+      ])
+      const closedColumns = (await geometry()).columns
+      expect(closedColumns).not.toBe(normalColumns)
+      await page.emulateMedia({ reducedMotion: 'no-preference' })
+
+      // Pause the real CSS transition at its midpoint so host scheduling cannot
+      // skip the partly covered frame whose underlying width is under test.
+      const held = await panel.evaluateHandle((node) => {
+        const controller = new AbortController()
+        const state = { animation: null as Animation | null, dispose: () => { controller.abort() } }
+        node.addEventListener('transitionrun', (event) => {
+          if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return
+          const slide = node.getAnimations().find(animation =>
+            'transitionProperty' in animation && animation.transitionProperty === 'transform')
+          if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable')
+          slide.pause()
+          slide.currentTime = Number(slide.effect.getComputedTiming().endTime) / 2
+          state.animation = slide
+          controller.abort()
+        }, { signal: controller.signal })
+        return state
+      })
+      try {
+        await expandOf(page).click()
+        await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused')
+        const entering = await panel.boundingBox()
+        if (entering === null) throw new Error('entering panel is not rendered')
+        expect(entering.x).toBeGreaterThan(0)
+        expect(entering.x).toBeLessThan(viewport.width)
+        expect((await geometry()).columns).toBe(closedColumns)
+        expect((await geometry()).animatingGrid).toBe(false)
+        expect(await frame.getAttribute('data-rightbar-fullscreen')).toBeNull()
+
+        await held.evaluate((state) => { (state.animation as Animation).finish() })
+        await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
+        expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
+        expect(await geometry()).toEqual({ columns: normalColumns, transition: 'none', handles: ['none'], animatingGrid: false })
+        await column.locator('[data-sidebar-right-mode="push"]').click()
+        expect((await geometry()).columns).toBe(normalColumns)
+        expect((await geometry()).animatingGrid).toBe(false)
+
+        await page.emulateMedia({ reducedMotion: 'reduce' })
+        await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
+        await column.locator('[data-sidebar-right-toggle]').click()
+        await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBeNull()
+        await expandOf(page).click()
+        await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
+        expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
+        expect((await geometry()).columns).toBe(normalColumns)
+        expect((await geometry()).animatingGrid).toBe(false)
+      } finally {
+        await held.evaluate((state) => {
+          state.dispose()
+          if (state.animation?.playState === 'paused') state.animation.finish()
+        })
+        await held.dispose()
+        try {
+          if (await panel.getAttribute('data-sidebar-right-panel') === 'fullscreen'
+            && await panel.getAttribute('data-sidebar-right-open') !== null) {
+            await column.locator('[data-sidebar-right-mode="push"]').click()
+          }
+        } finally {
+          await page.emulateMedia({ reducedMotion: null })
+        }
+      }
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    })
+
     it('keeps a capacity-closed panel closed after widening and uses fullscreen on a narrow viewport', async () => {
       const viewport = page.viewportSize()
       if (viewport === null) throw new Error('expected a fixed viewport')
@@ -448,6 +542,7 @@ describe('web e2e: shipped right Sidebar', () => {
         await page.setViewportSize({ width: 767, height: viewport.height })
         await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
         await expect.poll(async () => await width(panel)).toBe(767)
+        await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
         expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
         await column.locator('[data-sidebar-right-mode="push"]').click()
         await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
@@ -673,13 +768,11 @@ describe('web e2e: shipped right Sidebar', () => {
       await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5))
       await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title)
 
-      const splitOf = (pane: Locator): Locator => pane.locator('[data-dockkit-split-button]')
-      await expect.poll(async () => await splitOf(panes.first()).isDisabled()).toBe(true)
-      expect(await splitOf(panes.first()).getAttribute('data-dockkit-split-blocked')).toBe('budget')
+      const splitButtons = column.locator('[data-dockkit-split-button]')
+      await expect.poll(async () => await splitButtons.count()).toBe(0)
       expect(await panes.count()).toBe(2)
       await setPanelWidth(page, 560)
-      await expect.poll(async () => await splitOf(panes.last()).isDisabled()).toBe(true)
-      expect(await splitOf(panes.last()).getAttribute('data-dockkit-split-blocked')).toBe('budget')
+      await expect.poll(async () => await splitButtons.count()).toBe(0)
 
       const outer = column.locator('[data-dockkit-divider]').first()
       const before = await width(panes.last())
@@ -698,9 +791,7 @@ describe('web e2e: shipped right Sidebar', () => {
       await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y })
       await expect.poll(ratio).toBeCloseTo(0.5, 2)
       expect(await panes.count()).toBe(2)
-      expect(await splitOf(panes.first()).isDisabled()).toBe(true)
-      expect(await splitOf(panes.first()).getAttribute('data-dockkit-split-blocked')).toBe('budget')
-      expect(await splitOf(panes.first()).getAttribute('title')).toBe('Two panes is the limit')
+      expect(await splitButtons.count()).toBe(0)
 
       // 5. Two floats coexist, and one of them moves. Both leave the widest
       //    pane; a pane emptied by the first float is merged away, so the

+ 2 - 2
docs/subsystems/client-resources.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/client-resources.md
-client-resources.md: 728bbd7ab4113662be258bffa95ac962169182a9
-client-resources.zh.md: ddba98d9c876f6fc58c982dfbe95e58e7c01334d
+client-resources.md: a1643e61779e5c19450c7e1688b0ce7fcfbee786
+client-resources.zh.md: 418c9aef91a58a85ff8102617074a38813c5f94b

+ 1 - 1
docs/subsystems/client-resources.md

@@ -87,7 +87,7 @@ A consumer presents `failed` itself: the model keeps the last value beside the f
 
 A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
 
-Streams carry metadata, not content. The `file` provider's value is `{ version, bytes?, changed }`: `version` and `bytes` from the Host's `stat`, `changed` raised when the Host reports an agent write and cleared by `reload`. A consumer reads the file's text itself, by page, through the Workspace Files Remote namespace ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)).
+Streams carry metadata, not content. The `file` provider's value is `{ absolutePath, version, bytes?, changed }`: `absolutePath`, `version`, and `bytes` from the Host's `stat`, `changed` raised when the Host reports an agent write and cleared by `reload`. A consumer reads the file's text itself, by page, through the Workspace Files Remote namespace ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)).
 
 ## Limits
 

+ 1 - 1
docs/subsystems/client-resources.zh.md

@@ -87,7 +87,7 @@ export function FileHeader({ tab, useResource, t }: Props) {
 
 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
 
-流只推元数据不推内容。`file` 提供方的值是 `{ version, bytes?, changed }`:`version` 与 `bytes` 来自 Host 的 `stat`,`changed` 在 Host 报告 agent 写入时置起、由 `reload` 清除。消费方自己经 Workspace Files Remote 命名空间按页读文件文本([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。
+流只推元数据不推内容。`file` 提供方的值是 `{ absolutePath, version, bytes?, changed }`:`absolutePath`、`version` 与 `bytes` 来自 Host 的 `stat`,`changed` 在 Host 报告 agent 写入时置起、由 `reload` 清除。消费方自己经 Workspace Files Remote 命名空间按页读文件文本([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。
 
 ## 限制
 

+ 2 - 2
docs/subsystems/sidebar-right.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
-sidebar-right.md: a7a28003008a416112f3f67e337705bd7dae96d3
-sidebar-right.zh.md: a0f4e96cef50431ebdfb52a6eb2e0f8ce39d2c56
+sidebar-right.md: db4103a282287d8ea163d66b19efec27e0a0630f
+sidebar-right.zh.md: 1cb0b1e67e99acd7cb6aa12272cd57a5fdd207e0

+ 1 - 1
docs/subsystems/sidebar-right.md

@@ -109,7 +109,7 @@ The model is documented in [Client Resources](client-resources.md); this section
 
 `useResource<P>(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure, reload }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op without one ([read a resource](../../packages/client/resources/README.md#read-a-resource)).
 
-A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ version, bytes?, changed }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
+A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
 
 ## Workspace Files
 

+ 1 - 1
docs/subsystems/sidebar-right.zh.md

@@ -109,7 +109,7 @@ export function apply(ctx: Context): void {
 
 `useResource<P>(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure, reload }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,没有提供方时是空操作([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。
 
-资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ version, bytes?, changed }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
+资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
 
 ## Workspace Files
 

+ 2 - 2
packages/api/workspace-files/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md
-README.md: ad5f750a14f4e5f7cc3d06084fcab0445c9ab767
-README.zh.md: 38283b6e46b7b09a9811cc5352ed2e2ed17b0553
+README.md: f7442845bc3c592bee0c59817a72ad07c8c91a2a
+README.zh.md: 4acd022335ee7c276aca00d66e177c19b060df0a

+ 1 - 1
packages/api/workspace-files/README.md

@@ -71,7 +71,7 @@ Each failure is one `RemoteError` code with typed details, declared in [`src/typ
 
 ### Client file resources
 
-The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, `remote.workspaceFiles`, and `sessions`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component follows a file through its standard `useResource<'file'>(address)` prop and reads `{ version, bytes?, changed }`; content is fetched separately through the paged methods.
+The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, `remote.workspaceFiles`, and `sessions`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component follows a file through its standard `useResource<'file'>(address)` prop and reads `{ absolutePath, version, bytes?, changed }`; content is fetched separately through the paged methods.
 
 A `session/<sessionId>/<path>` resource address sends its relative path unchanged to the Host, which resolves and confines it against that Session's workspace root; the Client needs no Session `cwd`. An `absolute/<path>` address reads through the current Session. Both use the `dsh-resource://file/` grammar in [workspace-path](../../util/workspace-path/README.md). An absolute address without a current Session produces `workspace-file/unknown-workspace`; an unsupported address produces `workspace-file/unsupported-address`. These Client failures end the stream and make reload a no-op.
 

+ 1 - 1
packages/api/workspace-files/README.zh.md

@@ -71,7 +71,7 @@ kind: "package-reference"
 
 ### Client 文件资源
 
-浏览器导出向 `ctx.resources` 注册 `file` 提供者,要求 `resources`、`remote`、`remote.workspaceFiles` 和 `sessions` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件经标准 prop `useResource<'file'>(address)` 跟随文件,读取 `{ version, bytes?, changed }`;内容通过分页方法另行获取。
+浏览器导出向 `ctx.resources` 注册 `file` 提供者,要求 `resources`、`remote`、`remote.workspaceFiles` 和 `sessions` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件经标准 prop `useResource<'file'>(address)` 跟随文件,读取 `{ absolutePath, version, bytes?, changed }`;内容通过分页方法另行获取。
 
 `session/<sessionId>/<path>` 资源地址把相对路径原样发送给 Host,由 Host 按该 Session 的工作区根解析并检查包含关系;Client 不需要 Session `cwd`。`absolute/<path>` 地址经当前 Session 读取。两者都使用[workspace-path](../../util/workspace-path/README.zh.md)规定的 `dsh-resource://file/` 语法。没有当前 Session 的绝对地址产生 `workspace-file/unknown-workspace`;不支持的地址产生 `workspace-file/unsupported-address`。这些 Client 失败会结束流,并使刷新无动作。
 

+ 1 - 1
packages/api/workspace-files/src/client/provider.ts

@@ -177,5 +177,5 @@ function unknownWorkspace(address: string): RemoteError<'workspace-file/unknown-
  * @returns the metadata frame value.
  */
 function metadataOf(stat: WorkspaceFileStat, changed: boolean): WorkspaceFileResource {
-  return { version: stat.version, changed, ...(stat.bytes === undefined ? {} : { bytes: stat.bytes }) }
+  return { absolutePath: stat.absolutePath, version: stat.version, changed, ...(stat.bytes === undefined ? {} : { bytes: stat.bytes }) }
 }

+ 2 - 0
packages/api/workspace-files/src/client/types.ts

@@ -49,6 +49,8 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
  * know when its pages are stale.
  */
 export interface WorkspaceFileResource {
+  /** Absolute path in the Host filesystem, as returned by the last successful stat. */
+  readonly absolutePath: string
   /** The Host's latest report of the file's version: from `stat` first, then from each reported write. */
   readonly version: string
   /** Byte size as of the last `stat`, when the backend reports it. */

+ 6 - 6
packages/api/workspace-files/tests/host-path.client.spec.ts

@@ -41,7 +41,7 @@ describe('Host-resolved file paths', () => {
     expect(remote.calls).toEqual(['changes', 'accept', 'stat'])
     expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE })
     request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
-    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3, changed: false } } })
   })
 
   it('does not stat an opened changes stream until the Host acknowledges its subscription', async () => {
@@ -58,7 +58,7 @@ describe('Host-resolved file paths', () => {
     const request = await remote.waitForStat(0)
     expect(remote.calls).toEqual(['changes', 'accept', 'stat'])
     request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
-    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', changed: false } } })
   })
 
   it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => {
@@ -94,7 +94,7 @@ describe('Host-resolved file paths', () => {
     await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
     expect(remote.stats).toHaveLength(1)
     expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept'])
-    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: true } } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } })
   })
 
   it.each([
@@ -112,7 +112,7 @@ describe('Host-resolved file paths', () => {
     await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
     request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
     await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } })
-    await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: true } } })
+    await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } })
 
     await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } })
     await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
@@ -138,7 +138,7 @@ describe('Host-resolved file paths', () => {
     await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v3' } })
     retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } })
     await expect(retried).resolves.toEqual({
-      done: false, value: { ok: true, value: { version: 'v2', bytes: 5, changed: trigger === 'write' } },
+      done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5, changed: trigger === 'write' } },
     })
     await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3', changed: true } } })
     expect(remote.stats).toHaveLength(2)
@@ -166,7 +166,7 @@ describe('Host-resolved file paths', () => {
     for (const request of remote.stats.slice(3)) {
       request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4 } })
     }
-    const value = { done: false, value: { ok: true, value: { version: 'v1', bytes: 4, changed: false } } }
+    const value = { done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4, changed: false } } }
     await expect(reloaded).resolves.toEqual([value, value])
     await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/other.txt', version: 'other-v1' } })
     await expect(otherChange).resolves.toMatchObject({ value: { ok: true, value: { version: 'other-v1', changed: true } } })

+ 11 - 11
packages/api/workspace-files/tests/provider.client.spec.ts

@@ -118,7 +118,7 @@ describe('file provider — the address', () => {
     expect(remote.opened.map(o => o.sessionId)).toEqual([S1])
     expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: ABS_PATH })
     remote.stats[0]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } })
-    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3, changed: false } } })
     remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } })
     await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
     // A reload stats the same absolute path again.
@@ -127,7 +127,7 @@ describe('file provider — the address', () => {
     await settle()
     expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: ABS_PATH })
     remote.stats[1]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4 } })
-    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 4, changed: false } } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4, changed: false } } })
   })
 
   it('opens one change stream per session named by the addresses', async () => {
@@ -150,7 +150,7 @@ describe('file provider — the opening stat', () => {
     expect(remote.stats).toHaveLength(1)
     expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal })
     remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
-    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } })
   })
 
   it('omits bytes when the backend reports none', async () => {
@@ -158,7 +158,7 @@ describe('file provider — the opening stat', () => {
     const first = it.next()
     await settle()
     remote.stats[0]!.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } })
-    await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { version: 'v0', changed: false } } })
+    await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', changed: false } } })
   })
 
   it('yields the Host failure as a frame and keeps following the address', async () => {
@@ -178,7 +178,7 @@ describe('file provider — the opening stat', () => {
     source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
     await settle()
     remote.stats[1]!.resolve({ ok: true, value: stat('v1', 5) })
-    await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 5, changed: true } } })
+    await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5, changed: true } } })
   })
 
   it('lets a reload retry a failed opening stat', async () => {
@@ -191,7 +191,7 @@ describe('file provider — the opening stat', () => {
     const next = it.next()
     await settle()
     remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) })
-    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } })
   })
 
   it('ends without a frame when aborted during the stat', async () => {
@@ -219,7 +219,7 @@ describe('file provider — Host writes', () => {
   it('flags a write with its version and keeps the byte count', async () => {
     const { remote, it } = await live()
     remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
-    await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: true } } })
+    await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: true } } })
     expect(remote.stats).toHaveLength(1)
   })
 
@@ -254,7 +254,7 @@ describe('file provider — a reported disappearance', () => {
     await settle()
     expect(remote.stats).toHaveLength(2)
     remote.stats[1]!.resolve({ ok: true, value: stat('v2', 9) })
-    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v2', bytes: 9, changed: true } } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9, changed: true } } })
   })
 
   it('yields the not-found frame and keeps following, so a later write stats again and brings the file back flagged', async () => {
@@ -270,7 +270,7 @@ describe('file provider — a reported disappearance', () => {
     const back = it.next()
     await settle()
     remote.stats[2]!.resolve({ ok: true, value: stat('v3', 8) })
-    await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v3', bytes: 8, changed: true } } })
+    await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8, changed: true } } })
   })
 })
 
@@ -285,7 +285,7 @@ describe('file provider — reload', () => {
     expect(remote.stats).toHaveLength(2)
     expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: REL_PATH })
     remote.stats[1]!.resolve({ ok: true, value: stat('v1', 7) })
-    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 7, changed: false } } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 7, changed: false } } })
   })
 
   it('yields a failed stat as a frame and keeps the stream open', async () => {
@@ -334,7 +334,7 @@ describe('file provider — reload', () => {
     expect(remote.stats.slice(2).map(pending => pending.path)).toEqual([REL_PATH, HOST_PATH])
     remote.stats[2]!.resolve({ ok: true, value: stat('v1', 3) })
     remote.stats[3]!.resolve({ ok: true, value: stat('v1', 3) })
-    const cleared = { done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: false } } }
+    const cleared = { done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: false } } }
     await expect(nexts).resolves.toEqual([cleared, cleared])
   })
 

+ 2 - 2
packages/client/ui-dockkit/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md
-README.md: 782ac4d91418123bcfceef40a6a32e08aaf5651f
-README.zh.md: 8bba57a9c56a3cdfba520bfe2af71fb71b38f87a
+README.md: e012295852ad3c957695fcfbd0568a45b3f54856
+README.zh.md: 2423887d60460651a224ffe1cb0fc9b61363c0e2

+ 1 - 1
packages/client/ui-dockkit/README.md

@@ -52,7 +52,7 @@ Everything host-specific arrives through props:
 
 `DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Two props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`) and `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane). Hiding the add control moves nothing else in the strip. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
 
-`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions.
+`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitAtCapacity` hides the split control at the pane budget; its default is false, and a width-blocked control remains disabled.
 
 A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end.
 

+ 1 - 1
packages/client/ui-dockkit/README.zh.md

@@ -52,7 +52,7 @@ kind: "package-reference"
 
 `DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有两个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)与 `canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)。隐藏添加控件不会移动 tab 条里的其它任何东西。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
 
-`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。
+`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitAtCapacity` 在达到窗格预算时隐藏分栏控件,默认值为 false;宽度不足的控件仍以禁用状态显示。
 
 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。
 

+ 4 - 1
packages/client/ui-dockkit/src/components/DockSurface.tsx

@@ -33,6 +33,8 @@ export interface DockSurfaceProps {
    * split control disabled with `labels.splitPaneNarrow` (see README).
    */
   readonly canSplit: boolean
+  /** Hide the split control when the pane budget is spent; defaults to false. Width-blocked controls remain disabled. */
+  readonly hideSplitAtCapacity?: boolean
   /** Body drop geometry: all edge bands, or left/right halves with whole-pane moves once splitting is unavailable. */
   readonly dropZones?: 'edges' | 'horizontal'
   /** Smallest share a divider may leave a pane; defaults to the kit's fraction. */
@@ -153,7 +155,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean {
 /** The split tree and the gestures over it. */
 export function DockSurface({
   state, canSplit, canAddTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom,
-  dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION,
+  dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false,
 }: DockSurfaceProps): ReactNode {
   const surface = useRef<HTMLDivElement | null>(null)
   const [preview, setPreview] = useState<Preview>(NO_PREVIEW)
@@ -263,6 +265,7 @@ export function DockSurface({
       })
     },
     splitBlock,
+    hideSplitAtCapacity,
     canAddTab: canAddTab ?? ALWAYS,
     dropTarget: preview.dropTarget,
     horizontalDrops: dropZones === 'horizontal',

+ 17 - 15
packages/client/ui-dockkit/src/components/TabPanel.tsx

@@ -234,21 +234,23 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
           </button>
         )}
         <div className={css.stripFill} data-dockkit-strip-fill />
-        <button
-          type="button"
-          className={css.iconButton}
-          aria-label={callbacks.labels.splitPane}
-          title={splitTitle(callbacks.labels, block)}
-          disabled={block !== undefined}
-          data-dockkit-split-button={pane.id}
-          data-dockkit-split-blocked={block}
-          onClick={(event) => {
-            event.stopPropagation()
-            callbacks.onSplitPane(pane.id)
-          }}
-        >
-          <SplitGlyph />
-        </button>
+        {!(callbacks.hideSplitAtCapacity && block === 'budget') && (
+          <button
+            type="button"
+            className={css.iconButton}
+            aria-label={callbacks.labels.splitPane}
+            title={splitTitle(callbacks.labels, block)}
+            disabled={block !== undefined}
+            data-dockkit-split-button={pane.id}
+            data-dockkit-split-blocked={block}
+            onClick={(event) => {
+              event.stopPropagation()
+              callbacks.onSplitPane(pane.id)
+            }}
+          >
+            <SplitGlyph />
+          </button>
+        )}
         {/* The embedder's surface-wide controls, in the top-right pane only: the
             strip is the surface's top edge, and this pane's end is its corner. */}
         {pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && (

+ 2 - 0
packages/client/ui-dockkit/src/components/render.ts

@@ -23,6 +23,8 @@ export interface PaneCallbacks {
   readonly onDividerPressed: (splitId: SplitId, index: number, event: ReactPointerEvent<HTMLElement>) => void
   /** Why a pane cannot split right now, or `undefined` while it can. */
   readonly splitBlock: (paneId: PaneId) => SplitBlock | undefined
+  /** Hide budget-blocked split controls without hiding width-blocked controls. */
+  readonly hideSplitAtCapacity?: boolean
   /** Whether a pane's strip draws the add control. */
   readonly canAddTab: (paneId: PaneId) => boolean
   /** Live drop preview, or `undefined` while nothing is being dragged. */

+ 24 - 1
packages/client/ui-dockkit/tests/components.client.spec.tsx

@@ -258,11 +258,33 @@ describe('DockSurface', () => {
     expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
   })
 
+  it('hides capacity-blocked split controls when opted in and restores them when capacity returns', () => {
+    const controller = seededController()
+    controller.splitPane()
+    const state = controller.getSnapshot().state
+    layOut(dockPaneIds(state), 420)
+    const intents = spyIntents()
+    const props: DockSurfaceProps = {
+      state, canSplit: false, hideSplitAtCapacity: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
+    }
+    const view = render(<DockSurface {...props} />)
+    expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull()
+    expect(view.container.querySelectorAll('[data-dockkit-split-button]')).toHaveLength(0)
+    expect(view.container.querySelectorAll('[data-dockkit-pane]')).toHaveLength(2)
+
+    view.rerender(<DockSurface {...props} canSplit />)
+    const buttons = screen.getAllByRole('button', { name: TEST_LABELS.splitPane })
+    expect(buttons).toHaveLength(2)
+    expect(buttons.every(button => !button.hasAttribute('disabled'))).toBe(true)
+    fireEvent.click(buttons[0]!)
+    expect(intents.splitPane).toHaveBeenCalledExactlyOnceWith(dockPaneIds(state)[0])
+  })
+
   // jsdom lays nothing out, so the room rule reads the rectangles this spec
   // hands it: two panes, one wide enough for two halves and one not. The
   // strip's fixed part is 104px in both (the chrome pane's controls), the chip
   // minimum falls back to the stylesheet's 59px.
-  it('disables the split control, with its own title, in a pane too narrow for two halves', () => {
+  it.each([false, true])('keeps the width-blocked split control and its title with hideSplitAtCapacity=%s', (hideSplitAtCapacity) => {
     const controller = seededController()
     controller.setExpanded(true)
     controller.splitPane()
@@ -282,6 +304,7 @@ describe('DockSurface', () => {
       <DockSurface
         state={snapshot.state}
         canSplit
+        hideSplitAtCapacity={hideSplitAtCapacity}
         intents={controller}
         labels={TEST_LABELS}
         renderTab={tab => <p>{tab.contentId}</p>}

+ 2 - 2
packages/client/ui-layout/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
-README.md: a3a09460abc70dd9d219369a5cca80e4fe55d674
-README.zh.md: 407df5b21d3fd9bace66b4db899ffde499b31cb5
+README.md: 9f7a050425e203e1c5db58b7e4a03c5017cdf192
+README.zh.md: dc68b4b1265209a257703c5e66d7f28e65946270

+ 1 - 1
packages/client/ui-layout/README.md

@@ -39,7 +39,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority.
+One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame.
 
 </details>
 

+ 1 - 1
packages/client/ui-layout/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。
+一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。
 
 </details>
 

+ 6 - 0
packages/client/ui-layout/src/client/AppFrame.module.css

@@ -60,6 +60,12 @@
   transition: none;
 }
 
+/* Fullscreen covers the frame before its underlying columns change. */
+.frame[data-rightbar-fullscreen],
+.frame[data-rightbar-fullscreen] .handle {
+  transition: none;
+}
+
 @media (prefers-reduced-motion: reduce) {
   .handle {
     transition: none;

+ 1 - 0
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -200,6 +200,7 @@ export function AppFrame({
       }}
       data-sidebar-collapsed={sidebarCollapsed || undefined}
       data-rightbar-collapsed={cols.rightbar === 0 || undefined}
+      data-rightbar-fullscreen={panels.rightbarFullscreen || undefined}
       data-dragging={dragging || undefined}
     >
       <DocumentTitle

+ 16 - 0
packages/client/ui-layout/tests/app-frame.client.spec.tsx

@@ -325,13 +325,29 @@ describe('AppFrame right panel presentation', () => {
     act(() => { instance.actions.openRightbar(true, true) })
     expect(tracks(frame)).toEqual([280, 864])
     expect(rightOwner().width).toBe(864)
+    expect(frame.dataset.rightbarFullscreen).toBe('true')
     expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
     act(() => { instance.actions.openRightbar(true, false) })
     expect(tracks(frame)).toEqual([280, 864])
     expect(handleFor(frame, 'rightbar').style.left).toBe('1056px')
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
     act(() => { instance.actions.closeRightbar() })
     expect(tracks(frame)).toEqual([280, 0])
     expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
+  })
+
+  it('inserts a fullscreen track and its transition-suppression marker in the same render', () => {
+    const { frame, instance } = mountFrame()
+    expect(tracks(frame)).toEqual([280, 0])
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
+    act(() => { instance.actions.openRightbar(true, true) })
+    expect(tracks(frame)).toEqual([280, 864])
+    expect(frame.dataset.rightbarFullscreen).toBe('true')
+    expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
+    act(() => { instance.actions.openRightbar(true, false) })
+    expect(tracks(frame)).toEqual([280, 864])
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
   })
 
   it('retains fullscreen without a track when normal columns cannot fit', () => {

+ 2 - 2
packages/client/ui-sidebar-right/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
-README.md: df01e7edf0f19f14e70ee1560fb7a849f3f0df7f
-README.zh.md: 413d2828783214ffab7ddeb71bf5da4617466f99
+README.md: fff53260dfc1142cfa6eea0adaa3c25c14330d85
+README.zh.md: f5fcb8fe306d847c05d2c5333cc1322129b1a774

+ 2 - 2
packages/client/ui-sidebar-right/README.md

@@ -36,7 +36,7 @@ The layout itself — the split tree, its operations, the drag gestures, the flo
 <a id="presentations"></a>
 ## Presentations
 
-Normal and fullscreen presentations share the same content tree, so switching does not remount tabs. The normal panel anchors to the right column; fullscreen covers the viewport while retaining the wide-screen columns underneath. Opening below 768px uses fullscreen automatically; leaving fullscreen on a narrow viewport closes the panel, and widening does not reopen a closed panel.
+Normal and fullscreen presentations share the same content tree, so switching does not remount tabs. The normal panel anchors to the right column; fullscreen covers the viewport while retaining the wide-screen columns underneath. Opening below 768px uses fullscreen automatically; leaving fullscreen on a narrow viewport closes the panel, and widening does not reopen a closed panel. A fullscreen opening keeps the underlying columns unchanged until its slide finishes, then prepares the normal track without a column transition.
 
 | Mode | The track | The panel |
 |---|---|---|
@@ -92,7 +92,7 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi
 <a id="the-guide"></a>
 ## The guide
 
-The guide tab is a centred title, one line under it, and one entry box per `guide` entry the registered types contributed, in `order`. Picking a box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane seeds a guide through the kit's factory, one per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third.
+The guide tab is a centred title, one line under it, and one entry box per `guide` entry the registered types contributed, in `order`. Picking a box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane seeds a guide through the kit's factory, one per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
 
 <a id="copy"></a>
 ## Copy

+ 2 - 2
packages/client/ui-sidebar-right/README.zh.md

@@ -36,7 +36,7 @@ kind: "package-reference"
 <a id="presentations"></a>
 ## 呈现形态
 
-普通与全屏共用同一棵面板内容树,切换不会重挂载Tab。普通面板贴靠右栏;全屏面板覆盖窗口并保留宽屏底层列宽。窗口低于768px时打开右栏自动全屏;窄屏退出全屏会收起右栏,变宽不重新打开已关闭的右栏。
+普通与全屏共用同一棵面板内容树,切换不会重挂载Tab。普通面板贴靠右栏;全屏面板覆盖窗口并保留宽屏底层列宽。窗口低于768px时打开右栏自动全屏;窄屏退出全屏会收起右栏,变宽不重新打开已关闭的右栏。 全屏打开时,底层列宽保持不变,直到滑入结束后才无过渡地准备普通轨道。
 
 | 形态 | 轨道 | 面板 |
 |---|---|---|
@@ -92,7 +92,7 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私
 <a id="the-guide"></a>
 ## 引导页
 
-引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格通过套件的工厂播种一个引导页,每个新格一个。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。
+引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格通过套件的工厂播种一个引导页,每个新格一个。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。 达到两格上限时隐藏分栏控件;关闭回单格后恢复。
 
 <a id="copy"></a>
 ## 文案

+ 32 - 11
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx

@@ -8,9 +8,9 @@
  *
  * The panel stays mounted while collapsed, translated off the frame's right
  * edge, so opening and closing are one gesture in both presentations: a slide
- * from and to that edge. Because the frame's track transition reads the same
- * duration and curve, the panel's left edge and the conversation's right edge
- * travel together while squeezing.
+ * from and to that edge. Normal presentation moves the frame's tracks with
+ * the panel. A fullscreen opening reserves its underlying track only after
+ * the panel covers the frame, without animating those hidden columns.
  *
  * The panel has no header of its own: its two controls — presentation switch
  * and collapse — ride the docking kit's chrome seat at the end of the top-right
@@ -28,7 +28,7 @@
  * domain follows each session's store commits, including sessions off screen.
  */
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
-import type { ReactNode } from 'react'
+import type { ReactNode, RefObject } from 'react'
 import { createPortal } from 'react-dom'
 import type {
   HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
@@ -284,11 +284,12 @@ function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick
  * The panel: the docked surface with the two controls in its top-right strip,
  * anchored to the frame's right edge and slid off it while collapsed.
  */
-function SidebarPanel(panel: PanelProps & { width: number }): ReactNode {
-  const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, autoFullscreen } = panel
+function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<HTMLDivElement> }): ReactNode {
+  const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, autoFullscreen, panelRef } = panel
   const { expanded } = surface.layout
   return (
     <div
+      ref={panelRef}
       className={css.panel}
       style={{ width: fullscreen ? '100%' : width }}
       data-sidebar-right-panel={fullscreen ? 'fullscreen' : 'push'}
@@ -302,6 +303,7 @@ function SidebarPanel(panel: PanelProps & { width: number }): ReactNode {
         <DockSurface
           state={surface.layout}
           canSplit={canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2}
+          hideSplitAtCapacity
           dropZones="horizontal"
           minPaneFraction={0.2}
           canAddTab={paneId => guideIn(surface.layout, paneId) === undefined}
@@ -356,6 +358,7 @@ export function RightbarSeat({
   const shown = surface !== undefined && surface.layout.expanded
   const autoFullscreen = viewportWidth < 768
   const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen'
+  const panelRef = useRef<HTMLDivElement | null>(null)
   // The kit's room-rule readings, kept in a ref: the service reads them at
   // call time through the binding, and a reading never re-renders anything.
   const room = useRef<ReadonlyMap<PaneId, HalvesFit>>(new Map())
@@ -370,10 +373,28 @@ export function RightbarSeat({
     if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false)
   }, [actions, sessionId, shown, fullscreen, canShow])
 
-  // Before paint, so the frame's track and this panel's slide start in the same
-  // frame: the frame re-renders synchronously from this report, and both
-  // transitions read the same duration and curve.
-  useLayoutEffect(() => { syncPresentation({ shown, track, fullscreen }) }, [shown, track, fullscreen, syncPresentation])
+  // Fullscreen leaves the previous column report in force until its own slide
+  // completes. Normal presentation and zero-duration transitions report before paint.
+  useLayoutEffect(() => {
+    let disposed = false
+    const reportWhenCovered = (): void => {
+      if (disposed) return
+      // A shown panel renders unconditionally and attaches its ref before this effect.
+      const entering = shown && fullscreen
+        ? (panelRef.current as HTMLDivElement).getAnimations().filter(animation =>
+          'transitionProperty' in animation && animation.transitionProperty === 'transform'
+          && animation.playState !== 'finished' && animation.playState !== 'idle')
+        : []
+      if (entering.length === 0) {
+        syncPresentation({ shown, track, fullscreen })
+        return
+      }
+      // Cancellation can replace the transition or remove it for reduced motion.
+      void Promise.allSettled(entering.map(animation => animation.finished)).then(reportWhenCovered)
+    }
+    reportWhenCovered()
+    return () => { disposed = true }
+  }, [sessionId, shown, track, fullscreen, syncPresentation])
   // Leaving is part of that report: a seat that unmounts with its session must
   // hand the track back rather than leave one sized for a surface nobody draws.
   useLayoutEffect(() => () => { syncPresentation({ shown: false, track: false, fullscreen: false }) }, [syncPresentation])
@@ -395,7 +416,7 @@ export function RightbarSeat({
   }
   return (
     <>
-      <SidebarPanel {...panel} width={width} />
+      <SidebarPanel {...panel} width={width} panelRef={panelRef} />
       <Floats {...panel} />
     </>
   )

+ 141 - 6
packages/client/ui-sidebar-right/tests/seat.client.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 /** Sidebar presentation and tab subscriptions through the production slot renderer. */
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, fireEvent } from '@testing-library/react'
 import { useState } from 'react'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
@@ -23,11 +23,39 @@ declare module '../src/client/contract/params.ts' {
 const SESSION = 's-test' as SessionId
 const OTHER = 's-other' as SessionId
 const runtimes: SlotTestRuntime[] = []
+let getAnimationsDescriptor: PropertyDescriptor | undefined
+
+beforeEach(() => {
+  getAnimationsDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'getAnimations')
+  Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, writable: true, value: () => [] })
+})
 
 afterEach(async () => {
-  for (const runtime of runtimes.splice(0)) await runtime.dispose()
+  try {
+    for (const runtime of runtimes.splice(0)) await runtime.dispose()
+  } finally {
+    vi.restoreAllMocks()
+    if (getAnimationsDescriptor === undefined) Reflect.deleteProperty(Element.prototype, 'getAnimations')
+    else Object.defineProperty(Element.prototype, 'getAnimations', getAnimationsDescriptor)
+  }
 })
 
+/** Browser-owned animation completion controlled independently of the test clock. */
+function transition(property = 'transform') {
+  const done = Promise.withResolvers<Animation>()
+  let state: AnimationPlayState = 'running'
+  const animation = {
+    transitionProperty: property,
+    get playState() { return state },
+    finished: done.promise,
+  } as CSSTransition
+  return {
+    animation,
+    finish: () => { state = 'finished'; done.resolve(animation) },
+    cancel: () => { state = 'idle'; done.reject(new DOMException('Transition canceled', 'AbortError')) },
+  }
+}
+
 async function mountSeat(viewportWidth = 1440, canShow = true) {
   const runtime = await SlotTestRuntime.create()
   runtimes.push(runtime)
@@ -177,6 +205,108 @@ describe('RightbarSeat presentation', () => {
   })
 })
 
+describe('RightbarSeat fullscreen entry', () => {
+  it('retains the previous report until its transform finishes, then leaves the track in place on exit', async () => {
+    const h = await mountSeat()
+    act(() => { h.actions.setMode(SESSION, 'fullscreen') })
+    const panel = element(h.view.container, '[data-sidebar-right-panel]')
+    const slide = transition()
+    const unrelated = transition('opacity')
+    vi.spyOn(panel, 'getAnimations').mockReturnValue([slide.animation, unrelated.animation])
+    h.frame.closeRightbar.mockClear()
+    h.open()
+    expect(panel.hasAttribute('data-sidebar-right-open')).toBe(true)
+    expect(panel.dataset['sidebarRightPanel']).toBe('fullscreen')
+    expect(h.frame.openRightbar).not.toHaveBeenCalled()
+    expect(h.frame.closeRightbar).not.toHaveBeenCalled()
+    await act(async () => { slide.finish(); await slide.animation.finished })
+    expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(true, true)
+    fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false)
+    unrelated.finish()
+  })
+
+  it('reports immediately without a transform transition, including zero-duration and reduced-motion entry', async () => {
+    const h = await mountSeat()
+    act(() => { h.actions.setMode(SESSION, 'fullscreen') })
+    const unrelated = transition('opacity')
+    const ended = transition()
+    ended.finish()
+    vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations')
+      .mockReturnValue([unrelated.animation, ended.animation])
+    h.open()
+    expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(true, true)
+    unrelated.finish()
+  })
+
+  it('reports when reduced motion cancels the entering transition', async () => {
+    const h = await mountSeat(767, false)
+    const slide = transition()
+    vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations').mockReturnValue([slide.animation])
+    h.open()
+    expect(h.frame.openRightbar).not.toHaveBeenCalled()
+    await act(async () => { slide.cancel(); await Promise.allSettled([slide.animation.finished]) })
+    expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(false, true)
+  })
+
+  it('waits for a replacement transform after cancellation', async () => {
+    const h = await mountSeat(767, false)
+    const first = transition()
+    const replacement = transition()
+    const animations = vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations')
+      .mockReturnValue([first.animation])
+    h.open()
+    animations.mockReturnValue([replacement.animation])
+    await act(async () => { first.cancel(); await Promise.allSettled([first.animation.finished]) })
+    expect(h.frame.openRightbar).not.toHaveBeenCalled()
+    await act(async () => { replacement.finish(); await replacement.animation.finished })
+    expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(false, true)
+  })
+
+  it.each(['close', 'push', 'session', 'unmount'])('ignores a late completion after %s', async (change) => {
+    const h = await mountSeat()
+    act(() => { h.actions.setMode(SESSION, 'fullscreen') })
+    const slide = transition()
+    vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations').mockReturnValue([slide.animation])
+    h.open()
+    expect(h.frame.openRightbar).not.toHaveBeenCalled()
+    if (change === 'close') fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]'))
+    else if (change === 'push') fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
+    else if (change === 'session') {
+      await h.runtime.sessions.add({ id: OTHER })
+      act(() => { h.controller.openResource('dsh-resource://file/session/s-other/b.txt') })
+    } else await h.runtime.dispose()
+    const openCalls = [...h.frame.openRightbar.mock.calls]
+    const closeCalls = h.frame.closeRightbar.mock.calls.length
+    await act(async () => { slide.finish(); await slide.animation.finished })
+    expect(h.frame.openRightbar.mock.calls).toEqual(openCalls)
+    expect(h.frame.closeRightbar).toHaveBeenCalledTimes(closeCalls)
+  })
+
+  it('uses the current viewport report when entry crosses the fullscreen breakpoint', async () => {
+    const h = await mountSeat()
+    act(() => { h.actions.setMode(SESSION, 'fullscreen') })
+    const slide = transition()
+    vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations').mockReturnValue([slide.animation])
+    h.open()
+    h.view.update({ width: 420, viewportWidth: 500, canShow: false })
+    expect(h.frame.openRightbar).not.toHaveBeenCalled()
+    await act(async () => { slide.finish(); await slide.animation.finished })
+    expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(false, true)
+  })
+
+  it('does not delay normal presentation behind its slide', async () => {
+    const h = await mountSeat()
+    const slide = transition()
+    const animations = vi.spyOn(element(h.view.container, '[data-sidebar-right-panel]'), 'getAnimations')
+      .mockReturnValue([slide.animation])
+    h.open()
+    expect(h.frame.openRightbar).toHaveBeenCalledExactlyOnceWith(true, false)
+    expect(animations).not.toHaveBeenCalled()
+    slide.finish()
+  })
+})
+
 describe('slot-owned useTabInfo', () => {
   it('updates body and title navigation with no layout commit and retains the bound hook', async () => {
     const h = await mountSeat()
@@ -308,17 +438,17 @@ describe('slot-owned useTabInfo', () => {
     expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
   })
 
-  it('limits splits to two panes and adds a guide only to a pane without one', async () => {
+  it('hides split controls at two panes and adds a guide only to a pane without one', async () => {
     const h = await mountSeat()
     h.open()
+    const splitButtons = () => h.view.container.querySelectorAll<HTMLButtonElement>('[data-dockkit-split-button]')
+    expect(splitButtons()).toHaveLength(1)
     act(() => { h.controller.split() })
     expect(dockPaneIds(h.layout())).toHaveLength(2)
     const stored = h.instance.getSnapshot()
     act(() => { expect(h.controller.split()).toBeUndefined() })
     expect(h.instance.getSnapshot()).toBe(stored)
-    const splitButtons = h.view.container.querySelectorAll<HTMLButtonElement>('[data-dockkit-split-button]')
-    expect(splitButtons).toHaveLength(2)
-    expect([...splitButtons].every(button => button.disabled)).toBe(true)
+    expect(splitButtons()).toHaveLength(0)
     const right = dockPaneIds(h.layout())[1]!
     h.open('right.txt', { paneId: right })
     const guide = getPane(h.layout(), right).tabs.find(id => h.layout().tabs[id]?.kind === 'guide')!
@@ -327,6 +457,11 @@ describe('slot-owned useTabInfo', () => {
     expect(add.closest('[data-dockkit-pane]')?.getAttribute('data-dockkit-pane')).toBe(right)
     fireEvent.click(add)
     expect(getPane(h.layout(), right).tabs.filter(id => h.layout().tabs[id]?.kind === 'guide')).toHaveLength(1)
+    const closing = [...getPane(h.layout(), right).tabs]
+    act(() => { for (const tabId of closing) h.actions.closeTab(SESSION, tabId) })
+    expect(dockPaneIds(h.layout())).toHaveLength(1)
+    expect(splitButtons()).toHaveLength(1)
+    expect(splitButtons()[0]?.disabled).toBe(false)
   })
 })
 

+ 2 - 2
packages/client/ui-sidebar-textpreview/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-textpreview/README.md
-README.md: e4bc5081b113313efc5aba2f9f27b67fb8e8f461
-README.zh.md: dba5363107bfb146cf541b299ba1ded1789f7937
+README.md: aa8364023637598ca84635321376fed07eef90cf
+README.zh.md: ab157cb83045f414bf9cc9547c838d5c41cfd286

+ 2 - 2
packages/client/ui-sidebar-textpreview/README.md

@@ -27,7 +27,7 @@ The right Sidebar's plain-text viewer: one workspace text file, read one page of
 ## What it registers
 
 - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-textpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; everything else lands here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title.
-- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its header row shows the file's path with the type's two controls at its end: a wrap toggle (on by default; long lines wrap until the reader turns it off, per tab) and a reload button. The Sidebar's tab strip carries no controls of this type. The body takes the pane body's full height: the header row stays put and the file body below is the one scroller, so a short file leaves no unstyled space and a long file scrolls under a fixed path.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its header row shows the Host's absolute path and exposes its full value in a tooltip, with the type's two controls at its end: a wrap toggle (on by default; long lines wrap until the reader turns it off, per tab) and a reload button. The Sidebar's tab strip carries no controls of this type. The body takes the pane body's full height: the header row stays put and the file body below is the one scroller, so a short file leaves no unstyled space and a long file scrolls under a fixed path. Until metadata provides an absolute path, the header uses the requested path.
 - **One store and one face**, session-scoped and bucketed by tab id. The store holds the pages read so far (keyed by the 1-based line each starts at, with the file version they belong to), the end-of-file flag, the read in flight or its failure, and the view: scroll offset, wrap (initially on), and the navigation revision the body last answered. The face (`loadPage`, `reloadPages`) performs the reads and writes through the store's actions. The bucket is forgotten when the owner's `signal` aborts, which is when the tab record is gone.
 
 <a id="addresses"></a>
@@ -40,7 +40,7 @@ A tab's address is `dsh-resource://file/session/<sessionId>/<path relative to th
 
 The body reads its record, navigation and lifetime through `useTabInfo().tab`. Metadata and content come from different places:
 
-- `useResource<'file'>(tab.contentId)`, the standard hook from `@deepseek-ai/dsh-client-resources`, yields `{ version, bytes, changed }` from the `file` provider in `@deepseek-ai/dsh-api-workspace-files`. The body reads `changed` and the failed state: when the agent wrote the file after the last `stat`, a bar announces it with a reload button, and when the resource is `failed` — the file gone, or the Host refusing it — a failure bar takes that place with the failure's line and the same reload button, ahead of any pending `changed`. Either way the pages already read stay on screen: the text is never replaced under the reader.
+- `useResource<'file'>(tab.contentId)`, the standard hook from `@deepseek-ai/dsh-client-resources`, yields `{ absolutePath, version, bytes, changed }` from the `file` provider in `@deepseek-ai/dsh-api-workspace-files`. The body reads `changed` and the failed state: when the agent wrote the file after the last `stat`, a bar announces it with a reload button, and when the resource is `failed` — the file gone, or the Host refusing it — a failure bar takes that place with the failure's line and the same reload button, ahead of any pending `changed`. Either way the pages already read stay on screen: the text is never replaced under the reader.
 - Pages come from `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`, bound in `rpc.ts` and called by the face with the session and path the address names. The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next until `eof`. Each page carries its line count (`lines`), which is how one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted — the walk restarts from the first page, so the body never mixes two versions. A failed page shows one sentence per `workspace-file/*` code (`not-found`, `outside-workspace`, `too-large` for a page over the byte cap, `not-text`, `not-regular-file`) or the transport's own message, with a retry for the same page.
 - **Reload** — the change bar's button and the header's reload control both call the resource's `reload()` (a fresh `stat`, which clears `changed`) and the face's `reloadPages` (drop the pages, read the first one again). A reload retires the reads still in flight — the face keeps a request generation per tab, and a page settling from an older generation writes nothing. The scroll offset is kept, so the reader stays where they were.
 

+ 2 - 2
packages/client/ui-sidebar-textpreview/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 ## 注册了什么
 
 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-textpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其余一切落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。
-- **体** —— keyed 坑位 `sidebar.right.pane.tab`,键为类型的 id。它的头部行显示文件路径,末端是类型的两个控件:换行开关(默认开;长行折行直到读者关掉它,按 tab 记)与重新读取按钮。Sidebar 的 tab 条不承载这个类型的任何控件。体占满 pane 体的全部高度:头部行不动,其下的文件体是唯一的滚动者,于是短文件不留没有样式的空白,长文件在固定的路径下滚动。
+- **体** —— keyed 坑位 `sidebar.right.pane.tab`,键为类型的 id。它的头部行显示 Host 的绝对路径,并在提示中保留完整值,末端是类型的两个控件:换行开关(默认开;长行折行直到读者关掉它,按 tab 记)与重新读取按钮。Sidebar 的 tab 条不承载这个类型的任何控件。体占满 pane 体的全部高度:头部行不动,其下的文件体是唯一的滚动者,于是短文件不留没有样式的空白,长文件在固定的路径下滚动。 元数据尚未提供绝对路径时,头部使用请求路径。
 - **一个 store 与一个 face**,会话作用域、按 tab id 分桶。store 持有已读的页(以每页起始的 1 起行号为键,连同它们所属的文件版本)、文件末尾标志、进行中的读取或其失败,以及视图:滚动位置、换行(初始为开)、体最近答过的导航 revision。face(`loadPage`、`reloadPages`)执行读取并经 store 的 action 写入。owner 的 `signal` abort 时——即 tab 记录消失时——桶被忘掉。
 
 <a id="addresses"></a>
@@ -40,7 +40,7 @@ tab 的地址是 `dsh-resource://file/session/<sessionId>/<相对该会话工作
 
 正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。元数据与内容来自不同的地方:
 
-- `useResource<'file'>(tab.contentId)`——来自 `@deepseek-ai/dsh-client-resources` 的标准 hook——从 `@deepseek-ai/dsh-api-workspace-files` 的 `file` 提供方得到 `{ version, bytes, changed }`。体读 `changed` 与失败态:agent 在上次 `stat` 之后写了文件时,一条提示带着重新载入按钮出现;资源为 `failed` 时——文件没了,或 Host 拒绝——一条失败条占据同一位置,显示失败句与同一个重新载入按钮,并优先于尚未处理的 `changed`。两种情况下已读的页都留在屏幕上:正文绝不在读者眼前被替换。
+- `useResource<'file'>(tab.contentId)`——来自 `@deepseek-ai/dsh-client-resources` 的标准 hook——从 `@deepseek-ai/dsh-api-workspace-files` 的 `file` 提供方得到 `{ absolutePath, version, bytes, changed }`。体读 `changed` 与失败态:agent 在上次 `stat` 之后写了文件时,一条提示带着重新载入按钮出现;资源为 `failed` 时——文件没了,或 Host 拒绝——一条失败条占据同一位置,显示失败句与同一个重新载入按钮,并优先于尚未处理的 `changed`。两种情况下已读的页都留在屏幕上:正文绝不在读者眼前被替换。
 - 页来自 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`,在 `rpc.ts` 绑定、由 face 以地址所命名的会话与路径调用。首次挂载读第一页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`。每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用——从第一页重新走一遍,于是体永不混合两个版本。失败的页按 `workspace-file/*` 错误码各显示一句(`not-found`、`outside-workspace`、超过字节上限的页 `too-large`、`not-text`、`not-regular-file`)或传输层自己的消息,并带一个重读同一页的重试。
 - **重新载入** —— 变更提示条的按钮与头部的重新读取控件都调用资源的 `reload()`(重新 `stat`,清掉 `changed`)与 face 的 `reloadPages`(丢掉所有页,重读第一页)。重载淘汰仍在飞的读取——face 按 tab 记请求代次,旧代次结算的页什么也不写。滚动位置保留,读者停在原处。
 

+ 2 - 1
packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx

@@ -168,6 +168,7 @@ export function TextPreview({
     )
   }
   const next = loadedThrough + 1
+  const displayPath = meta.value?.absolutePath ?? file.path
   // Reload does two things at once: stat again through the resource (which
   // clears `changed`, or a failed frame) and read the pages again through the face.
   const reload = (): void => { meta.reload(); reloadPages(tab.id, file, signal) }
@@ -203,7 +204,7 @@ export function TextPreview({
           </p>
         )}
       <div className={css.header}>
-        <div className={css.path} title={file.path}>{file.path}</div>
+        <div className={css.path} title={displayPath} data-textpreview-path>{displayPath}</div>
         <button
           type="button"
           className={clsx(css.tool, state.wrap && css.toolOn)}

+ 3 - 2
packages/client/ui-sidebar-textpreview/tests/fixtures.client.ts

@@ -28,6 +28,7 @@ export const TAB_ID = 'tab-1' as TabId
 export const SESSION = 's-1' as SessionId
 /** The path relative to the session's workspace root, as the Host receives it. */
 export const PATH = 'work/notes.md'
+export const ABSOLUTE_PATH = '/host/project/work/notes.md'
 /** The tab's address: the file under this session's scope. */
 export const ADDRESS = 'dsh-resource://file/session/s-1/work/notes.md'
 /** What the address names, as the face receives it. */
@@ -35,7 +36,7 @@ export const FILE: SessionFile = { sessionId: SESSION, path: PATH }
 
 /** One page the Host would return: the lines joined without a terminator, and their count. */
 export function page(offset: number, lines: readonly string[], eof: boolean, version = 'v1'): RemoteResult<WorkspaceFileText> {
-  return { ok: true, value: { absolutePath: PATH, version, offset, text: lines.join('\n'), lines: lines.length, eof, bytes: 100 } }
+  return { ok: true, value: { absolutePath: ABSOLUTE_PATH, version, offset, text: lines.join('\n'), lines: lines.length, eof, bytes: 100 } }
 }
 
 /** One failed page read. */
@@ -45,7 +46,7 @@ export function failure(code: string, details: Record<string, unknown> = {}): Re
 
 /** The `file` resource's metadata: live, or failed beside the last live value. */
 function meta(changed: boolean, failure: RemoteFailure | undefined, reload: () => void): ResourceSnapshot<WorkspaceFileResource> {
-  const value: WorkspaceFileResource = { version: 'v1', bytes: 100, changed }
+  const value: WorkspaceFileResource = { absolutePath: ABSOLUTE_PATH, version: 'v1', bytes: 100, changed }
   return failure === undefined
     ? { status: 'live', value, failure: undefined, reload }
     : { status: 'failed', value, failure, reload }

+ 30 - 1
packages/client/ui-sidebar-textpreview/tests/text-preview.client.spec.tsx

@@ -13,7 +13,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import { TextPreview } from '../src/client/TextPreview.tsx'
-import { ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
+import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
 
 const LINE_HEIGHT = 20
 
@@ -67,6 +67,35 @@ function click(container: HTMLElement, selector: string): void {
 }
 
 describe('TextPreview — pages', () => {
+  it.each([ABSOLUTE_PATH, 'C:\\work\\project\\notes.md', '\\\\host\\share\\notes.md'])(
+    'shows the Host path %s in the header and tooltip even when text cannot be read',
+    async (absolutePath) => {
+      const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
+      h.useResource.mockReturnValue({
+        status: 'live', value: { absolutePath, version: 'v1', changed: false }, failure: undefined, reload: h.reload,
+      })
+      const view = render(<TextPreview {...h.props()} />)
+      await settle()
+      const path = view.container.querySelector('[data-textpreview-path]')
+      expect(path?.textContent).toBe(absolutePath)
+      expect(path?.getAttribute('title')).toBe(absolutePath)
+      expect(h.read).toHaveBeenCalledWith(SESSION, PATH, 1, h.controller.signal)
+    },
+  )
+
+  it('shows the requested path until Host metadata supplies its absolute path', async () => {
+    const h = harness({ 1: page(1, ['one'], true) })
+    const metadata = h.useResource()
+    h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined, reload: h.reload })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(PATH)
+    h.useResource.mockReturnValue(metadata)
+    view.rerender(<TextPreview {...h.props()} />)
+    expect(view.container.querySelector('[data-textpreview-path]')?.textContent).toBe(ABSOLUTE_PATH)
+    expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH)
+  })
+
   it('reads the first page on first mount and draws its lines, offering the next', async () => {
     const h = harness({ 1: page(1, ['one', 'two', 'three'], false) })
     const view = render(<TextPreview {...h.props()} />)

+ 6 - 0
snapshots/web/seeded-history/file-preview.expected.md

@@ -0,0 +1,6 @@
+- text: {{cwd}}/a.txt
+- button "Wrap lines" [pressed]:
+  - img
+- button "Read the file again":
+  - img
+- text: alpha