Просмотр исходного кода

Merge master to restore required CI scheduling

pku-xht 2 недель назад
Родитель
Сommit
e8a5e9dfd9
100 измененных файлов с 1674 добавлено и 822 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml
  2. 10 12
      .agents/notes/implemented/architecture/2026-09-05-client-resource-model.md
  3. 10 12
      .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. 32 32
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
  6. 32 32
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  8. 39 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  9. 39 0
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml
  11. 29 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
  12. 29 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml
  14. 2 0
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
  15. 2 0
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  17. 13 10
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  18. 13 10
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  19. 6 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml
  20. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md
  21. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md
  22. 1 0
      THIRD_PARTY_NOTICES.md
  23. 6 4
      apps/web/tests/details-session-lifecycle.e2e.ts
  24. 395 0
      apps/web/tests/document-preview.e2e.ts
  25. 2 0
      apps/web/tests/seeded-history.e2e.ts
  26. 56 52
      apps/web/tests/sidebar-right.e2e.ts
  27. 1 0
      apps/web/tsconfig.json
  28. 2 2
      docs/config-catalog.i18n.yaml
  29. 4 2
      docs/config-catalog.md
  30. 4 2
      docs/config-catalog.zh.md
  31. 2 2
      docs/module-graph.i18n.yaml
  32. 2 2
      docs/module-graph.md
  33. 2 2
      docs/module-graph.zh.md
  34. 2 2
      docs/subsystems/client-resources.i18n.yaml
  35. 6 9
      docs/subsystems/client-resources.md
  36. 6 9
      docs/subsystems/client-resources.zh.md
  37. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  38. 25 14
      docs/subsystems/sidebar-right.md
  39. 25 14
      docs/subsystems/sidebar-right.zh.md
  40. 2 2
      docs/subsystems/workspace.i18n.yaml
  41. 25 6
      docs/subsystems/workspace.md
  42. 25 6
      docs/subsystems/workspace.zh.md
  43. 2 2
      packages/api/workspace-files/README.i18n.yaml
  44. 18 16
      packages/api/workspace-files/README.md
  45. 18 16
      packages/api/workspace-files/README.zh.md
  46. 0 2
      packages/api/workspace-files/package.json
  47. 9 42
      packages/api/workspace-files/src/client/change-feed.ts
  48. 6 13
      packages/api/workspace-files/src/client/index.ts
  49. 22 53
      packages/api/workspace-files/src/client/provider.ts
  50. 7 25
      packages/api/workspace-files/src/client/types.ts
  51. 62 29
      packages/api/workspace-files/src/index.ts
  52. 1 1
      packages/api/workspace-files/src/types.ts
  53. 5 11
      packages/api/workspace-files/tests/apply.client.spec.ts
  54. 7 19
      packages/api/workspace-files/tests/change-feed.client.spec.ts
  55. 2 2
      packages/api/workspace-files/tests/changes.spec.ts
  56. 1 0
      packages/api/workspace-files/tests/harness.ts
  57. 25 56
      packages/api/workspace-files/tests/host-path.client.spec.ts
  58. 43 138
      packages/api/workspace-files/tests/provider.client.spec.ts
  59. 113 0
      packages/api/workspace-files/tests/read-all.spec.ts
  60. 5 4
      packages/api/workspace-files/tests/read-bytes.spec.ts
  61. 19 9
      packages/api/workspace-files/tests/read.spec.ts
  62. 3 4
      packages/api/workspace-files/tests/stat.spec.ts
  63. 0 1
      packages/api/workspace-files/tsconfig.client.json
  64. 4 4
      packages/bundle/web-app/cordis.patch.yml
  65. 1 1
      packages/bundle/web-app/package.json
  66. 1 1
      packages/client/AGENTS.md
  67. 2 2
      packages/client/resources/README.i18n.yaml
  68. 3 4
      packages/client/resources/README.md
  69. 3 4
      packages/client/resources/README.zh.md
  70. 0 7
      packages/client/resources/src/client/contract.ts
  71. 9 15
      packages/client/resources/src/client/resources.ts
  72. 69 2
      packages/client/resources/tests/apply.client.spec.ts
  73. 95 17
      packages/client/resources/tests/resources.client.spec.ts
  74. 1 1
      packages/client/ui-chat/package.json
  75. 4 4
      packages/client/ui-chat/src/client/apply.ts
  76. 2 2
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  77. 1 1
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  78. 1 1
      packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
  79. 1 1
      packages/client/ui-chat/tsconfig.json
  80. 1 1
      packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
  81. 1 1
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  82. 1 1
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  83. 1 1
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  84. 1 1
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  85. 1 1
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  86. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  87. 1 1
      packages/client/ui-dockkit/README.md
  88. 1 1
      packages/client/ui-dockkit/README.zh.md
  89. 4 1
      packages/client/ui-dockkit/src/components/DockSurface.tsx
  90. 16 12
      packages/client/ui-dockkit/src/components/FloatLayer.tsx
  91. 14 10
      packages/client/ui-dockkit/src/components/TabMenu.tsx
  92. 20 16
      packages/client/ui-dockkit/src/components/TabPanel.tsx
  93. 4 0
      packages/client/ui-dockkit/src/components/dockkit.module.css
  94. 2 0
      packages/client/ui-dockkit/src/components/render.ts
  95. 1 1
      packages/client/ui-dockkit/src/contract/adapter.ts
  96. 108 9
      packages/client/ui-dockkit/tests/components.client.spec.tsx
  97. 1 1
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  98. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  99. 1 1
      packages/client/ui-primitives/README.md
  100. 1 1
      packages/client/ui-primitives/README.zh.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: 75502ffc91af049bf89b7c36ec6ae3dc1339a5f8
-2026-09-05-client-resource-model.zh.md: d1430e88fc16b46a6ad32bbeacb1d59e0a7f6131
+2026-09-05-client-resource-model.md: e6ad8dae331d8a6392585f5dc8eb8fc52150bdc3
+2026-09-05-client-resource-model.zh.md: d716aa8a42f5c3037a329deabd572a56ab1b0537

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

@@ -8,7 +8,7 @@ English | [中文](2026-09-05-client-resource-model.zh.md)
 
 A right-Sidebar tab body, a chat card, or any other slot component often needs live data it knows only by address: the file an agent just wrote, later a chat node or a terminal. Before the resource model each consumer fetched for itself — the text preview owned its own Remote call and refresh loop — so every mount re-read, two components showing one file held two copies, switching tabs unmounted the body and lost its content, and each new kind of content meant a new bespoke hook.
 
-The tab record set the constraint. A tab must survive undo, redo, reload, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data.
+The tab record set the constraint. A tab must survive undo, redo, body remount, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. Browser-page reload resets the memory-only Sidebar state. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data.
 
 ## Decision
 
@@ -16,7 +16,7 @@ The tab record set the constraint. A tab must survive undo, redo, reload, and ho
 
 ### Addresses
 
-A resource address is a `dsh-resource://<type>/…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>`, with `session/<sessionId>` naming the session whose root resolves the file, or `dsh-resource://file/absolute/<absolute path>`, which carries no session and is read through the current one ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)).
+A resource address is a `dsh-resource://<type>/…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session/<sessionId>/<path>`, with `session/<sessionId>` naming the Session whose Host workspace resolves the relative or absolute path ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)).
 
 ### The service
 
