Explorar el Código

fix(client): address document preview review feedback

imccyu hace 2 semanas
padre
commit
4d07fbcb85
Se han modificado 32 ficheros con 410 adiciones y 124 borrados
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
  2. 17 17
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
  3. 17 17
      .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml
  5. 29 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
  6. 29 0
      .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  8. 3 4
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  9. 3 4
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  10. 33 9
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  11. 2 0
      packages/client/ui-sidebar-documentpreview/src/client/document/layout.ts
  12. 12 2
      packages/client/ui-sidebar-documentpreview/src/client/face.ts
  13. 8 3
      packages/client/ui-sidebar-documentpreview/src/client/html/bytes.ts
  14. 10 2
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx
  15. 1 19
      packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts
  16. 6 12
      packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts
  17. 6 3
      packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts
  18. 15 0
      packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts
  19. 7 1
      packages/client/ui-sidebar-documentpreview/tests/html-bootstrap.client.spec.ts
  20. 24 0
      packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx
  21. 1 1
      packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts
  22. 4 5
      packages/client/ui-sidebar-documentpreview/tests/pdf-store.client.spec.ts
  23. 126 4
      packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx
  24. 5 3
      packages/client/ui-sidebar-right/src/client/stores.ts
  25. 16 0
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  26. 21 0
      packages/client/ui-sidebar-right/tests/stores.client.spec.ts
  27. 2 2
      packages/util/workspace-path/README.i18n.yaml
  28. 1 1
      packages/util/workspace-path/README.md
  29. 1 1
      packages/util/workspace-path/README.zh.md
  30. 0 5
      snapshots/web/seeded-history/command-row.expected.md
  31. 0 5
      snapshots/web/seeded-history/feedback-row.expected.md
  32. 1 0
      snapshots/web/seeded-history/file-preview.expected.md

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

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

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

@@ -8,15 +8,15 @@ 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 bounds content 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.
+`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 |
 |---|---|---|---|
@@ -42,7 +42,7 @@ 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 checks both files under the same workspace rules. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics.
+- **`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.
 
@@ -52,16 +52,16 @@ Two path vocabularies leave the service, and each method uses exactly one. `read
 
 `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`, `readAll`, `readRelated`, `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.
+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
 
@@ -70,7 +70,7 @@ 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/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`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
@@ -99,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**, `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 for resolution and containment against that Session's workspace root; 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 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 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`. [Document Preview](2026-09-08-document-preview-operations.md) owns file-address authorization, content loading, and per-tab freshness.
+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.
 
@@ -127,7 +127,7 @@ 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
 
@@ -136,13 +136,13 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
 - 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. 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; 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, 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 and Host path authorization. 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
 

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

@@ -8,15 +8,15 @@ 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 运行时入口。
 
 | 面 | 包 | 文件 | 依赖 |
 |---|---|---|---|
@@ -42,7 +42,7 @@ 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) 负责其加载与地址语义。
+- **`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`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。
 
@@ -52,16 +52,16 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 
 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。
 
-### 四道关
+### 文件检查与工作区包含
 
-每次 `read`、`readBytes`、`readAll`、`readRelated`、`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,前缀测试看不见离开根的链接。
+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` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。
 
 ### 失败
 
@@ -70,7 +70,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
 | 代码 | 何时 | Details |
 |---|---|---|
 | `workspace-file/not-found` | 路径处无条目,或文件在过关后消失 | `{ path }` |
-| `workspace-file/outside-workspace` | 解析出的目标不在工作区根内 | `{ path }` |
+| `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`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
@@ -99,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`。
 
 - **值是元数据**,`WorkspaceFileStat { absolutePath, version, bytes? }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并比较版本判断它们是否过时;新鲜度判断和刷新属于各消费方,不属于共享观察。
-- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对或绝对路径原样交给 Host,由 Host 按该会话的工作区根解析并检查包含关系,不要求 Client 持有 cwd。`absolute` 地址不带 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 tab 所属 Session。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流。
+- **地址命名文件,作用域决定读取会话。** `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` 发布。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责文件地址授权、内容加载和逐 tab 新鲜度。
+[资源模型](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。
 
@@ -127,7 +127,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 
 **在 `FileSystem` 基类里给 `readByteRange` 一个默认实现。** 基于 `readBytes` 的非抽象默认能免去测试替身一个方法,但只能靠把文件从头读到窗口末端来实现——正是上文否决的行为——或者传一个无界上限。改为抽象方法,由每个提供者与替身实现。
 
-**字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。由文件系统判定包含关系。
+**工作区操作使用字符串前缀包含判定。** 把解析后的路径字符串与根比较比 `fs.contains` 简单,但 `resolve` 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。`list` 与 `changes` 由文件系统判定包含关系。
 
 ## Consequences
 
@@ -136,13 +136,13 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。
 - 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。
 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。
-- 关的顺序先报种类后报位置,页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
+- 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。
 - `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 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限和 Host 路径授权。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
 

+ 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-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: 0afc029298292493429d8648976e46901c411e2e
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: ac076db2d5797fe1cc58f54d8daa1972ac6a85a2
+2026-09-05-sidebar-text-preview-and-file-tree.md: e8565f0e516b03b267a87f47763532928941ba21
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 93396b1618223c95899969f63a93c33b8977e0b5

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

@@ -12,7 +12,7 @@ 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
 
@@ -28,7 +28,7 @@ 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.
+`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.
 
@@ -96,7 +96,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.
@@ -114,7 +114,6 @@ The text preview's `tests/` cover the registry claim and yielding (through the r
 - Line numbers, syntax highlighting, rendered Markdown, images, and search in the text preview; a total line count or 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
 

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

@@ -12,7 +12,7 @@ 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 方式持有。
 
 ### 引导页
 
@@ -28,7 +28,7 @@ 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 上的文字被缩短。
+`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` 地址都路由给这个类型,而造地址的调用方本应使用助手。
 
@@ -96,7 +96,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 页,所以身在文件深处的读者重载后回到文件开头再往后翻;滚动位置保留但可能指向已加载文本之外。
@@ -114,7 +114,6 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 - 文本预览的行号、语法高亮、Markdown 渲染、图片与搜索;总行数或文件末尾标记。
 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。
 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。
-- `ui-sidebar-textpreview` 与 `ui-sidebar-files` 的中文 README 对照。
 
 ## Related
 

+ 33 - 9
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -10,7 +10,7 @@
  * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of
  * the path row; the Sidebar's strip carries none of them.
  */
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import type { ReactNode } from 'react'
 import clsx from 'clsx'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -25,6 +25,7 @@ import type { TextStore } from './store.ts'
 import type { DocumentContent } from './document/contract.ts'
 import { matchingDocumentPreviews } from './document/registry.ts'
 import type { DocumentPreviewDefinition } from './document/registry.ts'
+import { DOCUMENT_LAYOUT_READY_EVENT } from './document/layout.ts'
 import { PLAIN_BODY_ID } from './text/index.ts'
 import { loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts'
 import css from './TextPreview.module.css'
@@ -69,7 +70,29 @@ export function TextPreview({
   const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0]
   const mode = selected?.loading
   const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
-  const bodyRef = useRef<HTMLDivElement>(null)
+  const bodyRef = useRef<HTMLDivElement | null>(null)
+  const layoutRestore = useRef<{ content: unknown; scrollTop: number; pending: boolean }>({
+    content: undefined, scrollTop: 0, pending: false,
+  })
+  if (layoutRestore.current.content !== current?.complete) {
+    layoutRestore.current = {
+      content: current?.complete,
+      scrollTop: state?.scrollTop ?? 0,
+      pending: current?.complete !== undefined,
+    }
+  }
+  const restoreAfterDocumentLayout = useCallback((event: Event) => {
+    const restore = layoutRestore.current
+    if (!restore.pending) return
+    const body = event.currentTarget as HTMLDivElement
+    body.scrollTop = restore.scrollTop
+    restore.pending = false
+  }, [])
+  const setBody = useCallback((body: HTMLDivElement | null) => {
+    bodyRef.current?.removeEventListener(DOCUMENT_LAYOUT_READY_EVENT, restoreAfterDocumentLayout)
+    bodyRef.current = body
+    body?.addEventListener(DOCUMENT_LAYOUT_READY_EVENT, restoreAfterDocumentLayout)
+  }, [restoreAfterDocumentLayout])
   const [menuOpen, setMenuOpen] = useState(false)
   // Every tab of this type is a `file` resource address, so its params are the
   // `file` type's; the union is narrowed on the one field read, not validated.
@@ -113,7 +136,8 @@ export function TextPreview({
       }
       return
     }
-    scrollToLine(body, line)
+    const landed = scrollToLine(body, line)
+    if (!landed && line <= loadedThrough) return
     actions.navigated(tab.id, navigation.revision)
     // Recorded here as well as by the scroll event, so the store holds the
     // landing before any later navigation reads it.
@@ -127,8 +151,8 @@ export function TextPreview({
     if (mode === 'bytes-complete') {
       return current?.complete === undefined ? undefined : { kind: 'bytes', data: current.complete.data }
     }
-    if (loaded.length === 0) return undefined
-    return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current?.eof ?? false }
+    if (current === undefined || loaded.length === 0) return undefined
+    return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof }
   }, [mode, loaded, current?.complete, current?.eof])
 
   if (state === undefined || selected === undefined) {
@@ -228,7 +252,7 @@ export function TextPreview({
         </button>
       </div>
       <div
-        ref={bodyRef}
+        ref={setBody}
         className={clsx(css.body, state.wrap && css.wrap)}
         data-textpreview-body
         data-textpreview-wrap={state.wrap ? '' : undefined}
@@ -261,15 +285,15 @@ export function TextPreview({
             </button>
           </p>
         )}
-        {mode === 'text-pages' && loaded.length > 0 && !current?.eof && current?.failure === undefined && (
+        {mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && (
           <button
             type="button"
             className={css.more}
-            disabled={current?.loading ?? true}
+            disabled={current.loading}
             data-textpreview-more
             onClick={loadNext}
           >
-            {current?.loading ? <LoadingIndicator label={t('loading')} /> : t('loadMore')}
+            {current.loading ? <LoadingIndicator label={t('loading')} /> : t('loadMore')}
           </button>
         )}
       </div>

+ 2 - 0
packages/client/ui-sidebar-documentpreview/src/client/document/layout.ts

@@ -0,0 +1,2 @@
+/** Event emitted when an asynchronous document body has committed its scrollable layout. */
+export const DOCUMENT_LAYOUT_READY_EVENT = 'dsh-document-layout-ready'

+ 12 - 2
packages/client/ui-sidebar-documentpreview/src/client/face.ts

@@ -144,8 +144,18 @@ export function textFace(
           actions.failed(tabId, result.error)
           return
         }
-        reads.version = result.value.version
-        actions.complete(tabId, documentFileBytes(result.value))
+        let file
+        try {
+          file = documentFileBytes(result.value)
+        } catch (error) {
+          actions.failed(tabId, Object.assign(
+            new Error('document file byte response has malformed base64 data', { cause: error }),
+            { name: 'RemoteError', isDSHRemoteError: true as const, code: 'gateway/internal' as const, details: {} },
+          ))
+          return
+        }
+        reads.version = file.version
+        actions.complete(tabId, file)
       })
     }
     const restart = (

+ 8 - 3
packages/client/ui-sidebar-documentpreview/src/client/html/bytes.ts

@@ -1,5 +1,7 @@
 /** UTF-8 decoding for file bytes and encoding only for the iframe's script payload. */
 
+const BASE64_CHUNK_BYTES = 0x8000
+
 /**
  * Decode complete UTF-8 text, rejecting invalid byte sequences.
  * @param data - complete UTF-8 bytes.
@@ -15,7 +17,10 @@ export function decodeText(data: Uint8Array<ArrayBuffer>): string {
  * @returns base64 of its UTF-8 bytes.
  */
 export function encodeText(text: string): string {
-  let binary = ''
-  for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte)
-  return btoa(binary)
+  const bytes = new TextEncoder().encode(text)
+  const chunks: string[] = []
+  for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_BYTES) {
+    chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_BYTES)))
+  }
+  return btoa(chunks.join(''))
 }