@@ -30,32 +30,30 @@ interface Resources {
 interface ResourceProvider<P extends ResourceProtocol> {
   readonly protocol: P
   open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
-  reload?(address: string): void
 }
 
 interface ResourceSnapshot<Value> {
   readonly status: 'none' | 'loading' | 'live' | 'failed'
   readonly value: Value | undefined
   readonly failure: RemoteFailure | undefined
-  readonly reload: () => void
 }
 
 type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
 ```
 
-`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileResource`); the resources package re-exports the type.
+`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileStat`); the resources package re-exports the type.
 
 ### The hook
 
-`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource<P>(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its 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 when the protocol has no provider or no `reload`.
+`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource<P>(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value.
 
 ### 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 `{ absolutePath, 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? }`, and a consumer reads content itself through the [Workspace Files service](2026-09-05-workspace-files-service.md).
 
 ### Lifecycle
 
-One record exists per address. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render.
+One record exists per address. Holds start and stop observation, not the underlying file or Session. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render.
 
 The right Sidebar's Tab domain pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream and switching back reads the latest value; a record restored by undo is a new pin, and a resource the model already let go is read again ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). `openResource(address)` accepts resource addresses only; pages such as the guide and the file tree are opened by kind and never enter the resource model.
 
@@ -63,7 +61,7 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco
 
 **Session-bound resources: `useResource` on the session kit and a `(session, address)` identity.** The first form. Rejected because a file is not a session concern — the session is only who authorizes the path — and because the model must serve protocols and components outside any session scope. Identity became the address alone, the scope moved into the address grammar, and the hook moved to the global kit.
 
-**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer reads content by page, which is also what lets one open tab hold a multi-megabyte file at the cost of one page.
+**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer chooses how much content to read and retain.
 
 **Failure as a thrown error, wrapping a non-`RemoteFailure` throw as `gateway/internal`.** Rejected: the Remote face never rejects, so anything a provider throws is a bug, and wrapping it would be a fallback that hides the bug from the developer who caused it. A failure is an `ok: false` frame; a throw surfaces.
 
@@ -71,17 +69,17 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco
 
 **A hand-parsed scheme prefix instead of the URL parser.** The first `protocolOf` matched a regular expression for the scheme. Rejected once addresses were URLs: the parser already decides validity and case, and a string it rejects should read as "no protocol" rather than be half-parsed.
 
-**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is owner props on the tab plus one client-wide `useResource` keyed by address.
+**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is the framework-bound `useTabInfo` on the tab plus one client-wide `useResource` keyed by address.
 
 ## Consequences
 
-Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, reload, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration.
+Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, body remount, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration.
 
 The costs are recorded here so they are not rediscovered. Records are never reclaimed: memory grows with the number of distinct addresses ever read, not with reads. Abort compliance rests with the provider; the model drops what a released stream still yields but cannot stop a provider that ignores the signal before its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call has to mint one. A navigation address or a malformed string reads as `none` rather than an error, which keeps mixed address lists cheap to render but gives a misspelled protocol no diagnostic beyond the missing value.
 
 ## Testing
 
-`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, failure frames beside the last value, and `reload` forwarding. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook.
+`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, and failure frames beside the last value. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook.
 
 ## Deferred
 

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

@@ -8,7 +8,7 @@ Status: implemented
 
 右侧 Sidebar 的 tab 正文、聊天卡片或任何别的 slot 组件,常常需要只以地址可知的活数据:agent 刚写的文件,将来的聊天节点或终端。资源模型出现前每个消费方各自取数——文本预览自己持有 Remote 调用与刷新循环——于是每次挂载都重读、两个组件显示同一文件就持有两份、切 tab 卸载正文就丢内容,每种新内容都意味着一个新的专用 hook。
 
-约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、刷新与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。
+约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、正文重挂载与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。浏览器页面刷新会重置只在内存中的 Sidebar 状态。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。
 
 ## Decision
 
@@ -16,7 +16,7 @@ Status: implemented
 
 ### 地址
 
-资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>`,`session/<sessionId>` 命名以其根解析该文件的会话;或 `dsh-resource://file/absolute/<绝对路径>`,不带会话、经当前会话读取([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。
+资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session/<sessionId>/<path>`,`session/<sessionId>` 命名由其 Host 工作区解析相对或绝对路径的 Session([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。
 
 ### 服务
 
@@ -30,32 +30,30 @@ interface Resources {
 interface ResourceProvider<P extends ResourceProtocol> {
   readonly protocol: P
   open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
-  reload?(address: string): void
 }
 
 interface ResourceSnapshot<Value> {
   readonly status: 'none' | 'loading' | 'live' | 'failed'
   readonly value: Value | undefined
   readonly failure: RemoteFailure | undefined
-  readonly reload: () => void
 }
 
 type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
 ```
 
-`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileResource`);resources 包再导出这个类型。
+`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileStat`);resources 包再导出这个类型。
 
 ### hook
 
-`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource<P>(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作。
+`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource<P>(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。
 
 ### 帧
 
-提供方产出 `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)按页读内容。
+提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)读内容。
 
 ### 生命周期
 
-每个地址一条记录。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。
+每个地址一条记录。持有只控制观察的启停,不控制底层文件或 Session 的生灭。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。
 
 右侧 Sidebar 的 Tab 域在每条打开的 tab 记录存续期内钉住其地址,所以切 tab 卸载正文不关流、切回读到最新值;撤销恢复的记录是一次新的钉住,模型已放掉的资源会重新读取([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。`openResource(address)` 只收资源地址;引导页与文件树这类页面按 kind 打开,从不进入资源模型。
 
@@ -63,7 +61,7 @@ type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnap
 
 **会话绑定的资源:`useResource` 挂会话标准件、身份为 `(session, address)`。** 第一版形态。被否,因为文件不是会话的事——会话只是路径的授权者——而且模型必须服务会话作用域之外的协议与组件。身份改为只有地址,作用域进入地址语法,hook 移到全局标准件。
 
-**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方按页读内容,这也是一个打开的 tab 能以一页的代价承载数兆字节文件的原因。
+**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方选择读取并保留多少内容。
 
 **以抛错表达失败,并把非 `RemoteFailure` 的抛出包装成 `gateway/internal`。** 被否:Remote 面从不 reject,所以提供方抛出的任何东西都是 bug,包装它就是把 bug 藏起来不让肇事者看见的 fallback。失败是 `ok: false` 帧;抛出就冒出来。
 
@@ -71,17 +69,17 @@ type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnap
 
 **手写 scheme 前缀解析代替 URL 解析器。** 第一版 `protocolOf` 用正则匹配 scheme。地址成为 URL 后被否:解析器已经决定合法性与大小写,它拒绝的字串应读作「无协议」而不是被解析一半。
 
-**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上的 owner props 加一个按地址的客户端级 `useResource`。
+**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上框架绑定的 `useTabInfo` 加一个按地址的客户端级 `useResource`。
 
 ## Consequences
 
-任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、刷新或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。
+任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、正文重挂载或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。
 
 代价记录在此以免被重新发现。记录不回收:内存随读过的不同地址数增长,而非随读取次数增长。中止合规归提供方;模型会丢弃已释放的流仍产出的帧,却阻止不了忽略信号的提供方跑到下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。导航地址或畸形字串读作 `none` 而非报错,这让混合地址列表渲染起来便宜,却让拼错的协议除了缺值之外没有任何诊断。
 
 ## Testing
 
-`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存、`reload` 转发。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。
+`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。
 
 ## Deferred
 

+ 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: a95e083f8cea57b957a2c060fe1b7f2b76050153
-2026-09-05-workspace-files-service.zh.md: 59c638157f3e65efe3b89b220401434d1f2370b0
+2026-09-05-workspace-files-service.md: 39b73b71517934cf3007f042ac58061f655d6b85
+2026-09-05-workspace-files-service.zh.md: e4769a44a3517dffe36003e93cdeb3b258d6b443

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

@@ -8,30 +8,32 @@ English | [中文](2026-09-05-workspace-files-service.zh.md)
 
 The Web client needs to look at files inside a session's workspace from a browser that may not be on the Host machine: a file the agent produced, the path a `read` tool row names, later a file tree and previews of files that are neither small nor text. The one endpoint that read a workspace file over the wire lived on the Session Controller as `workspace-file.ts`, beside session lifecycle it had nothing to do with. It returned a whole file under one total byte cap, so a large log could not be looked at even in part and a binary could not be looked at at all; it had no `stat`, no listing, and no change signal, so a preview could not learn that the agent had rewritten the file without re-reading it; and its result named the file by a Host `url`, a spelling nothing on the Client used as an address.
 
-Two constraints frame any answer. Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fences writes and edits only and says so — so a web-facing read endpoint must own every fence itself, and the fences must survive a symlink that leaves the workspace, which a string-prefix test cannot see. And `dsh-fs` exposed one raw-byte read, `readBytes(target, signal, maxBytes)`, which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file.
+Two constraints frame the service. File reads through `ctx.fs` use the Session's composed filesystem backend, whose read authority may extend outside the workspace, while directory-tree and change-feed consumers are workspace-rooted. The service preserves the backend's read decisions for regular files while enforcing file-kind and bounded-buffer checks; `list` and `changes` retain workspace containment. And `dsh-fs` exposed one raw-byte read, `readBytes(target, signal, maxBytes)`, which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file.
 
 ## Decision
 
-`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. Every method confines itself to the workspace root the sandbox policy resolves for the addressed session, names files by their absolute path in the filesystem's execution world, and pages or windows content so that no method ever buffers a whole file. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code.
+`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. File methods resolve relative paths from the workspace root but inherit the Session filesystem backend's read authority; `list` and `changes` remain workspace-scoped. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns this split and its security consequences. Results name files by their absolute path in the filesystem's execution world, and content is bounded by page, byte window, or complete-file cap. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code.
 
 ### Package topology
 
-[dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes this note's choice of separate Host and Client packages; the file service, authorization, paging, and change-feed decisions here remain in force. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry.
+[dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes this note's choice of separate Host and Client packages; the file service, paging, and change-feed decisions here remain in force. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) supersedes the original workspace-containment choice for file methods. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry.
 
 | Face | Package | Files | Depends on |
 |---|---|---|---|
 | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` |
 | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` |
 
-`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.
+`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.
 
 ### The `workspaceFiles` Remote namespace
 
-Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The five signatures, as `src/index.ts` declares them:
+Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The seven signatures, as `src/index.ts` declares them:
 
 ```ts ignore-check
 @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
 @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@@ -40,25 +42,26 @@ Every Host method takes the target `Agent` first, resolved by the Gateway from t
 - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only.
 - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream.
 - **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`.
+- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics.
 - **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered.
 - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation.
 
 ### Paths on the wire
 
-Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd.
+Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd.
 
 `version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end.
 
-### The four gates
+### File checks and workspace containment
 
-Every `read`, `readBytes`, `stat`, and `list` passes four gates in order, and the constraints are the service's own because the filesystem does not confine reads. The path is inspected before containment is decided, so a caller learns whether an outside path exists and what kind it is before `outside-workspace` refuses it; that is accepted because the caller is the Session's own owner, who can already read the Host through the Agent.
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
 
 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`.
-2. **Containment.** The path resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way (the session's cwd, falling back to the policy's configured root). A `..` traversal or an absolute path outside the root is `outside-workspace`. A string-prefix comparison is never used: `resolve` realpaths, so a prefix test cannot see a link that leaves the root.
-3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so.
+2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
+3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`.
 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected.
 
-After the gates the file methods `stat` the target once more, because the file may have gone or changed kind between the inspection and the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. The gate order has one visible consequence: an entry outside the root whose type already disqualifies it reports its kind, not its position.
+After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position.
 
 ### Failures
 
@@ -67,20 +70,20 @@ Each failure is one `RemoteError` code with typed details, declared beside the t
 | Code | When | Details |
 |---|---|---|
 | `workspace-file/not-found` | no entry at the path, or the file vanished after the gates | `{ path }` |
-| `workspace-file/outside-workspace` | the resolved target is not inside the workspace root | `{ path }` |
-| `workspace-file/too-large` | a page's text or a requested byte window exceeds `maxBytes` | `{ path, limit }` |
+| `workspace-file/outside-workspace` | a `list` target is not inside the workspace root | `{ path }` |
+| `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` |
 | `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` |
-| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
+| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
 | `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` |
 | `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` |
-| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address with no current Session | `{ address }` |
+| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` |
 | `gateway/bad-request` | an empty path, or an `offset`, `limit`, or `length` that is not an integer in range | `{}` |
 
 The set is append-only: a code may be added, and none is renamed or removed, because consumers branch on these strings across the wire.
 
 ### Configuration
 
-Three fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries. The file itself has no size cap: a caller pages or windows through it.
+Four fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries; `maxFileBytes` (default 33,554,432, 32 MiB) caps complete and related-file reads. Paged and windowed reads impose no whole-file size cap.
 
 ### The `readByteRange` seam in `dsh-fs`
 
@@ -96,27 +99,27 @@ It returns the bytes at `[offset, offset + length)`, shorter when the file ends
 
 ### The Client `file` provider
 
-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 Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The Document Preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`.
 
-- **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.
+- **The value is metadata**, `WorkspaceFileStat { absolutePath, version, bytes? }`; 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 compares versions to know when they are stale; freshness and refresh belong to each consumer, not the shared observation.
+- **The address names the file; its scope selects the Session.** A `session` address's relative or absolute path reaches the Host unchanged; the workspace root supplies the base for a relative path, and the Session filesystem backend decides read access. Client cwd is not a prerequisite. An `absolute` address has no Session and fails with `workspace-file/unknown-workspace`; no current or tab Session is borrowed. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream.
+- **The frames.** The first frame is a `stat` 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 that version 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, gone is a `not-found` frame with the previous value left for display. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file 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.
 - **Navigation parameters.** `SidebarRightResourceParamsMap.file` is `WorkspaceFileParams { line?: number }`, a 1-based line to reveal. A line travels as a navigation parameter and not as part of the address, because the file is one piece of content whether it opens at the top or at line 400.
 
 ### Related notes
 
-The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource://<type>/…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`.
+The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource://<type>/…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness.
 
 ## Alternatives considered
 
-**Keeping the workspace file endpoint on the Session Controller.** The first form: one `read` under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading, statting, listing, and observing files inside a workspace root — and everything that queries workspace files belongs to it, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose.
+**Keeping the workspace file endpoint on the Session Controller.** The first form: one `read` under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading and statting files plus listing and observing the workspace — and those queries belong together, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose.
 
 **A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries.
 
 **Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises.
 
-**Log-reachable authorization for the read.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. Enough for produced files, but a typed path or a directory tree could never open. Path containment inside the workspace root was chosen, with the endpoint owning the constraints the filesystem's unconfined reads do not, and containment decided by `fs.contains` on resolved targets so a symlink cannot escape it.
+**Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations.
 
 **Whole-file read and slice for the byte window.** The interim form of `readBytes` read the file from its start to the window's end through `readBytes(target, signal, offset + length)` and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report `eof: false`, which contradicts the reason the method exists. Rejected in favour of the `readByteRange` seam, whose bound is the window.
 
@@ -124,28 +127,25 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
 
 **A default `readByteRange` in the `FileSystem` base class.** A non-abstract default over `readBytes` would have spared the test doubles a method but could only be implemented by reading the whole file up to the window's end, the very behaviour rejected above, or by passing an unbounded cap. Abstract, with every provider and double implementing it.
 
-**String-prefix containment.** Comparing resolved path strings against the root is simpler than `fs.contains`, but `resolve` realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment.
+**String-prefix containment for workspace operations.** Comparing resolved path strings against the root is simpler than `fs.contains`, but `resolve` realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment for `list` and `changes`.
 
 ## Consequences
 
 - Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate.
-- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host and never a whole file; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines.
+- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce `maxFileBytes`; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines.
 - Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks.
-- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. An address built from another spelling of the same file — a workspace root reached through a symlink — opens and stats it, but its change frames never match, so `changed` stays false until a reload.
+- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. A follower binds to successful `stat.absolutePath`, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key.
 - Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version.
-- The gate order reports kind before position, a page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory, because a generation's queue is unbounded; each is a known trade-off recorded in the package README.
+- File-kind inspection precedes backend reads, and `list` reports kind before an outside position. A page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README.
 - The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action.
-- `readBytes` has no shipped consumer yet: it is the wire form the image and binary previews build on.
+- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats.
 
 ## Testing
 
-Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, `list` with truncation, symlink children, and `not-directory`, the `changes` stream driven by `fs/observed` and filtered by root, and every gate and code against a real local backend, because a fake filesystem would let a prefix test pass the symlink case the gate exists to catch. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, reload, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
+Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, outside-workspace reads and backend refusals, `list` with containment, truncation, symlink children, and `not-directory`, and the `changes` stream driven by `fs/observed` and filtered by root. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. `readAll` and `readRelated` specs cover complete-read caps, outside base and related paths, and Host backend authorization. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
 
 ## Deferred
 
-- A web e2e chain through the Sidebar: open a file, have the agent write it, see `changed`, reload.
-- Aliasing a follower under the Host's canonical spelling once the first `stat` reveals it, so a symlinked workspace root still receives change frames.
 - A bound on a `changes` generation's queue.
 - The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only.
 - Scopes other than `session` in the file address; the grammar leaves room, the provider serves one.
-- Reload delivery per record: today `reload` re-stats every follower of the file's absolute path in the session, so two records naming one file — a `session` and an `absolute` address, or two readers with different addresses — clear each other's `changed` flag.

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

@@ -8,30 +8,32 @@ Status: implemented
 
 Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工作区里的文件:agent 产出的文件、`read` 工具行点名的路径,之后还有文件树,以及既不小也不是文本的文件预览。唯一一个经线路读取工作区文件的端点以 `workspace-file.ts` 住在 Session Controller 上,与它毫无关系的会话生命周期为邻。它在一个总字节上限之下返回整个文件,因此大日志连一部分都看不了、二进制根本看不了;它没有 `stat`、没有列举、没有变更信号,预览不重读就无法得知 agent 已改写文件;其结果还以 Host 的 `url` 命名文件,而 Client 上没有任何东西把这种拼法当地址用。
 
-两个约束框定了任何答案。经 `ctx.fs` 的读取是有意不受限的——沙箱后端只围栏写与编辑,并明说了这一点——所以面向 web 的读端点必须自己拥有每一道围栏,而且围栏必须经得住一条离开工作区的符号链接,这是字符串前缀测试看不见的。另外 `dsh-fs` 只暴露一种原始字节读取 `readBytes(target, signal, maxBytes)`,它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。
+两个约束框定了这项服务。经 `ctx.fs` 的文件读取使用 Session 组合后的文件系统后端,其读取权限可能延伸到工作区外,而目录树与变更流消费方以工作区为根。服务为普通文件保留后端的读取决策,同时执行文件类型与有界缓冲检查;`list` 与 `changes` 保留工作区包含限制。另外 `dsh-fs` 只暴露一种原始字节读取 `readBytes(target, signal, maxBytes)`,它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。
 
 ## Decision
 
-`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。每个方法都把自己限制在沙箱策略为被寻址会话解析出的工作区根内,以文件在文件系统执行环境中的绝对路径命名文件,并对内容分页或开窗,因此没有任何方法会缓冲整个文件。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。
+`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。文件方法从工作区根解析相对路径,但继承 Session 文件系统后端的读取权限;`list` 与 `changes` 仍限于工作区。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)拥有这一分层及其安全后果。结果以文件在文件系统执行环境中的绝对路径命名文件,内容则受页、字节窗口或整文件上限约束。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。
 
 ### 包拓扑
 
-[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、授权、分页和变更流约定保持不变。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。
+[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、分页和变更流约定保持不变。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)取代文件方法原有的工作区包含选择。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。
 
 | 面 | 包 | 文件 | 依赖 |
 |---|---|---|---|
 | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` |
 | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` |
 
-`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。
+`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。
 
 ### `workspaceFiles` Remote 命名空间
 
-每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。五个签名照 `src/index.ts` 的声明:
+每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。七个签名照 `src/index.ts` 的声明:
 
 ```ts ignore-check
 @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
 @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
 @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
 @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@@ -40,25 +42,26 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。
 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。
 - **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。
+- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。
 - **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。
 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。
 
 ### 线路上的路径
 
-离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。
+离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。
 
 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。
 
-### 四道关
+### 文件检查与工作区包含
 
-每次 `read`、`readBytes`、`stat` 与 `list` 依次过四道关,而这些约束是服务自己的,因为文件系统并不限制读取。路径先被检视再判定是否在工作区内,因此调用方在 `outside-workspace` 拒绝之前就能得知工作区外的路径是否存在、是何种类;这一点被接受,因为调用方就是 Session 的所有者,本来就能经 Agent 读 Host。
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
 
 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。
-2. **包含关系。** 路径解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`(会话 cwd,退而取策略配置的根)。`..` 爬出或根外绝对路径为 `outside-workspace`。从不使用字符串前缀比较:`resolve` 会取 realpath,前缀测试看不见离开根的链接。
-3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。
+2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
+3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。
 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。
 
-过关之后文件方法再对目标 `stat` 一次,因为在检查与读取之间文件可能已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。关的顺序有一个可见后果:根外条目若类型本身已不合格,报告的是其种类而不是其位置。
+路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。
 
 ### 失败
 
@@ -67,20 +70,20 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 | 代码 | 何时 | Details |
 |---|---|---|
 | `workspace-file/not-found` | 路径处无条目,或文件在过关后消失 | `{ path }` |
-| `workspace-file/outside-workspace` | 解析出的目标不在工作区根内 | `{ path }` |
-| `workspace-file/too-large` | 一页文本或所请求的字节窗口超过 `maxBytes` | `{ path, limit }` |
+| `workspace-file/outside-workspace` | `list` 的目标不在工作区根内 | `{ path }` |
+| `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` |
 | `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` |
-| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
+| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
 | `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` |
 | `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` |
-| `workspace-file/unknown-workspace` | Client 铸出:没有当前会话时的 `absolute` 地址 | `{ address }` |
+| `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` |
 | `gateway/bad-request` | 空路径,或不是范围内整数的 `offset`、`limit`、`length` | `{}` |
 
 这个集合只增不改不删:可以新增代码,但不重命名、不移除任何一个,因为消费方跨线路按这些字符串分支。
 
 ### 配置
 
-三个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限。文件本身没有大小上限:调用方分页或开窗读完它。
+四个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限;`maxFileBytes`(默认 33,554,432,即 32 MiB)限制全文及关联文件读取。分页和开窗读取不限制整个文件的大小。
 
 ### `dsh-fs` 中的 `readByteRange` seam
 
@@ -96,27 +99,27 @@ abstract readByteRange(target: FsTarget, range: { offset: number; length: number
 
 ### Client `file` 提供者
 
-Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。文本预览包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。
+Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。Document Preview 包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。
 
-- **值是元数据**,`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 则流静默结束。
+- **值是元数据**,`WorkspaceFileStat { absolutePath, version, bytes? }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并比较版本判断它们是否过时;新鲜度判断和刷新属于各消费方,不属于共享观察。
+- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对或绝对路径原样交给 Host;工作区根为相对路径提供基准,Session 文件系统后端决定读取权限。Client 不需要持有 cwd。`absolute` 地址不带 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 tab 所属 Session。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流。
+- **帧。** 第一帧是 `stat`或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生该版本、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是新元数据,不在则是保留上一个值供展示的 `not-found` 帧。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件会让资源恢复正常。中止 signal 则流静默结束。
 - **每会话一条 `changes` 订阅。** 首位跟随者打开 `remote.$stream`,最后一位离开时释放,后继流和插件拆除等待关闭完成。Client 接受 Host 的 `ready` 后才开始首次 `stat`;本地发出 WebSocket 请求不是 Host 确认。跟随者先按地址注册,缓冲路径未知期间的变更,成功 stat 后按返回的 `absolutePath` 过滤排队与实时帧,反斜杠归一为斜杠。尚未成功绑定时,Session 内任何写入均可触发重新 stat。载体掉线由 Gateway 监督器重连;Host 结束或终态失败会结束跟随者,并保留最近元数据,直到重新打开。
 - **导航参数。** `SidebarRightResourceParamsMap.file` 是 `WorkspaceFileParams { line?: number }`,即要显露的 1 起算行号。行号作为导航参数而不是地址的一部分传递,因为不论从顶部还是第 400 行打开,文件都是同一份内容。
 
 ### 相关记录
 
-[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。
+[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的七个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。
 
 ## Alternatives considered
 
-**把工作区文件端点留在 Session Controller 上。** 最初形态:总字节上限之下的一个 `read`,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——在工作区根内读取、stat、列举与观察文件——凡查询工作区文件的都归它,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。
+**把工作区文件端点留在 Session Controller 上。** 最初形态:总字节上限之下的一个 `read`,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——读取和 stat 文件,并列举和观察工作区——这些查询应归于一处,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。
 
 **带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。
 
 **经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。
 
-**读取的"日志可达"授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件。对产出文件够用,但手输的路径或目录树永远打不开。选择了工作区根内的路径包含,端点自行承担文件系统不受限读取所不具备的约束,并由 `fs.contains` 对已解析目标判定包含关系,使符号链接无法逃逸。
+**文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。
 
 **为字节窗口整文件读取再切片。** `readBytes` 的临时形态经 `readBytes(target, signal, offset + length)` 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 `eof: false`,与该方法存在的理由相悖。被否,改为以窗口为界的 `readByteRange` seam。
 
@@ -124,28 +127,25 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 
 **在 `FileSystem` 基类里给 `readByteRange` 一个默认实现。** 基于 `readBytes` 的非抽象默认能免去测试替身一个方法,但只能靠把文件从头读到窗口末端来实现——正是上文否决的行为——或者传一个无界上限。改为抽象方法,由每个提供者与替身实现。
 
-**字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。由文件系统判定包含关系。
+**工作区操作使用字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。`list` 与 `changes` 由文件系统判定包含关系。
 
 ## Consequences
 
 - 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。
-- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存、从不整文件;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。
+- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 `maxFileBytes` 约束;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。
 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。
-- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。由同一文件另一种拼法铸出的地址——经符号链接到达的工作区根——能打开并 stat 它,但其变更帧永不匹配,因此 `changed` 在刷新前保持 false。
+- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。
 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。
-- 关的顺序先报种类后报位置,页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
+- 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。
-- `readBytes` 尚无随包交付的消费方:它是图片与二进制预览赖以构建的线路形态。
+- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`。
 
 ## Testing
 
-`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、带截断、符号链接子项与 `not-directory` 的 `list`、由 `fs/observed` 驱动并按根过滤的 `changes` 流,以及针对真实本地后端的每道关与每个代码——因为假文件系统会让前缀测试放过这道关本为捕获的符号链接场景。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、刷新、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
+`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、工作区外读取及后端拒绝、带包含限制、截断、符号链接子项与 `not-directory` 的 `list`,以及由 `fs/observed` 驱动并按根过滤的 `changes` 流。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限、工作区外基准与关联路径,以及 Host 后端授权。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
 
 ## Deferred
 
-- 一条经 Sidebar 的 web e2e 链:打开文件、让 agent 写它、看到 `changed`、刷新。
-- 在首次 `stat` 揭示 Host 的规范拼法后为跟随者加别名,使经符号链接的工作区根也能收到变更帧。
 - 给 `changes` 一代流的队列加上限。
 - `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。
 - 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。
-- 按记录投递重载:今天 `reload` 重新 stat 该会话中此文件绝对路径的所有跟随者,因此命名同一文件的两条记录——`session` 与 `absolute` 地址,或地址不同的两个读者——会互相清掉 `changed` 标记。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-08-document-preview-operations.md
+2026-09-08-document-preview-operations.md: 316f33371d393846cce5b598ee23289cecd6c178
+2026-09-08-document-preview-operations.zh.md: 97759db32d139a44fe33fd2c5e2eb7ec0c8960fd

+ 39 - 0
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md

@@ -0,0 +1,39 @@
+# Agent Note: Document preview and file addresses
+
+Status: implemented
+
+English | [中文](2026-09-08-document-preview-operations.zh.md)
+
+## Problem
+
+File viewers need different loading policies and may offer several implementations for one extension. A change stream cannot also express an on-demand read without mixing live data with callable capabilities. HTML dependencies additionally need the Host's filesystem authorization and path resolution, not the browser's current directory.
+
+## Decision
+
+Document Preview separates resource observation from content reads. The [resource model](2026-09-05-client-resource-model.md) shares observations by address alone: `source(address)`, `pin(address, signal)`, and provider `open(address, { signal })` carry no consuming Session. Providers return `AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>`; `useResource` exposes only `{ status, value, failure }`. Holds start and stop observation, not the underlying file or Session. Content reads use ordinary injected Preview callbacks.
+
+[Workspace Files](../../../../packages/api/workspace-files/README.md) retains Host line reads, byte windows, bounded complete reads, and bounded reads relative to another file's directory. Its Client `file` provider observes only `stat` and `changes`, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. The Host resolves every path through the Session filesystem; file reads inherit that backend's read authority, while directory listing and change observation stay workspace-scoped.
+
+Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity.
+
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
+
+Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob.
+
+## Alternatives considered
+
+**Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads.
+
+**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview.
+
+**UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation.
+
+**File-reading methods on every resource.** Chat and terminal resources have independent data and operation semantics; only observation registration and lifetime are common.
+
+**A preview resource wrapper, content Session, or second resource Hook.** These duplicate addressing, cancellation, subscriptions, and ownership already provided by Resource and Workspace Files. Loading policy belongs to the preview owner.
+
+**A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support.
+
+## Consequences
+
+Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and lazy continuous PDF Worker rendering.

+ 39 - 0
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 文档预览与文件地址
+
+Status: implemented
+
+[English](2026-09-08-document-preview-operations.md) | 中文
+
+## 问题
+
+文件预览器需要不同的加载策略,同一扩展名也可能对应多种实现。变更流无法同时表达按需读取而又不把实时数据与可调用能力混在一起。HTML 依赖还需要 Host 的文件系统授权和路径解析,而非浏览器的当前目录。
+
+## 决策
+
+Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-05-client-resource-model.zh.md)只按地址共享观察:`source(address)`、`pin(address, signal)` 和提供方的 `open(address, { signal })` 均不携带消费 Session。提供方返回 `AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>`;`useResource` 只暴露 `{ status, value, failure }`。持有只控制观察的启停,不控制底层文件或 Session 的生灭。内容通过普通注入的 Preview 回调读取。
+
+[Workspace Files](../../../../packages/api/workspace-files/README.zh.md) 保留 Host 的行读取、字节窗口、有上限的全文读取和相对另一文件目录的有界读取。Client `file` 提供方只观察 `stat` 与 `changes`,`ResourceProtocolMap.file` 直接为 `WorkspaceFileStat`。Host 通过 Session 文件系统解析每条路径;文件读取继承该后端的读取权限,目录列举与变更观察仍限定于工作区。
+
+可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
+
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
+
+Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob。
+
+## 考虑过的替代方案
+
+**把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
+
+**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。
+
+**把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。
+
+**让所有资源提供文件读取方法。** Chat 与终端资源的数据和操作语义各自独立,只有观察的注册和生命周期是共用机制。
+
+**预览资源包装层、内容 Session 或第二个资源 Hook。** 这些方案重复了 Resource 和 Workspace Files 已提供的寻址、取消、订阅和归属。加载策略属于预览所有者。
+
+**本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。
+
+## 影响
+
+替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及惰性连续 PDF Worker 渲染。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-09-workspace-file-read-authority.md
+2026-09-09-workspace-file-read-authority.md: 35ac15f5e463910be3b3a0cfe7b80dc69c129ad3
+2026-09-09-workspace-file-read-authority.zh.md: 559a59af0bfa988ff880fad22470397e4e74f296

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md

@@ -0,0 +1,29 @@
+# Agent Note: Workspace file read authority
+
+Status: implemented
+
+English | [中文](2026-09-09-workspace-file-read-authority.zh.md)
+
+## Problem
+
+Workspace Files serves both file content and workspace navigation. Applying workspace containment to every operation creates a second read policy above the Session filesystem backend and prevents a user from previewing paths that the same Session can read outside its workspace. HTML preview also needs direct relative JavaScript and stylesheet files, including `..` paths, while its script-enabled document can use the browser network.
+
+## Decision
+
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps.
+
+`list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate.
+
+`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML.
+
+The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox.
+
+## Alternatives considered
+
+**Contain every operation within the workspace.** This gives previews a narrower policy than the Session filesystem backend, blocks explicitly addressed readable files, and prevents HTML beside external assets from rendering. Workspace containment remains where the operation itself represents the workspace.
+
+**Permit outside reads but block all iframe networking.** A stricter CSP would reduce exfiltration risk, but it would also reject external assets and network behavior intentionally retained for the static-HTML preview. The opaque sandbox protects the parent application; it does not promise network isolation.
+
+## Consequences
+
+Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. A previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 工作区文件读取权限
+
+Status: implemented
+
+[English](2026-09-09-workspace-file-read-authority.md) | 中文
+
+## Problem
+
+Workspace Files 同时提供文件内容与工作区导航。对所有操作应用工作区包含限制,会在 Session 文件系统后端之上形成第二套读取策略,并阻止用户预览同一 Session 在工作区外可读的路径。HTML 预览还需要直接读取相对 JavaScript 与样式表文件,包括含 `..` 的路径,而启用脚本的文档可以使用浏览器网络。
+
+## Decision
+
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。
+
+`list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。
+
+`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。
+
+[Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。
+
+## Alternatives considered
+
+**把所有操作限制在工作区内。** 这会让预览采用比 Session 文件系统后端更窄的策略,阻止读取明确寻址的可读文件,并使位于外部资源旁的 HTML 无法渲染。操作本身代表工作区时,仍保留工作区包含限制。
+
+**允许根外读取,但阻断 iframe 的全部网络。** 更严格的 CSP 可以降低数据外传风险,但也会拒绝静态 HTML 预览有意保留的外部资源与网络行为。不透明 sandbox 保护父应用,但不承诺网络隔离。
+
+## Consequences
+
+持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.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-04-right-sidebar-docking-infrastructure.md
-2026-09-04-right-sidebar-docking-infrastructure.md: 8e1d1b5518c3b80a05be877ee9796883b6eb9a60
-2026-09-04-right-sidebar-docking-infrastructure.zh.md: 6830bf74734bbc8072b201ded6194e1d20ddbc88
+2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e
+2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710

+ 2 - 0
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md

@@ -37,6 +37,8 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes;
 
 ### State
 
+[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes.
+
 `ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
 
 ### Beyond the surface

+ 2 - 0
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md

@@ -37,6 +37,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 
 ### 状态
 
+[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。
+
 `ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
 
 ### 面之外

+ 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: a21946c73302561a2cb8539ca314e0a5d4fb25b8
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: dc02c159e0e052a21f2f1c80ac2e6f30bf5cf416
+2026-09-05-sidebar-text-preview-and-file-tree.md: 1a743c35634c5772e8173b770228a7f736a9d710
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 72253286862f1e8729683fb719e83cfcf544d8e8

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

@@ -12,10 +12,12 @@ Each answer carries product rules that code alone does not explain: why a text f
 
 ## Decision
 
-Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the **text preview** (`ui-sidebar-textpreview`), and the **file tree** (`ui-sidebar-files`). Each registers a static definition into `ctx.sidebarRightTabs` and a body into the keyed `sidebar.right.pane.tab` seat under the definition's `id`, inside its own `ctx.effect`, so the type exists exactly as long as its plugin. The guide and the tree are page types opened by kind; the text preview is a viewer that claims every `file` resource address at the lowest band. A type's controls live in its own body; the pane's tab strip carries only the panel's actions. Copy is locale-owned in each package's namespace (`sidebarRight`, `sidebarTextpreview`, `sidebarFiles`).
+Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the **document preview** (`ui-sidebar-documentpreview`), and the **file tree** (`ui-sidebar-files`). Each registers a static definition into `ctx.sidebarRightTabs` and a body into the keyed `sidebar.right.pane.tab` seat under the definition's `id`, inside its own `ctx.effect`, so the type exists exactly as long as its plugin. The guide and the tree are page types opened by kind; the document preview is a viewer that claims Session-scoped `file` resource addresses at the lowest band. A type's controls live in its own body; the pane's tab strip carries only the panel's actions. Copy is locale-owned in each package's namespace (`sidebarRight`, `sidebarDocumentPreview`, `sidebarFiles`).
 
 ### The guide
 
+[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes this section's default-guide selection; guide registration, replacement and uniqueness remain unchanged.
+
 The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open.
 
 The body is a centred column — a lead line (`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`), one line of copy (`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`), and a grid of entry boxes at most 480px wide, each box at least 160px, filling as many columns as fit. The boxes are projected from every registered type's `guide[]` in `order`, through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. A box shows the contributing type's glyph, title, and description, and picking it calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened.
@@ -26,21 +28,23 @@ A pane holds at most one guide, and the docking layer enforces it as product beh
 
 ### The text preview
 
-`text` is the fallback viewer for every file. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-textpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', title: basenameOf }`. The pattern contains `:` and so matches the whole address; `fallback` is the lowest band, so a type at `extension` or `builtin` with a narrower pattern (`*.png`, say) takes those addresses and everything else lands here, while the text type stays in the candidate list for any file. The `id` is the package name and doubles as the `key` of the body seat, so an extension that takes the `text` kind over cannot make the seat pick up this body by mistake. The title is the address's decoded last segment: the whole address stays the content identity — two files with one name in different directories, or one path under two sessions, are two tabs — and only the chip text is shortened.
+The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) supersedes this section's renderer, loading, and resource-observation details. The fallback tab registration, paged source navigation, and body-owned controls remain in force.
+
+`text` is the fallback viewer for Session-scoped files. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`. `canOpen` accepts only addresses whose parsed scope is `session`. The pattern contains `:` and so matches the whole address; `fallback` is the lowest band, so a type at `extension` or `builtin` with a narrower pattern (`*.png`, say) takes those addresses and everything else lands here, while the text type stays in the candidate list for any file. The `id` is the package name and doubles as the `key` of the body seat, so an extension that takes the `text` kind over cannot make the seat pick up this body by mistake. The title is the address's decoded last segment: the whole address stays the content identity — two files with one name in different directories, or one path under two sessions, are two tabs — and only the chip text is shortened.
 
-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.
+A tab uses `dsh-resource://file/session/<sessionId>/<path>`, where the path may be relative or absolute ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers). `hostFileOf` accepts only this Session scope and takes both Session and path from the address; a Session-less `absolute` address is not claimed. A malformed claimed address throws as a programming error.
 
-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.
+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 `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readAll`. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading.
 
 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.
 
 Navigation is a `line`. The `read` tool row passes its 1-based `offset` as `openResource(address, { params: { line } })`, and the produced-file chip passes nothing; the body narrows `navigation.params` to `SidebarRightResourceParamsMap['file']` (`{ line?: number }`, declared by the `file` type's owner) without runtime validation, because caller and body meet at a typed same-process boundary. If the loaded pages do not reach the line, the body reads the next page, again, until they do or the file ends — pages load in order; there is no seek — then scrolls the line to the top of the body and highlights it, once per `navigation.revision`. The store records the answered revision, so a body remounting for the same revision restores the scroll offset instead of jumping, and a new `openResource` for the same file (revealed, not duplicated) arrives as a new revision and jumps again. A line past the end of the file stops silently at `eof`; a page that fails while walking stops the walk and shows the failure line.
 
-A changed file is announced, not applied. When the `file` resource reports `changed` — the agent wrote the file through a tool after the last `stat` — a bar above the path row says `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.` with a `重新载入` / `Reload` button. Only the click does two things at once: `meta.reload()` (a fresh `stat`, which clears `changed`) and `reloadPages` (drop every page, read the first one again). The scroll offset is kept, so the reader stays where they were. Nothing else triggers a reload: the tree and the preview do not watch the filesystem, and an external edit is not announced. A resource that turns `failed` — the file deleted, or the Host refusing it — puts a failure bar in the same place, its line from `failure-line.ts` and the same reload button, ahead of any pending `changed`; the pages already read stay beneath it.
+A changed file is announced, not applied. The body compares the loaded version and the observation captured at read start with later `WorkspaceFileStat.version`; a difference shows the change bar. Reload rereads only this tab through the Preview face and does not mutate shared resource metadata or another tab. A resource failure takes the same bar's place above any content already loaded.
 
-The body's header is one row: the file's path as the address names it on the left (12px, tertiary colour, one line, ellipsis when it overflows, full path on hover) and two 24px controls at its right end — a wrap toggle (`自动换行` / `Wrap lines`, pressed state shown, **on by default** per tab: long lines wrap and never scroll horizontally until the reader turns it off, whereupon the file body scrolls horizontally on its own) and a reload button (`重新读取文件` / `Read the file again`) that does exactly what the change bar's button does. Neither control is ever disabled. The preview takes the pane body's full height (`height: 100%` against the pane body, which is a block scroller of definite height) so a short file leaves no separately styled space below it, and the file body — monospace, 13px, line height 1.6, 10px vertical padding — is the only scroller: the header and the change bar stay put while a long file scrolls under them.
+The body's header is one row: the full file path on the left and the matching-renderer menu, conditional wrap toggle, and reload button on the right. The [Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns the current controls and renderer behavior. The preview takes the pane body's full height, and its document body is the scroller beneath the fixed header and change bar.
 
-A failed page keeps the pages already shown and adds one sentence at the end of the loaded text, in terms of the file rather than the transport, with a `重试` / `Retry` button that reads the same page again: `workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`; `workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`; `workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.` with the byte cap rendered as `2 MB`; `workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`; `workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`; any other failure, carrier or unclassified, `读取失败:{message}` / `Read failed: {message}` with the failure's own message. The mapping lives in `failure-line.ts`, apart from the component so it is testable on its own; a code the reader does not name falls to the generic line carrying the carrier's message. A directory or a binary file therefore shows one failure line and nothing else; an empty file shows the header and an empty body with no marker.
+A failed read keeps content already shown and adds a localized failure with a retry action. The Preview names actionable file failures and falls back to the carrier message for other codes; `outside-workspace` belongs to directory listing and is not a Preview-specific failure.
 
 ### The file tree
 
@@ -94,7 +98,7 @@ Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「
 
 ## Consequences
 
-- A type written outside `ui-sidebar-right` has a complete template: `ui-sidebar-textpreview` shows a viewer with an address-derived read, an exclusive Slot store bucketed by tab, an inject face, typed navigation params, and body-owned controls; `ui-sidebar-files` shows a page type with a guide entry and a lazily filled store; the guide shows a chain fallback.
+- A type written outside `ui-sidebar-right` has a complete template: `ui-sidebar-documentpreview` shows a viewer with an address-derived read, an exclusive Slot store bucketed by tab, an inject face, typed navigation params, and body-owned controls; `ui-sidebar-files` shows a page type with a guide entry and a lazily filled store; the guide shows a chain fallback.
 - Reading by page bounds every request (`maxLines` lines, `maxBytes` bytes) at the cost of a **Load more** control, no total line count, and sequential walks to a deep line; a navigation to line 40,000 of a large file reads eight pages first.
 - Announcing a change instead of applying it keeps the reader's place during an agent's repeated writes, at the cost of showing stale text until the reader clicks; an external edit is never announced.
 - Reload reads the first page only, so a reader deep in a file reloads into the top of it and pages forward again; the scroll offset is preserved but may point past the loaded text.
@@ -109,10 +113,9 @@ The text preview's `tests/` cover the registry claim and yielding (through the r
 ## Deferred
 
 - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`.
-- Line numbers, syntax highlighting, rendered Markdown, images, and search in the text preview; a total line count or end-of-file marker.
+- Images, search, a total line count, and an end-of-file marker.
 - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree.
 - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries.
-- Chinese README counterparts for `ui-sidebar-textpreview` and `ui-sidebar-files`.
 
 ## Related
 

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

@@ -12,10 +12,12 @@ Status: implemented
 
 ## Decision
 
-Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、**文本预览**(`ui-sidebar-textpreview`)与**文件树**(`ui-sidebar-files`)。每个类型都在自己的 `ctx.effect` 里把静态定义注册进 `ctx.sidebarRightTabs`、把体注册进 keyed 坑位 `sidebar.right.pane.tab`(键 = 定义的 `id`),因此类型的寿命恰等于其插件。引导页与文件树是按 kind 打开的页类型;文本预览是以最低档认领每个 `file` 资源地址的查看器。类型的控件住在自己的体里;pane 的 tab 条只承载面板自身的动作。文案由各包的命名空间(`sidebarRight`、`sidebarTextpreview`、`sidebarFiles`)以 locale 方式持有。
+Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、**文档预览**(`ui-sidebar-documentpreview`)与**文件树**(`ui-sidebar-files`)。每个类型都在自己的 `ctx.effect` 里把静态定义注册进 `ctx.sidebarRightTabs`、把体注册进 keyed 坑位 `sidebar.right.pane.tab`(键 = 定义的 `id`),因此类型的寿命恰等于其插件。引导页与文件树是按 kind 打开的页类型;文档预览是以最低档认领 Session 作用域 `file` 资源地址的查看器。类型的控件住在自己的体里;pane 的 tab 条只承载面板自身的动作。文案由各包的命名空间(`sidebarRight`、`sidebarDocumentPreview`、`sidebarFiles`)以 locale 方式持有。
 
 ### 引导页
 
+[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代本节的默认引导选择;引导页注册、替换和唯一性保持不变。
+
 引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。
 
 体是一根居中的列——一句引导语(`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`)、一行文案(`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`),以及一组最宽 480px 的入口框栅格,每框至少 160px,能放几列放几列。入口框按 `order` 从每个已注册类型的 `guide[]` 投影而来,经注册表可观察的 `guide()` 列表,因此后注册的类型不用引导页知道就能出现。一个框显示贡献类型的图标、标题与说明;点选它调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。
@@ -26,21 +28,23 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、
 
 ### 文本预览
 
-`text` 是每个文件的兜底查看器。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-textpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', title: basenameOf }`。pattern 含 `:`,因此匹配整个地址;`fallback` 是最低档,所以 `extension` 或 `builtin` 档上一个 pattern 更窄的类型(比如 `*.png`)接走那些地址,其余一切落到这里,而 text 类型对任何文件都留在候选列表中。`id` 是包名,兼作体坑位的 `key`,于是一个接管了 `text` kind 的扩展不可能让坑位误拿到这个体。标题是地址解码后的最后一段:整个地址仍是内容身份——不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab——只有 chip 上的文字被缩短。
+[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)取代本节的渲染器、加载和资源观察细节。兜底 tab 注册、分页源码导航与正文自有控件仍然有效。
+
+`text` 是 Session 作用域文件的兜底查看器。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`。`canOpen` 只接受解析后 scope 为 `session` 的地址。pattern 含 `:`,因此匹配整个地址;`fallback` 是最低档,所以 `extension` 或 `builtin` 档上一个 pattern 更窄的类型(比如 `*.png`)接走那些地址,其余一切落到这里,而 text 类型对任何文件都留在候选列表中。`id` 是包名,兼作体坑位的 `key`,于是一个接管了 `text` kind 的扩展不可能让坑位误拿到这个体。标题是地址解码后的最后一段:整个地址仍是内容身份——不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab——只有 chip 上的文字被缩短。
 
-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` 地址都路由给这个类型,而造地址的调用方本应使用助手。
+tab 使用 `dsh-resource://file/session/<sessionId>/<path>`,其中路径可以是相对路径或绝对路径([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md)负责该语法与 `fileAddressFor` / `parseFileAddress` 辅助函数)。`hostFileOf` 只接受这种 Session scope,并从地址取得 Session 与路径;不认领不带 Session 的 `absolute` 地址。被认领的地址若格式错误,则作为程序错误抛出。
 
-元数据与内容来自不同的地方。`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 里而不是体里。
+元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过 `readAll` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。
 
 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 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。
 
 导航是一个 `line`。`read` 工具行把它 1 起的 `offset` 以 `openResource(address, { params: { line } })` 传来,产物 chip 什么都不传;体把 `navigation.params` 收窄为 `SidebarRightResourceParamsMap['file']`(`{ line?: number }`,由 `file` 类型的拥有者声明),不做运行时校验,因为调用方与体相遇在同进程的类型化边界上。已加载的页够不到该行时,体读下一页,再读,直到覆盖它或文件结束——页按顺序加载,没有 seek——然后把该行滚到体顶部并高亮,每个 `navigation.revision` 一次。store 记下已答过的 revision,于是同一 revision 下重新挂载的体恢复滚动位置而不再跳;对同一文件再次 `openResource`(聚焦而非复制)以新 revision 到来并再跳一次。超出文件末尾的行在 `eof` 处静默停下;补页途中失败的页终止补页并显示失败行。
 
-文件变了只提示,不应用。当 `file` 资源报告 `changed`——agent 在上次 `stat` 之后经工具写了该文件——路径行上方出现一条提示 `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.`,带一个 `重新载入` / `Reload` 按钮。只有点击才同时做两件事:`meta.reload()`(重新 `stat`,清掉 `changed`)与 `reloadPages`(丢掉所有页,重读第 1 页)。滚动位置保留,读者停在原处。没有别的东西触发重载:树和预览都不监听文件系统,外部编辑不会被提示。资源变为 `failed`——文件被删,或 Host 拒绝——时,同一位置出现一条失败条,句子来自 `failure-line.ts`,带同一个重新载入按钮,并优先于尚未处理的 `changed`;已读的页留在它下方。
+文件变了只提示,不应用。正文把已加载版本及读取开始时捕获的观察版本与后续 `WorkspaceFileStat.version` 比较;不同则显示变更提示。重新载入只通过 Preview face 重读当前 tab,不修改共享资源元数据或其他 tab。资源失败占用同一个提示位置,已加载内容仍保留在下方。
 
-体的头部是一行:左边是地址所命名的文件路径(12px、三级色、单行、溢出省略号、悬停显示完整路径),右端是两个 24px 控件——换行开关(`自动换行` / `Wrap lines`,显示按下态,**默认开**、按 tab 记:长行折行、绝不横向滚动,直到读者关掉它,此后文件体自己横向滚动)与一个重新读取按钮(`重新读取文件` / `Read the file again`),做的恰是变更提示条按钮做的事。两个控件都永不禁用。预览占满 pane 体的全部高度(对 pane 体取 `height: 100%`;pane 体是高度确定的块级滚动容器),于是短文件下方不留另一块样式不同的空白,而文件体——等宽、13px、行高 1.6、上下 10px 内边距——是唯一的滚动者:长文件在头部与变更提示条之下滚动,二者不动。
+正文头部为一行:左侧显示完整文件路径,右侧放匹配渲染器菜单、按条件出现的换行开关和重新载入按钮。[Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md)负责当前控件与渲染器行为。预览占满 pane 正文的全部高度,其文档正文是固定头部与变更提示条下方的滚动区域。
 
-某页失败时,已显示的页保留,并在已加载文本末尾加一句以文件而非传输为主语的说明,带一个重读同一页的 `重试` / `Retry` 按钮:`workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`;`workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`;`workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.`,字节上限渲染为 `2 MB` 这样的形式;`workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`;`workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`;其余任何失败,无论载体层还是未分类,`读取失败:{message}` / `Read failed: {message}` 并带上失败自身的消息。映射住在 `failure-line.ts` 里,与组件分开以便单独测试;读者未命名的错误码落到带传输层消息的通用句。目录或二进制文件因此只显示一行失败说明;空文件显示头部与一个空的体,没有任何标记。
+读取失败时保留已显示的内容,并增加本地化失败说明与重试操作。Preview 为可处理的文件错误提供专用文案,其他代码使用载体消息兜底;`outside-workspace` 属于目录列举,不是 Preview 专用失败。
 
 ### 文件树
 
@@ -94,7 +98,7 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 
 ## Consequences
 
-- `ui-sidebar-right` 之外写的类型有了一份完整样板:`ui-sidebar-textpreview` 演示一个查看器——由地址推出的读取、按 tab 分桶的独占 Slot store、inject face、类型化的导航参数与体内自有控件;`ui-sidebar-files` 演示一个带引导入口、懒填充 store 的页类型;引导页演示一个链 fallback。
+- `ui-sidebar-right` 之外写的类型有了一份完整样板:`ui-sidebar-documentpreview` 演示一个查看器——由地址推出的读取、按 tab 分桶的独占 Slot store、inject face、类型化的导航参数与体内自有控件;`ui-sidebar-files` 演示一个带引导入口、懒填充 store 的页类型;引导页演示一个链 fallback。
 - 按页读取让每次请求都有界(`maxLines` 行、`maxBytes` 字节),代价是一个 **加载更多** 控件、没有总行数,以及到深处某行的顺序补页;导航到一个大文件的第 40,000 行要先读八页。
 - 只提示不应用,让读者在 agent 反复写入期间保住位置,代价是点击之前显示的是旧文本;外部编辑永不提示。
 - 重新载入只读第 1 页,所以身在文件深处的读者重载后回到文件开头再往后翻;滚动位置保留但可能指向已加载文本之外。
@@ -109,10 +113,9 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 ## Deferred
 
 - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。
-- 文本预览的行号、语法高亮、Markdown 渲染、图片与搜索;总行数或文件末尾标记。
+- 图片、搜索、总行数与文件末尾标记。
 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。
 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。
-- `ui-sidebar-textpreview` 与 `ui-sidebar-files` 的中文 README 对照。
 
 ## Related
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-08-sidebar-default-pages.md
+2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4
+2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36

+ 27 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md

@@ -0,0 +1,27 @@
+# Agent Note: Sidebar default pages and close protection
+
+Status: implemented
+
+English | [中文](2026-09-08-sidebar-default-pages.zh.md)
+
+## Problem
+
+A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again.
+
+## Decision
+
+The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one.
+
+A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only.
+
+This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active.
+
+## Alternatives considered
+
+**Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry.
+
+**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior.
+
+## Consequences
+
+One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide.

+ 27 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: Sidebar 默认页与关闭保护
+
+Status: implemented
+
+[English](2026-09-08-sidebar-default-pages.md) | 中文
+
+## 问题
+
+只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。
+
+## 决策
+
+Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。
+
+单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。
+
+本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。
+
+## 考虑过的替代方案
+
+**统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。
+
+**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。
+
+## 后果
+
+单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -92,6 +92,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
 | [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
 | [`open`](https://github.com/sindresorhus/open) | MIT |
+| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 |
 | [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
 | [`react`](https://github.com/facebook/react) | MIT |
 | [`react-dom`](https://github.com/facebook/react) | MIT |

+ 6 - 4
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -243,8 +243,10 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     // CSS width assigned by the grid solver.
     await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 })
       .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 })
-    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
-    await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
+    await expect.poll(async () => ({
+      filesVisible: await column.locator('[data-files-state="tree"]').isVisible(),
+      errors: tripwire.pageErrors,
+    })).toEqual({ filesVisible: true, errors: [] })
     await column.locator('[data-dockkit-add-tab]').click()
     const split = column.locator('[data-dockkit-split-button]').first()
     await expect.poll(() => split.isDisabled()).toBe(false)
@@ -253,7 +255,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
     await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1)
     const retainedA = await paneSnapshot(page)
-    expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Start']])
+    expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Files']])
     await checkpoint('A normal: two panes')
 
     await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
@@ -272,7 +274,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await expect.poll(() => detailsTrack(page)).toBe(0)
     await open()
     expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
-    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
+    await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
     const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ })
     await workspaceDirectory.waitFor({ timeout: 15_000 })
     await workspaceDirectory.click()

+ 395 - 0
apps/web/tests/document-preview.e2e.ts

@@ -0,0 +1,395 @@
+/** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, relative } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Locator, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
+import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
+const EXPECTED = join(SNAPSHOT_DIR, 'document.expected.md')
+const PAGING_PATCH = join(SNAPSHOT_DIR, 'paging.patch.yml')
+const PAGE_LINES = 64
+const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url))
+const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
+const MODE = webSnapshotMode()
+
+/** Successful render evidence stays outside the committed snapshot inventory. */
+async function successShot(page: Page, name: string): Promise<void> {
+  await mkdir(SHOT_DIR, { recursive: true })
+  await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true })
+}
+
+/** Trigger the document owner's native scroll handler after a real first page overflows. */
+async function scrollForNextPage(body: Locator): Promise<void> {
+  await body.evaluate((node) => {
+    if (node.scrollHeight <= node.clientHeight) throw new Error('paging fixture does not overflow the document body')
+    node.scrollTop = node.scrollHeight
+  })
+}
+
+/** Read the solid vector fill away from antialiased page edges. */
+async function canvasColor(canvas: Locator): Promise<string> {
+  return await canvas.evaluate((node) => {
+    const surface = node as HTMLCanvasElement
+    const context = surface.getContext('2d')
+    if (context === null) throw new Error('PDF canvas has no 2D context')
+    const pixel = context.getImageData(Math.floor(surface.width / 2), Math.floor(surface.height / 2), 1, 1).data
+    if (Number(pixel[3]) !== 255) return 'transparent'
+    if (Number(pixel[0]) - Number(pixel[2]) > 150) return 'red'
+    if (Number(pixel[2]) - Number(pixel[0]) > 150) return 'blue'
+    return 'other'
+  })
+}
+
+describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let outsideRoot: string | undefined
+
+  beforeAll(async () => {
+    outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
+    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      try {
+        await scaffold?.close()
+      } finally {
+        if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('opens Markdown, isolated HTML, and a rendered PDF from the Session workspace', async () => {
+    onTestFailed(async () => {
+      await mkdir(SHOT_DIR, { recursive: true })
+      await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
+    })
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
+    const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
+    if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
+    if (outsideRoot === undefined) throw new Error('outside fixture directory is unavailable')
+    const outsideScript = join(outsideRoot, 'outside.js')
+    const outsideReference = relative(cwd, outsideScript).replace(/\\/g, '/')
+    const markdownText = [
+      '# Markdown smoke', '', 'Rendered from the workspace.', '',
+      ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(),
+      '# Markdown tail',
+    ].join('\n')
+    const codeLines = [
+      ...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`),
+      'const tail = "CODE_TAIL";',
+    ]
+    await Promise.all([
+      writeFile(join(cwd, 'smoke.md'), markdownText),
+      writeFile(join(cwd, 'pages.ts'), codeLines.join('\n')),
+      writeFile(join(cwd, 'notes.unknown'), 'UNKNOWN_SUFFIX\nPlain fallback.'),
+      writeFile(join(cwd, 'smoke.html'), [
+        '<!doctype html><link rel="stylesheet" href="./local.css">',
+        '<h1>HTML smoke</h1><p id="result">pending</p><p id="local-result">pending</p><p id="parent-result">pending</p>',
+        '<p id="outside-result">pending</p>',
+        '<script>document.getElementById("result").textContent="INLINE_OK";',
+        'try{parent.document.documentElement.setAttribute("data-document-preview-escape","true");document.getElementById("parent-result").textContent="parent-accessible"}',
+        'catch(error){const result=document.getElementById("parent-result");result.textContent="parent-blocked";result.dataset.error=error.name}</script>',
+        '<script src="./local.js"></script>',
+        `<script src="${outsideReference}"></script>`,
+      ].join('\n')),
+      writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'),
+      writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'),
+      writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'),
+      writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
+    ])
+
+    const column = page.locator('[data-rightbar-col]')
+    await page.locator('[data-sidebar-right-expand]').click()
+    await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
+    await column.locator('[data-files-reload]').click()
+    const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
+    const addTab = column.locator('[data-dockkit-add-tab]')
+    expect(await column.locator('[data-dockkit-tab]').count()).toBe(1)
+    const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText()
+    const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
+    expect(initialFilesClose).toBe(0)
+    await filesTab.click({ button: 'right' })
+    expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(0)
+    await addTab.waitFor({ state: 'visible' })
+    const initialAdd = await addTab.count()
+    expect(initialAdd).toBe(1)
+    await addTab.click()
+    await column.locator('[data-sidebar-right-guide]').waitFor({ state: 'visible' })
+    await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(2)
+    const guideTab = column.locator('[data-dockkit-tab]').filter({ hasNot: page.getByText('Files', { exact: true }) })
+    const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count()
+    const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count()
+    expect(guideClose).toBe(1)
+    expect(filesCloseWithGuide).toBe(0)
+    await expect.poll(() => addTab.count()).toBe(0)
+    const addWithGuide = await addTab.count()
+    await guideTab.locator('[data-dockkit-tab-close]').click()
+    await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
+    await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0)
+    await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(1)
+    await addTab.waitFor({ state: 'visible' })
+    const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
+    const restoredAdd = await addTab.count()
+    expect(restoredFilesClose).toBe(0)
+    expect(restoredAdd).toBe(1)
+    const preview = column.locator('[data-document-preview]')
+    const openFile = async (name: string): Promise<void> => {
+      await filesTab.click()
+      await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
+      await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
+    }
+    const viewer = preview.locator('[data-document-viewer-menu]')
+    const body = preview.locator('[data-textpreview-body]')
+    const sections = ['# Document preview']
+    sections.push([
+      '## Sidebar tabs', '',
+      `- Default tab: ${defaultTitle}`,
+      `- Files close buttons (alone -> with guide -> restored): ${[initialFilesClose, filesCloseWithGuide, restoredFilesClose].join(' -> ')}`,
+      `- Manual guide close buttons: ${guideClose}`,
+      `- Add buttons (Files -> guide -> Files): ${[initialAdd, addWithGuide, restoredAdd].join(' -> ')}`,
+    ].join('\n'))
+
+    await openFile('smoke.md')
+    await expect.poll(() => viewer.innerText()).toBe('Markdown')
+    await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).waitFor({ timeout: 15_000 })
+    expect(await preview.getByText('Rendered from the workspace.', { exact: true }).isVisible()).toBe(true)
+    const heading = await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).innerText()
+    const markdownTail = preview.getByRole('heading', { name: 'Markdown tail', exact: true })
+    await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
+    expect(await markdownTail.count()).toBe(0)
+    await scrollForNextPage(body)
+    await markdownTail.waitFor({ timeout: 15_000 })
+    await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
+    expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1)
+    expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1)
+    const tailHeading = await markdownTail.innerText()
+    await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded()
+    await successShot(page, 'markdown')
+    const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) })
+    const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab')
+    expect(markdownTabId).not.toBeNull()
+    const tabCount = await column.locator('[data-dockkit-tab]').count()
+    const markdownViewers = [await viewer.innerText()]
+    const sourceFonts: Array<{ fontSize: string; lineHeight: string }> = []
+    for (const label of ['Code', 'Plain text']) {
+      await viewer.click()
+      await page.getByRole('menuitem', { name: label, exact: true }).click()
+      await expect.poll(() => viewer.innerText()).toBe(label)
+      if (label === 'Code') {
+        await preview.locator('.shiki').waitFor({ timeout: 15_000 })
+        expect(await preview.locator('.shiki').textContent()).toBe(markdownText)
+      } else {
+        await expect.poll(async () => (await preview.locator('[data-textpreview-line]').first().textContent())?.trim()).toBe('# Markdown smoke')
+      }
+      const source = label === 'Code' ? preview.locator('.shiki') : preview.locator('[data-textpreview-line]').first()
+      sourceFonts.push(await source.evaluate((node) => {
+        const style = getComputedStyle(node)
+        return { fontSize: style.fontSize, lineHeight: style.lineHeight }
+      }))
+      expect(await markdownTab.getAttribute('data-dockkit-tab')).toBe(markdownTabId)
+      expect(await column.locator('[data-dockkit-tab]').count()).toBe(tabCount)
+      markdownViewers.push(await viewer.innerText())
+    }
+    expect(sourceFonts[1]).toEqual(sourceFonts[0])
+    sections.push([
+      '## Markdown', '',
+      `- Heading: ${heading}`,
+      `- Tail loaded by scrolling: ${tailHeading}`,
+      `- Viewers: ${markdownViewers.join(' -> ')}`,
+      `- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`,
+    ].join('\n'))
+
+    await openFile('smoke.html')
+    await expect.poll(() => viewer.innerText()).toBe('HTML')
+    const iframe = preview.locator('[data-html-preview]')
+    await iframe.waitFor({ timeout: 15_000 })
+    expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts')
+    const html = page.frameLocator('[data-html-preview]')
+    await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 })
+    await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
+    await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_OK')
+    await expect.poll(() => html.locator('#outside-result').innerText()).toBe('OUTSIDE_JS_OK')
+    await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(12, 34, 56)')
+    await expect.poll(() => html.locator('#parent-result').innerText()).toBe('parent-blocked')
+    expect(await html.locator('#parent-result').getAttribute('data-error')).toBe('SecurityError')
+    expect(await page.locator('html').getAttribute('data-document-preview-escape')).toBeNull()
+    await successShot(page, 'html')
+    sections.push([
+      '## HTML', '',
+      `- Viewer: ${await viewer.innerText()}`,
+      `- Sandbox: ${await iframe.getAttribute('sandbox')}`,
+      `- Inline script: ${await html.locator('#result').innerText()}`,
+      `- Local script: ${await html.locator('#local-result').innerText()}`,
+      `- Outside-workspace script: ${await html.locator('#outside-result').innerText()}`,
+      `- Local stylesheet: ${await html.locator('#local-result').evaluate(node => getComputedStyle(node).color)}`,
+      `- Parent access: ${await html.locator('#parent-result').innerText()} (${await html.locator('#parent-result').getAttribute('data-error')})`,
+      `- Parent unchanged: ${String(await page.locator('html').getAttribute('data-document-preview-escape') === null)}`,
+    ].join('\n'))
+
+    await openFile('smoke.pdf')
+    await expect.poll(() => viewer.innerText()).toBe('PDF')
+    const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
+    await canvas.waitFor({ state: 'visible', timeout: 30_000 })
+    expect(await preview.locator('[role="toolbar"]').count()).toBe(0)
+    expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
+    await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
+    const firstColor = await canvasColor(canvas)
+    expect(firstColor).toBe('red')
+    const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
+    expect(workerNames).toContain('dsh-pdf')
+    await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
+    const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true })
+    await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
+    await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
+    const secondColor = await canvasColor(secondPage)
+    expect(secondColor).toBe('blue')
+    expect(await body.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true)
+    const pdfTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.pdf', { exact: true }) })
+    const pdfTabId = await pdfTab.getAttribute('data-dockkit-tab')
+    expect(pdfTabId).not.toBeNull()
+    await filesTab.click()
+    await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
+    await pdfTab.click()
+    await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
+    await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
+    await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
+    const restoredColor = await canvasColor(secondPage)
+    expect(restoredColor).toBe('blue')
+    expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId)
+    await successShot(page, 'pdf')
+    sections.push([
+      '## PDF', '',
+      `- Viewer: ${await viewer.innerText()}`,
+      `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`,
+      `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
+      `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
+      `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
+      `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
+    ].join('\n'))
+
+    const releaseRead = Promise.withResolvers<undefined>()
+    let waitingForRead = false
+    const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles)
+    const heldRead = vi.spyOn(scaffold.ctx.workspaceFiles, 'read').mockImplementation(async (agent, path, range, signal) => {
+      if (path === 'pages.ts' && (range.offset ?? 1) === 1) {
+        waitingForRead = true
+        await releaseRead.promise
+      }
+      return readPage(agent, path, range, signal)
+    })
+    let initialReading = false
+    try {
+      await openFile('pages.ts')
+      await expect.poll(() => waitingForRead).toBe(true)
+      const reading = preview.locator('[data-document-loading]')
+      initialReading = await reading.isVisible()
+      expect(initialReading).toBe(true)
+      expect(await preview.locator('[data-code-preview]').count()).toBe(0)
+      const indicator = await reading.boundingBox()
+      const scroller = await body.boundingBox()
+      if (indicator === null || scroller === null) throw new Error('reading indicator or document body is not rendered')
+      expect(indicator.y).toBeGreaterThanOrEqual(scroller.y)
+      expect(indicator.y + indicator.height).toBeLessThanOrEqual(scroller.y + scroller.height)
+      await successShot(page, 'code-reading')
+    } finally {
+      releaseRead.resolve(undefined)
+      heldRead.mockRestore()
+    }
+    await expect.poll(() => viewer.innerText()).toBe('Code')
+    const highlightedLines = preview.locator('.shiki .line')
+    await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES)
+    const codeBlock = preview.locator('.md-code-block')
+    expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true')
+    await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content))
+      .not.toMatch(/^(?:none|normal)$/u)
+    const numbering = await highlightedLines.first().evaluate((node) => {
+      const line = getComputedStyle(node)
+      const before = getComputedStyle(node, '::before')
+      return {
+        counterIncrement: line.counterIncrement,
+        gutterWidth: Number.parseFloat(before.width),
+        sourceInset: Number.parseFloat(line.paddingInlineStart),
+      }
+    })
+    expect(numbering.counterIncrement).toBe('source-line 1')
+    expect(numbering.gutterWidth).toBeGreaterThan(0)
+    expect(numbering.sourceInset).toBeGreaterThan(numbering.gutterWidth)
+    const prefix = await highlightedLines.allTextContents()
+    expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES))
+    await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
+    await scrollForNextPage(body)
+    await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length)
+    const completed = await highlightedLines.allTextContents()
+    expect(completed).toEqual(codeLines)
+    await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
+    const scrollTop = await body.evaluate((node) => {
+      const target = Math.floor((node.scrollHeight - node.clientHeight) / 2)
+      if (target <= 0) throw new Error('code fixture does not overflow the document body')
+      node.scrollTop = target
+      return target
+    })
+    await expect.poll(() => body.evaluate((node) => {
+      const banner = node.querySelector('.md-code-block')?.firstElementChild
+      const firstLine = node.querySelector('.shiki .line')
+      if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line')
+      const bounds = node.getBoundingClientRect()
+      const clipTop = bounds.top + node.clientTop
+      const bannerBounds = banner.getBoundingClientRect()
+      const hit = document.elementFromPoint(bounds.left + node.clientLeft + node.clientWidth / 2, clipTop + 1)
+      return {
+        scrollTop: node.scrollTop,
+        position: getComputedStyle(banner).position,
+        topGap: bannerBounds.top - clipTop,
+        firstLineAbove: firstLine.getBoundingClientRect().top < clipTop,
+        topCoveredByBanner: hit !== null && banner.contains(hit),
+      }
+    })).toEqual({ scrollTop, position: 'sticky', topGap: 0, firstLineAbove: true, topCoveredByBanner: true })
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
+    await page.evaluate(() => navigator.clipboard.writeText(''))
+    await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()
+    await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n'))
+    sections.push([
+      '## Code paging', '',
+      `- Viewer: ${await viewer.innerText()}`,
+      `- Initial reading indicator: ${initialReading}`,
+      `- Lines: ${prefix.length} -> ${completed.length}`,
+      `- Prefix retained: ${String(JSON.stringify(completed.slice(0, prefix.length)) === JSON.stringify(prefix))}`,
+      `- Tail: ${completed.at(-1)}`,
+    ].join('\n'))
+
+    await openFile('notes.unknown')
+    await expect.poll(() => viewer.innerText()).toBe('Plain text')
+    const plainLines = preview.locator('[data-textpreview-line]')
+    await expect.poll(() => plainLines.count()).toBe(2)
+    const fallback = (await plainLines.allTextContents()).map(line => line.trim())
+    expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.'])
+    sections.push(['## Unknown suffix', '', `- Viewer: ${await viewer.innerText()}`, `- Text: ${fallback.join(' | ')}`].join('\n'))
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
+    await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml'])
+  })
+})

+ 2 - 0
apps/web/tests/seeded-history.e2e.ts

@@ -433,6 +433,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // 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')
+    await page.getByRole('button', { name: 'Open the sidebar', exact: true }).waitFor({ state: 'visible' })
+    await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
   })
 
   it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {

+ 56 - 52
apps/web/tests/sidebar-right.e2e.ts

@@ -132,7 +132,7 @@ async function resetSidebar(page: Page): Promise<Locator> {
   const column = page.locator('[data-rightbar-col]')
   await expandOf(page).waitFor({ timeout: 15_000 })
   await ensureExpanded(page, column)
-  await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+  await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
   await width(column)
   return column
 }
@@ -370,7 +370,7 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(Math.round(gapAfter)).toBe(Math.round(gapBefore))
 
       // The panel is in the column, not over it, and carries the seeded tab —
-      // whose body arrives through the guide type's keyed registration, not from
+      // whose body arrives through the Files type's keyed registration, not from
       // any dispatch inside the seat. Its two controls sit at the end of the
       // top-right pane's strip: the panel has no header row of its own.
       expect(await column.locator('[data-sidebar-right-panel="push"]').count()).toBe(1)
@@ -380,8 +380,7 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(await chrome.locator('[data-sidebar-right-toggle]').count()).toBe(1)
 
       // One centre line across the strip: chip text, split, and the two panel
-      // controls all sit at the same height. The add control joins the check
-      // below, once the strip draws it.
+      // controls all sit at the same height. The add control joins the check below.
       const centreY = async (selector: string): Promise<number> => {
         const box = await column.locator(selector).first().boundingBox()
         if (box === null) throw new Error(`${selector} is not rendered`)
@@ -392,24 +391,26 @@ describe('web e2e: shipped right Sidebar', () => {
         expect(await centreY(selector), selector).toBe(textLine)
       }
 
-      // The guide is unique per pane, so while this pane holds one its strip
-      // offers no add control. Closing it brings the control back, and the
-      // control opens the guide again in that pane.
+      // Files is permanent. A manual guide is closable and suppresses another
+      // add control in its pane until it is closed.
       const addTab = column.locator('[data-dockkit-add-tab]')
-      expect(await addTab.count()).toBe(0)
-      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
-      await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
-      await column.locator('[data-dockkit-tab-close]').first().click()
-      await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
+      const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
+      await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
+      expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
       await expect.poll(async () => await addTab.count()).toBe(1)
       expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine)
       await addTab.click()
-      await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
       await expect.poll(async () => await addTab.count()).toBe(0)
+      expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
+      expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
       // Back to the seeded shape the cases below start from.
-      await column.locator('[data-dockkit-tab-close]').first().click()
-      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+      await guideTab.locator('[data-dockkit-tab-close]').click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
+      await expect.poll(async () => await addTab.count()).toBe(1)
       await shot(page, '02-squeezed-panel')
 
       expect(tripwire.pageErrors).toEqual([])
@@ -648,12 +649,12 @@ describe('web e2e: shipped right Sidebar', () => {
       // text type claims the address.
       const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` })
       await chip.click()
-      await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
 
       // Opening the same content again focuses rather than duplicating.
       await panes.first().locator('[data-dockkit-tab]').first().click()
       await chip.click()
-      await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
 
       // The body arrives through the text type's keyed registration, and its
       // content came over the wire from the real file.
@@ -684,15 +685,15 @@ describe('web e2e: shipped right Sidebar', () => {
       )
       await expect.poll(async () => await panes.count()).toBe(2)
 
-      await panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
-        .locator('[data-dockkit-tab-close]').click()
+      const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
+      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
       await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
 
-      // The guide is unique per pane: panes seeded with one offer no
-      // add control; the pane holding only the file is the one that does.
+      // Neither pane holds a guide, so both offer an add control.
       const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
       await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1)
-      expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(1)
+      expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(2)
 
       // Floating leaves the column entirely, and survives collapsing it. The
       // pane the tab was alone in goes with it: an emptied pane never stays.
@@ -780,7 +781,7 @@ describe('web e2e: shipped right Sidebar', () => {
       const first = panes.first()
       const strip = first.locator('[data-dockkit-strip]')
       await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
-      await expect.poll(async () => await tabTitles(first)).toEqual(['Start', SAMPLE_NAME])
+      await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME])
       const order = await tabTitles(first)
       // The insertion index is measured against chip midpoints, not strip width.
       await dragTo(page, first.locator('[data-dockkit-tab]').last(),
@@ -824,10 +825,8 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(await panes.count()).toBe(2)
       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
-      //    second one comes from whichever pane is widest by then.
-      const floatOne = panes.last().locator('[data-dockkit-tab]').first()
+      // 5. A manual guide and the document float while Files stays docked.
+      const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
       await floatByDrag(page, floatOne)
       await expect.poll(async () => await floats.count()).toBe(1)
       const box = await floats.first().boundingBox()
@@ -835,7 +834,9 @@ describe('web e2e: shipped right Sidebar', () => {
       await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
       await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
 
-      const second = panes.last().locator('[data-dockkit-tab]').first()
+      await panes.last().locator('[data-dockkit-add-tab]').click()
+      await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start'])
+      const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
       await floatByDrag(page, second)
       await expect.poll(async () => await floats.count()).toBe(2)
 
@@ -849,34 +850,33 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(tripwire.warnings).toEqual([])
     }, 90_000)
 
-    it('drops a pane whose last tab closes, and reseeds the guide when none is left', async () => {
+    it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => {
       onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
       const column = await resetSidebar(page)
       const panes = column.locator('[data-dockkit-pane]')
+      expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
       await panes.first().locator('[data-dockkit-split-button]').click()
       await expect.poll(async () => await panes.count()).toBe(2)
-      const closeAllIn = async (pane: Locator): Promise<void> => {
-        const tabs = await pane.locator('[data-dockkit-tab]').count()
-        for (let i = 0; i < tabs; i += 1) {
-          await pane.locator('[data-dockkit-tab-close]').first().click()
-        }
-      }
-
-      // Closing a pane's last tab drops the pane: there is no separate
-      // "close pane" gesture, and none is needed.
-      let count = await panes.count()
-      expect(count).toBeGreaterThan(1)
-      while (count > 1) {
-        await closeAllIn(panes.nth(count - 1))
-        await expect.poll(async () => await panes.count()).toBe(count - 1)
-        count -= 1
-      }
+      await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }),
+        await pointIn(panes.nth(1), 0.5, 0.5))
+      await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME])
+      const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
+      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
+      await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
 
-      // The last pane cannot be dropped, so closing everything in it reseeds
-      // the guide: the surface always has one tab to look at.
-      await closeAllIn(panes.first())
-      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
-      expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
+      // The remaining ordinary document is also unclosable while alone.
+      expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0)
+      await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5))
+      await expect.poll(async () => await panes.count()).toBe(1)
+      const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
+      await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
+      await documentTab.locator('[data-dockkit-tab-close]').click()
+      await expect.poll(async () => await documentTab.count()).toBe(0)
+      expect(await panes.count()).toBe(1)
+      expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
 
       expect(tripwire.pageErrors).toEqual([])
       expect(tripwire.warnings).toEqual([])
@@ -902,10 +902,12 @@ describe('web e2e: shipped right Sidebar', () => {
 
       await ensureExpanded(page, column)
       await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
+      await column.locator('[data-dockkit-add-tab]').click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       // No "more" control on the chip: the chip carries its close, and the menu
       // is the secondary press.
       expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0)
-      await column.locator('[data-dockkit-tab]').first().click({ button: 'right' })
+      await column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }).click({ button: 'right' })
       const menu = page.locator('[data-dockkit-tab-menu]')
       await expect.poll(async () => await menu.count()).toBe(1)
 
@@ -947,6 +949,8 @@ describe('web e2e: shipped right Sidebar', () => {
         const column = zhPage.locator('[data-rightbar-col]')
         await expandOf(zhPage).waitFor({ timeout: 20_000 })
         await expandOf(zhPage).click()
+        await expect.poll(async () => await tabTitles(column)).toEqual(['文件'])
+        await column.locator('[data-dockkit-add-tab]').click()
 
         const guide = column.locator('[data-sidebar-right-guide]')
         await expect.poll(async () => await guide.count()).toBe(1)
@@ -954,7 +958,7 @@ describe('web e2e: shipped right Sidebar', () => {
         // the column has the width, and a screenshot taken mid-transition reads
         // as a layout defect that is not there.
         expect(await width(column)).toBeGreaterThan(300)
-        await expect.poll(async () => await tabTitles(column)).toEqual(['开始'])
+        await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始'])
         await expect.poll(async () => await guide.locator('p').first().innerText())
           .toBe('侧栏用来放你想一直看着的东西。')
         await shot(zhPage, '05-guide-copy-zh')

+ 1 - 0
apps/web/tsconfig.json

@@ -41,6 +41,7 @@
     "tests/trajectory-virtualization.e2e.ts",
     "tests/lifecycle-chrome.e2e.ts",
     "tests/details-session-lifecycle.e2e.ts",
+    "tests/document-preview.e2e.ts",
     "tests/plugin-config.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: 66ad1fdb1368ec1de7ebb3ed75843116228fb16e
-config-catalog.zh.md: 20d18c61088d5f7d29941c8d65abc05e85674b03
+config-catalog.md: f850bdefbbcef812de23d40b0ac64139737e37ba
+config-catalog.zh.md: f925a079826a9ce2a308e9208101bbd5c1a17e72

+ 4 - 2
docs/config-catalog.md

@@ -246,6 +246,8 @@ export interface Config {
    * way. The file itself has no size cap: a caller pages through it.
    */
   readonly maxBytes: number
+  /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */
+  readonly maxFileBytes: number
   /** Default and largest page size in lines; a request asking for more is refused. */
   readonly maxLines: number
   /** Cap on returned directory entries; the rest is dropped and reported cut. */
@@ -253,7 +255,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/api/workspace-files/src/index.ts:57`](../packages/api/workspace-files/src/index.ts)
+Source: [`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts)
 
 <a id="deepseek-aidsh-attachment-local"></a>
 
@@ -3480,9 +3482,9 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-sidebar-textpreview` ([`packages/client/ui-sidebar-textpreview/src/index.ts`](../packages/client/ui-sidebar-textpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))

+ 4 - 2
docs/config-catalog.zh.md

@@ -248,6 +248,8 @@ export interface Config {
    * way. The file itself has no size cap: a caller pages through it.
    */
   readonly maxBytes: number
+  /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */
+  readonly maxFileBytes: number
   /** Default and largest page size in lines; a request asking for more is refused. */
   readonly maxLines: number
   /** Cap on returned directory entries; the rest is dropped and reported cut. */
@@ -255,7 +257,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/api/workspace-files/src/index.ts:57`](../packages/api/workspace-files/src/index.ts)
+来源:[`packages/api/workspace-files/src/index.ts:50`](../packages/api/workspace-files/src/index.ts)
 
 <a id="deepseek-aidsh-attachment-local"></a>
 
@@ -3482,9 +3484,9 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-sidebar-textpreview`([`packages/client/ui-sidebar-textpreview/src/index.ts`](../packages/client/ui-sidebar-textpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 63979cd054bcc7afdaf98801b72ef7074f691645
-module-graph.zh.md: 6e999b796de35741930332f775910c0da2e06582
+module-graph.md: 7df850a31816568944c3ca065efe36b6fc6e3951
+module-graph.zh.md: 31e49c3ae738471d0375dc13e39d629b8f47956a

+ 2 - 2
docs/module-graph.md

@@ -173,9 +173,9 @@ flowchart TD
     pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
+    pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
-    pkg_client_ui_sidebar_textpreview["client-ui-sidebar-textpreview"]
     pkg_client_ui_skill["client-ui-skill"]
     pkg_client_ui_slots["client-ui-slots"]
     pkg_client_ui_subagent["client-ui-subagent"]
@@ -1248,9 +1248,9 @@ flowchart TD
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
+| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
-| [`client-ui-sidebar-textpreview`](../packages/client/ui-sidebar-textpreview) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |

+ 2 - 2
docs/module-graph.zh.md

@@ -175,9 +175,9 @@ flowchart TD
     pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
+    pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
-    pkg_client_ui_sidebar_textpreview["client-ui-sidebar-textpreview"]
     pkg_client_ui_skill["client-ui-skill"]
     pkg_client_ui_slots["client-ui-slots"]
     pkg_client_ui_subagent["client-ui-subagent"]
@@ -1250,9 +1250,9 @@ flowchart TD
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
+| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
-| [`client-ui-sidebar-textpreview`](../packages/client/ui-sidebar-textpreview) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |

+ 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: a1643e61779e5c19450c7e1688b0ce7fcfbee786
-client-resources.zh.md: 418c9aef91a58a85ff8102617074a38813c5f94b
+client-resources.md: d89ba6af433cd41066835be85d348b8fdf2afef0
+client-resources.zh.md: 6bfbb3e4470f89c1a90853427ca6f3a684120b3e

+ 6 - 9
docs/subsystems/client-resources.md

@@ -8,19 +8,19 @@ This page is the developer reference: how to write an address, how to register a
 
 ## Addresses
 
-A resource address is a `dsh-resource://<type>/…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>` or `dsh-resource://file/absolute/<absolute path without its leading />`, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`.
+A resource address is a `dsh-resource://<type>/…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session/<sessionId>/<path>`, where path is workspace-relative or absolute with its leading slashes preserved, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`.
 
 | Address | Protocol key | Reads as |
 |---|---|---|
 | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered |
-| `dsh-resource://file/absolute/home/me/notes.md` | `file` | the metadata of that absolute path, read through the current session and confined to its workspace |
+| `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed |
 | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits |
 | `sidebar://guide` | — | `none`: a navigation address |
 | `/home/me/notes.md` | — | `none`: not a URL |
 
 ## Registering a provider
 
-The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught. `reload(address)` is optional and asks the open stream for a fresh frame.
+The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught.
 
 ```ts ignore-check
 import type { Context } from '@deepseek-ai/cordis'
@@ -43,7 +43,6 @@ export function apply(ctx: Context): void {
       yield await ctx.remote.notes.read(id, signal)
       for await (const change of ctx.remote.notes.follow(id, signal)) yield change
     },
-    reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) },
   }), 'my-notes: note resource provider')
 }
 ```
@@ -61,21 +60,19 @@ Every slot component receives `useResource` in its props, whatever its scope ([S
 | `live` | The latest frame succeeded | the latest `ok` value | `undefined` |
 | `failed` | The latest frame reported a failure | the last `ok` value, kept | the frame's `RemoteFailure` |
 
-`reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or the provider has no `reload`; the function is reference-stable per address, so a body may hold it.
-
 ```tsx ignore-check
 import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type {} from '@deepseek-ai/dsh-api-workspace-files/client'
 
 type Props = PropsRuntime<'sidebar.right.pane.tab'>
 
-export function FileHeader({ tab, useResource, t }: Props) {
+export function FileHeader({ useTabInfo, useResource, t }: Props) {
+  const { tab } = useTabInfo()
   const meta = useResource<'file'>(tab.contentId)
   if (meta.status === 'failed') return <p role="alert">{t('failed', { code: meta.failure.code })}</p>
   return (
     <header>
       {tab.title}
-      {meta.value?.changed && <button type="button" onClick={meta.reload}>{t('reload')}</button>}
     </header>
   )
 }
@@ -87,7 +84,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 `{ 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)).
+Streams carry metadata, not content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)).
 
 ## Limits
 

+ 6 - 9
docs/subsystems/client-resources.zh.md

@@ -8,19 +8,19 @@
 
 ## 地址
 
-资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。
+资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session/<sessionId>/<path>`,其中 path 可以相对工作区根,也可以是保留前导斜杠的绝对路径,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。
 
 | 地址 | 协议键 | 读作 |
 |---|---|---|
 | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) |
-| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 该绝对路径的元数据,经当前会话读取、受其工作区限制 |
+| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session |
 | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 |
 | `sidebar://guide` | — | `none`:导航地址 |
 | `/home/me/notes.md` | — | `none`:不是 URL |
 
 ## 注册提供方
 
-协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。`reload(address)` 可选,请已打开的流给一个新帧。
+协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。
 
 ```ts ignore-check
 import type { Context } from '@deepseek-ai/cordis'
@@ -43,7 +43,6 @@ export function apply(ctx: Context): void {
       yield await ctx.remote.notes.read(id, signal)
       for await (const change of ctx.remote.notes.follow(id, signal)) yield change
     },
-    reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) },
   }), 'my-notes: note resource provider')
 }
 ```
@@ -61,21 +60,19 @@ export function apply(ctx: Context): void {
 | `live` | 最新一帧成功 | 最新的 `ok` 值 | `undefined` |
 | `failed` | 最新一帧报告了失败 | 保留的上一个 `ok` 值 | 该帧的 `RemoteFailure` |
 
-`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作;该函数按地址引用稳定,正文可以长期持有。
-
 ```tsx ignore-check
 import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type {} from '@deepseek-ai/dsh-api-workspace-files/client'
 
 type Props = PropsRuntime<'sidebar.right.pane.tab'>
 
-export function FileHeader({ tab, useResource, t }: Props) {
+export function FileHeader({ useTabInfo, useResource, t }: Props) {
+  const { tab } = useTabInfo()
   const meta = useResource<'file'>(tab.contentId)
   if (meta.status === 'failed') return <p role="alert">{t('failed', { code: meta.failure.code })}</p>
   return (
     <header>
       {tab.title}
-      {meta.value?.changed && <button type="button" onClick={meta.reload}>{t('reload')}</button>}
     </header>
   )
 }
@@ -87,7 +84,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` 提供方的值是 `{ 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))。
+流只推元数据不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`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: 59eda91862282d240d49764d53c17f531ff4bf21
-sidebar-right.zh.md: 3ecb5e9ef496471316414b6aeb0c683a78188025
+sidebar-right.md: 807edc5873c97a256cb66923e776b0573af3186f
+sidebar-right.zh.md: a56ebe819e8fbc63f5e6aa7697eb08e03b8d1c46

+ 25 - 14
docs/subsystems/sidebar-right.md

@@ -8,9 +8,9 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re
 
 ## Position and ownership
 
-One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab.
+One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries.
 
-A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types.
+A tab type is two registrations that share the definition's `id`: a static definition in `ctx.sidebarRightTabs` saying which addresses its `kind` opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types.
 
 | Package | Role |
 |---|---|
@@ -19,7 +19,7 @@ A tab type is two registrations that share one `kind`: a static definition in `c
 | [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` |
 | [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider |
 | [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` |
-| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types |
+| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types |
 
 ## Addresses
 
@@ -90,7 +90,7 @@ Placement is the caller's option, never a type's property. The conversation call
 
 ## Slots and owner props
 
-The subsystem declares four slots; a tab type registers into the first, optionally the second, and any package into the others ([hierarchy](slots.md)).
+The Sidebar declares four extension slots; its document tab declares the additional keyed document body below ([hierarchy](slots.md)).
 
 | Slot | Cardinality | Purpose |
 |---|---|---|
@@ -98,29 +98,40 @@ The subsystem declares four slots; a tab type registers into the first, optional
 | `sidebar.right.pane.tab.title` | keyed by the definition's `id`, Session scope | The chip's title, with the same owner share as the body. Optional: without an entry the chip shows the `title(address)` text captured at open time; a type with a live title reads its own store here. |
 | `sidebar.right.tab.guide` | chain, Session scope | Replaces the guide tab's contents without replacing the tab; the first non-declining entry takes the body, otherwise the shipped guide renders. |
 | `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. |
+| `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. |
 
 A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters.
 
 `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)).
 
+## Document renderers
+
+The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership.
+
+The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control.
+
+[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array<ArrayBuffer>` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes.
+
+Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)).
+
 ## Resource model
 
-The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource://<type>/…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }), reload?(address) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch.
+The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource://<type>/…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch.
 
-`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)).
+`useResource<P>(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure }`: `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. ([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 `{ 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)).
+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? }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
 
 ## Workspace Files
 
-The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote namespace answer for files inside the addressed Session's workspace root: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding; `list(path)` returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set; `changes()` yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). Every call passes the same four gates — the path is inside the workspace root, symlinks are refused, page, window, and entry caps hold, `read`'s text is UTF-8 — and fails with a `workspace-file/*` error code otherwise ([failures](../../packages/api/workspace-files/README.md)).
+The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes()` likewise remains workspace-scoped and yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File operations reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)).
 
-[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider and declares `ResourceProtocolMap.file`. It sends a Session address's relative path unchanged to the Host and binds change filtering to the first successful `stat.absolutePath`. It waits for the Host's `ready` frame before stat, retaining changes delivered during that read. An absolute address uses the current Session; only its absence produces Client `workspace-file/unknown-workspace`. No Client `cwd` is required.
+[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results.
 
 ## Shipped types
 
-- **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, every new pane is seeded with one, and the strip's add control appears only while its pane has none ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
-- **`text`** — `fallback`, `dsh-resource://file/**`. Reads metadata through `useResource<'file'>` and the file's lines by page through `read`; honours `params.line` on every navigation; keeps pages, scroll, and wrap in its own store ([README](../../packages/client/ui-sidebar-textpreview/README.md)).
+- **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
+- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
 - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
 
 <a id="not-built"></a>
@@ -129,9 +140,9 @@ The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote
 - Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another.
 - A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal.
 - A capability-discovery array (`features`) on the service.
-- An `option` priority band: nothing lists a type without letting it claim.
+- An `option` priority band for tab types: nothing lists a tab type without letting it claim.
 - Retitling a record: `title(address)` is captured once; a live chip comes from the title slot, not from the record.
-- Naming an implementation when opening: `openResource` names a kind at most, and the kind's implementation in force answers.
+- Naming a tab implementation when opening: `openResource` names a kind at most; document-renderer selection belongs to the file tab's toolbar.
 - An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate.
 - Navigation addresses beyond the Sidebar's own `sidebar://<kind>` bookkeeping; their grammar waits for the navigation controller as a whole.
-- A user-facing undo, a content navigation stack, tab icons, and closing restrictions ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)).
+- A user-facing undo, a content navigation stack, and tab icons ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)).

+ 25 - 14
docs/subsystems/sidebar-right.zh.md

@@ -8,9 +8,9 @@
 
 ## 定位与归属
 
-每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。
+每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。
 
-一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。
+一个 tab 类型是共用定义 `id` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明其 `kind` 打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。
 
 | 包 | 职责 |
 |---|---|
@@ -19,7 +19,7 @@
 | [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` |
 | [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 |
 | [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` |
-| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 |
+| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 |
 
 ## 地址
 
@@ -90,7 +90,7 @@ export function apply(ctx: Context): void {
 
 ## Slot 与 owner props
 
-本子系统声明四个 slot;tab 类型注册进第一个,可选地注册第二个,任何包都可注册进其余两个([层级](slots.zh.md))。
+Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 文档正文 slot([层级](slots.zh.md))。
 
 | Slot | Cardinality | 用途 |
 |---|---|---|
@@ -98,29 +98,40 @@ export function apply(ctx: Context): void {
 | `sidebar.right.pane.tab.title` | 按定义的 `id` keyed,会话作用域 | chip 的标题,owner share 与正文相同。可选:没有条目时 chip 显示打开时捕获的 `title(address)` 文本;有活标题的类型在此读自己的 store。 |
 | `sidebar.right.tab.guide` | chain,会话作用域 | 替换引导 tab 的内容而不替换 tab;第一个不拒绝的条目接管正文,否则渲染自带引导。 |
 | `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 |
+| `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 |
 
 正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。
 
 `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。
 
+## 文档渲染器
+
+`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。
+
+[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。
+
+[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。
+
+Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。
+
 ## 资源模型
 
-模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource://<type>/…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }), reload?(address) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。
+模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource://<type>/…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。
 
-`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<P>(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。
 
-资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
+资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
 
 ## Workspace Files
 
-Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间负责所寻址会话工作区根之内的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码;`list(path)` 返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`;`changes()` 在订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。每次调用都过同样四关——路径在工作区根内、拒绝符号链接、页、窗口与条目上限、`read` 的 UTF-8 文本——否则以 `workspace-file/*` 错误码失败([失败](../../packages/api/workspace-files/README.zh.md))。
+Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes()` 同样限定于工作区,订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件操作拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。
 
-[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方并声明 `ResourceProtocolMap.file`。它把 Session 地址的相对路径原样发送给 Host,按首次成功的 `stat.absolutePath` 绑定变更过滤。它在 stat 前等待 Host 的 `ready` 帧,并保留读取期间到达的变更。绝对地址使用当前 Session;只有缺少当前 Session 时才产生 Client `workspace-file/unknown-workspace`。Client 不需要 `cwd`。
+[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。
 
 ## 内置类型
 
-- **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,每个新 pane 都种入一个,tab 条的新增控件只在本 pane 没有引导时出现([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
-- **`text`**——`fallback`,`dsh-resource://file/**`。经 `useResource<'file'>` 读元数据、经 `read` 按页读文件行;每次导航都响应 `params.line`;页、滚动与换行放在自己的 store 里([README](../../packages/client/ui-sidebar-textpreview/README.zh.md))。
+- **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
+- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
 
 <a id="not-built"></a>
@@ -129,9 +140,9 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
 - 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。
 - `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。
 - 服务上的能力探测数组(`features`)。
-- `option` 优先级档:没有「只列出、不许认领」的类型。
+- tab 类型的 `option` 优先级档:没有「只列出、不许认领」的 tab 类型。
 - 改写记录的标题:`title(address)` 只捕获一次;活的 chip 来自标题 slot,而不是记录。
-- 打开时点名某个实现:`openResource` 最多点名一个 kind,由该 kind 的生效实现应答。
+- 打开时点名某个 tab 实现:`openResource` 最多点名一个 kind;文档渲染器由文件 tab 的工具栏选择。
 - 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。
 - Sidebar 自身 `sidebar://<kind>` 记账之外的导航地址;其语法等导航控制器整体做时再定。
-- 面向用户的撤销、内容导航栈、tab 图标与关闭限制([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。
+- 面向用户的撤销、内容导航栈与 tab 图标([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。

+ 2 - 2
docs/subsystems/workspace.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/workspace.md
-workspace.md: 3e54ef3d0a2339d50fbc8a4132b91d7f82701321
-workspace.zh.md: a9b8375f388538a3588a84f2d71e47b89ddc679a
+workspace.md: a7c4b8843f0a0b2d2fb251157f7928a40df1e405
+workspace.zh.md: 3474cbf147848b04a4ec51147983a343bcc1e91d

+ 25 - 6
docs/subsystems/workspace.md

@@ -246,13 +246,13 @@ Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/wo
 
 ### `ctx.workspaceFiles` — `WorkspaceFiles`
 
-Host Remote service over the composed filesystem, confined to one workspace.
+Host Remote file reads and workspace directory observations over the composed filesystem.
 
 ```ts cordis-catalog
 /**
- * Read one page of lines from a UTF-8 text file inside the Agent's workspace.
+ * Read one page of lines from a UTF-8 file readable by the filesystem backend.
  * @param agent - target Agent resolved from the Session identity on the wire.
- * @param path - workspace path, absolute or relative to the workspace root.
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the line window; omitted fields take the page defaults.
  * @param signal - caller cancellation.
  * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
@@ -260,20 +260,39 @@ Host Remote service over the composed filesystem, confined to one workspace.
 @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
 
 /**
- * Read one byte window of a regular file inside the Agent's workspace: raw
+ * Read one byte window of a regular file readable by the filesystem backend: raw
  * bytes, no text decoding and no binary rejection.
  * @param agent - target Agent resolved from the Session identity on the wire.
- * @param path - workspace path, absolute or relative to the workspace root.
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the byte window; omitted fields take the window defaults.
  * @param signal - caller cancellation.
  * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
  */
 @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
+/**
+ * Read a complete regular file as bytes, subject to the configured full-file cap.
+ * @param agent - target Agent whose workspace resolves relative paths.
+ * @param path - absolute or workspace-relative file path.
+ * @param signal - caller cancellation.
+ * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
+ */
+@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+
+/**
+ * Read a complete file relative to another file's directory, including outside the workspace.
+ * @param agent - Agent whose workspace resolves the base file's relative path.
+ * @param path - base file, absolute or workspace-relative.
+ * @param relativePath - relative filesystem path, not a URL or absolute path.
+ * @param signal - caller cancellation.
+ * @returns the complete related file using the ordinary file-size and access checks.
+ */
+@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+
 /**
  * Report one regular file's identity, version, and size without its content.
  * @param agent - target Agent resolved from the Session identity on the wire.
- * @param path - workspace path, absolute or relative to the workspace root.
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param signal - caller cancellation.
  * @returns the file's absolute path, current version, and byte size.
  */

+ 25 - 6
docs/subsystems/workspace.zh.md

@@ -246,13 +246,13 @@ Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/wo
 
 ### `ctx.workspaceFiles` — `WorkspaceFiles`
 
-Host Remote service over the composed filesystem, confined to one workspace.
+Host Remote file reads and workspace directory observations over the composed filesystem.
 
 ```ts cordis-catalog
 /**
- * Read one page of lines from a UTF-8 text file inside the Agent's workspace.
+ * Read one page of lines from a UTF-8 file readable by the filesystem backend.
  * @param agent - target Agent resolved from the Session identity on the wire.
- * @param path - workspace path, absolute or relative to the workspace root.
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the line window; omitted fields take the page defaults.
  * @param signal - caller cancellation.
  * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
@@ -260,20 +260,39 @@ Host Remote service over the composed filesystem, confined to one workspace.
 @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
 
 /**
- * Read one byte window of a regular file inside the Agent's workspace: raw
+ * Read one byte window of a regular file readable by the filesystem backend: raw
  * bytes, no text decoding and no binary rejection.
  * @param agent - target Agent resolved from the Session identity on the wire.
- * @param path - workspace path, absolute or relative to the workspace root.
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param range - the byte window; omitted fields take the window defaults.
  * @param signal - caller cancellation.
  * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
  */
 @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
+/**
+ * Read a complete regular file as bytes, subject to the configured full-file cap.
+ * @param agent - target Agent whose workspace resolves relative paths.
+ * @param path - absolute or workspace-relative file path.
+ * @param signal - caller cancellation.
+ * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
+ */
+@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+
+/**
+ * Read a complete file relative to another file's directory, including outside the workspace.
+ * @param agent - Agent whose workspace resolves the base file's relative path.
+ * @param path - base file, absolute or workspace-relative.
+ * @param relativePath - relative filesystem path, not a URL or absolute path.
+ * @param signal - caller cancellation.
+ * @returns the complete related file using the ordinary file-size and access checks.
+ */
+@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
+
 /**
  * Report one regular file's identity, version, and size without its content.
  * @param agent - target Agent resolved from the Session identity on the wire.
- * @param path - workspace path, absolute or relative to the workspace root.
+ * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
  * @param signal - caller cancellation.
  * @returns the file's absolute path, current version, and byte size.
  */

+ 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: ec15f52bf17fffca2b225fa427a7405922aaf2b3
-README.zh.md: 7e45d5c9a6073435e26e1237791a328db654d839
+README.md: e49f5bef7291233eb688bc9aeab44e56efb3d7fa
+README.zh.md: 1d3808969ac377408801a7516978bbebd6860b62

+ 18 - 16
packages/api/workspace-files/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Workspace file service for the web GUI: paged read, byte windows, stat, directory listing, and the Agent-write change feed inside the Session workspace root, exposed as the workspaceFiles Remote namespace."
+description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and Agent-write observation inside the Session workspace root."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use this package to browse and inspect files within a Session's workspace from the web client. It reads UTF-8 text one page of lines at a time, reads raw bytes in bounded windows, reports file versions and sizes, lists direct directory children, and streams changes caused by Agent file operations. Every operation stays within the workspace root selected for the addressed Session, independent of the filesystem backend's working directory. Client components can also follow live file metadata and build the Sidebar file tree through the shared Remote API.
+Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and Agent-write change observation remain workspace-scoped. The service exposes no mutation operation.
 
 ## Table of Contents
 
@@ -32,12 +32,14 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway;
 | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | Identity, version, and size of one regular file, without content |
 | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently |
 | `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | One window of raw bytes from any regular file, base64-encoded |
+| `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated |
+| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host |
 | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory |
 | `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then Agent observations inside the workspace root |
 
 ### Addressing and paths
 
-`read`, `stat`, and `list` accept a workspace path that is absolute or relative to the Session's workspace root. Two path vocabularies leave the service, and each method uses exactly one: `read`, `stat`, and `changes` report a file as its absolute path in the filesystem's execution world, symlinks resolved (`WorkspaceFileStat.absolutePath`, `WorkspaceFileChange.absolutePath`), because their consumer is the Client resource system, which follows changes by that path; `list` reports the listed directory as a workspace path relative to the root — empty for the root itself — because its consumer is a tree rooted there, and a child's path is that value joined with the entry name by `/`.
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only Agent observations inside the workspace root.
 
 ### Pages
 
@@ -47,9 +49,9 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway;
 
 `read` pages by lines and never by bytes; a byte window is `readBytes`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as base64 `data`, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page.
 
-### The four gates
+### File-read and directory checks
 
-Every read, stat, and listing passes four gates in this order. First, `lstat` inspects the path itself before anything follows it: a symlink, wherever it points, fails `read` and `stat` with `not-regular-file` and `list` with `not-directory`, each carrying the entry's `kind`. Second, containment: the path resolves to a target and `ctx.fs.contains(root, target)` decides, so a `..` traversal or an absolute path outside the root fails with `outside-workspace` — never a string-prefix comparison, which cannot see a realpath that leaves the root. Third, the caps: a page whose text exceeds `maxBytes` fails with `too-large` instead of arriving shortened — the file itself has no size cap — while `maxEntries` cuts a listing and sets `truncated`. Fourth, text: content that is not UTF-8 up to the end of the page, or a page that carries a NUL byte, fails with `not-text`; bytes past the page are not inspected. A missing path fails with `not-found`; an empty path is a `gateway/bad-request`.
+Every operation first uses `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. `list` alone requires the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty path is a `gateway/bad-request`.
 
 ### The change feed
 
@@ -60,6 +62,7 @@ Every read, stat, and listing passes four gates in this order. First, `lstat` in
 | Field | Default | Meaning |
 |---|---|---|
 | `maxBytes` | `2097152` (2 MiB) | Inclusive byte cap on one page's text and on one byte window; a larger page or window fails |
+| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readAll` and `readRelated`; larger files fail with `too-large` |
 | `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused |
 | `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut |
 
@@ -67,15 +70,15 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Failures
 
-Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace`, `workspace-file/too-large` (with `limit`, the page and window cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text.
+Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace` (directory listing only), `workspace-file/too-large` (with `limit`, the applicable page, window, or complete-file cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text.
 
 ### 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 `{ absolutePath, 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`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through Remote reads. Any UI, including Global components, shares the observation for the same complete address.
 
-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.
+A `session/<sessionId>/<path>` address carries the authorizing Session and a relative or absolute path; leading slashes are preserved, as in `dsh-resource://file/session/s//etc/hosts`. The Host receives the path unchanged and owns resolution and access checks; the Client needs no Session `cwd`. `absolute/<path>` remains parseable but has no authorizing Session and fails with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Unsupported addresses fail with `workspace-file/unsupported-address`. [Workspace-path](../../util/workspace-path/README.md) owns the grammar; the generic Resource layer knows only the address and `signal`.
 
-The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version raises `changed` while retaining the last byte size; duplicate versions are ignored. An absent notice or reload re-stats the file. A failed stat keeps the address followed; a later write or reload can recover it, and any Session write can trigger a retry before the first successful path binding. Reload clears `changed`; a Host-triggered re-stat keeps it raised. Frames are `RemoteResult` values, and programming exceptions remain uncaught.
+The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version updates metadata while retaining the last byte size; duplicate versions are ignored. An absent notice re-stats the file. A failed stat keeps the address followed; a later write can recover it, and any Session write can trigger a retry before the first successful path binding. Frames are `RemoteResult` values, and programming exceptions remain uncaught.
 
 One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters.
 
@@ -89,13 +92,13 @@ One supervised `changes` stream serves every followed file in a Session. Followe
 
 ### Design concept
 
-Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fences writes and edits only — so every constraint here is the service's own. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window, so neither a huge file nor one giant line can hold more than a page in memory; the NUL scan then runs on the page. One `stat` before the stream names the version and size the page reports. The path gate runs before containment on purpose: `lstat` is path-shaped and sees the link, while `resolve` follows it; the price is that an entry outside the root reports its own kind before its position.
+Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports.
 
 ### Source map
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` |
+| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `readAll`, `readRelated`, `stat`, `list` |
 | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: `fs/observed` subscription and one queue per open `changes` generation |
 | [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages |
 | [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-Session change feed |
@@ -116,7 +119,7 @@ Typert generates the Host and Client Remote artifacts exposed by `./typert` and
 - [Remote assembly](../../api/remotes/README.md) — how Client packages reach the `workspaceFiles` namespace.
 - [Client resources](../../client/resources/README.md) — the resource model, `useResource`, pins, and provider lifetime.
 - [Workspace path helpers](../../util/workspace-path/README.md) — `fileAddressFor` and `parseFileAddress`, the `dsh-resource://file/…` address grammar both ends share.
-- [Sidebar text preview](../../client/ui-sidebar-textpreview/README.md) — the tab type that follows a file through the `file` provider and reads its pages.
+- [Sidebar text preview](../../client/ui-sidebar-documentpreview/README.md) — the tab type that follows a file through the `file` provider and reads its pages.
 
 -----
 
@@ -134,14 +137,13 @@ None; this package neither assembles nor sends a provider request.
 <a id="known-limitations-and-deferred-work"></a>
 
 - **Agent writes only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame.
-- **Kind before position** — an entry outside the workspace whose type already disqualifies it reports `not-regular-file` or `not-directory`, not `outside-workspace`, because the path gate precedes containment.
+- **Directory scope only** — `list` and `changes` stay inside the Session workspace even though file preview reads may use any path readable by the filesystem backend.
 - **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`.
 - **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes.
-- **Version precedes content** — the `version` on a page is the stat's, taken before the stream; a write landing between the two leaves the page one version behind, which the next `changes` frame reports.
+- **Reads are not transactional** — result metadata comes from stat before content is read; a concurrent write can make the reported version and returned contents differ.
 - **Unbounded generation queue** — a `changes` generation buffers every contained observation until its consumer pulls; a stalled consumer grows Host memory for the life of the stream.
 - **`maxEntries` bounds the answer, not the listing** — `list` asks `ctx.fs.listDir` for every child and cuts the array afterwards, so a directory far above the cap still costs the Host the whole listing (on `fs-local`, one stat per child); bounding that work needs a limit on the filesystem seam's `listDir`.
-- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened; reload does not reopen the stream.
-- **Reload is shared by path** — a reload re-stats every follower of that absolute path in the Session and clears their `changed` flags, including readers that did not reload their content. Per-record reload delivery remains deferred.
+- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 18 - 16
packages/api/workspace-files/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "面向 Web GUI 的工作区文件服务:在 Session 工作区根内做分页读取、字节窗口、stat、目录列举与 Agent 写入变更流,以 workspaceFiles Remote 命名空间暴露。"
+description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察 Agent 写入。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用本包可从 Web Client 浏览和检查 Session 工作区内的文件。它按行分页读取 UTF-8 文本、按有界窗口读取原始字节、报告文件版本与大小、列举目录的直接子项,并流式推送 Agent 文件操作造成的变更。每项操作都限定在为被寻址 Session 选择的工作区根内,不受文件系统后端工作目录影响。Client 组件还可经共享 Remote API 跟随实时文件元数据并构建 Sidebar 文件树。
+使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与 Agent 写入变更观察仍限定于工作区。本服务不提供修改操作。
 
 ## 目录
 
@@ -32,12 +32,14 @@ kind: "package-reference"
 | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | 一个普通文件的身份、版本与大小,不含内容 |
 | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 |
 | `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 任意普通文件的一个原始字节窗口,base64 编码 |
+| `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 |
+| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 |
 | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 |
 | `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的 Agent 观察 |
 
 ### 寻址与路径
 
-`read`、`stat` 与 `list` 接受工作区路径,可以是绝对路径,也可以是相对于 Session 工作区根的路径。离开服务的路径词汇有两套,每个方法只用其中一套:`read`、`stat` 与 `changes` 以文件系统执行环境中的绝对路径报告文件,符号链接已解析(`WorkspaceFileStat.absolutePath`、`WorkspaceFileChange.absolutePath`),因为其消费方是 Client 资源系统,它按这条路径跟随变更;`list` 以相对于根的工作区路径报告被列举目录——根自身为空串——因为其消费方是一棵以根为起点的树,子项路径就是该值与条目名以 `/` 连接。
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内的 Agent 观察。
 
 ### 分页
 
@@ -47,9 +49,9 @@ kind: "package-reference"
 
 `read` 按行分页,绝不按字节;字节窗口走 `readBytes`。`range.offset` 是 0 起算的首字节,缺省为 0;`range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 base64 的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。
 
-### 四道关
+### 文件读取与目录检查
 
-每次读取、stat 与列举依次过四道关。第一,`lstat` 在跟随任何东西之前检查路径本身:符号链接不论指向哪里,`read` 与 `stat` 都以 `not-regular-file`、`list` 都以 `not-directory` 拒绝,并带上条目的 `kind`。第二,包含判定:路径解析为目标后由 `ctx.fs.contains(root, target)` 裁决,所以 `..` 上溯或根外绝对路径都以 `outside-workspace` 失败——绝不做字符串前缀比较,那看不见离开根的 realpath。第三,上限:文本超过 `maxBytes` 的页以 `too-large` 失败而不是被截短送达——文件本身没有大小上限——`maxEntries` 则截断列举并置 `truncated`。第四,文本:到该页末尾为止非 UTF-8 的内容,或含 NUL 字节的页,以 `not-text` 失败;页之后的字节不检查。路径不存在以 `not-found` 失败;空路径是 `gateway/bad-request`。
+每项操作都先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。只有 `list` 要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。
 
 ### 变更流
 
@@ -60,6 +62,7 @@ kind: "package-reference"
 | 字段 | 默认值 | 含义 |
 |---|---|---|
 | `maxBytes` | `2097152`(2 MiB) | 单页文本与单个字节窗口的字节上限(含);更大的页或窗口失败 |
+| `maxFileBytes` | `33554432`(32 MiB) | `readAll` 和 `readRelated` 的完整文件字节上限(含);更大文件以 `too-large` 失败 |
 | `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 |
 | `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 |
 
@@ -67,15 +70,15 @@ kind: "package-reference"
 
 ### 失败
 
-每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`、`workspace-file/too-large`(带 `limit`,即页与窗口上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。
+每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`(仅目录列举)、`workspace-file/too-large`(带 `limit`,即适用的页、窗口或完整文件上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。
 
 ### Client 文件资源
 
-浏览器导出向 `ctx.resources` 注册 `file` 提供者,要求 `resources`、`remote`、`remote.workspaceFiles` 和 `sessions` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件经标准 prop `useResource<'file'>(address)` 跟随文件,读取 `{ absolutePath, version, bytes?, changed }`;内容通过分页方法另行获取。
+浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,内容另经 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。
 
-`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 失败会结束流,并使刷新无动作。
+`session/<sessionId>/<path>` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/<path>` 仍可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。不支持的地址以 `workspace-file/unsupported-address` 失败。语法由 [workspace-path](../../util/workspace-path/README.zh.md) 定义;Resource 泛型层只认地址和 `signal`。
 
-提供者等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本置 `changed`,并保留最近的字节大小;重复版本被忽略。消失通知或刷新会重新 stat 文件。stat 失败后仍跟随地址,后续写入或刷新可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。刷新清除 `changed`,由 Host 触发的重新 stat 保留标记。帧是 `RemoteResult` 值,编程异常不被捕获。
+提供者等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本更新元数据并保留最近的字节大小;重复版本被忽略。消失通知会重新 stat 文件。stat 失败后仍跟随地址,后续写入可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。帧是 `RemoteResult` 值,编程异常不被捕获。
 
 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。
 
@@ -89,13 +92,13 @@ kind: "package-reference"
 
 ### 设计概念
 
-经 `ctx.fs` 的读取是有意不加限制的——沙箱后端只围栏写与编辑——所以这里的每条约束都是服务自己的。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回,所以无论多大的文件或多长的单行都不会在内存里超过一页;随后在该页上做 NUL 扫描。流之前的一次 `stat` 给出页所报告的版本与大小。路径关有意先于包含判定:`lstat` 面向路径、看得见链接,而 `resolve` 会跟随它;代价是根外条目会先报告自己的类型再报告位置。
+经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。
 
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` |
+| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`readAll`、`readRelated`、`stat`、`list` |
 | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:`fs/observed` 订阅与每个打开的 `changes` generation 各一条队列 |
 | [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 |
 | [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与每 Session 变更流 |
@@ -116,7 +119,7 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物
 - [Remote 装配](../../api/remotes/README.zh.md)——Client 包如何触达 `workspaceFiles` 命名空间。
 - [Client 资源](../../client/resources/README.zh.md)——资源模型、`useResource`、pin 与提供者生命周期。
 - [工作区路径辅助](../../util/workspace-path/README.zh.md)——`fileAddressFor` 与 `parseFileAddress`,两端共享的 `dsh-resource://file/…` 地址语法。
-- [Sidebar 文本预览](../../client/ui-sidebar-textpreview/README.zh.md)——经 `file` 提供者跟随文件并读取其页的 tab 类型。
+- [Sidebar 文本预览](../../client/ui-sidebar-documentpreview/README.zh.md)——经 `file` 提供者跟随文件并读取其页的 tab 类型。
 
 -----
 
@@ -134,14 +137,13 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物
 <a id="known-limitations-and-deferred-work"></a>
 
 - **仅覆盖 Agent 写入**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。
-- **类型先于位置**——根外条目若类型本身就不合格,报告的是 `not-regular-file` 或 `not-directory` 而非 `outside-workspace`,因为路径关先于包含判定。
+- **仅目录受限**——尽管文件预览可以读取文件系统后端允许的任意路径,`list` 与 `changes` 仍限定在 Session 工作区内。
 - **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。
 - **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。
-- **版本先于内容**——页上的 `version` 来自流之前的 stat;两者之间落地的写入会让该页落后一个版本,下一帧 `changes` 会报告它。
+- **读取不具备事务性**——结果元数据来自内容读取之前的 stat;并发写入可能使报告版本与返回内容不一致。
 - **generation 队列无界**——一个 `changes` generation 会缓冲每一条被包含的观察直到消费方 pull;停滞的消费方会在流的生命期内持续增长 Host 内存。
 - **`maxEntries` 限制的是答案,不是列举**——`list` 让 `ctx.fs.listDir` 列出全部子项后再截断数组,远超上限的目录仍让 Host 付出整个列举的代价(`fs-local` 上每个子项一次 stat);要限制这份工作,需要文件系统 seam 的 `listDir` 支持上限。
-- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开;刷新不会重开流。
-- **刷新按路径共享**——同一会话中,一次刷新会重新 stat 此绝对路径的全部跟随者并清除其 `changed` 标记,包括没有重读内容的其它读者。按记录投递刷新仍是延期工作。
+- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 0 - 2
packages/api/workspace-files/package.json

@@ -41,7 +41,6 @@
     "client": {
       "inject": [
         "@deepseek-ai/dsh-api-gateway",
-        "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-resources"
       ],
       "platform": "web"
@@ -65,7 +64,6 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
-    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-fs": "workspace:^",

+ 9 - 42
packages/api/workspace-files/src/client/change-feed.ts

@@ -7,8 +7,7 @@
  * arrives, hands each frame to the followers of its path, and disposes the
  * stream when the last follower leaves. A follower buffers session changes
  * until `stat` supplies its Host absolute path, then filters queued and live
- * frames by that path, with `\\` normalized to `/`. Resource addresses identify
- * reload requests; they never determine a notification path.
+ * frames by that path, with `\\` normalized to `/`.
  */
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceFileChange, WorkspaceFileWatchFrame } from '../types.ts'
@@ -26,7 +25,7 @@ function keyOf(path: string): string {
 
 /** Notices of one follower, delivered in order and pulled by its consumer. */
 class Follower implements AsyncIterable<WorkspaceFileNotice> {
-  private readonly pending: Array<{ readonly key: string | undefined; readonly notice: WorkspaceFileNotice }> = []
+  private readonly pending: Array<{ readonly key: string; readonly notice: WorkspaceFileNotice }> = []
   private readonly started = Promise.withResolvers<boolean>()
   private wake: (() => void) | undefined
   private ended = false
@@ -39,15 +38,9 @@ class Follower implements AsyncIterable<WorkspaceFileNotice> {
   readonly ready = this.started.promise
 
   /**
-   * @param address - resource address used for reload lookup.
    * @param leave - unregisters this follower and its abort listener.
    */
-  constructor(readonly address: string, private readonly leave: () => void) {}
-
-  /** The normalized Host path, absent until a successful stat. */
-  get key(): string | undefined {
-    return this.hostKey
-  }
+  constructor(private readonly leave: () => void) {}
 
   /**
    * Select the Host path for queued and future changes.
@@ -65,9 +58,9 @@ class Follower implements AsyncIterable<WorkspaceFileNotice> {
   /**
    * Queue one notice.
    * @param notice - what the consumer receives next.
-   * @param key - normalized Host path for a change; absent for a reload.
+   * @param key - normalized Host path for the change.
    */
-  push(notice: WorkspaceFileNotice, key?: string): void {
+  push(notice: WorkspaceFileNotice, key: string): void {
     this.pending.push({ key, notice })
     this.wake?.()
   }
@@ -90,7 +83,7 @@ class Follower implements AsyncIterable<WorkspaceFileNotice> {
       while (true) {
         const next = this.pending.shift()
         if (next !== undefined) {
-          if (next.key === undefined || this.hostKey === undefined || next.key === this.hostKey) yield next.notice
+          if (this.hostKey === undefined || next.key === this.hostKey) yield next.notice
           continue
         }
         if (this.ended) return
@@ -155,22 +148,6 @@ class SessionFeed {
     if (this.followers.size === 0) this.close()
   }
 
-  /**
-   * Reload an address and every follower bound to the same Host path.
-   * @param address - the resource address requesting a reload.
-   */
-  requestRestat(address: string): void {
-    const keys = new Set<string>()
-    for (const follower of this.followers) {
-      if (follower.address === address && follower.key !== undefined) keys.add(follower.key)
-    }
-    for (const follower of this.followers) {
-      if (follower.address === address || (follower.key !== undefined && keys.has(follower.key))) {
-        follower.push({ kind: 'restat' })
-      }
-    }
-  }
-
   private async pump(): Promise<void> {
     try {
       for await (const item of this.stream) {
@@ -250,7 +227,7 @@ export class ChangeFeed {
   constructor(private readonly remote: WorkspaceFilesRemote) {}
 
   /**
-   * Follow one resource address in one session before its Host path is known.
+   * Follow one resource in one session before its Host path is known.
    *
    * The follower is registered on call, not on first pull. Changes delivered
    * to this Client are queued while stat is pending. The first follower starts
@@ -262,18 +239,17 @@ export class ChangeFeed {
    * any session write can trigger a retry; after binding, only matching queued
    * and live changes pass.
    * @param sessionId - the session whose workspace holds the file.
-   * @param address - the resource address, used only for reload lookup.
    * @param signal - ends the follow.
    * @returns a single-consumer subscription with Host-path binding and explicit disposal.
    */
-  follow(sessionId: SessionId, address: string, signal: AbortSignal): Follower {
+  follow(sessionId: SessionId, signal: AbortSignal): Follower {
     const feed = signal.aborted ? undefined : this.feedOf(sessionId)
     const leave = (): void => {
       signal.removeEventListener('abort', leave)
       follower.end()
       feed?.remove(follower)
     }
-    const follower = new Follower(address, leave)
+    const follower = new Follower(leave)
     if (feed === undefined) {
       follower.end()
     } else {
@@ -283,15 +259,6 @@ export class ChangeFeed {
     return follower
   }
 
-  /**
-   * Ask an address and its same-session Host-path peers to `stat` again.
-   * @param sessionId - the session whose workspace holds the file.
-   * @param address - the resource address requesting a reload.
-   */
-  requestRestat(sessionId: SessionId, address: string): void {
-    this.sessions.get(sessionId)?.requestRestat(address)
-  }
-
   /**
    * Wait for every stream that is still closing, so an owner tearing down
    * leaves no Host stream behind.

+ 6 - 13
packages/api/workspace-files/src/client/index.ts

@@ -7,29 +7,22 @@
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-gateway/client'
-// The `sessions` service face supplies the current Session for absolute addresses.
-import type {} from '@deepseek-ai/dsh-api-session-controller/client'
 import type {} from '@deepseek-ai/dsh-client-resources/client'
 import { ChangeFeed } from './change-feed.ts'
-import { createFileResourceProvider, type SessionLookup } from './provider.ts'
+import { createFileResourceProvider } from './provider.ts'
 
-export type { SessionLookup } from './provider.ts'
-export type { WorkspaceFileParams, WorkspaceFileResource } from './types.ts'
+export type { WorkspaceFileParams } from './types.ts'
 
-/** Required browser services: the resource model, the Remote carrier and its namespace, and the Session list. */
-export const inject = ['resources', 'remote', 'remote.workspaceFiles', 'sessions']
+/** Required browser services: the resource model, the Remote carrier and its namespace. */
+export const inject = ['resources', 'remote', 'remote.workspaceFiles']
 
 /**
  * Client plugin body: register the `file` provider for this plugin's lifetime.
- * @param ctx - client root context carrying `resources`, the Remote face, and `sessions`.
+ * @param ctx - client root context carrying `resources` and the Remote face.
  */
 export function apply(ctx: ClientContext): void {
-  // The current Session changes with navigation; absolute addresses read it on demand.
-  const sessions: SessionLookup = {
-    current: () => ctx.sessions.list.getSnapshot().current,
-  }
   const changes = new ChangeFeed(ctx.remote)
-  const provider = createFileResourceProvider(ctx.remote, changes, sessions)
+  const provider = createFileResourceProvider(ctx.remote, changes)
   ctx.effect(() => {
     const release = ctx.resources.register(provider)
     // Teardown waits for every session stream still closing, so the plugin

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

@@ -3,23 +3,22 @@
  * `RemoteResult` frames.
  *
  * An address names the file in one of two scopes. A `session` address,
- * `dsh-resource://file/session/<sessionId>/<path>`, carries a path relative to
- * that Session's workspace root: the Host receives the relative path as-is and
+ * `dsh-resource://file/session/<sessionId>/<path>`, carries an absolute path or one
+ * relative to that Session's workspace root: the Host receives the path as-is and
  * resolves it against the root it holds. Only the Host's `stat.absolutePath`
  * selects the change-feed key; no Client Session summary is needed.
  * An `absolute` address, `dsh-resource://file/absolute/<path>`, carries no
- * Session and is read through the Session on screen. An address neither scope
+ * Session and cannot authorize a Host call. An address neither scope
  * resolves yields one failure frame — `workspace-file/unsupported-address` for
  * a string outside the grammar, `workspace-file/unknown-workspace` when the
- * absolute address has no current Session — and ends.
+ * address carries no Session — and ends.
  *
  * The first frame is the file's `stat`; every Host-reported write yields the
- * metadata flagged `changed`; a reported disappearance, or a write while the
- * last stat had failed, runs `stat` again and flags what it finds; a reload
- * runs `stat` again and clears the flag. Failures travel as `ok: false` frames, never as thrown errors: the
+ * metadata with its reported version; a reported disappearance, or a write while the
+ * last stat had failed, runs `stat` again. Failures travel as `ok: false` frames, never as thrown errors: the
  * Remote face does not reject, and anything thrown inside the stream is a
  * programming error the resource model lets surface. A failed stat does not end
- * the stream: the next write or reload stats again. One {@link ChangeFeed}
+ * the stream: the next write stats again. One {@link ChangeFeed}
  * serves every open file of the Client.
  */
 import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
@@ -30,61 +29,49 @@ import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
 import type { WorkspaceFileStat } from '../types.ts'
 import type { ChangeFeed } from './change-feed.ts'
 import type { WorkspaceFilesRemote } from './remote.ts'
-import type { WorkspaceFileResource } from './types.ts'
-
-/** The current Session used to authorize an absolute address. */
-export interface SessionLookup {
-  /**
-   * The Session on screen, which an `absolute` address is read through.
-   * @returns its id, or `undefined` while no Session is current.
-   */
-  current(): SessionId | undefined
-}
 
 /** The Session and unmodified path submitted to the Host. */
 interface HostFile {
   readonly sessionId: SessionId
-  /** The path the Host receives: workspace-relative for a `session` address, absolute for an `absolute` one. */
+  /** The path the Host receives: absolute or workspace-relative. */
   readonly path: string
 }
 
 /**
- * Build the `file` provider over one Remote face, one change feed, and the Client's Session list.
+ * Build the `file` provider over one Remote face and one change feed.
  * @param remote - the Remote face carrying `workspaceFiles.stat`.
  * @param changes - the per-session change fan-out.
- * @param sessions - the current Session, read for absolute addresses on every open and reload.
  * @returns the provider to register into `ctx.resources`.
  */
 export function createFileResourceProvider(
   remote: WorkspaceFilesRemote,
   changes: ChangeFeed,
-  sessions: SessionLookup,
 ): ResourceProvider<'file'> {
   return {
     protocol: 'file',
-    async *open(address, { signal }): AsyncIterable<RemoteResult<WorkspaceFileResource>> {
-      const resolved = resolve(address, sessions)
+    async *open(address, { signal }): AsyncIterable<RemoteResult<WorkspaceFileStat>> {
+      const resolved = resolve(address)
       if (!resolved.ok) {
         yield resolved
         return
       }
       const { sessionId, path } = resolved.value
       // Queue changes delivered to this Client while stat is pending.
-      const notices = changes.follow(sessionId, address, signal)
+      const notices = changes.follow(sessionId, signal)
       const stat = (): Promise<RemoteResult<WorkspaceFileStat>> => remote.workspaceFiles.stat(sessionId, path, signal)
       // Read through a call: a plain `signal.aborted` is narrowed to `false` by
       // the first check and would read as always-false after the later awaits.
       const aborted = (): boolean => signal.aborted
       // Undefined while the last stat failed: the follow is on the address, not
-      // on the file, so a write or a reload can still bring the file live.
-      let current: WorkspaceFileResource | undefined
+      // on the file, so a write can still bring the file live.
+      let current: WorkspaceFileStat | undefined
       try {
         if (!await notices.ready || aborted()) return
         const first = await stat()
         if (aborted()) return
         if (first.ok) {
           notices.bind(first.value.absolutePath)
-          current = metadataOf(first.value, false)
+          current = first.value
           yield { ok: true, value: current }
         } else {
           yield first
@@ -97,11 +84,11 @@ export function createFileResourceProvider(
             // Frames report observations: holding this version already means the
             // consumer learns nothing new.
             if (notice.version === current.version) continue
-            current = { ...current, version: notice.version, changed: true }
+            current = { ...current, version: notice.version }
             yield { ok: true, value: current }
             continue
           }
-          // A Host notice may mean stale content; only a reload clears the flag.
+          // A Host notice may mean stale content.
           const again = await stat()
           if (aborted()) return
           if (!again.ok) {
@@ -110,27 +97,22 @@ export function createFileResourceProvider(
             continue
           }
           notices.bind(again.value.absolutePath)
-          current = metadataOf(again.value, notice.kind !== 'restat')
+          current = again.value
           yield { ok: true, value: current }
         }
       } finally {
         notices.dispose()
       }
     },
-    reload(address) {
-      const resolved = resolve(address, sessions)
-      if (resolved.ok) changes.requestRestat(resolved.value.sessionId, address)
-    },
   }
 }
 
 /**
  * Resolve one address to the Host call it stands for, or to the failure frame it earns.
  * @param address - the full address, scheme included.
- * @param sessions - the Client's Session list.
  * @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure.
  */
-function resolve(address: string, sessions: SessionLookup): RemoteResult<HostFile> {
+function resolve(address: string): RemoteResult<HostFile> {
   const parsed = parseFileAddress(address)
   if (parsed === undefined) return { ok: false, error: unsupportedAddress(address) }
   if (parsed.scope === 'session') {
@@ -138,9 +120,7 @@ function resolve(address: string, sessions: SessionLookup): RemoteResult<HostFil
     const sessionId = parsed.sessionId as SessionId
     return { ok: true, value: { sessionId, path: parsed.path } }
   }
-  const sessionId = sessions.current()
-  if (sessionId === undefined) return { ok: false, error: unknownWorkspace(address) }
-  return { ok: true, value: { sessionId, path: parsed.path } }
+  return { ok: false, error: unknownWorkspace(address) }
 }
 
 /**
@@ -157,25 +137,14 @@ function unsupportedAddress(address: string): RemoteError<'workspace-file/unsupp
 }
 
 /**
- * The failure frame's error for an absolute address with no current Session.
+ * The failure frame's error for an absolute address with no Session.
  * @param address - the offending address.
  * @returns the typed error.
  */
 function unknownWorkspace(address: string): RemoteError<'workspace-file/unknown-workspace'> {
   return new RemoteError(
     'workspace-file/unknown-workspace',
-    `${address} requires a current Session`,
+    `${address} requires a dsh-resource://file/session/<sessionId>/<path> address`,
     { address },
   )
 }
-
-/**
- * The resource value one `stat` result amounts to.
- * @param stat - what the Host reported.
- * @param changed - whether the consumer's content may be stale: `true` after a
- *   Host notice prompted the stat, `false` for the opening stat and a reload's.
- * @returns the metadata frame value.
- */
-function metadataOf(stat: WorkspaceFileStat, changed: boolean): WorkspaceFileResource {
-  return { absolutePath: stat.absolutePath, version: stat.version, changed, ...(stat.bytes === undefined ? {} : { bytes: stat.bytes }) }
-}

+ 7 - 25
packages/api/workspace-files/src/client/types.ts

@@ -5,15 +5,15 @@
 // Bring the base `ResourceProtocolMap` declaration into this program so the
 // augmentation below merges into it instead of declaring a second interface.
 import type {} from '@deepseek-ai/dsh-client-resources/client'
+import type { WorkspaceFileStat } from '../types.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface ResourceProtocolMap {
     /**
      * One workspace file's metadata, addressed as
-     * `dsh-resource://file/session/<sessionId>/<workspace-relative path>` or
-     * `dsh-resource://file/absolute/<absolute path>`.
+     * `dsh-resource://file/session/<sessionId>/<path>` (absolute or workspace-relative).
      */
-    file: WorkspaceFileResource
+    file: WorkspaceFileStat
   }
 }
 
@@ -27,13 +27,13 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
   interface RemoteErrorDetailsMap {
     /**
      * The address is not a `dsh-resource://file/` address in a scope the
-     * provider serves: `session/<sessionId>/<workspace-relative path>` or
+     * provider recognizes: `session/<sessionId>/<path>` or
      * `absolute/<absolute path>`. Raised by the Client provider; the Host never
      * emits it.
      */
     'workspace-file/unsupported-address': { readonly address: string }
     /**
-     * An `absolute` address has no current Session to authorize its Host call.
+     * An `absolute` address has no Session to authorize its Host call.
      * Raised by the Client provider; the Host never emits it. Session addresses
      * are resolved by the Host without a Client Session summary.
      */
@@ -41,28 +41,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
   }
 }
 
-/**
- * One workspace file as the resource model carries it: metadata only.
- *
- * The stream reports that the file moved on; it never carries content. A
- * consumer reads the text itself, by page, and uses `version` and `changed` to
- * 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. */
-  readonly bytes?: number
-  /** The Host reported a write after the last `stat`; a reload (`stat` again) clears it. */
-  readonly changed: boolean
-}
-
 /** One Host-reported write inside the session's workspace. */
 export type WorkspaceFileEdit =
   | { readonly kind: 'changed'; readonly version: string }
   | { readonly kind: 'absent' }
 
-/** What one follower of a path receives: a Host write, or a local request to `stat` again. */
-export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: 'restat' }
+/** What one follower of a path receives: a Host write. */
+export type WorkspaceFileNotice = WorkspaceFileEdit

+ 62 - 29
packages/api/workspace-files/src/index.ts

@@ -1,20 +1,12 @@
 /**
- * Workspace file service: paged text reads, byte-window reads, stats, directory
- * listings, and the agent-write change feed inside one session's workspace
- * root, exposed as the `workspaceFiles` Remote namespace.
+ * Workspace file service: read-only file previews, workspace directory
+ * listings, and the agent-write change feed, exposed as `workspaceFiles`.
  *
- * Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend
- * fences writes and edits only, and says so. Every constraint this service
- * needs is therefore its own, and there are four:
- *
- * 1. The path is authorized by containment in the session's workspace root.
- * 2. Containment is decided by {@link FileSystem.contains}, never by comparing
- *    path strings: `resolve` realpaths, so a prefix test cannot see a symlink
- *    that leaves the root. `lstat` rejects a link before that follow happens.
- * 3. Every cap is validated Config, changeable per deployment. A page is cut by
- *    lines and refused, not shortened, when its bytes exceed the byte cap; a
- *    listing is cut by entries and says so.
- * 4. Failures are one `RemoteError` per reason, declared in `./types`.
+ * File reads follow the composed filesystem's read access, including paths
+ * outside the workspace. The Session's policy supplies the base for relative
+ * paths, not a read-containment restriction. Directory listings and change
+ * observations remain workspace-scoped. File-kind checks and configured read
+ * caps apply to every preview; this service exposes no mutations.
  *
  * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
  * goes, so the file is read only up to the first character past the page and
@@ -25,6 +17,7 @@
  * file content across the wire, which is a different level of exposure.
  */
 
+import { posix, win32 } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -63,6 +56,8 @@ export interface Config {
    * way. The file itself has no size cap: a caller pages through it.
    */
   readonly maxBytes: number
+  /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */
+  readonly maxFileBytes: number
   /** Default and largest page size in lines; a request asking for more is refused. */
   readonly maxLines: number
   /** Cap on returned directory entries; the rest is dropped and reported cut. */
@@ -164,12 +159,13 @@ function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry {
   }
 }
 
-/** Host Remote service over the composed filesystem, confined to one workspace. */
+/** Host Remote file reads and workspace directory observations over the composed filesystem. */
 export class WorkspaceFiles extends TypertRemoteService {
   static inject = ['fs', 'sandboxPolicy', 'typert']
 
   static Config: z<Config> = z.object({
     maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024),
+    maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER - 1).default(32 * 1024 * 1024),
     maxLines: z.number().step(1).min(1).default(5000),
     maxEntries: z.number().step(1).min(1).default(2000),
   })
@@ -186,9 +182,9 @@ export class WorkspaceFiles extends TypertRemoteService {
   }
 
   /**
-   * Read one page of lines from a UTF-8 text file inside the Agent's workspace.
+   * Read one page of lines from a UTF-8 file readable by the filesystem backend.
    * @param agent - target Agent resolved from the Session identity on the wire.
-   * @param path - workspace path, absolute or relative to the workspace root.
+   * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param range - the line window; omitted fields take the page defaults.
    * @param signal - caller cancellation.
    * @returns the page, the file's version at the stat before it, and whether it reaches the last line.
@@ -205,10 +201,10 @@ export class WorkspaceFiles extends TypertRemoteService {
   }
 
   /**
-   * Read one byte window of a regular file inside the Agent's workspace: raw
+   * Read one byte window of a regular file readable by the filesystem backend: raw
    * bytes, no text decoding and no binary rejection.
    * @param agent - target Agent resolved from the Session identity on the wire.
-   * @param path - workspace path, absolute or relative to the workspace root.
+   * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param range - the byte window; omitted fields take the window defaults.
    * @param signal - caller cancellation.
    * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte.
@@ -222,10 +218,51 @@ export class WorkspaceFiles extends TypertRemoteService {
     return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof }
   }
 
+  /**
+   * Read a complete regular file as bytes, subject to the configured full-file cap.
+   * @param agent - target Agent whose workspace resolves relative paths.
+   * @param path - absolute or workspace-relative file path.
+   * @param signal - caller cancellation.
+   * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large.
+   */
+  @Remote
+  async readAll(agent: Agent, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+    const { target, info } = await this.locateFile(agent, path, signal)
+    const limit = this.config.maxFileBytes
+    if (info.size !== undefined && info.size > limit) {
+      throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
+    }
+    const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal)
+    if (data.length > limit) {
+      throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
+    }
+    return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true }
+  }
+
+  /**
+   * Read a complete file relative to another file's directory, including outside the workspace.
+   * @param agent - Agent whose workspace resolves the base file's relative path.
+   * @param path - base file, absolute or workspace-relative.
+   * @param relativePath - relative filesystem path, not a URL or absolute path.
+   * @param signal - caller cancellation.
+   * @returns the complete related file using the ordinary file-size and access checks.
+   */
+  @Remote
+  async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+    const relative = relativePath.replace(/\\/g, '/')
+    if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) {
+      throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {})
+    }
+    const { target } = await this.locateFile(agent, path, signal)
+    const absolute = this.ctx.fs.processPath(target)
+    const paths = absolute.startsWith('/') ? posix : win32
+    return this.readAll(agent, paths.resolve(paths.dirname(absolute), relative), signal)
+  }
+
   /**
    * Report one regular file's identity, version, and size without its content.
    * @param agent - target Agent resolved from the Session identity on the wire.
-   * @param path - workspace path, absolute or relative to the workspace root.
+   * @param path - absolute path or path relative to the workspace root; files outside it are allowed.
    * @param signal - caller cancellation.
    * @returns the file's absolute path, current version, and byte size.
    */
@@ -314,12 +351,8 @@ export class WorkspaceFiles extends TypertRemoteService {
   }
 
   /**
-   * Gates 1 and 2 up to the point where the path's own type is known. The
-   * path is inspected before containment is decided, so a caller learns whether
-   * an outside path exists and what kind it is before `outside-workspace`
-   * refuses it; the caller is the Session's own owner, who can read the Host
-   * through the Agent anyway, and the accepted cost buys one `lstat` gate for
-   * every method instead of two resolution orders.
+   * Inspect the requested path itself before resolution follows its final
+   * component. Directory containment is checked separately by `list`.
    */
   private async inspect(
     agent: Agent,
@@ -352,11 +385,11 @@ export class WorkspaceFiles extends TypertRemoteService {
    * changed kind in between.
    */
   private async locateFile(agent: Agent, path: string, signal: AbortSignal): Promise<{ target: FsTarget; info: FsInfo }> {
-    const { root, workspaceRoot, entry } = await this.inspect(agent, path, signal)
+    const { workspaceRoot, entry } = await this.inspect(agent, path, signal)
     if (entry.type !== 'file') {
       throw new RemoteError('workspace-file/not-regular-file', `"${path}" is a ${entry.type}`, { path, kind: entry.type })
     }
-    const target = await this.confine(root, workspaceRoot, path, signal)
+    const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal })
     const info = await this.ctx.fs.stat(target, signal)
     if (info === undefined) {
       throw new RemoteError('workspace-file/not-found', `no entry at "${path}"`, { path })

+ 1 - 1
packages/api/workspace-files/src/types.ts

@@ -146,7 +146,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
   interface RemoteErrorDetailsMap {
     /** No entry exists at that path inside the workspace. */
     'workspace-file/not-found': { readonly path: string }
-    /** The path resolves outside the session's workspace root. */
+    /** The directory listing path resolves outside the session's workspace root. */
     'workspace-file/outside-workspace': { readonly path: string }
     /** The requested page exceeds the configured byte cap; nothing is returned. */
     'workspace-file/too-large': { readonly path: string; readonly limit: number }

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

@@ -1,27 +1,22 @@
 /**
  * The plugin body: one `file` provider registered into `ctx.resources` for the
- * fiber's lifetime, reading through `ctx.remote` and resolving addresses
- * authorizing absolute addresses through the current `ctx.sessions` entry.
+ * fiber's lifetime, reading through `ctx.remote` in the Session each address names.
  */
 import { Context } from '@deepseek-ai/cordis'
 import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
-import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
 import { describe, expect, it, onTestFinished } from 'vitest'
 import { apply, inject } from '../src/client/index.ts'
 import { FakeRemote, settle } from './fake-remote.client.ts'
 
 describe('workspace-files client apply', () => {
-  it('registers the file provider over ctx.remote and ctx.sessions, and unregisters it with the fiber', async () => {
+  it('registers the file provider over ctx.remote, and unregisters it with the fiber', async () => {
     const ctx = new Context()
     const remote = new FakeRemote()
     const controller = new AbortController()
     const pulls: Array<Promise<unknown>> = []
     ctx.provide('remote', remote as never)
     ctx.provide('remote.workspaceFiles', remote.workspaceFiles as never)
-    // No Session summary or cwd is available; only absolute addresses need current.
-    ctx.provide('sessions', {
-      list: { getSnapshot: () => ({ byId: {}, current: 's1' }) },
-    } as never)
     const registered: Array<ResourceProvider<'file'>> = []
     let released = 0
     ctx.provide('resources', {
@@ -45,10 +40,9 @@ describe('workspace-files client apply', () => {
 
     expect(registered.map(provider => provider.protocol)).toEqual(['file'])
     const signal = controller.signal
-    // A session address reaches the Host as its relative path; an absolute one
-    // is read through the current Session with its absolute path.
+    // A session address reaches the Host with its relative or absolute path.
     pulls.push(registered[0]!.open(sessionFileAddress('s1', 'a.txt'), { signal })[Symbol.asyncIterator]().next())
-    pulls.push(registered[0]!.open(absoluteFileAddress('/etc/hosts'), { signal })[Symbol.asyncIterator]().next())
+    pulls.push(registered[0]!.open(sessionFileAddress('s1', '/etc/hosts'), { signal })[Symbol.asyncIterator]().next())
     await settle()
     expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([['s1', 'a.txt'], ['s1', '/etc/hosts']])
     expect(remote.opened).toHaveLength(1)

+ 7 - 19
packages/api/workspace-files/tests/change-feed.client.spec.ts

@@ -1,6 +1,6 @@
 /**
  * The change feed's promises: one Host stream per session, delivery by absolute
- * path, local stat requests, and a follower's life bounded by its signal or by
+ * path, and a follower's life bounded by its signal or by
  * the stream's end.
  */
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -16,7 +16,7 @@ function harness() {
   const remote = new FakeRemote()
   const feed = new ChangeFeed(remote)
   const follow = (sessionId: SessionId, path: string, controller = new AbortController()) => {
-    const follower = feed.follow(sessionId, path, controller.signal)
+    const follower = feed.follow(sessionId, controller.signal)
     follower.bind(path)
     return { it: follower[Symbol.asyncIterator](), controller }
   }
@@ -27,10 +27,10 @@ describe('ChangeFeed — one Host stream per session', () => {
   it('starts a later follower from the existing session acknowledgement without opening another stream', async () => {
     const { remote, feed } = harness()
     const controller = new AbortController()
-    const first = feed.follow(S1, 'first-resource', controller.signal)
+    const first = feed.follow(S1, controller.signal)
     try {
       await expect(first.ready).resolves.toBe(true)
-      const second = feed.follow(S1, 'second-resource', controller.signal)
+      const second = feed.follow(S1, controller.signal)
       await expect(second.ready).resolves.toBe(true)
       expect(remote.calls).toEqual(['changes', 'accept'])
       expect(remote.opened).toHaveLength(1)
@@ -189,26 +189,14 @@ describe('ChangeFeed — delivery', () => {
     await expect(two.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } })
     await expect(peek(one.it)).resolves.toBe('silent')
   })
-
-  it('hands a stat request to the followers of that path only', async () => {
-    const { feed, follow } = harness()
-    const mine = follow(S1, '/w/a.txt')
-    const other = follow(S1, '/w/b.txt')
-    await settle()
-    feed.requestRestat(S1, '/w/a.txt')
-    feed.requestRestat(S2, '/w/a.txt')
-    feed.requestRestat(S1, '/w/nobody.txt')
-    await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'restat' } })
-    await expect(peek(other.it)).resolves.toBe('silent')
-  })
 })
 
 describe('ChangeFeed — a follower ends', () => {
   it('ends every follower and disposes the session stream for an unknown wire frame kind', async () => {
     const { remote, feed } = harness()
     const controller = new AbortController()
-    const first = feed.follow(S1, 'first-resource', controller.signal)
-    const second = feed.follow(S1, 'second-resource', controller.signal)
+    const first = feed.follow(S1, controller.signal)
+    const second = feed.follow(S1, controller.signal)
     const firstIterator = first[Symbol.asyncIterator]()
     const secondIterator = second[Symbol.asyncIterator]()
     try {
@@ -248,7 +236,7 @@ describe('ChangeFeed — a follower ends', () => {
     const { remote, feed } = harness()
     const controller = new AbortController()
     controller.abort()
-    const it = feed.follow(S1, '/w/a.txt', controller.signal)[Symbol.asyncIterator]()
+    const it = feed.follow(S1, controller.signal)[Symbol.asyncIterator]()
     await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
     await settle()
     expect(remote.opened).toEqual([])

+ 2 - 2
packages/api/workspace-files/tests/changes.spec.ts

@@ -149,7 +149,7 @@ describe('workspaceFiles.changes — ending', () => {
     })
     let service: WorkspaceFiles | undefined
     const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => {
-      service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 })
+      service = new WorkspaceFiles(ctx, { maxBytes: 1, maxFileBytes: 1, maxLines: 1, maxEntries: 1 })
     }, { inject: ['fs', 'sandboxPolicy'] }))
     try {
       if (service === undefined) throw new Error('plugin body did not run')
@@ -267,7 +267,7 @@ describe('workspaceFiles.changes — ending', () => {
   it('ends every open generation when the owning fiber is disposed', async () => {
     let service: WorkspaceFiles | undefined
     const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => {
-      service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 })
+      service = new WorkspaceFiles(ctx, { maxBytes: 1, maxFileBytes: 1, maxLines: 1, maxEntries: 1 })
     }, { inject: ['fs', 'sandboxPolicy'] }))
     if (service === undefined) throw new Error('plugin body did not run')
     const stream = open(service)

+ 1 - 0
packages/api/workspace-files/tests/harness.ts

@@ -64,6 +64,7 @@ export async function openWorkspace(prefix: string): Promise<Harness> {
       }
       service = new WorkspaceFiles(ctx, {
         maxBytes: caps?.maxBytes ?? 1024 * 1024,
+        maxFileBytes: caps?.maxFileBytes ?? 1024 * 1024,
         maxLines: caps?.maxLines ?? 5000,
         maxEntries: caps?.maxEntries ?? 2000,
       })

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

@@ -1,10 +1,10 @@
-/** Host-resolved file identities across pending stats, retries, reloads, and disposal. */
+/** Host-resolved file identities across pending stats, retries, and disposal. */
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
 import { describe, expect, it, onTestFinished } from 'vitest'
 import { ChangeFeed } from '../src/client/change-feed.ts'
-import { createFileResourceProvider, type SessionLookup } from '../src/client/provider.ts'
+import { createFileResourceProvider } from '../src/client/provider.ts'
 import { FakeRemote, settle } from './fake-remote.client.ts'
 
 const SESSION = 'host-only' as SessionId
@@ -12,10 +12,10 @@ const RELATIVE = 'linked/a b.txt'
 const ADDRESS = sessionFileAddress(SESSION, RELATIVE)
 const CANONICAL = '/host/canonical/a b.txt'
 
-function harness(sessions: SessionLookup = { current: () => SESSION }) {
+function harness() {
   const remote = new FakeRemote()
   const changes = new ChangeFeed(remote)
-  const provider = createFileResourceProvider(remote, changes, sessions)
+  const provider = createFileResourceProvider(remote, changes)
   const open = (address = ADDRESS) => {
     const controller = new AbortController()
     const iterator = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
@@ -27,21 +27,21 @@ function harness(sessions: SessionLookup = { current: () => SESSION }) {
       await iterator.return?.()
       await changes.settle()
     })
-    return { iterator, controller, reload: () => { provider.reload!(address) } }
+    return { iterator, controller }
   }
   return { remote, changes, open }
 }
 
 describe('Host-resolved file paths', () => {
   it('accepts Host ready before submitting the unmodified relative path without a Client Session summary', async () => {
-    const { remote, open } = harness({ current: () => { throw new Error('session addresses do not read current') } })
+    const { remote, open } = harness()
     const { iterator } = open()
     const first = iterator.next()
     const request = await remote.waitForStat(0)
     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: { absolutePath: CANONICAL, version: 'v0', bytes: 3, changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } } })
   })
 
   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: { absolutePath: CANONICAL, version: 'v0', changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } } })
   })
 
   it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => {
@@ -94,12 +94,12 @@ 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: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } })
   })
 
   it.each([
-    ['session', ADDRESS, RELATIVE],
-    ['absolute', absoluteFileAddress('/shortcut/a.txt'), '/shortcut/a.txt'],
+    ['relative', ADDRESS, RELATIVE],
+    ['absolute', sessionFileAddress(SESSION, '/shortcut/a.txt'), '/shortcut/a.txt'],
   ])('filters queued and live %s changes using the Host canonical path, not the input path', async (_, address, path) => {
     const { remote, open } = harness()
     const { iterator } = open(address)
@@ -111,18 +111,18 @@ describe('Host-resolved file paths', () => {
     await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-before-stat' } })
     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: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } })
+    await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } })
+    await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } })
 
     await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } })
     await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
-    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } })
     expect(remote.stats).toHaveLength(1)
   })
 
-  it.each(['reload', 'write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async (trigger) => {
+  it.each(['write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async () => {
     const { remote, open } = harness()
-    const { iterator, reload } = open()
+    const { iterator } = open()
     const first = iterator.next()
     const request = await remote.waitForStat(0)
     const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE })
@@ -130,66 +130,35 @@ describe('Host-resolved file paths', () => {
     await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
     const source = remote.opened[0]!.source
     const retried = iterator.next()
-    if (trigger === 'reload') reload()
-    else await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } })
+    await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } })
     const retry = await remote.waitForStat(1)
     expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE })
     await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other' } })
     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: { absolutePath: CANONICAL, version: 'v2', bytes: 5, changed: trigger === 'write' } },
+      done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } },
     })
-    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3', changed: true } } })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3' } } })
     expect(remote.stats).toHaveLength(2)
   })
 
-  it('reloads session and absolute addresses bound to the same Host key without re-statting other files', async () => {
-    const { remote, open } = harness()
-    const session = open()
-    const absolute = open(absoluteFileAddress('/shortcut/a.txt'))
-    const other = open(sessionFileAddress(SESSION, 'other.txt'))
-    const firsts = Promise.all([session.iterator.next(), absolute.iterator.next(), other.iterator.next()])
-    await remote.waitForStat(2)
-    for (const request of remote.stats) {
-      request.resolve({ ok: true, value: {
-        absolutePath: request.path === 'other.txt' ? '/host/other.txt' : CANONICAL,
-        version: 'v0', bytes: 3,
-      } })
-    }
-    await firsts
-    const reloaded = Promise.all([session.iterator.next(), absolute.iterator.next()])
-    const otherChange = other.iterator.next()
-    session.reload()
-    await remote.waitForStat(4)
-    expect(remote.stats.slice(3).map(request => request.path)).toEqual([RELATIVE, '/shortcut/a.txt'])
-    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: { 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 } } })
-    expect(remote.stats).toHaveLength(5)
-    expect(remote.opened).toHaveLength(1)
-  })
-
-  it('binds a changed canonical path on reload before filtering writes received during that stat', async () => {
+  it('binds a changed canonical path after disappearance before filtering writes received during that stat', async () => {
     const { remote, open } = harness()
-    const { iterator, reload } = open()
+    const { iterator } = open()
     const first = iterator.next()
     const request = await remote.waitForStat(0)
     request.resolve({ ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } })
     await first
-    reload()
     const reloaded = iterator.next()
+    await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', absent: true } })
     const retry = await remote.waitForStat(1)
     const source = remote.opened[0]!.source
     await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', version: 'old-target-write' } })
     await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
     retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1' } })
-    await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: false } } })
-    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } })
+    await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } })
     expect(remote.stats).toHaveLength(2)
   })
 

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