+ 10 - 2
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx

@@ -1,9 +1,10 @@
 /** PDF page presentation; binary content and tab information come from the document owner. */
-import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
 import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { DocumentPreviewProps } from '../document/contract.ts'
+import { DOCUMENT_LAYOUT_READY_EVENT } from '../document/layout.ts'
 import { LoadingIndicator } from '../LoadingIndicator.tsx'
 import { DEFAULT_PDF_VIEW, type PdfStore } from './store.ts'
 import { renderPdfPage, type PdfDocument } from './document.ts'
@@ -38,6 +39,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
   const { tab } = props.useTabInfo()
   const view = props.useStore(state => state.byTab[tab.id] ?? DEFAULT_PDF_VIEW)
   const data = props.content.kind === 'bytes' ? props.content.data : undefined
+  const body = useRef<HTMLElement>(null)
   const [load, setLoad] = useState<LoadState>()
   const [attempt, setAttempt] = useState(0)
   const { retainTab, actions, t } = props
@@ -63,6 +65,12 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
       void session.dispose()
     }
   }, [data, tab.signal, attempt])
+  useLayoutEffect(() => {
+    if (load?.kind === 'loaded' && load.data === data) {
+      // The loaded branch renders this section in the same commit as the layout effect.
+      (body.current as HTMLElement).dispatchEvent(new Event(DOCUMENT_LAYOUT_READY_EVENT, { bubbles: true }))
+    }
+  }, [data, load])
 
   if (data === undefined) return <p className={css.status} role="alert">{t('unsupported')}</p>
   if (load?.data !== data) return <LoadingIndicator className={css.status} label={t('loading')} />
@@ -72,7 +80,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
       <Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
     </div>
   }