@@ -1,7 +1,7 @@
 /**
  * The `file` provider's frame stream: how the two address scopes resolve to a
- * Host call and a change-feed key, the opening stat, the write flag that
- * carries no content, the reload and disappearance that stat again, failures
+ * Host call and a change-feed key, the opening stat, the write version that
+ * carries no content, the disappearance that stats again, failures
  * as frames, and the life bounded by the signal.
  */
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -12,8 +12,7 @@ import type { WorkspaceFileStat } from '../src/types.ts'
 import { describe, expect, it, onTestFinished } from 'vitest'
 import { ChangeFeed } from '../src/client/change-feed.ts'
 import { createFileResourceProvider } from '../src/client/provider.ts'
-import type { SessionLookup } from '../src/client/provider.ts'
-import { FakeRemote, peek, settle } from './fake-remote.client.ts'
+import { FakeRemote, settle } from './fake-remote.client.ts'
 
 const S1 = 's1' as SessionId
 const S2 = 's2' as SessionId
@@ -23,23 +22,17 @@ const HOST_PATH = '/w/a b.txt'
 const ADDRESS = sessionFileAddress(S1, REL_PATH)
 /** A file outside every workspace root, addressed absolutely. */
 const ABS_PATH = '/etc/hosts'
-const ABS_ADDRESS = absoluteFileAddress(ABS_PATH)
-
-/** Only the current Session is available to the provider; there are no Client roots. */
-function sessionsWith(current: SessionId | undefined): SessionLookup {
-  return { current: () => current }
-}
+const ABS_ADDRESS = sessionFileAddress(S1, ABS_PATH)
 
 const stat = (version: string, bytes: number): WorkspaceFileStat => ({ absolutePath: HOST_PATH, version, bytes })
 const notFound = (): RemoteFailure => new RemoteError('workspace-file/not-found', 'no such file', { path: REL_PATH })
 
-function opened(address = ADDRESS, sessions = sessionsWith(S1)) {
+function opened(address = ADDRESS) {
   const remote = new FakeRemote()
   const changes = new ChangeFeed(remote)
-  const provider = createFileResourceProvider(remote, changes, sessions)
+  const provider = createFileResourceProvider(remote, changes)
   const controller = new AbortController()
   const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
-  const reload = (): void => { provider.reload!(address) }
   onTestFinished(async () => {
     controller.abort()
     for (const request of remote.stats) {
@@ -48,7 +41,7 @@ function opened(address = ADDRESS, sessions = sessionsWith(S1)) {
     await it.return?.()
     await changes.settle()
   })
-  return { remote, provider, changes, controller, it, reload }
+  return { remote, provider, changes, controller, it }
 }
 
 /** Open, answer the opening stat, and hand back the bench once the first frame is out. */
@@ -80,9 +73,9 @@ describe('file provider — the address', () => {
     expect(remote.opened).toEqual([])
   })
 
-  it('rejects an absolute address with no current Session without touching the Remote', async () => {
-    const address = ABS_ADDRESS
-    const { remote, it } = opened(address, sessionsWith(undefined))
+  it('rejects an absolute address with no Session without touching the Remote', async () => {
+    const address = absoluteFileAddress(ABS_PATH)
+    const { remote, it } = opened(address)
     const first = await it.next()
     expect(first.done).toBe(false)
     expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unknown-workspace', details: { address } } })
@@ -91,14 +84,6 @@ describe('file provider — the address', () => {
     expect(remote.opened).toEqual([])
   })
 
-  it('reloads nothing for an address it does not resolve', async () => {
-    for (const bench of [opened('dsh-resource://file/shared/x/w/a.txt'), opened(ABS_ADDRESS, sessionsWith(undefined))]) {
-      bench.reload()
-      await settle()
-      expect(bench.remote.stats).toEqual([])
-    }
-  })
-
   it('hands the Host a session address\'s relative path and follows the stat absolute path', async () => {
     const { remote, it } = opened()
     const first = it.next()
@@ -108,31 +93,24 @@ describe('file provider — the address', () => {
     await first
     // The Host's frame names the file absolutely; the follower keyed by the resolved path receives it.
     remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
-    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
   })
 
-  it('reads an absolute address through the Session on screen, with the absolute path as both Host path and follow key', async () => {
-    const { remote, it, reload } = opened(ABS_ADDRESS)
+  it('reads an absolute path through the Session in its address, with the absolute path as both Host path and follow key', async () => {
+    const { remote, it } = opened(ABS_ADDRESS)
     const first = it.next()
     await settle()
     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: { absolutePath: ABS_PATH, version: 'v0', bytes: 3, changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } } })
     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.
-    reload()
-    const next = it.next()
-    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: { absolutePath: ABS_PATH, version: 'v1', bytes: 4, changed: false } } })
+    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
   })
 
   it('opens one change stream per session named by the addresses', async () => {
     const remote = new FakeRemote()
-    const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1))
+    const provider = createFileResourceProvider(remote, new ChangeFeed(remote))
     const signal = new AbortController().signal
     void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
     void provider.open(sessionFileAddress(S2, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
@@ -143,14 +121,14 @@ describe('file provider — the address', () => {
 })
 
 describe('file provider — the opening stat', () => {
-  it('stats the decoded relative path in the session the address names and yields its metadata unflagged', async () => {
+  it('stats the decoded relative path in the session the address names and yields its metadata', async () => {
     const { remote, it, controller } = opened()
     const first = it.next()
     await settle()
     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: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3 } } })
   })
 
   it('omits bytes when the backend reports none', async () => {
@@ -158,7 +136,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: { absolutePath: HOST_PATH, version: 'v0', changed: false } } })
+    await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } } })
   })
 
   it('yields the Host failure as a frame and keeps following the address', async () => {
@@ -174,24 +152,11 @@ describe('file provider — the opening stat', () => {
     const pending = it.next()
     await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
     expect(remote.stats).toHaveLength(1)
-    // The agent creates the file: the write stats again and the value goes live, flagged as changed.
+    // The agent creates the file: the write stats again and the value goes live.
     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: { absolutePath: HOST_PATH, version: 'v1', bytes: 5, changed: true } } })
-  })
-
-  it('lets a reload retry a failed opening stat', async () => {
-    const { remote, it, reload } = opened()
-    const first = it.next()
-    await settle()
-    remote.stats[0]!.resolve({ ok: false, error: notFound() })
-    await first
-    reload()
-    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: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } })
+    await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5 } } })
   })
 
   it('ends without a frame when aborted during the stat', async () => {
@@ -205,7 +170,7 @@ describe('file provider — the opening stat', () => {
 
   it('shares one change stream between two files of a session', async () => {
     const remote = new FakeRemote()
-    const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1))
+    const provider = createFileResourceProvider(remote, new ChangeFeed(remote))
     const signal = new AbortController().signal
     void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
     void provider.open(sessionFileAddress(S1, 'b.txt'), { signal })[Symbol.asyncIterator]().next()
@@ -216,10 +181,10 @@ describe('file provider — the opening stat', () => {
 })
 
 describe('file provider — Host writes', () => {
-  it('flags a write with its version and keeps the byte count', async () => {
+  it('reports 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: { absolutePath: HOST_PATH, 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 } } })
     expect(remote.stats).toHaveLength(1)
   })
 
@@ -231,7 +196,7 @@ describe('file provider — Host writes', () => {
     const pending = it.next()
     await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
     source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
-    await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+    await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
   })
 
   it('does not lose a write reported during the opening stat', async () => {
@@ -241,23 +206,35 @@ describe('file provider — Host writes', () => {
     remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
     await settle()
     remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
-    await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } })
-    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+    await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } })
+    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
   })
 })
 
 describe('file provider — a reported disappearance', () => {
-  it('stats again and, when the file is still there, yields its fresh metadata flagged', async () => {
+  it('ends quietly when aborted during the stat', async () => {
+    const { remote, it, controller, changes } = await live()
+    const next = it.next()
+    await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
+    const request = await remote.waitForStat(1)
+    controller.abort()
+    request.resolve({ ok: true, value: stat('late', 9) })
+    await expect(next).resolves.toEqual({ done: true, value: undefined })
+    await changes.settle()
+    expect(remote.disposed).toEqual(['workspace file changes of s1'])
+  })
+
+  it('stats again and, when the file is still there, yields its fresh metadata', async () => {
     const { remote, it } = await live('v0', 3)
     remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
     const next = it.next()
     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: { absolutePath: HOST_PATH, version: 'v2', bytes: 9, changed: true } } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9 } } })
   })
 