-  return <section className={css.body} data-pdf-preview>
+  return <section ref={body} className={css.body} data-pdf-preview>
     {Array.from({ length: load.document.numPages }, (_, index) => (
       <PdfPage key={index} document={load.document} page={index + 1}
         requested={index === 0 || view.page === index + 1} onVisible={pageVisible} signal={tab.signal} t={t} />

+ 1 - 19
packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts

@@ -1,16 +1,7 @@
 /** Copy owned by the PDF renderer. */
 export const zh = {
   title: 'PDF',
-  toolbar: 'PDF 阅读工具',
-  previous: '上一页',
-  next: '下一页',
-  page: '页码',
-  pageCount: '共 {total} 页',
   pageImage: 'PDF 第 {page} 页',
-  zoomIn: '放大',
-  zoomOut: '缩小',
-  resetZoom: '重置缩放',
-  zoom: '{percent}%',
   loading: '正在打开 PDF…',
   rendering: '正在绘制页面…',
   failed: '无法显示 PDF:{message}',
@@ -26,16 +17,7 @@ export type PdfLocaleKey = keyof typeof zh
 /** English PDF-renderer dictionary. */
 export const en = {
   title: 'PDF',
-  toolbar: 'PDF reading tools',
-  previous: 'Previous page',
-  next: 'Next page',
-  page: 'Page',
-  pageCount: 'of {total}',
   pageImage: 'PDF page {page}',
-  zoomIn: 'Zoom in',
-  zoomOut: 'Zoom out',
-  resetZoom: 'Reset zoom',
-  zoom: '{percent}%',
   loading: 'Opening PDF…',
   rendering: 'Rendering page…',
   failed: 'Cannot display PDF: {message}',
@@ -47,7 +29,7 @@ export const en = {
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** PDF navigation, loading, and failure messages. */
+    /** PDF page, loading, and failure messages. */
     sidebarPdf: PdfLocaleKey
   }
 }

+ 6 - 12
packages/client/ui-sidebar-documentpreview/src/client/pdf/store.ts

@@ -2,28 +2,26 @@
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 
-/** One tab's page selection and zoom factor. */
+/** One tab's last visible page. */
 export interface PdfView {
   readonly page: number
-  readonly zoom: number
 }
 
-/** Initial viewing preferences before a tab changes page or zoom. */
-export const DEFAULT_PDF_VIEW: PdfView = { page: 1, zoom: 1 }
+/** Initial viewing position before a tab reaches another page. */
+export const DEFAULT_PDF_VIEW: PdfView = { page: 1 }
 
-/** Page and zoom state isolated by the owning tab record. */
+/** Page state isolated by the owning tab record. */
 export interface PdfState {
   byTab: Record<TabId, PdfView>
 }
 
 type PdfActions = {
   page: (draft: PdfState, tabId: TabId, page: number) => void
-  zoom: (draft: PdfState, tabId: TabId, zoom: number) => void
   forget: (draft: PdfState, tabId: TabId) => void
 }
 
 /**
- * Declare page and zoom preferences isolated by tab identity.
+ * Declare the last visible page isolated by tab identity.
  * @returns a store declaration instantiated by the document slot for each Session.
  */
 export function createPdfStore(): EngineStoreHandle<PdfState, PdfActions> {
@@ -32,11 +30,7 @@ export function createPdfStore(): EngineStoreHandle<PdfState, PdfActions> {
     actions: {
       /** @param draft - view state. @param tabId - owning tab. @param page - selected 1-based page. */
       page: (draft, tabId: TabId, page: number) => {
-        draft.byTab[tabId] = { ...(draft.byTab[tabId] ?? DEFAULT_PDF_VIEW), page }
-      },
-      /** @param draft - view state. @param tabId - owning tab. @param zoom - selected scale factor. */
-      zoom: (draft, tabId: TabId, zoom: number) => {
-        draft.byTab[tabId] = { ...(draft.byTab[tabId] ?? DEFAULT_PDF_VIEW), zoom }
+        draft.byTab[tabId] = { page }
       },
       /** @param draft - view state. @param tabId - closed tab whose preferences are discarded. */
       forget: (draft, tabId: TabId) => {

+ 6 - 3
packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts

@@ -39,10 +39,13 @@ export function lastLineLoaded(pages: readonly LoadedPage[]): number {
  * Reveal a plain-text or highlighted source line.
  * @param body - scrolling document body.
  * @param line - 1-based source line to reveal.
+ * @returns Whether the current renderer exposes that line.
  */
-export function scrollToLine(body: HTMLElement, line: number): void {
+export function scrollToLine(body: HTMLElement, line: number): boolean {
   const plain = body.querySelector(`[data-textpreview-line="${line}"]`)
-  const code = body.querySelectorAll('.shiki .line').item(line - 1)
+  const code = body.querySelectorAll('[data-code-preview] pre .line').item(line - 1)
   const row = plain ?? code
-  if (row instanceof HTMLElement) body.scrollTop = row.offsetTop
+  if (!(row instanceof HTMLElement)) return false
+  body.scrollTop = row.offsetTop
+  return true
 }

+ 15 - 0
packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts

@@ -108,6 +108,7 @@ function bench(sessionId = 'other-session' as SessionId) {
     settleAll: (result: RemoteResult<DocumentFileBytes>, key?: number) => whole.settle(result.ok
       ? { ok: true, value: { ...result.value, data: btoa(String.fromCharCode(...result.value.data)) } }
       : result, key),
+    settleAllWire: whole.settle,
     outstandingAll: whole.outstanding,
     outstanding: () => pending.map(call => call.offset),
     tab: () => instance.getSnapshot().byTab[TAB_1],
@@ -239,6 +240,20 @@ describe('textFace', () => {
     expect(tab()).toMatchObject({ loading: false, failure: undefined, complete: complete().value })
   })
 
+  it('records malformed complete-byte wire data as a failed read', async () => {
+    const { face, settleAllWire, tab, controller } = bench()
+    face.loadAll(TAB_1, FILE, controller.signal)
+    await settleAllWire({
+      ok: true,
+      value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: '!!!', eof: true, bytes: 3 },
+    })
+    expect(tab()).toMatchObject({
+      mode: 'bytes-complete', loading: false, version: undefined,
+      failure: { code: 'gateway/internal', message: 'document file byte response has malformed base64 data' },
+    })
+    expect(tab()?.complete).toBeUndefined()
+  })
+
   it('reloads complete bytes, discarding the old result and preserving the view', async () => {
     const { instance, face, bytes, settleAll, tab, controller } = bench()
     face.loadAll(TAB_1, FILE, controller.signal)

+ 7 - 1
packages/client/ui-sidebar-documentpreview/tests/html-bootstrap.client.spec.ts

@@ -3,7 +3,7 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { runInNewContext } from 'node:vm'
 import { createHtmlDocument } from '../src/client/html/bootstrap.ts'
-import { decodeText } from '../src/client/html/bytes.ts'
+import { decodeText, encodeText } from '../src/client/html/bytes.ts'
 
 afterEach(() => { vi.restoreAllMocks() })
 
@@ -57,4 +57,10 @@ describe('HTML bootstrap', () => {
     expect(() => createHtmlDocument({ data: new Uint8Array([255]), assets: [] })).toThrow()
     expect(decodeText(utf8('雪\u2028\u2029'))).toBe('雪\u2028\u2029')
   })
+
+  it('base64-encodes a large UTF-8 payload in browser-safe chunks', () => {
+    const source = `${'0123456789abcdef'.repeat(16_384)}雪`
+    const bytes = Uint8Array.from(atob(encodeText(source)), character => character.charCodeAt(0))
+    expect(decodeText(bytes)).toBe(source)
+  })
 })

+ 24 - 0
packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx

@@ -12,6 +12,7 @@ import type { openPdf } from '../src/client/pdf/runtime.ts'
 const engine = vi.hoisted(() => ({ open: vi.fn<typeof openPdf>(), render: vi.fn<typeof renderPdfPage>() }))
 vi.mock('../src/client/pdf/runtime.ts', () => ({ openPdf: engine.open }))
 vi.mock('../src/client/pdf/document.ts', () => ({ renderPdfPage: engine.render }))
+import { DOCUMENT_LAYOUT_READY_EVENT } from '../src/client/document/layout.ts'
 import { PdfBody, type PdfBodyProps } from '../src/client/pdf/PdfBody.tsx'
 import { createPdfStore, type PdfState } from '../src/client/pdf/store.ts'
 import { en } from '../src/client/pdf/locales.ts'
@@ -99,7 +100,10 @@ describe('PDF body', () => {
   it('shows loading, omits the paging toolbar, and renders a continuous page sequence', async () => {
     const h = harness()
     const view = render(<h.View />)
+    const layoutReady = vi.fn()
+    view.container.addEventListener(DOCUMENT_LAYOUT_READY_EVENT, layoutReady)
     expect(screen.getByRole('status').textContent).toBe('Opening PDF…')
+    expect(layoutReady).not.toHaveBeenCalled()
     expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
     await act(async () => { loads[0]!.deferred.resolve(documentOf()) })
     await act(async () => {})
@@ -109,6 +113,7 @@ describe('PDF body', () => {
     expect(screen.getAllByRole('img').map(image => image.getAttribute('aria-label')))
       .toEqual(['PDF page 1', 'PDF page 2', 'PDF page 3'])
     expect(engine.render.mock.calls.map(([, page, zoom]) => [page, zoom])).toEqual([[1, 1], [2, 1], [3, 1]])
+    expect(layoutReady).toHaveBeenCalledOnce()
   })
 
   it('keeps the replacement document when the previous load settles late', async () => {
@@ -163,6 +168,25 @@ describe('PDF body', () => {
     expect(IntersectionObserverStub.instances.every(instance => instance.disconnected)).toBe(true)
   })
 
+  it('ignores successful and failed page renders after their body unmounts', async () => {
+    const success = Promise.withResolvers<{ width: number; height: number }>()
+    const failure = Promise.withResolvers<{ width: number; height: number }>()
+    engine.render.mockReturnValueOnce(success.promise).mockReturnValueOnce(failure.promise)
+    const h = harness()
+    const view = render(<h.View />)
+    await act(async () => { loads[0]!.deferred.resolve(documentOf(2)) })
+    const signals = engine.render.mock.calls.map(([, , , , signal]) => signal)
+    expect(signals).toHaveLength(2)
+    view.unmount()
+    expect(signals.every(signal => signal.aborted)).toBe(true)
+    await act(async () => {
+      success.resolve({ width: 100, height: 100 })
+      failure.reject(new Error('late render failure'))
+      await Promise.allSettled([success.promise, failure.promise])
+    })
+    expect(view.container.childElementCount).toBe(0)
+  })
+
   it('renders a structured Worker failure through its own locale', async () => {
     const h = harness()
     render(<h.View />)

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts

@@ -60,7 +60,7 @@ describe('PDF registration', () => {
       expect(instance.getSnapshot().byTab).toEqual({})
       const other = new AbortController()
       face.retainTab('two' as TabId, other.signal)
-      instance.actions.zoom('two' as TabId, 2)
+      instance.actions.page('two' as TabId, 2)
       await fiber.dispose()
       expect(instance.getSnapshot().byTab).toEqual({})
       expect(previews.getSnapshot()).toEqual([])

+ 4 - 5
packages/client/ui-sidebar-documentpreview/tests/pdf-store.client.spec.ts

@@ -4,16 +4,15 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import { createPdfStore } from '../src/client/pdf/store.ts'
 
 describe('PDF view store', () => {
-  it('keeps page and zoom together for each tab and forgets only the closed tab', () => {
+  it('keeps the last visible page for each tab and forgets only the closed tab', () => {
     const instance = createPdfStore().create()
     const one = 'one' as TabId
     const two = 'two' as TabId
     instance.actions.page(one, 3)
-    instance.actions.zoom(one, 1.5)
-    instance.actions.zoom(two, 0.75)
-    expect(instance.getSnapshot().byTab).toEqual({ one: { page: 3, zoom: 1.5 }, two: { page: 1, zoom: 0.75 } })
+    instance.actions.page(two, 2)
+    expect(instance.getSnapshot().byTab).toEqual({ one: { page: 3 }, two: { page: 2 } })
     instance.actions.forget(one)
-    expect(instance.getSnapshot().byTab).toEqual({ two: { page: 1, zoom: 0.75 } })
+    expect(instance.getSnapshot().byTab).toEqual({ two: { page: 2 } })
   })
 
   it('creates independent Session store instances', () => {

+ 126 - 4
packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

@@ -9,11 +9,17 @@
  * set to. Both are the browser's job; the specs assert the body's arithmetic
  * over them.
  */
+import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
 import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
 import { TextPreview } from '../src/client/TextPreview.tsx'
+import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
+import { CodeBody } from '../src/client/code/CodeBody.tsx'
+import { DOCUMENT_LAYOUT_READY_EVENT } from '../src/client/document/layout.ts'
+import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
 import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
 
 const LINE_HEIGHT = 20
@@ -28,13 +34,18 @@ beforeAll(() => {
     configurable: true,
     get(this: HTMLElement) {
       const line = this.getAttribute('data-textpreview-line')
-      return line === null ? 0 : (Number(line) - 1) * LINE_HEIGHT
+      if (line !== null) return (Number(line) - 1) * LINE_HEIGHT
+      if (!this.matches('[data-code-preview] pre .line')) return 0
+      const rows = this.closest('[data-code-preview]')?.querySelectorAll('pre .line') ?? []
+      return Array.from(rows).indexOf(this) * LINE_HEIGHT
     },
   })
   Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
     configurable: true,
     get(this: HTMLElement & { __scrollTop?: number }) { return this.__scrollTop ?? 0 },
-    set(this: HTMLElement & { __scrollTop?: number }, value: number) { this.__scrollTop = value },
+    set(this: HTMLElement & { __scrollTop?: number }, value: number) {
+      this.__scrollTop = this.querySelector('[data-test-pdf-loading]') === null ? value : 0
+    },
   })
 })
 
@@ -45,7 +56,55 @@ afterAll(() => {
   }
 })
 
-afterEach(cleanup)
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
+
+class PendingIntersectionObserver {
+  static instances: PendingIntersectionObserver[] = []
+  readonly observed = new Set<Element>()
+
+  constructor(private readonly callback: IntersectionObserverCallback) {
+    PendingIntersectionObserver.instances.push(this)
+  }
+
+  observe(element: Element): void { this.observed.add(element) }
+  unobserve(element: Element): void { this.observed.delete(element) }
+  disconnect(): void {}
+  takeRecords(): IntersectionObserverEntry[] { return [] }
+
+  intersect(element: Element): void {
+    this.callback(
+      [{ target: element, isIntersecting: true } as IntersectionObserverEntry],
+      this as unknown as IntersectionObserver,
+    )
+  }
+}
+
+function codeProps(h: ReturnType<typeof harness>, navigation: { params?: unknown; revision: number }): TextPreviewProps {
+  const props = h.props(navigation)
+  const definition: DocumentPreviewDefinition = {
+    id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
+  }
+  return {
+    ...props,
+    useDocumentPreviews: selector => selector([definition]),
+    renderSlot: (_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />,
+  }
+}
+
+let finishPdfLayout: (() => void) | undefined
+
+function AsyncPdfLayout(): ReactNode {
+  const [ready, setReady] = useState(false)
+  const section = useRef<HTMLElement>(null)
+  finishPdfLayout = () => { setReady(true) }
+  useLayoutEffect(() => {
+    if (ready) section.current?.dispatchEvent(new Event(DOCUMENT_LAYOUT_READY_EVENT, { bubbles: true }))
+  }, [ready])
+  return ready ? <section ref={section} data-test-pdf-ready /> : <span data-test-pdf-loading />
+}
 
 function body(container: HTMLElement): HTMLElement {
   const element = container.querySelector<HTMLElement>('[data-textpreview-body]')
@@ -286,6 +345,69 @@ describe('TextPreview — the file\'s metadata', () => {
 })
 
 describe('TextPreview — navigation and view', () => {
+  it('waits to answer a loaded-line navigation until the renderer exposes the line', async () => {
+    const h = harness({ 1: page(1, ['a', 'b'], true) })
+    const definition: DocumentPreviewDefinition = {
+      id: 'no-lines', extensions: ['md'], title: () => 'No lines', loading: 'text-pages', wrap: true,
+    }
+    const props: TextPreviewProps = {
+      ...h.props({ params: { line: 2 }, revision: 1 }),
+      useDocumentPreviews: selector => selector([definition]),
+      renderSlot: () => <div data-test-no-lines />,
+    }
+    const view = render(<TextPreview {...props} />)
+    await settle()
+    expect(body(view.container).scrollTop).toBe(0)
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBeUndefined()
+  })
+
+  it('restores complete-document scrolling after its asynchronous layout is ready', () => {
+    const h = harness()
+    h.instance.actions.loading(TAB_ID, 'bytes-complete', 'v1')
+    h.instance.actions.complete(TAB_ID, {
+      absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0,
+      data: new Uint8Array([1]), bytes: 1, eof: true,
+    })
+    h.instance.actions.scrolled(TAB_ID, 320)
+    const definition: DocumentPreviewDefinition = {
+      id: 'pdf', extensions: ['md'], title: () => 'PDF', loading: 'bytes-complete', wrap: false,
+    }
+    const props: TextPreviewProps = {
+      ...h.props(),
+      useDocumentPreviews: selector => selector([definition]),
+      renderSlot: () => <AsyncPdfLayout />,
+    }
+    const view = render(<TextPreview {...props} />)
+    const scrollBody = body(view.container)
+    expect(scrollBody.scrollTop).toBe(0)
+    act(() => { finishPdfLayout?.() })
+    expect(scrollBody.scrollTop).toBe(320)
+
+    fireEvent.scroll(scrollBody, { target: { scrollTop: 480 } })
+    view.container.querySelector('[data-test-pdf-ready]')
+      ?.dispatchEvent(new Event(DOCUMENT_LAYOUT_READY_EVENT, { bubbles: true }))
+    expect(scrollBody.scrollTop).toBe(480)
+  })
+
+  it('lands on code lines before and after syntax highlighting is ready', async () => {
+    PendingIntersectionObserver.instances = []
+    vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver)
+    const h = harness({ 1: page(1, ['const a = 1', 'const b = 2', 'const c = 3'], true) })
+    const view = render(<TextPreview {...codeProps(h, { params: { line: 2 }, revision: 1 })} />)
+    await settle()
+    expect(view.container.querySelector('[data-code-preview] pre.shiki')).toBeNull()
+    expect(view.container.querySelectorAll('[data-code-preview] pre .line')).toHaveLength(3)
+    expect(body(view.container).scrollTop).toBe(LINE_HEIGHT)
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
+
+    const block = view.container.querySelector('[data-code-preview] .md-code-block')!
+    act(() => { PendingIntersectionObserver.instances[0]!.intersect(block) })
+    await waitFor(() => { expect(view.container.querySelector('[data-code-preview] pre.shiki')).not.toBeNull() })
+    view.rerender(<TextPreview {...codeProps(h, { params: { line: 3 }, revision: 2 })} />)
+    expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT)
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
+  })
+
   it('loads until the navigated line is held, then jumps to it once and marks it', async () => {
     const h = harness({ 1: page(1, ['a', 'b', 'c'], false), 4: page(4, ['d', 'e', 'f'], true) })
     const view = render(<TextPreview {...h.props({ params: { line: 5 }, revision: 1 })} />)

+ 5 - 3
packages/client/ui-sidebar-right/src/client/stores.ts

@@ -63,11 +63,13 @@ type SurfacePlan = (state: LayoutState, mint: Mint, makeTab: (id: TabId) => TabR
  * Decide whether an explicit close may remove a tab.
  * @param surface - current surface including default-tab protection.
  * @param tabId - tab requested for closing.
- * @returns false for missing tabs, protected defaults, and a pane's last tab.
+ * @returns false for missing tabs, protected docked defaults, and a docked pane's last tab.
  */
 export function canCloseTab(surface: SurfaceState, tabId: TabId): boolean {
-  if (surface.layout.tabs[tabId] === undefined || surface.permanentTabIds.includes(tabId)) return false
-  return findTabPane(surface.layout, tabId).tabs.length > 1
+  if (surface.layout.tabs[tabId] === undefined) return false
+  const pane = findTabPane(surface.layout, tabId)
+  if (pane.host === 'float') return true
+  return !surface.permanentTabIds.includes(tabId) && pane.tabs.length > 1
 }
 
 /** Build a default tab and retain its close protection outside the docking kit's data. */

+ 16 - 0
packages/client/ui-sidebar-right/tests/seat.client.spec.tsx

@@ -164,6 +164,22 @@ describe('RightbarSeat presentation', () => {
     expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['text', 'text'])
   })
 
+  it('offers close for a floating tab while the docked pane keeps its sole tab', async () => {
+    const h = await mountSeat()
+    const floating = h.open('floating.txt')
+    act(() => { h.controller.float(floating.id) })
+    const paneId = getPane(h.layout(), h.layout().floats[0]!).id
+    expect(h.view.container.querySelector('[data-dockkit-tab-close]')).toBeNull()
+    const close = document.querySelector<HTMLButtonElement>(`[data-dockkit-float-close="${paneId}"]`)
+    expect(close).not.toBeNull()
+
+    fireEvent.click(close!)
+
+    expect(h.layout().tabs[floating.id]).toBeUndefined()
+    expect(h.layout().floats).toHaveLength(0)
+    expect(getPane(h.layout(), h.layout().rootId).tabs).toHaveLength(1)
+  })
+
   it('keeps the panel mounted while collapsed and releases the frame on unmount', async () => {
     const h = await mountSeat()
     const panel = element(h.view.container, '[data-sidebar-right-panel]')

+ 21 - 0
packages/client/ui-sidebar-right/tests/stores.client.spec.ts

@@ -137,6 +137,27 @@ describe('createSidebarRightStore — the sequence', () => {
 })
 
 describe('createSidebarRightStore — floating panels and dividers', () => {
+  it('closes the sole tab in a floating pane while retaining the docked pane', () => {
+    const { actions, layout, entries, guide } = harness()
+    const floating = guide()
+    actions.floatTab(SESSION, floating, { x: 10, y: 20, width: 300, height: 200 })
+    expect(findTabPane(layout(), floating).host).toBe('float')
+    const recorded = entries()
+
+    actions.closeTab(SESSION, floating)
+
+    expect(layout().tabs[floating]).toBeUndefined()
+    expect(layout().floats).toHaveLength(0)
+    expect(getPane(layout(), layout().rootId).tabs).toHaveLength(1)
+    expect(entries()).toBe(recorded + 1)
+
+    const docked = getPane(layout(), layout().rootId).tabs[0]!
+    const retained = layout()
+    actions.closeTab(SESSION, docked)
+    expect(layout()).toBe(retained)
+    expect(entries()).toBe(recorded + 1)
+  })
+
   it('moves and resizes a floating panel, one entry each', () => {
     const { actions, layout, entries, guide } = harness()
     actions.floatTab(SESSION, guide(), { x: 10, y: 20, width: 300, height: 200 })

+ 2 - 2
packages/util/workspace-path/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/util/workspace-path/README.md
-README.md: 1d6b6ce7903844ae244228c2bd9aea1f7944c61b
-README.zh.md: 32fd64fa7d2c1e6dcc36c3dba15d4599669bfd30
+README.md: d89969a8df83e1fcd396c086cddea518af813660
+README.zh.md: 2655fa8c354cc8b1091909aa57856667eb6129d9

+ 1 - 1
packages/util/workspace-path/README.md

@@ -24,7 +24,7 @@ Browser-safe path helpers shared by Workspace-facing client and controller packa
 
 A resource address is `dsh-resource://<type>/…`, and the type — the URI host — is the resource protocol key (`file`, or one a plugin declares in `ResourceProtocolMap`); any other scheme is a navigation protocol, defined elsewhere. `dsh-resource://file/session/<sessionId>/<path>` names the Session that authorizes the Host read and a workspace-relative or absolute path. Leading slashes remain part of the path: `/etc/hosts` is `dsh-resource://file/session/s//etc/hosts`, a Windows drive is `dsh-resource://file/session/s/C:/x/y.txt`, and UNC is `dsh-resource://file/session/s///server/share/y.txt`. The Host resolves paths and enforces access. The `absolute/<path>` form remains parseable but carries no authorizing Session, so the file provider cannot read it and Preview does not claim it; neither current nor Tab Session is borrowed. The grammar lives in [`src/file-address.ts`](src/file-address.ts); the path helpers stay in [`src/index.ts`](src/index.ts), which re-exports it.
 
-`sessionFileAddress(sessionId, path)` normalizes `\` to `/` and drops leading `./`, but preserves leading `/` characters. Every id and path segment is component-encoded with `:` kept literal. `fileAddressFor(sessionId, cwd, path)` always builds a Session address: paths inside `cwd` become relative; other absolute paths, including when `cwd` is unknown, stay absolute within that Session address. `absoluteFileAddress(absolutePath)` builds only the Session-less form. `parseFileAddress(address)` uses `new URL()` and returns `{ scope, sessionId, path }` for a Session address, preserving its relative or absolute path; the Session-less form returns `{ scope, path }` with its absolute path. Another type or scheme, an unknown scope, a missing id or path, a non-URL, or a malformed escape returns `undefined`.
+`sessionFileAddress(sessionId, path)` normalizes `\` to `/` and drops leading `./`, but preserves leading `/` characters. Every id and path segment is component-encoded with `:` kept literal. `fileAddressFor(sessionId, cwd, path)` always builds a Session address: paths inside `cwd` become relative; other absolute paths, including when `cwd` is unknown, stay absolute within that Session address. `absoluteFileAddress(absolutePath)` builds only the Session-less form. `parseFileAddress(address)` checks the exact file-address prefix, ignores query and fragment suffixes, decodes each segment, and returns `{ scope, sessionId, path }` for a Session address or `{ scope, path }` for the Session-less form. Another type or scheme, an unknown scope, a missing id or path, or a malformed escape returns `undefined`.
 
 -----
 

+ 1 - 1
packages/util/workspace-path/README.zh.md

@@ -24,7 +24,7 @@ kind: "package-library"
 
 资源地址 = `dsh-resource://<type>/…`,type(URI 的 host)即资源协议键(`file`,或插件在 `ResourceProtocolMap` 中声明的键);其他 scheme 属导航协议,另行定义。`dsh-resource://file/session/<sessionId>/<path>` 指定授权 Host 读取的 Session,以及工作区相对或绝对路径。前导斜杠保留在路径中:`/etc/hosts` 对应 `dsh-resource://file/session/s//etc/hosts`,Windows 盘符对应 `dsh-resource://file/session/s/C:/x/y.txt`,UNC 对应 `dsh-resource://file/session/s///server/share/y.txt`。Host 解析路径并执行访问检查。`absolute/<path>` 形式仍可解析,但不携带授权 Session,因此 file 提供方不能读取,Preview 也不认领;两者均不借用当前或 Tab Session。语法住在 [`src/file-address.ts`](src/file-address.ts);路径辅助函数留在 [`src/index.ts`](src/index.ts) 并再导出它。
 
-`sessionFileAddress(sessionId, path)` 将 `\` 归一为 `/`,去掉前导 `./`,但保留前导 `/` 字符。id 和每个路径段都做组件编码,`:` 保持字面。`fileAddressFor(sessionId, cwd, path)` 始终构造 Session 地址:`cwd` 内的路径转为相对路径;其他绝对路径(包括 `cwd` 未知时)仍作为该 Session 地址内的绝对路径。`absoluteFileAddress(absolutePath)` 只构造不带 Session 的形式。`parseFileAddress(address)` 使用 `new URL()`,为 Session 地址返回 `{ scope, sessionId, path }`,保留其相对或绝对路径;不带 Session 的形式返回 `{ scope, path }`,其中 path 为绝对路径。其他 type 或 scheme、未知作用域、缺 id 或路径、非 URL 或错误转义都返回 `undefined`。
+`sessionFileAddress(sessionId, path)` 将 `\` 归一为 `/`,去掉前导 `./`,但保留前导 `/` 字符。id 和每个路径段都做组件编码,`:` 保持字面。`fileAddressFor(sessionId, cwd, path)` 始终构造 Session 地址:`cwd` 内的路径转为相对路径;其他绝对路径(包括 `cwd` 未知时)仍作为该 Session 地址内的绝对路径。`absoluteFileAddress(absolutePath)` 只构造不带 Session 的形式。`parseFileAddress(address)` 检查精确的文件地址前缀、忽略查询与片段后缀、逐段解码,并为 Session 地址返回 `{ scope, sessionId, path }`,为不带 Session 的形式返回 `{ scope, path }`。其他 type 或 scheme、未知作用域、缺 id 或路径,或错误转义都返回 `undefined`。
 
 -----
 

+ 0 - 5
snapshots/web/seeded-history/command-row.expected.md

@@ -4,14 +4,9 @@
   - button "Session log":
     - text: Session log
     - img
-  - button "Open the sidebar":
-    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-- navigation "Turn navigation":
-  - button "Jump to turn 1"
-  - button "Jump to turn 2"
 - button "System prompt":
   - img
   - img

+ 0 - 5
snapshots/web/seeded-history/feedback-row.expected.md

@@ -4,14 +4,9 @@
   - button "Session log":
     - text: Session log
     - img
-  - button "Open the sidebar":
-    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-- navigation "Turn navigation":
-  - button "Jump to turn 1"
-  - button "Jump to turn 2"
 - button "System prompt":
   - img
   - img

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

@@ -1,4 +1,5 @@
 - text: {{cwd}}/a.txt
+- button "Open with": Plain text
 - button "Wrap lines" [pressed]:
   - img
 - button "Read the file again":