-  it('yields the not-found frame and keeps following, so a later write stats again and brings the file back flagged', async () => {
+  it('yields the not-found frame and keeps following, so a later write stats again and brings the file back', async () => {
     const { remote, it } = await live('v0', 3)
     const source = remote.opened[0]!.source
     source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
@@ -270,79 +247,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: { absolutePath: HOST_PATH, version: 'v3', bytes: 8, changed: true } } })
-  })
-})
-
-describe('file provider — reload', () => {
-  it('stats again and clears the flag', async () => {
-    const { remote, it, reload } = await live('v0', 3)
-    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
-    await it.next()
-    reload()
-    const next = it.next()
-    await settle()
-    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: { absolutePath: HOST_PATH, version: 'v1', bytes: 7, changed: false } } })
-  })
-
-  it('yields a failed stat as a frame and keeps the stream open', async () => {
-    const { remote, it, reload } = await live()
-    reload()
-    const next = it.next()
-    await settle()
-    const error = notFound()
-    remote.stats[1]!.resolve({ ok: false, error })
-    await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } })
-    await expect(peek(it)).resolves.toBe('silent')
-  })
-
-  it('ends quietly when aborted during the stat', async () => {
-    const { remote, it, reload, controller } = await live()
-    reload()
-    const next = it.next()
-    await settle()
-    controller.abort()
-    remote.stats[1]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) })
-    await expect(next).resolves.toEqual({ done: true, value: undefined })
-  })
-
-  it('re-stats every record of the path on one record\'s reload: a session record and an absolute record of one file share it', async () => {
-    const remote = new FakeRemote()
-    const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1))
-    const signal = new AbortController().signal
-    const session = provider.open(ADDRESS, { signal })[Symbol.asyncIterator]()
-    const absolute = provider.open(absoluteFileAddress(HOST_PATH), { signal })[Symbol.asyncIterator]()
-    const firsts = Promise.all([session.next(), absolute.next()])
-    await settle()
-    expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([[S1, REL_PATH], [S1, HOST_PATH]])
-    remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
-    remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) })
-    await firsts
-    expect(remote.opened).toHaveLength(1)
-    // One Host write flags both records: they follow one path.
-    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
-    await expect(session.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
-    await expect(absolute.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
-    // A reload on the session record re-stats both and clears both flags:
-    // delivery is per path, not per record.
-    provider.reload!(ADDRESS)
-    const nexts = Promise.all([session.next(), absolute.next()])
-    await settle()
-    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: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: false } } }
-    await expect(nexts).resolves.toEqual([cleared, cleared])
-  })
-
-  it('is a no-op for a file nobody has open', async () => {
-    const { remote, reload } = opened()
-    reload()
-    await settle()
-    expect(remote.stats).toHaveLength(0)
+    await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8 } } })
   })
 })
 

+ 113 - 0
packages/api/workspace-files/tests/read-all.spec.ts

@@ -0,0 +1,113 @@
+/** Full-file reads retain the ordinary file gates and never return a silently truncated result. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdir, symlink, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { FsVersion } from '@deepseek-ai/dsh-fs'
+import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+
+let harness: Harness
+
+beforeEach(async () => { harness = await openWorkspace('dsh-workspace-files-read-all-') })
+afterEach(async () => { await harness.dispose() })
+
+describe('workspaceFiles.readAll', () => {
+  it('reads the complete bytes independently of the window cap', async () => {
+    const bytes = Buffer.from([0, 255, 1, 2])
+    await writeFile(join(harness.workspace, 'file.bin'), bytes)
+    const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(agent, 'file.bin', signal())
+    expect(Buffer.from(result.data, 'base64')).toEqual(bytes)
+    expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 })
+  })
+
+  it('returns an empty complete file', async () => {
+    await writeFile(join(harness.workspace, 'empty'), '')
+    expect(await harness.endpoint().readAll(agent, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
+  })
+
+  it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => {
+    const path = join(harness[location], 'large')
+    await writeFile(path, 'abcde')
+    const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
+    expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, path, signal())))
+      .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } })
+    expect(read).not.toHaveBeenCalled()
+  })
+
+  it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => {
+    await writeFile(join(harness.workspace, 'growing'), 'abcde')
+    vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } })
+    expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, 'growing', signal()))).code)
+      .toBe('workspace-file/too-large')
+  })
+
+  it('reads outside files while retaining missing-file and kind failures', async () => {
+    await mkdir(join(harness.workspace, 'directory'))
+    await writeFile(join(harness.outside, 'outside'), 'outside')
+    const files = harness.endpoint()
+    expect((await failureOf(files.readAll(agent, 'missing', signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(files.readAll(agent, 'directory', signal()))).code).toBe('workspace-file/not-regular-file')
+    const outside = await files.readAll(agent, join(harness.outside, 'outside'), signal())
+    expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside')
+  })
+})
+
+describe('workspaceFiles.readRelated', () => {
+  it('resolves relative paths from the base file directory on the Host', async () => {
+    await mkdir(join(harness.workspace, 'nested'))
+    await writeFile(join(harness.workspace, 'nested/base.txt'), 'base')
+    await writeFile(join(harness.workspace, 'nested/near.txt'), 'near')
+    await writeFile(join(harness.workspace, 'root.txt'), 'root')
+    const files = harness.endpoint()
+    const near = await files.readRelated(agent, 'nested/base.txt', './near.txt', signal())
+    const root = await files.readRelated(agent, 'nested/base.txt', '../root.txt', signal())
+    expect(Buffer.from(near.data, 'base64').toString()).toBe('near')
+    expect(Buffer.from(root.data, 'base64').toString()).toBe('root')
+    const fromRoot = await files.readRelated(agent, 'root.txt', 'nested\\near.txt', signal())
+    expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near')
+  })
+
+  it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => {
+    expect((await failureOf(harness.endpoint().readRelated(agent, 'base', path, signal()))).code).toBe('gateway/bad-request')
+  })
+
+  it('resolves related files on either side of the workspace root', async () => {
+    await writeFile(join(harness.workspace, 'base'), 'base')
+    await writeFile(join(harness.outside, 'outside'), 'outside')
+    const files = harness.endpoint()
+    expect((await failureOf(files.readRelated(agent, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found')
+    const fromOutside = await files.readRelated(agent, join(harness.outside, 'outside'), '../workspace/base', signal())
+    const toOutside = await files.readRelated(agent, 'base', '../outside/outside', signal())
+    expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base')
+    expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside')
+  })
+
+  it('reads sibling assets beside an outside HTML file with escaped path characters', async () => {
+    await mkdir(join(harness.outside, 'space # assets'))
+    const base = join(harness.outside, 'space # assets', 'page.html')
+    await writeFile(base, '<script src="./app.js"></script>')
+    await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET')
+    const result = await harness.endpoint().readRelated(agent, base, './app.js', signal())
+    expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET')
+  })
+
+  it.each([
+    ['C:\\external\\page.html', 'C:\\external\\app.js'],
+    ['\\\\server\\share\\page.html', '\\\\server\\share\\app.js'],
+    ['/external/back\\slash.html', '/external/app.js'],
+  ])('uses the backend process-path syntax for related reads from %s', async (base, expected) => {
+    await writeFile(join(harness.workspace, 'base'), 'base')
+    const files = harness.endpoint()
+    vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base)
+    const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true })
+    const caller = signal()
+    await files.readRelated(agent, 'base', './app.js', caller)
+    expect(read).toHaveBeenCalledWith(agent, expected, caller)
+  })
+
+  it('rejects a related symlink rather than following it', async () => {
+    await writeFile(join(harness.workspace, 'base'), 'base')
+    await writeFile(join(harness.workspace, 'target'), 'target')
+    await symlink('target', join(harness.workspace, 'link'))
+    expect((await failureOf(harness.endpoint().readRelated(agent, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file')
+  })
+})

+ 5 - 4
packages/api/workspace-files/tests/read-bytes.spec.ts

@@ -138,9 +138,10 @@ describe('workspaceFiles.readBytes — the gates it shares with read', () => {
     expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request')
   })
 
-  it('rejects an absolute path outside the workspace', async () => {
-    await writeFile(join(harness.outside, 'secret.bin'), RAMP)
-    const failure = await failureOf(endpoint().readBytes(agent, join(harness.outside, 'secret.bin'), {}, signal()))
-    expect(failure.code).toBe('workspace-file/outside-workspace')
+  it('reads a bounded byte window outside the workspace', async () => {
+    await writeFile(join(harness.outside, 'sample.bin'), RAMP)
+    const result = await endpoint().readBytes(agent, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal())
+    expect(decode(result.data)).toEqual(RAMP.subarray(2, 6))
+    expect(result).toMatchObject({ offset: 2, eof: false })
   })
 })

+ 19 - 9
packages/api/workspace-files/tests/read.spec.ts

@@ -125,17 +125,27 @@ describe('workspaceFiles.read — the line window', () => {
   })
 })
 
-describe('workspaceFiles.read — gate 1 and 2: authorization by containment', () => {
-  it('rejects an absolute path outside the workspace', async () => {
-    await writeFile(join(outside, 'secret.txt'), 'no', 'utf8')
-    const failure = await failureOf(endpoint().read(agent, join(outside, 'secret.txt'), {}, signal()))
-    expect(failure.code).toBe('workspace-file/outside-workspace')
+describe('workspaceFiles.read — read access and file kinds', () => {
+  it('reads an absolute path outside the workspace without mutating it', async () => {
+    await writeFile(join(outside, 'notes.txt'), 'outside\nread only\n', 'utf8')
+    const write = vi.spyOn(harness.ctx.fs, 'writeText')
+    const edit = vi.spyOn(harness.ctx.fs, 'editText')
+    const result = await endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())
+    expect(result).toMatchObject({ text: 'outside\nread only', lines: 2, eof: true })
+    expect(write).not.toHaveBeenCalled()
+    expect(edit).not.toHaveBeenCalled()
   })
 
-  it('rejects a traversal that climbs out of the workspace', async () => {
-    await writeFile(join(outside, 'secret.txt'), 'no', 'utf8')
-    const failure = await failureOf(endpoint().read(agent, '../outside/secret.txt', {}, signal()))
-    expect(failure.code).toBe('workspace-file/outside-workspace')
+  it('resolves a relative file outside the workspace on the Host', async () => {
+    await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
+    expect(await endpoint().read(agent, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true })
+  })
+
+  it('preserves a filesystem provider refusal for an outside file', async () => {
+    await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
+    const refusal = new FsError('backend denied read', 'FS_SANDBOX_DENIED')
+    vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(refusal)
+    await expect(endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal)
   })
 
   it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => {

+ 3 - 4
packages/api/workspace-files/tests/stat.spec.ts

@@ -43,7 +43,7 @@ describe('workspaceFiles.stat', () => {
     await expect(harness.endpoint().stat(agent, 'notes.txt', controller.signal)).rejects.toThrow()
   })
 
-  it('resolves the workspace root and then the confined target under the caller\'s signal', async () => {
+  it('resolves the workspace root and then the file under the caller\'s signal', async () => {
     await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
     const fs = harness.ctx.fs
     const original = fs.resolve.bind(fs)
@@ -61,7 +61,7 @@ describe('workspaceFiles.stat', () => {
     expect(result).toEqual({ absolutePath: result.absolutePath, version: 'v-sizeless' })
   })
 
-  it('applies the read gates: symlink, directory, outside, missing, empty', async () => {
+  it('accepts outside files and refuses symlinks, directories, missing and empty paths', async () => {
     await writeFile(join(harness.outside, 'secret.txt'), 'no', 'utf8')
     await symlink(join(harness.outside, 'secret.txt'), join(harness.workspace, 'link.txt'))
     await mkdir(join(harness.workspace, 'src'))
@@ -71,8 +71,7 @@ describe('workspaceFiles.stat', () => {
       details: { kind: 'symlink' },
     })
     expect((await failureOf(endpoint.stat(agent, 'src', signal()))).details).toMatchObject({ kind: 'directory' })
-    expect((await failureOf(endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal()))).code)
-      .toBe('workspace-file/outside-workspace')
+    expect(await endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal())).toMatchObject({ bytes: 2 })
     expect((await failureOf(endpoint.stat(agent, 'nope.txt', signal()))).code).toBe('workspace-file/not-found')
     expect((await failureOf(endpoint.stat(agent, '', signal()))).code).toBe('gateway/bad-request')
   })

+ 0 - 1
packages/api/workspace-files/tsconfig.client.json

@@ -16,7 +16,6 @@
   "references": [
     { "path": "../../../vendor/cordis" },
     { "path": "../gateway/tsconfig.client.json" },
-    { "path": "../session-controller/tsconfig.client.json" },
     { "path": "../../core/session" },
     { "path": "../../client/resources" },
     { "path": "../../client/ui-slots" },

+ 4 - 4
packages/bundle/web-app/cordis.patch.yml

@@ -225,10 +225,10 @@
       name: '@deepseek-ai/dsh-client-ui-sidebar-right'
 
 
-    # The right Sidebar's first outside tab type: workspace text files, read
-    # through the bounded workspaceFiles endpoint.
-    - id: ui-sidebar-textpreview
-      name: '@deepseek-ai/dsh-client-ui-sidebar-textpreview'
+    # The right Sidebar's document tab: bounded file reads with selectable
+    # Markdown, code, HTML, PDF, and plain-text renderers.
+    - id: ui-sidebar-documentpreview
+      name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 
     # The right Sidebar's workspace file tree tab type.
     - id: ui-sidebar-files

+ 1 - 1
packages/bundle/web-app/package.json

@@ -84,7 +84,7 @@
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-sidebar-textpreview": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^",
     "@deepseek-ai/dsh-client-ui-skill": "workspace:^",
     "@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",

+ 1 - 1
packages/client/AGENTS.md

@@ -23,7 +23,7 @@ How live data reaches render code, and what UI domains may share:
 1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
 2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
 3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration.
-4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots).
+4. **UI domains share JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions use these values. Transient file-read and document-preview content may also carry `Uint8Array`: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots).
 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
 

+ 2 - 2
packages/client/resources/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/resources/README.md
-README.md: 7c32293c8d4250b11f8beaaf473a62145c915bce
-README.zh.md: b53089a2449f379f43156f610b587656a998ece3
+README.md: 6b5afc82230be6930c41a01a584aeed39f596c37
+README.zh.md: 6b928883bb54cd4663efa7a20a7bc768d4f8399e

+ 3 - 4
packages/client/resources/README.md

@@ -33,12 +33,12 @@ Nothing needs configuration to mount: the plugin provides `ctx.resources` and co
 <a id="read-a-resource"></a>
 ### Read a resource
 
-Every slot component receives `useResource` in its props. `useResource<P>(address)` names the protocol as the type argument and returns `{ status, value, failure, reload }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. `reload()` asks the provider for a fresh value and is a no-op without one. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once.
+Every slot component receives `useResource` in its props. `useResource<P>(address)` names the protocol as the type argument and returns `{ status, value, failure }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once.
 
 <a id="provide-a-protocol"></a>
 ### Provide a protocol
 
-The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts. `reload` is optional:
+The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts:
 
 ```ts ignore-check
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -54,7 +54,6 @@ export function apply(ctx) {
       yield await readNote(address, signal)
       for await (const change of followNote(address, signal)) yield change
     },
-    reload(address) { requestReread(address) },
   }), 'my-notes: note resource provider')
 }
 ```
@@ -72,7 +71,7 @@ A protocol has exactly one provider; a second registration throws. Registering a
 <a id="lifecycle"></a>
 ### Lifecycle
 
-One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount. `reload` is one function per record and never changes.
+One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount.
 
 <a id="failures"></a>
 ### Failures

+ 3 - 4
packages/client/resources/README.zh.md

@@ -33,12 +33,12 @@ kind: "package-reference"
 <a id="read-a-resource"></a>
 ### 读取资源
 
-每个 slot 组件都在 props 上收到 `useResource`。`useResource<P>(address)` 以类型参数命名协议,返回 `{ status, value, failure, reload }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。`reload()` 请提供方给一个新值,没有提供方时是空操作。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。
+每个 slot 组件都在 props 上收到 `useResource`。`useResource<P>(address)` 以类型参数命名协议,返回 `{ status, value, failure }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。
 
 <a id="provide-a-protocol"></a>
 ### 提供协议
 
-协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止。`reload` 可选:
+协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止:
 
 ```ts ignore-check
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -54,7 +54,6 @@ export function apply(ctx) {
       yield await readNote(address, signal)
       for await (const change of followNote(address, signal)) yield change
     },
-    reload(address) { requestReread(address) },
   }), 'my-notes: note resource provider')
 }
 ```
@@ -72,7 +71,7 @@ export function apply(ctx) {
 <a id="lifecycle"></a>
 ### 生命周期
 
-每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。`reload` 每条记录一个函数,永不变化。
+每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。
 
 <a id="failures"></a>
 ### 失败

+ 0 - 7
packages/client/resources/src/client/contract.ts

@@ -50,8 +50,6 @@ export interface ResourceSnapshot<Value> {
   readonly value: Value | undefined
   /** The latest frame's failure; present only while `status` is `failed`. */
   readonly failure: RemoteFailure | undefined
-  /** Ask the provider for a fresh frame; a no-op when its protocol has no provider or no `reload`. */
-  readonly reload: () => void
 }
 
 /**
@@ -84,11 +82,6 @@ export interface ResourceProvider<P extends ResourceProtocol> {
    * @returns the frame stream; it must stop once `ctx.signal` aborts.
    */
   open(address: string, ctx: ResourceOpenContext): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
-  /**
-   * Produce a fresh frame on the open stream. Absent when the protocol has no refresh.
-   * @param address - the full address, a `dsh-resource://<type>/…` URL.
-   */
-  reload?(address: string): void
 }
 
 /**

+ 9 - 15
packages/client/resources/src/client/resources.ts

@@ -23,7 +23,6 @@ import type {
 interface RuntimeProvider {
   readonly protocol: string
   open(address: string, ctx: ResourceOpenContext): AsyncIterable<RemoteResult<unknown>>
-  reload?(address: string): void
 }
 
 /** One address: its state, its holders, and the running stream. */
@@ -33,7 +32,6 @@ interface ResourceRecord {
   readonly protocol: string | undefined
   readonly store: SnapshotStore<ResourceSnapshot<unknown>>
   readonly source: ObservableSnapshot<ResourceSnapshot<unknown>>
-  readonly reload: () => void
   /** Subscribers plus pins; the stream runs while this is positive. */
   holders: number
   /** Present while the provider's stream runs; aborting it ends the stream. */
@@ -69,8 +67,8 @@ export function protocolOf(address: string): string | undefined {
   return parsed.hostname === '' ? undefined : parsed.hostname.toLowerCase()
 }
 
-function idle(status: 'none' | 'loading', reload: () => void): ResourceSnapshot<unknown> {
-  return { status, value: undefined, failure: undefined, reload }
+function idle(status: 'none' | 'loading'): ResourceSnapshot<unknown> {
+  return { status, value: undefined, failure: undefined }
 }
 
 /** The `ctx.resources` implementation. */
@@ -120,17 +118,13 @@ export class ResourceRegistry implements Resources {
 
   private create(address: string): ResourceRecord {
     const protocol = protocolOf(address)
-    const reload = (): void => {
-      this.providerOf(protocol)?.reload?.(address)
-    }
     const store = createSnapshotStore<ResourceSnapshot<unknown>>(
-      idle(this.providerOf(protocol) === undefined ? 'none' : 'loading', reload),
+      idle(this.providerOf(protocol) === undefined ? 'none' : 'loading'),
     )
     const record: ResourceRecord = {
       address,
       protocol,
       store,
-      reload,
       holders: 0,
       controller: undefined,
       source: {
@@ -170,7 +164,7 @@ export class ResourceRegistry implements Resources {
     record.holders -= 1
     if (record.holders > 0) return
     this.stop(record)
-    record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading', record.reload))
+    record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading'))
   }
 
   /** The provider arrived: a held record opens its stream, an idle one turns `loading`. */
@@ -179,13 +173,13 @@ export class ResourceRegistry implements Resources {
       this.start(record)
       return
     }
-    record.store.set(idle('loading', record.reload))
+    record.store.set(idle('loading'))
   }
 
   /** The provider left: the stream ends and the record reports `none`. */
   private detach(record: ResourceRecord): void {
     this.stop(record)
-    record.store.set(idle('none', record.reload))
+    record.store.set(idle('none'))
   }
 
   private start(record: ResourceRecord): void {
@@ -193,7 +187,7 @@ export class ResourceRegistry implements Resources {
     if (provider === undefined) return
     const controller = new AbortController()
     record.controller = controller
-    if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading', record.reload))
+    if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading'))
     void this.consume(record, provider, controller.signal)
   }
 
@@ -210,8 +204,8 @@ export class ResourceRegistry implements Resources {
       // to nobody; ending the loop also returns the iterator.
       if (signal.aborted) break
       record.store.set(frame.ok
-        ? { status: 'live', value: frame.value, failure: undefined, reload: record.reload }
-        : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error, reload: record.reload })
+        ? { status: 'live', value: frame.value, failure: undefined }
+        : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error })
     }
   }
 }

+ 69 - 2
packages/client/resources/tests/apply.client.spec.ts

@@ -4,17 +4,21 @@
  * hook reaches every slot component as `useResource`, and both leave with the
  * fiber so a reload of the plugin registers cleanly again.
  */
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act } from '@testing-library/react'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import { apply, inject, type ResourceSnapshot, type UseResource } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { ResourceRegistry } from '../src/client/resources.ts'
+import type { ResourceProvider } from '../src/client/contract.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
     'resources.probe': { kind: 'single'; scope: 'root' }
+    'resources.sessionProbe': { kind: 'single'; scope: 'session' }
+    'resources.sessionPeer': { kind: 'single'; scope: 'session' }
   }
   interface ResourceProtocolMap {
     feed: string
@@ -33,7 +37,11 @@ const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolv
 
 async function boot(): Promise<SlotTestRuntime> {
   const rt = await SlotTestRuntime.create()
-  await rt.declare({ 'resources.probe': { kind: 'single', scope: 'root' } })
+  await rt.declare({
+    'resources.probe': { kind: 'single', scope: 'root' },
+    'resources.sessionProbe': { kind: 'single', scope: 'session' },
+    'resources.sessionPeer': { kind: 'single', scope: 'session' },
+  })
   return rt
 }
 
@@ -95,4 +103,63 @@ describe('client-resources apply', () => {
     await runtime.mount({ inject: [...inject], apply })
     expect(hook).toBeTypeOf('function')
   })
+
+  it('shares one address across Root and Session components without reopening on selection', async () => {
+    runtime = await boot()
+    const rt = runtime
+    const firstId = await rt.sessions.add({ id: 'first-session' })
+    const secondId = await rt.sessions.add({ id: 'second-session' }, { current: false })
+    await rt.mount({ inject: [...inject], apply })
+    const opened = Promise.withResolvers<undefined>()
+    const open = vi.fn<ResourceProvider<'feed'>['open']>(async function* () {
+      try { yield { ok: true as const, value: 'shared data' } } finally { opened.resolve(undefined) }
+    })
+    await act(async () => {
+      rt.ctx.effect(() => rt.ctx.resources.register({ protocol: 'feed', open }), 'spec: shared address')
+    })
+    const source = rt.ctx.resources.source(A)
+    const seen: {
+      root?: ResourceSnapshot<string>
+      first?: ResourceSnapshot<string>
+      second?: ResourceSnapshot<string>
+      firstSession?: string
+      secondSession?: string
+    } = {}
+    rt.slots.register({ name: 'resources.probe' }, ({ useResource }: PropsRuntime<'resources.probe'>) => {
+      seen.root = useResource<'feed'>(A)
+      return null
+    })
+    rt.slots.register({ name: 'resources.sessionProbe' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionProbe'>) => {
+      seen.firstSession = sessionId
+      seen.first = useResource<'feed'>(A)
+      return null
+    })
+    rt.slots.register({ name: 'resources.sessionPeer' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionPeer'>) => {
+      seen.secondSession = sessionId
+      seen.second = useResource<'feed'>(A)
+      return null
+    })
+    rt.renderSlot('resources.probe', {})
+    rt.renderSlot('resources.sessionProbe', {})
+    rt.renderSlot('resources.sessionPeer', {})
+    await act(async () => { await opened.promise })
+    const snapshot = source.getSnapshot()
+    expect(snapshot).toEqual({ status: 'live', value: 'shared data', failure: undefined })
+    expect(seen.root).toBe(snapshot)
+    expect(seen.first).toBe(snapshot)
+    expect(seen.second).toBe(snapshot)
+    expect([seen.firstSession, seen.secondSession]).toEqual([firstId, firstId])
+    expect(open).toHaveBeenCalledTimes(1)
+    expect(open.mock.calls[0]![0]).toBe(A)
+    expect(open.mock.calls[0]![1]).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
+
+    await rt.sessions.setCurrent(secondId)
+    expect([seen.firstSession, seen.secondSession]).toEqual([secondId, secondId])
+    expect(rt.ctx.resources.source(A)).toBe(source)
+    expect(seen.root).toBe(snapshot)
+    expect(seen.first).toBe(snapshot)
+    expect(seen.second).toBe(snapshot)
+    expect(open).toHaveBeenCalledTimes(1)
+    expect(open.mock.calls[0]![1].signal.aborted).toBe(false)
+  })
 })

+ 95 - 17
packages/client/resources/tests/resources.client.spec.ts

@@ -4,7 +4,7 @@
  * Providers are scripted feeds so every transition is driven by the spec,
  * never by timing.
  */
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -14,6 +14,7 @@ import type { ResourceOpenContext, ResourceProvider } from '../src/client/contra
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface ResourceProtocolMap {
     feed: string
+    counter: number
   }
 }
 
@@ -27,6 +28,7 @@ interface Feed {
   end(): void
   /** Whether the consumer returned the iterator (its `finally` ran). */
   readonly returned: boolean
+  readonly closed: Promise<undefined>
 }
 
 type Step = { readonly kind: 'frame'; readonly frame: RemoteResult<string> } | { readonly kind: 'end' }
@@ -35,6 +37,7 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab
   const steps: Step[] = []
   let wake: (() => void) | undefined
   let returned = false
+  const closed = Promise.withResolvers<undefined>()
   const notify = (): void => { wake?.(); wake = undefined }
   async function* stream(): AsyncGenerator<RemoteResult<string>> {
     try {
@@ -47,10 +50,12 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab
       }
     } finally {
       returned = true
+      closed.resolve(undefined)
     }
   }
   const feed: Feed = {
     ctx,
+    closed: closed.promise,
     push: (value) => { steps.push({ kind: 'frame', frame: { ok: true, value } }); notify() },
     fail: (error) => { steps.push({ kind: 'frame', frame: { ok: false, error } }); notify() },
     end: () => { steps.push({ kind: 'end' }); notify() },
@@ -62,6 +67,10 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab
 /** A `feed` provider whose every `open` is recorded and spec-driven. */
 function scriptedProvider() {
   const opens: Feed[] = []
+  onTestFinished(async () => {
+    for (const feed of opens) feed.end()
+    await Promise.all(opens.map(feed => feed.closed))
+  })
   const provider = {
     protocol: 'feed' as const,
     open: vi.fn((_address: string, ctx: ResourceOpenContext) => {
@@ -69,7 +78,6 @@ function scriptedProvider() {
       opens.push(feed)
       return stream
     }),
-    reload: vi.fn(),
   } satisfies ResourceProvider<'feed'>
   return { provider, opens, last: () => opens[opens.length - 1]! }
 }
@@ -333,27 +341,97 @@ describe('ResourceRegistry streams', () => {
   })
 })
 
-describe('ResourceRegistry reload', () => {
-  it('forwards reload to the protocol\'s provider, and stays a no-op without one', () => {
+describe('ResourceRegistry addresses', () => {
+  it('opens different complete addresses independently and supplies only the lifetime signal', async () => {
     const b = bench()
-    b.snapshot().reload()
     b.registry.register(b.provider)
-    b.snapshot().reload()
-    expect(b.provider.reload).toHaveBeenCalledWith(A)
-
-    const bare = bench()
-    bare.registry.register({ protocol: 'feed', open: bare.provider.open })
-    expect(() => { bare.snapshot().reload() }).not.toThrow()
+    const other = A + '?variant=second'
+    const first = b.registry.source(A)
+    const second = b.registry.source(other)
+    const releaseFirst = first.subscribe(() => {})
+    const firstFeed = b.last()
+    const releaseSecond = second.subscribe(() => {})
+    const secondFeed = b.last()
+    expect(first).not.toBe(second)
+    expect(b.provider.open.mock.calls).toEqual([[A, firstFeed.ctx], [other, secondFeed.ctx]])
+    expect(firstFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
+    expect(secondFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
+    firstFeed.push('first data')
+    secondFeed.push('second data')
+    await vi.waitFor(() => { expect(first.getSnapshot().value).toBe('first data') })
+    await vi.waitFor(() => { expect(second.getSnapshot().value).toBe('second data') })
+    releaseFirst()
+    expect(firstFeed.ctx.signal.aborted).toBe(true)
+    expect(secondFeed.ctx.signal.aborted).toBe(false)
+    expect(second.getSnapshot().value).toBe('second data')
+    releaseSecond()
+    expect(secondFeed.ctx.signal.aborted).toBe(true)
   })
+})
 
-  it('keeps one reload function per address across state changes', async () => {
+describe('ResourceRegistry stream generations', () => {
+  it.each(['value', 'failure'] as const)('drops late %s frames after a released resource reopens', async (kind) => {
     const b = bench()
     b.registry.register(b.provider)
     const source = b.registry.source(A)
-    const { reload } = source.getSnapshot()
-    source.subscribe(() => {})
-    b.last().push('v1')
-    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
-    expect(source.getSnapshot().reload).toBe(reload)
+    const releaseFirst = source.subscribe(() => {})
+    const oldFeed = b.last()
+    oldFeed.push('old data')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') })
+    releaseFirst()
+    const releaseCurrent = source.subscribe(() => {})
+    const currentFeed = b.last()
+    expect(currentFeed).not.toBe(oldFeed)
+    currentFeed.push('current data')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('current data') })
+    const current = source.getSnapshot()
+    if (kind === 'value') oldFeed.push('late old data')
+    else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {}))
+    await oldFeed.closed
+    expect(oldFeed.returned).toBe(true)
+    expect(source.getSnapshot()).toBe(current)
+    releaseCurrent()
+  })
+
+  it.each(['value', 'failure'] as const)('drops old-provider %s frames after replacement', async (kind) => {
+    const b = bench()
+    const releaseProvider = b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    const unsubscribe = source.subscribe(() => {})
+    const oldFeed = b.last()
+    oldFeed.push('old data')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') })
+    releaseProvider()
+    expect(oldFeed.ctx.signal.aborted).toBe(true)
+    expect(source.getSnapshot()).toEqual({ status: 'none', value: undefined, failure: undefined })
+    const replacement = scriptedProvider()
+    b.registry.register(replacement.provider)
+    expect(source.getSnapshot()).toEqual({ status: 'loading', value: undefined, failure: undefined })
+    replacement.last().push('replacement data')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('replacement data') })
+    const current = source.getSnapshot()
+    if (kind === 'value') oldFeed.push('late old data')
+    else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {}))
+    await oldFeed.closed
+    expect(oldFeed.returned).toBe(true)
+    expect(source.getSnapshot()).toBe(current)
+    unsubscribe()
+  })
+
+  it('streams another protocol as plain numbers', async () => {
+    const b = bench()
+    const closed = Promise.withResolvers<undefined>()
+    const dispose = b.registry.register({
+      protocol: 'counter',
+      async *open() {
+        try { yield { ok: true as const, value: 1 } } finally { closed.resolve(undefined) }
+      },
+    })
+    onTestFinished(dispose)
+    const source = b.registry.source('dsh-resource://counter/one')
+    const unsubscribe = source.subscribe(() => {})
+    await closed.promise
+    expect(source.getSnapshot()).toEqual({ status: 'live', value: 1, failure: undefined })
+    unsubscribe()
   })
 })

+ 1 - 1
packages/client/ui-chat/package.json

@@ -68,7 +68,7 @@
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-sidebar-textpreview": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",

+ 4 - 4
packages/client/ui-chat/src/client/apply.ts

@@ -7,7 +7,7 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 // The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below.
-import type {} from '@deepseek-ai/dsh-client-ui-sidebar-textpreview/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
 import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
 // Type-only service and declaration merges used by the apply world.
 import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -121,9 +121,9 @@ export function apply(ctx: Context): void {
           // content stays in the product, beside the conversation that produced
           // it. A relative path, or an absolute one inside the session's
           // workspace, is addressed under this session's scope,
-          // `dsh-resource://file/session/<id>/<relative path>`; an absolute path
-          // elsewhere is addressed as `dsh-resource://file/absolute/<path>` and
-          // read through the session on screen. Which tab type claims the
+          // `dsh-resource://file/session/<id>/<path>`; an absolute path
+          // elsewhere keeps its absolute spelling in the same Session's address.
+          // Which tab type claims the
           // address is the Sidebar's decision, not this call site's.
           // A line travels as a navigation parameter, not as part of the
           // address: the file is one piece of content whether it is opened at

+ 2 - 2
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -154,9 +154,9 @@ describe('Chat inject API', () => {
     // Session; the Client need not know it.
     await injected.openFile('src/a.ts')
     expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-2/src/a.ts')
-    // An absolute path outside every known root carries no Session in its address.
+    // An absolute path outside every known root still names its Session.
     await injected.openFile('/abs/a.ts')
-    expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/absolute/abs/a.ts')
+    expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-2//abs/a.ts')
     await b.runtime.dispose()
   })
 

+ 1 - 1
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -45,7 +45,7 @@ import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
 import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 afterEach(() => {
   cleanup()

+ 1 - 1
packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx

@@ -29,7 +29,7 @@ function noPendingInteraction() {
 }
 
 // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 function mount(mode: 'normal' | 'compact' = 'compact') {
   const source = createSnapshotStore(mode)

+ 1 - 1
packages/client/ui-chat/tsconfig.json

@@ -99,7 +99,7 @@
       "path": "../ui-workspace"
     },
     {
-      "path": "../ui-sidebar-textpreview"
+      "path": "../ui-sidebar-documentpreview"
     }
   ]
 }

+ 1 - 1
packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx

@@ -13,7 +13,7 @@ import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.
 import { en } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 afterEach(() => {
   cleanup()

+ 1 - 1
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -33,7 +33,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 afterEach(cleanup)
 

+ 1 - 1
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -26,7 +26,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).

+ 1 - 1
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -33,7 +33,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).

+ 1 - 1
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -24,7 +24,7 @@ import { zh } from '../src/client/locales.ts'
 import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 afterEach(cleanup)
 

+ 1 - 1
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -34,7 +34,7 @@ import type {
 import type { ViewTab } from '../src/client/contract/views.ts'
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).

+ 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: 330bff66577464a26eac50d92ee54cc0cdefc8df
-README.zh.md: f5259400055a28ea99f2205f9ba0a28d2cc13bd2
+README.md: f4f76072bd16cd75bff74715f8f9437348ee036a
+README.zh.md: 39e20b42bfabb49630b1c9e108b43010d6e7b199

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

@@ -37,7 +37,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
 - `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it.
 - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does).
 
-**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
+**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. The optional `canCloseTab(tabId)` hides the chip, menu, and floating close controls; the embedder enforces closing in its intent handler. A chip is a capsule carrying its close control when allowed; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
 
 <a id="embedding-it"></a>
 ## Embedding it

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

@@ -37,7 +37,7 @@ kind: "package-reference"
 - `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。
 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
 
-**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
+**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
 
 <a id="embedding-it"></a>
 ## 如何嵌入

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

@@ -45,6 +45,8 @@ export interface DockSurfaceProps {
    * end controls where they are and the chips as the only shrinking part.
    */
   readonly canAddTab?: (paneId: PaneId) => boolean
+  /** Whether a tab offers close controls; defaults to true. Called per tab on every render. */
+  readonly canCloseTab?: (tabId: TabId) => boolean
   readonly intents: DockIntents
   readonly labels: DockLabels
   readonly renderTab: TabRenderer
@@ -154,7 +156,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,
+  state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom,
   dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false,
 }: DockSurfaceProps): ReactNode {
   const surface = useRef<HTMLDivElement | null>(null)
@@ -267,6 +269,7 @@ export function DockSurface({
     splitBlock,
     hideSplitAtCapacity,
     canAddTab: canAddTab ?? ALWAYS,
+    canCloseTab: canCloseTab ?? ALWAYS,
     dropTarget: preview.dropTarget,
     horizontalDrops: dropZones === 'horizontal',
     draggingTabId: preview.draggingTabId,

+ 16 - 12
packages/client/ui-dockkit/src/components/FloatLayer.tsx

@@ -15,7 +15,7 @@
 import { useState } from 'react'
 import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
 import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts'
-import type { FloatRect, LayoutState, PaneId } from '../contract/types.ts'
+import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts'
 import { FLOAT_MIN_SIZE } from '../engine/constraints.ts'
 import { movedRect, resizedRect } from '../engine/geometry.ts'
 import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts'
@@ -28,6 +28,8 @@ export interface FloatLayerProps {
   readonly intents: DockIntents
   readonly labels: DockLabels
   readonly renderTab: TabRenderer
+  /** Whether a floating tab offers its close control; defaults to true. Called per tab on every render. */
+  readonly canCloseTab?: (tabId: TabId) => boolean
   /** The panel header's title content; omit to show the record's `title` text (see `DockSurfaceProps`). */
   readonly renderTabTitle?: TabRenderer
 }
@@ -60,7 +62,7 @@ function raised(state: LayoutState, paneId: PaneId): boolean {
 }
 
 /** Every floating panel, in z order. */
-export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }: FloatLayerProps): ReactNode {
+export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode {
   const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined)
   const begin = useGesture(() => { setPreview(undefined) })
 
@@ -127,16 +129,18 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }
               >
                 ⇤
               </button>
-              <button
-                type="button"
-                className={css.iconButton}
-                aria-label={labels.closeFloat}
-                data-dockkit-float-close={paneId}
-                onPointerDown={(event) => { event.stopPropagation() }}
-                onClick={() => { intents.closeTab(tab.id) }}
-              >
-                ✕
-              </button>
+              {(canCloseTab?.(tab.id) ?? true) && (
+                <button
+                  type="button"
+                  className={css.iconButton}
+                  aria-label={labels.closeFloat}
+                  data-dockkit-float-close={paneId}
+                  onPointerDown={(event) => { event.stopPropagation() }}
+                  onClick={() => { intents.closeTab(tab.id) }}
+                >
+                  ✕
+                </button>
+              )}
             </header>
             <div className={css.floatBody}>{renderTab(tab)}</div>
             <div

+ 14 - 10
packages/client/ui-dockkit/src/components/TabMenu.tsx

@@ -12,7 +12,7 @@
  * portal's synthetic events through the strip, which is why the press guards
  * below remain necessary.
  */
-import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { Children, useEffect, useLayoutEffect, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import { createPortal } from 'react-dom'
 import type { DockLabels } from '../contract/adapter.ts'
@@ -26,10 +26,12 @@ export interface TabMenuProps {
   readonly labels: DockLabels
   /** The control that opened the menu; the menu hangs below its left edge. */
   readonly anchor: HTMLElement
+  /** Whether to offer close; custom items remain available when false. */
+  readonly canCloseTab: boolean
   readonly onClose: () => void
   /** Dismiss without acting. */
   readonly onDismiss: () => void
-  /** Embedder items, rendered after the kit's own; absent means none. */
+  /** Embedder ARIA menu items, rendered after the kit's own; absent means none. */
   readonly extras: ReactNode
 }
 
@@ -47,19 +49,18 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties {
 }
 
 /** The actions menu body, anchored to the control that opened it. */
-export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
+export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
   const self = useRef<HTMLDivElement | null>(null)
   const [position, setPosition] = useState<CSSProperties | undefined>(undefined)
+  const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '')
 
   useLayoutEffect(() => {
-    /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
     if (self.current === null) return
     setPosition(placeMenu(anchor, self.current))
-  }, [anchor])
+  }, [anchor, canCloseTab, hasItems])
 
   useEffect(() => {
     const menu = self.current
-    /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
     if (menu === null) return undefined
     // A press anywhere but inside the menu dismisses it; one with no element
     // target (dispatched to the window itself) counts as outside.
@@ -71,8 +72,9 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP
     // so the menu must be gone before that handler runs.
     window.addEventListener('pointerdown', onPointerDown, true)
     return () => { window.removeEventListener('pointerdown', onPointerDown, true) }
-  }, [onDismiss])
+  }, [onDismiss, hasItems])
 
+  if (!hasItems) return null
   return createPortal(
     <div
       className={css.menu}
@@ -89,9 +91,11 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP
       onPointerDown={(event) => { event.stopPropagation() }}
       onClick={(event) => { event.stopPropagation() }}
     >
-      <button type="button" role="menuitem" className={css.menuItem} data-dockkit-menu-close onClick={onClose}>
-        {labels.closeTab}
-      </button>
+      {canCloseTab && (
+        <button type="button" role="menuitem" className={css.menuItem} data-dockkit-menu-close onClick={onClose}>
+          {labels.closeTab}
+        </button>
+      )}
       {/* Embedder items last: the kit's own item is the same in every menu, so
           a reader looks for it in the same place every time. */}
       {extras}

+ 20 - 16
packages/client/ui-dockkit/src/components/TabPanel.tsx

@@ -3,7 +3,7 @@
  * active tab's body with the dock preview overlay. Presentational; every gesture
  * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`.
  *
- * A chip is a capsule carrying one control, its close, at its right end; the
+ * A chip is a capsule carrying an optional close control at its right end; the
  * context menu (secondary press) carries the same close plus whatever the
  * embedder appends. The chips sit in their own box, the strip's one shrinking
  * part: in a narrow pane they ellipsize and then clip there, so the add
@@ -136,6 +136,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
           {pane.tabs.map((tabId, index) => {
             const tab = getTab(state, tabId)
             const selected = tabId === pane.activeTabId
+            const canClose = callbacks.canCloseTab(tabId)
             return (
               <Fragment key={tabId}>
                 {stripIndex === index && <div className={css.caret} data-dockkit-caret={index} />}
@@ -188,25 +189,28 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
                   }}
                 >
                   <span className={css.tabTitle} data-dockkit-tab-title>{callbacks.renderTabTitle?.(tab) ?? tab.title}</span>
-                  <button
-                    type="button"
-                    className={css.tabClose}
-                    aria-label={callbacks.labels.closeTab}
-                    data-dockkit-tab-close={tabId}
-                    // A nested control stops its own press: otherwise the press
-                    // starts a drag, captures the pointer, and this click never lands.
-                    onPointerDown={(event) => { event.stopPropagation() }}
-                    onClick={(event) => {
-                      event.stopPropagation()
-                      callbacks.onCloseTab(tabId)
-                    }}
-                  >
-                    <CloseGlyph />
-                  </button>
+                  {canClose && (
+                    <button
+                      type="button"
+                      className={css.tabClose}
+                      aria-label={callbacks.labels.closeTab}
+                      data-dockkit-tab-close={tabId}
+                      // A nested control stops its own press: otherwise the press
+                      // starts a drag, captures the pointer, and this click never lands.
+                      onPointerDown={(event) => { event.stopPropagation() }}
+                      onClick={(event) => {
+                        event.stopPropagation()
+                        callbacks.onCloseTab(tabId)
+                      }}
+                    >
+                      <CloseGlyph />
+                    </button>
+                  )}
                   {menu?.tabId === tabId && (
                     <TabMenu
                       labels={callbacks.labels}
                       anchor={menu.anchor}
+                      canCloseTab={canClose}
                       onClose={() => { setMenu(undefined); callbacks.onCloseTab(tabId) }}
                       onDismiss={() => { setMenu(undefined) }}
                       extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })}

+ 4 - 0
packages/client/ui-dockkit/src/components/dockkit.module.css

@@ -269,6 +269,10 @@
   border-radius: 6px;
 }
 
+.menu:not(:has([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'])) {
+  display: none;
+}
+
 .menuItem {
   padding: 5px 8px;
   color: var(--dsw-alias-label-primary);

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

@@ -27,6 +27,8 @@ export interface PaneCallbacks {
   readonly hideSplitAtCapacity?: boolean
   /** Whether a pane's strip draws the add control. */
   readonly canAddTab: (paneId: PaneId) => boolean
+  /** Whether a tab's chip and menu offer close. */
+  readonly canCloseTab: (tabId: TabId) => boolean
   /** Live drop preview, or `undefined` while nothing is being dragged. */
   readonly dropTarget: DropTarget | undefined
   /** Show both horizontal landing regions while a body split is being targeted. */

+ 1 - 1
packages/client/ui-dockkit/src/contract/adapter.ts

@@ -48,7 +48,7 @@ export type TabRenderer = (tab: TabRecord) => ReactNode
  * because the menu closes on its own items only.
  * @param tab - the tab whose menu is open.
  * @param dismiss - close the menu without acting.
- * @returns the extra items, or nothing.
+ * @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing.
  */
 export type TabMenuExtras = (tab: TabRecord, dismiss: () => void) => ReactNode
 

+ 108 - 9
packages/client/ui-dockkit/tests/components.client.spec.tsx

@@ -8,8 +8,10 @@
  * jsdom lays nothing out, so the gesture specs hand the surface a layout: panes
  * of one width side by side, each 600px tall with a 36px strip and 100px chips.
  */
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import type { DockIntents } from '../src/contract/adapter.ts'
 import type { PaneId, TabId } from '../src/contract/types.ts'
 import { DockController } from '../src/engine/controller.ts'
@@ -18,7 +20,7 @@ import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts
 import { floatRectAt } from '../src/engine/geometry.ts'
 import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx'
 import type { TabMenuExtras } from '../src/contract/adapter.ts'
-import { FloatLayer } from '../src/components/FloatLayer.tsx'
+import { FloatLayer, type FloatLayerProps } from '../src/components/FloatLayer.tsx'
 import { dockPaneIds, getPane } from '../src/engine/tree.ts'
 import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts'
 
@@ -53,7 +55,7 @@ function renderSurface(
   intents: DockIntents,
   canSplit = true,
   renderTabMenuItems?: TabMenuExtras,
-  options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction'> = {},
+  options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction' | 'canCloseTab'> = {},
 ) {
   const snapshot = controller.getSnapshot()
   return render(
@@ -456,6 +458,83 @@ describe('DockSurface', () => {
     expect(screen.queryByRole('menu')).toBeNull()
   })
 
+  it('offers close only for tabs the embedder allows', () => {
+    const controller = seededController()
+    const allowed = controller.openContent({ contentId: 'resource:a', title: 'A', kind: 'test' })
+    const intents = spyIntents()
+    renderSurface(controller, intents, true, undefined, { canCloseTab: tabId => tabId === allowed })
+    expect(screen.getByRole('button', { name: TEST_LABELS.closeTab }).getAttribute('data-dockkit-tab-close')).toBe(allowed)
+    fireEvent.contextMenu(screen.getByRole('tab', { name: 'Start' }))
+    expect(screen.queryByRole('menu')).toBeNull()
+    fireEvent.contextMenu(screen.getByRole('tab', { name: /A/u }))
+    fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
+    expect(intents.closeTab).toHaveBeenCalledExactlyOnceWith(allowed)
+  })
+
+  it.each([
+    ['absent', undefined],
+    ['null', null],
+    ['conditional', false],
+    ['empty array', []],
+    ['empty text', ''],
+  ])('hides close and creates no popup when extras are %s', (_, extras) => {
+    const controller = seededController()
+    const intents = spyIntents()
+    renderSurface(controller, intents, true, extras === undefined ? undefined : () => extras, { canCloseTab: () => false })
+    expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    expect(screen.queryByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeNull()
+    expect(screen.queryByRole('menu')).toBeNull()
+    expect(intents.closeTab).not.toHaveBeenCalled()
+  })
+
+  it('updates chip and open-menu close controls when eligibility props change', () => {
+    const controller = seededController()
+    const intents = spyIntents()
+    const props: DockSurfaceProps = {
+      state: controller.getSnapshot().state,
+      canSplit: true,
+      intents,
+      labels: TEST_LABELS,
+      renderTab: () => null,
+      canCloseTab: () => false,
+    }
+    const { rerender } = render(<DockSurface {...props} />)
+    expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
+    rerender(<DockSurface {...props} canCloseTab={() => true} />)
+    expect(screen.getByRole('button', { name: TEST_LABELS.closeTab })).toBeDefined()
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    expect(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeDefined()
+    rerender(<DockSurface {...props} />)
+    expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
+    expect(screen.queryByRole('menu')).toBeNull()
+    rerender(<DockSurface {...props} canCloseTab={() => true} />)
+    fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
+    expect(intents.closeTab).toHaveBeenCalledTimes(1)
+    expect(screen.queryByRole('menu')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeTab }))
+    expect(intents.closeTab).toHaveBeenCalledTimes(2)
+  })
+
+  it.each([false, true])('hides the popup when an extras component renders no content inside a wrapper: %s', (wrapped) => {
+    const EmptyExtras = () => null
+    renderSurface(seededController(), spyIntents(), true,
+      () => wrapped ? <div style={{ display: 'contents' }}><EmptyExtras /></div> : <EmptyExtras />,
+      { canCloseTab: () => false })
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    const menu = document.querySelector<HTMLElement>('[data-dockkit-tab-menu]')
+    if (menu === null) throw new Error('expected the extras component menu')
+    // Vitest stubs CSS Modules; bind the real stylesheet's menu selector to its generated class.
+    const style = document.createElement('style')
+    style.textContent = readFileSync(resolve(import.meta.dirname, '../src/components/dockkit.module.css'), 'utf8')
+      .replaceAll(/\.menu(?=[:\s{])/g, `.${menu.className}`)
+    document.head.append(style)
+    onTestFinished(() => { style.remove() })
+    expect(menu.querySelectorAll('[role^="menuitem"]')).toHaveLength(0)
+    expect(getComputedStyle(menu).display).toBe('none')
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
   it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => {
     const controller = seededController()
     renderSurface(controller, spyIntents())
@@ -501,7 +580,7 @@ describe('DockSurface', () => {
     expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull()
   })
 
-  it('appends embedder menu items after its own, and hands them the tab and a dismiss', () => {
+  it.each([true, false])('keeps embedder menu items and their tab and dismiss when close is allowed: %s', (canClose) => {
     const controller = seededController()
     const acted = vi.fn<(contentId: string) => void>()
     const extras: TabMenuExtras = (tab, dismiss) => (
@@ -514,17 +593,20 @@ describe('DockSurface', () => {
         embedder item
       </button>
     )
-    renderSurface(controller, spyIntents(), true, extras)
+    const intents = spyIntents()
+    renderSurface(controller, intents, true, extras, { canCloseTab: () => canClose })
     fireEvent.contextMenu(screen.getByRole('tab'))
 
     // Order is contract: the kit's own item stays in the same place in every
     // menu, so an embedder item cannot displace it.
-    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
-      TEST_LABELS.closeTab, 'embedder item',
-    ])
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(
+      canClose ? [TEST_LABELS.closeTab, 'embedder item'] : ['embedder item'],
+    )
+    expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab }) !== null).toBe(canClose)
 
     fireEvent.click(screen.getByTestId('extra'))
     expect(acted).toHaveBeenCalledWith('seed:start')
+    expect(intents.closeTab).not.toHaveBeenCalled()
     expect(screen.queryByRole('menu')).toBeNull()
   })
 
@@ -895,7 +977,12 @@ describe('FloatLayer', () => {
   }
 
   /** One floating panel over a spied intent set. */
-  function floating(): { intents: ReturnType<typeof spyIntents>; paneId: PaneId; tabId: TabId; panel: HTMLElement } {
+  function floating(canCloseTab?: FloatLayerProps['canCloseTab']): {
+    intents: ReturnType<typeof spyIntents>
+    paneId: PaneId
+    tabId: TabId
+    panel: HTMLElement
+  } {
     const controller = seededController()
     const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
     const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 })
@@ -906,6 +993,7 @@ describe('FloatLayer', () => {
         intents={intents}
         labels={TEST_LABELS}
         renderTab={tab => <p data-testid="float-body">{tab.title}</p>}
+        {...canCloseTab === undefined ? {} : { canCloseTab }}
       />,
     )
     const panel = document.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`)
@@ -927,6 +1015,17 @@ describe('FloatLayer', () => {
     expect(intents.closeTab).toHaveBeenCalledWith(tabId)
   })
 
+  it('hides a floating tab close control when the embedder disallows it', () => {
+    const canCloseTab = vi.fn(() => false)
+    const { intents, tabId } = floating(canCloseTab)
+    expect(canCloseTab).toHaveBeenCalledWith(tabId)
+    expect(screen.queryByRole('button', { name: TEST_LABELS.closeFloat })).toBeNull()
+    expect(screen.getByTestId('float-body').textContent).toBe('a.txt')
+    fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat }))
+    expect(intents.unfloatPane).toHaveBeenCalledTimes(1)
+    expect(intents.closeTab).not.toHaveBeenCalled()
+  })
+
   it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => {
     const { intents, lower, panel, part } = twoFloats()
     fireEvent.pointerDown(panel(lower))

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

@@ -11,7 +11,7 @@ import { createLayoutStore } from '../src/client/stores.ts'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
-const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
 let selectedSession: SessionId | undefined
 let selectedSessionTitle: string | undefined
 let workspacesReady = true

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
-README.md: 3e0922690cc68a477e4b1d5ca1ed142d713369f0
-README.zh.md: de8487499ad31affc95986125c910b368ae39b95
+README.md: 7b670394f6ca70ea369fc79766b26119f670f753
+README.zh.md: 8ddefde928a723992945f801fb7407a2d22f5f27

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

@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. |
+| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. |
 

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

@@ -52,7 +52,7 @@ kind: "package-library"
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。 |
+| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 |
 

Некоторые файлы не были показаны из-за большого количества измененных файлов