Kaynağa Gözat

feat(textpreview): add paged file tabs and retained reader state

imccyu 1 hafta önce
ebeveyn
işleme
d10af0654f

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

@@ -0,0 +1,122 @@
+# Agent Note: Sidebar text preview and file tree
+
+Status: implemented
+
+English | [中文](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)
+
+## Problem
+
+The right Sidebar's [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md) and its [tab type registry](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) give a plugin a place to register a tab type, but a surface with no types is an empty column. Three questions had to be answered by shipped code before anyone else could register a type: what a new pane shows before it holds content, how a file the agent produced or read is looked at without leaving the product, and how a reader finds a file the conversation never mentioned. The answers also had to demonstrate the type authoring model end to end — a static definition, a body in a keyed seat, a Slot store and inject face for the type's own state, `useResource` for live data behind an address — so that a type written outside `ui-sidebar-right` has a worked template rather than a contract alone.
+
+Each answer carries product rules that code alone does not explain: why a text file loads by page instead of whole, why a changed file is announced rather than refreshed, why the file tree is a page type that claims no address, why the guide gives its tab away instead of opening beside itself. This note records those decisions for the three shipped types.
+
+## 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`).
+
+### The guide
+
+The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open.
+
+The body is a centred column — a lead line (`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`), one line of copy (`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`), and a grid of entry boxes at most 480px wide, each box at least 160px, filling as many columns as fit. The boxes are projected from every registered type's `guide[]` in `order`, through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. A box shows the contributing type's glyph, title, and description, and picking it calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened.
+
+The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident.
+
+A pane holds at most one guide, and the docking layer enforces it as product behaviour: the strip's add control hides while a guide is present, opening the guide into such a pane focuses it, a guide is never duplicated, and a guide dragged, dropped, or docked into a pane that already has one merges into it (the arriving tab closes). Settling a surface reseeds the guide when the root pane empties, so there is always at least one tab and never an empty pane.
+
+### 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.
+
+A tab's address is `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>` or `dsh-resource://file/absolute/<absolute path>` ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers in `dsh-util-workspace-path`). The preview never splits the string itself: `hostFileOf` in `rpc.ts` calls `parseFileAddress` and yields the `{ sessionId, path }` the endpoint takes — a `session` address reads under the session it names with the relative path the Host resolves, an `absolute` address reads under the session the slot was mounted for with the absolute path — and a malformed address throws, a programming error, because the registry routes every `file` address to this type and a caller building one is expected to use the helper.
+
+Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `{ version, bytes, changed }` from the `file` provider; the body reads `changed` and the resource's failed state. Content is the type's own business, read one page of lines at a time through `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` with no `limit`, so the page length is the Host's configured cap (`maxLines`, 5000 lines by default, and a page may not exceed `maxBytes`, 2 MB by default). The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next page until `eof`, disabled and reading `正在读取…` / `Reading…` while a read is in flight, and absent once the file has ended or a page failed. Pages are appended in file order with no separators and no line numbers, each carrying its line count (`lines`) so one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted and the walk restarts from the first page, so the body never shows two versions at once. The face keeps a request generation per tab: a reload bumps it, and a page settling from an older generation writes nothing. A tab switched away from and back reads nothing, because the pages live in the store, not the body.
+
+The store is Slot-standard: one exclusive instance per session, bucketed by tab id, holding `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`. Bucketing by tab, not by file, is deliberate — two tabs of one file scroll independently. The face (`loadPage`, `reloadPages`) is the only asynchronous half: it marks a read in flight, awaits the Remote result, and writes a page or a failure through the store's actions, writing nothing if the owner's `signal` has fired. The `signal` also ends the bucket: the face arms one abort listener per tab at the tab's first read, and that listener forgets the bucket — not the body, which mounts and unmounts as tabs switch; a tab that never read has no bucket and no listener, and a record can end while its body is unmounted behind another tab. Scroll offset, wrap, and the navigation already answered therefore outlive the body: a tab comes back where the reader left it rather than re-reading or jumping again. Nothing persists across a page reload.
+
+Navigation is a `line`. The `read` tool row passes its 1-based `offset` as `openResource(address, { params: { line } })`, and the produced-file chip passes nothing; the body narrows `navigation.params` to `SidebarRightResourceParamsMap['file']` (`{ line?: number }`, declared by the `file` type's owner) without runtime validation, because caller and body meet at a typed same-process boundary. If the loaded pages do not reach the line, the body reads the next page, again, until they do or the file ends — pages load in order; there is no seek — then scrolls the line to the top of the body and highlights it, once per `navigation.revision`. The store records the answered revision, so a body remounting for the same revision restores the scroll offset instead of jumping, and a new `openResource` for the same file (revealed, not duplicated) arrives as a new revision and jumps again. A line past the end of the file stops silently at `eof`; a page that fails while walking stops the walk and shows the failure line.
+
+A changed file is announced, not applied. When the `file` resource reports `changed` — the agent wrote the file through a tool after the last `stat` — a bar above the path row says `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.` with a `重新载入` / `Reload` button. Only the click does two things at once: `meta.reload()` (a fresh `stat`, which clears `changed`) and `reloadPages` (drop every page, read the first one again). The scroll offset is kept, so the reader stays where they were. Nothing else triggers a reload: the tree and the preview do not watch the filesystem, and an external edit is not announced. A resource that turns `failed` — the file deleted, or the Host refusing it — puts a failure bar in the same place, its line from `failure-line.ts` and the same reload button, ahead of any pending `changed`; the pages already read stay beneath it.
+
+The body's header is one row: the file's path as the address names it on the left (12px, tertiary colour, one line, ellipsis when it overflows, full path on hover) and two 24px controls at its right end — a wrap toggle (`自动换行` / `Wrap lines`, pressed state shown, **on by default** per tab: long lines wrap and never scroll horizontally until the reader turns it off, whereupon the file body scrolls horizontally on its own) and a reload button (`重新读取文件` / `Read the file again`) that does exactly what the change bar's button does. Neither control is ever disabled. The preview takes the pane body's full height (`height: 100%` against the pane body, which is a block scroller of definite height) so a short file leaves no separately styled space below it, and the file body — monospace, 13px, line height 1.6, 10px vertical padding — is the only scroller: the header and the change bar stay put while a long file scrolls under them.
+
+A failed page keeps the pages already shown and adds one sentence at the end of the loaded text, in terms of the file rather than the transport, with a `重试` / `Retry` button that reads the same page again: `workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`; `workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`; `workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.` with the byte cap rendered as `2 MB`; `workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`; `workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`; any other failure, carrier or unclassified, `读取失败:{message}` / `Read failed: {message}` with the failure's own message. The mapping lives in `failure-line.ts`, apart from the component so it is testable on its own; a code the reader does not name falls to the generic line carrying the carrier's message. A directory or a binary file therefore shows one failure line and nothing else; an empty file shows the header and an empty body with no marker.
+
+### The file tree
+
+`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: IconFolderClose16 }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does.
+
+The root is the session's working directory as the Host reports it in the session list (`useSessions().byId[sessionId].cwd`), labelled by `workspaceTitleOf` from `dsh-util-workspace-path` — the final non-empty path segment — with the root string itself as the label when the path is separator-only. A session without a working directory shows one line (`noWorkspace`) and issues no request. There is no root chooser and no way to browse upward: the Host's `list` refuses paths outside the Session's workspace root, so the one directory the client can list is the one it shows.
+
+The tree is not one resource, and that decides where its state lives. A directory listing per level, expanded lazily, is view state the type owns, so it sits in a Slot-standard exclusive store (one instance per session) bucketed by tab id: `{ root, levels, expanded }`, with `levels` keyed by absolute path to `loading | ready | failed` and `expanded` the absolute paths currently open, root included. A resource has one address and one current value; a tree that pins a resource per expanded level would make the resource model carry which directories a reader has opened, which is the type's business. `useResource` stays for content with a single address.
+
+The face is the tree's only asynchronous half. `start(tabId, root, signal)` seeds the bucket with the root expanded and lists it; `toggle(tabId, path, loaded, signal)` flips the expanded set and lists the level only the first time; `load(tabId, path, signal)` marks `loading`, calls `remote.workspaceFiles.list(sessionId, absolutePath, signal)`, and writes `ready` or `failed`. The adapter keeps the listing's `entries` and `truncated` and drops its workspace-relative `path`: every key in the tree is absolute, and a child's key is its parent joined with the entry name by `/`. Collapsing keeps the level, so reopening draws from memory without a request; a level that failed is likewise kept and not retried on reopen — reload is the retry. The owner's `signal` ends a bucket: on abort the tab is forgotten and a listing that settles afterwards writes nothing, and a mounted body never re-seeds a bucket whose signal has fired.
+
+Rows are the reader's order, not the endpoint's: directories first, then files and other entries, each group by `Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })` so `file2` precedes `file10` and case does not split the list. Dotfiles are shown like any other name; the tree filters nothing the Host returned. The three entry types draw differently: `directory` is a button with `aria-expanded` and an open/closed folder glyph whose children indent by 14px per level; `file` is a button with the document glyph and no size column; `other` (a symlink, socket, or device) is a greyed, non-focusable span with `aria-disabled` and a tooltip saying it cannot be opened, so a directory is reported whole without offering a click that would fail. A level the Host cut at its `maxEntries` cap ends with a `truncated` marker after the entries; an empty level says `empty`; a listing in flight shows `loading` under its directory.
+
+A file click is `tabActions.openResource(fileAddressFor(sessionId, root, absolutePath))`: the entry's absolute path under the tree's root becomes the `dsh-resource://file/session/<sessionId>/<path relative to the root>` address, each segment percent-encoded. The tree never names a viewer: the registry's claim decides who draws the address (`text` today, at `fallback`), and an extension that claims `dsh-resource://file/**` above it takes the click without the tree changing. The open lands in the pane holding the files tab at call time, and an already-open tab for the same address is revealed rather than duplicated — both the navigation controller's defaults. The user's call was explicit: a file opened from the tree does not force a split; it takes a new tab where the tree is.
+
+Reload is the tree's one control, an icon button (`reload`) at the right of the root's header row. It resets every level and lists again exactly the paths in `expanded`; a level that was listed and then collapsed is dropped and fetched anew the next time it opens. The control lives in the body because a type's controls belong to its body: the pane's tab strip carries only the kit's and the panel's actions, and no per-type tools seat exists. The tree does not watch the filesystem; a level changes only when reloaded or first expanded, and the `changes` stream is the text viewer's concern.
+
+Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「正在读取…」/ "Reading…", `empty` 「空目录」/ "Empty directory", `truncated` 「条目太多,只显示了一部分。」/ "Too many entries; showing only some of them.", `noWorkspace` 「这个会话没有工作区目录。」/ "This session has no workspace directory.", `entry.other` 「这不是文件或目录,没法打开。」/ "Not a file or a directory, so it cannot be opened.", `reload` 「重新读取」/ "Reload". Failure lines are one per Host code, in terms of the directory: `workspace-file/not-found` 「这个目录不在了。可能已被移动或删除。」/ "That directory is gone. It may have been moved or deleted.", `workspace-file/outside-workspace` 「这个目录在工作区之外,侧栏不会读取它。」/ "That directory is outside the workspace, so the sidebar will not read it.", `workspace-file/not-directory` 「这不是一个目录。」/ "That is not a directory."; any other failure, carrier or unclassified, shows `error.unavailable` 「读取失败:{message}」/ "Read failed: {message}" with the failure's own message, because the tree has nothing useful to add to a transport-level error.
+
+## Alternatives considered
+
+**A per-pane tools seat for the active tab's controls (`sidebar.right.pane.tab.tools`).** Shipped for one review round for the text preview's wrap and reload and the tree's reload, then removed on the user's call: it put type-private buttons on the panel's strip beside the split and collapse controls, where they read as panel chrome. A type's controls belong in its own body; the preview's sit at the right end of its path row and the tree's at the right end of its root row.
+
+**Keep `Show in folder`.** A directory has no destination in the Sidebar, and the product decision was no secondary entry to the desktop opener. Removed, with the capability loss stated: `openFile('.')` names a directory, which the text preview refuses with `not-regular-file`, so the row offers nothing rather than a button that always fails.
+
+**Content in the resource stream.** Content can be arbitrarily large, so the `file` resource carries metadata (`version`, `bytes`, `changed`) and the preview reads content by page through `workspaceFiles.read`; the `changed` flag is a notice, not a payload.
+
+**Reload re-fetches every page that was loaded.** The alternative to the shipped rule (drop every page, read the first one again). Not taken: re-fetching the loaded range means several sequential reads before anything can be shown, and the loaded range after an agent edit no longer describes the same lines; the reader keeps their scroll offset and asks for more where the loaded text ends. The reader's place can land in empty space when the earlier view was deep in the file, which is stated as a consequence.
+
+**Refresh the text under the reader when the file changes.** Rejected: reloading under a reader loses their place, and a file the agent is writing changes repeatedly. The bar waits for a click.
+
+**Whole-file read, or seekable pages.** A whole-file read has no bound; seekable pages need a line index the Host does not keep. Pages load in order from the first, and a navigation to a deep line walks pages until it is covered — the cost is stated under Consequences and the seek is deferred.
+
+**Validate `line` at run time.** The first form accepted `unknown` params and treated anything but a positive integer as no request. Rejected once `params` became typed: the `file` type's owner declares `{ line?: number }` in `SidebarRightResourceParamsMap`, caller and body meet at a typed same-process boundary, and the repository rule is not to add runtime validation there.
+
+**The read's session from the slot for every address.** The first form read under the session the body was mounted for. Kept only for the `absolute` scope, which names no session: a `session` address carries its session precisely so that one relative path in two sessions means two files.
+
+**Wrap off by default.** The first form. Reversed on the user's review: a preview column is narrow, and long lines scrolling horizontally hide the text; wrap is on until the reader turns it off, per tab.
+
+**Fill the pane by changing the docking kit's `.paneBody`.** The pane body is a block scroller with a definite height, not a flex container, so the preview's `flex: 1` did nothing and the pane body scrolled a 30,000px-tall preview. Rejected in favour of `height: 100%` on the preview root: the fix is the type's, the kit stays unaware of its bodies, and the file body becomes the one scroller so the header stays put and line jumps scroll the right element.
+
+**Key the store by file, not by tab.** Rejected: two tabs of one file are two reading positions; the pages could be shared but the view could not, and the saving is one page read.
+
+**A package-local `file:///` address builder, and a package-local basename for the tree's root label.** Rejected: a file address must carry its scope — the session whose root resolves a relative path, or the absolute path itself — hence the shared `fileAddressFor`; one `workspaceTitleOf` serves every workspace-label surface.
+
+**Model the whole tree as one resource.** Rejected: a resource has one address and one current value, and a tree that pins a resource per expanded level would make the resource model carry which directories a reader has opened, which is the type's business.
+
+**The guide as a chain entry rather than the chain's fallback.** Rejected: with the shipped guide as an entry, a product's replacement and the shipped guide would both be candidates and the winner would depend on registration order; as the fallback there is always exactly one body and it cannot be outvoted by accident.
+
+**The guide opens the picked type beside itself.** Rejected: the guide is a doorway, and a pane holding the guide plus what it opened would show a doorway that leads nowhere further; `openTab(kind, { replaceTab: true })` hands the tab over.
+
+## 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.
+- 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.
+- Per-tab view state survives tab switches and remounts and is gone with the tab or the page; nothing is persisted.
+- The file tree renders whatever the Host lists, so a large directory shows up to `maxEntries` rows plus a marker with no search or filter, and a reader finds a deep file by expanding levels one at a time.
+- Every user-facing string of the three types is locale-owned and listed in this note, so a copy review has one place to read them.
+
+## Testing
+
+The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier.
+
+## Deferred
+
+- Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`.
+- Line numbers, syntax highlighting, rendered Markdown, images, and search in the text preview; a total line count or end-of-file marker.
+- 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
+
+- [Right Sidebar docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md) — the panel, panes, and the guide's one-per-pane rule.
+- [Sidebar tab types and navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) — the registry, bands, `id`, `openTab` / `openResource`, and owner props these types consume.
+- [Client resource model](../architecture/2026-09-05-client-resource-model.md) — `useResource` and the `file` protocol's metadata.
+- [Workspace Files service](../architecture/2026-09-05-workspace-files-service.md) — the address grammar, `stat` / `read` / `list` / `changes`, and the error codes the failure lines map.

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

@@ -0,0 +1,122 @@
+# Agent Note: Sidebar 文本预览与文件树
+
+Status: implemented
+
+[English](2026-09-05-sidebar-text-preview-and-file-tree.md) | 中文
+
+## Problem
+
+右侧 Sidebar 的[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)与[tab 类型注册表](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)给了插件一个注册 tab 类型的位置,但没有类型的停靠面只是一根空列。三个问题必须先由随包交付的代码答出来,别人才谈得上注册类型:一个新 pane 在承载内容之前显示什么;agent 产出或读过的文件如何不离开产品就能查看;读者如何找到会话从未提到的文件。这些答案还要把类型作者模型完整演示一遍——静态定义、keyed 坑位里的体、类型自有状态用的 Slot store 与 inject face、地址背后活数据用的 `useResource`——让 `ui-sidebar-right` 之外写的类型有一份可照抄的样板,而不只有一份契约。
+
+每个答案都带着代码本身解释不了的产品规则:文本文件为什么按页读而不是整读,文件变了为什么只提示不刷新,文件树为什么是不认领任何地址的页类型,引导页为什么交出自己的 tab 而不是在旁边再开一个。本文为三个随包交付的类型记下这些决定。
+
+## 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 方式持有。
+
+### 引导页
+
+引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。
+
+体是一根居中的列——一句引导语(`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`)、一行文案(`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`),以及一组最宽 480px 的入口框栅格,每框至少 160px,能放几列放几列。入口框按 `order` 从每个已注册类型的 `guide[]` 投影而来,经注册表可观察的 `guide()` 列表,因此后注册的类型不用引导页知道就能出现。一个框显示贡献类型的图标、标题与说明;点选它调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。
+
+体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。
+
+一个 pane 最多持有一个引导页,停靠层把这条作为产品行为强制执行:有引导页时 tab 条的添加控件隐藏,往这样的 pane 打开引导页只是聚焦它,引导页永不复制,被拖拽、落下或回坞进已有引导页的 pane 的引导页并入它(来者关闭)。settle 一个 surface 时,根 pane 空了就重新播下引导页,于是永远至少有一个 tab、永远没有空 pane。
+
+### 文本预览
+
+`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 上的文字被缩短。
+
+tab 的地址是 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<绝对路径>`([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md) 拥有这套语法及 `dsh-util-workspace-path` 里的 `fileAddressFor` / `parseFileAddress` 助手)。预览从不自己拆这个串:`rpc.ts` 里的 `hostFileOf` 调 `parseFileAddress` 得到端点所需的 `{ sessionId, path }`——`session` 地址在它命名的会话下以 Host 解析的相对路径读取,`absolute` 地址在坑位被挂载的会话下以绝对路径读取——畸形地址直接抛错,那是程序错误,因为注册表把每个 `file` 地址都路由给这个类型,而造地址的调用方本应使用助手。
+
+元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——从 `file` 提供者得到 `{ version, bytes, changed }`;体读 `changed` 与资源的失败态。内容是类型自己的事,经 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)` 一次读一页行,不传 `limit`,因此页长就是 Host 配置的上限(`maxLines`,默认 5000 行;且一页不得超过 `maxBytes`,默认 2 MB)。首次挂载读第 1 页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`,读取进行中它禁用并显示 `正在读取…` / `Reading…`,文件读完或某页失败后消失。页按文件顺序追加,没有分隔也没有行号,每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用,从第一页重新走一遍,于是体永不同时显示两个版本。face 按 tab 记请求代次:重载递增它,旧代次结算的页什么也不写。切走再切回的 tab 什么都不读,因为页住在 store 里而不是体里。
+
+store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持有 `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`。按 tab 而非按文件分桶是有意的——同一文件的两个 tab 各自滚动。face(`loadPage`、`reloadPages`)是唯一的异步半边:它标记读取进行中,等待 Remote 结果,再经 store 的 action 写入一页或一次失败;若 owner 的 `signal` 已触发则什么也不写。`signal` 同时终结这个桶:face 在 tab 首次读取时挂一个 abort 监听器,由它忘掉桶——不是体,体随 tab 切换反复挂载卸载;从未读过的 tab 没有桶也没有监听器,而 tab 记录可能在其体被另一 tab 挡住而卸载时结束。因此滚动位置、换行与已答过的导航都活得比体久:tab 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。
+
+导航是一个 `line`。`read` 工具行把它 1 起的 `offset` 以 `openResource(address, { params: { line } })` 传来,产物 chip 什么都不传;体把 `navigation.params` 收窄为 `SidebarRightResourceParamsMap['file']`(`{ line?: number }`,由 `file` 类型的拥有者声明),不做运行时校验,因为调用方与体相遇在同进程的类型化边界上。已加载的页够不到该行时,体读下一页,再读,直到覆盖它或文件结束——页按顺序加载,没有 seek——然后把该行滚到体顶部并高亮,每个 `navigation.revision` 一次。store 记下已答过的 revision,于是同一 revision 下重新挂载的体恢复滚动位置而不再跳;对同一文件再次 `openResource`(聚焦而非复制)以新 revision 到来并再跳一次。超出文件末尾的行在 `eof` 处静默停下;补页途中失败的页终止补页并显示失败行。
+
+文件变了只提示,不应用。当 `file` 资源报告 `changed`——agent 在上次 `stat` 之后经工具写了该文件——路径行上方出现一条提示 `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.`,带一个 `重新载入` / `Reload` 按钮。只有点击才同时做两件事:`meta.reload()`(重新 `stat`,清掉 `changed`)与 `reloadPages`(丢掉所有页,重读第 1 页)。滚动位置保留,读者停在原处。没有别的东西触发重载:树和预览都不监听文件系统,外部编辑不会被提示。资源变为 `failed`——文件被删,或 Host 拒绝——时,同一位置出现一条失败条,句子来自 `failure-line.ts`,带同一个重新载入按钮,并优先于尚未处理的 `changed`;已读的页留在它下方。
+
+体的头部是一行:左边是地址所命名的文件路径(12px、三级色、单行、溢出省略号、悬停显示完整路径),右端是两个 24px 控件——换行开关(`自动换行` / `Wrap lines`,显示按下态,**默认开**、按 tab 记:长行折行、绝不横向滚动,直到读者关掉它,此后文件体自己横向滚动)与一个重新读取按钮(`重新读取文件` / `Read the file again`),做的恰是变更提示条按钮做的事。两个控件都永不禁用。预览占满 pane 体的全部高度(对 pane 体取 `height: 100%`;pane 体是高度确定的块级滚动容器),于是短文件下方不留另一块样式不同的空白,而文件体——等宽、13px、行高 1.6、上下 10px 内边距——是唯一的滚动者:长文件在头部与变更提示条之下滚动,二者不动。
+
+某页失败时,已显示的页保留,并在已加载文本末尾加一句以文件而非传输为主语的说明,带一个重读同一页的 `重试` / `Retry` 按钮:`workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`;`workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`;`workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.`,字节上限渲染为 `2 MB` 这样的形式;`workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`;`workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`;其余任何失败,无论载体层还是未分类,`读取失败:{message}` / `Read failed: {message}` 并带上失败自身的消息。映射住在 `failure-line.ts` 里,与组件分开以便单独测试;读者未命名的错误码落到带传输层消息的通用句。目录或二进制文件因此只显示一行失败说明;空文件显示头部与一个空的体,没有任何标记。
+
+### 文件树
+
+`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: IconFolderClose16 }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。
+
+根是 Host 在会话列表里上报的会话工作目录(`useSessions().byId[sessionId].cwd`),标签由 `dsh-util-workspace-path` 的 `workspaceTitleOf` 给出——路径最后一个非空段——路径只有分隔符时用根串本身作标签。没有工作目录的会话只显示一行(`noWorkspace`),不发请求。没有根选择器,也不能往上浏览:Host 的 `list` 拒绝会话工作区根之外的路径,所以客户端能列的那一个目录就是它显示的目录。
+
+树不是一个资源,这决定了它的状态住在哪。逐层懒加载的目录列表是类型自己拥有的视图状态,所以它住在 Slot 标准的独占 store(每会话一实例)里、按 tab id 分桶:`{ root, levels, expanded }`,`levels` 以绝对路径为键取 `loading | ready | failed`,`expanded` 是当前展开的绝对路径集合,含根。资源有一个地址和一个当前值;一棵为每个展开层钉一个资源的树,会让资源模型背上「读者展开了哪些目录」,而那是类型的事。`useResource` 留给只有一个地址的内容。
+
+face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态播种桶并列出根;`toggle(tabId, path, loaded, signal)` 翻转展开集合并只在第一次列出该层;`load(tabId, path, signal)` 标 `loading`,调 `remote.workspaceFiles.list(sessionId, absolutePath, signal)`,写 `ready` 或 `failed`。适配层保留列表的 `entries` 与 `truncated`、丢弃其工作区相对 `path`:树里每个键都是绝对路径,子键 = 父路径以 `/` 拼上条目名。折叠保留该层,再展开直接从内存画不再请求;失败的层同样保留、再展开不重试——重试靠重新读取。owner 的 `signal` 终结一个桶:abort 时忘掉该 tab,其后才结算的列表什么也不写,已挂载的体也不会给 signal 已触发的桶重新播种。
+
+行序是读者的序,不是端点的序:目录在前,文件与其他条目在后,组内按 `Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })`,于是 `file2` 排在 `file10` 前、大小写不拆开列表。dotfiles 与其他名字一样显示;Host 返回的东西树一个不过滤。三种条目类型画法不同:`directory` 是带 `aria-expanded` 的按钮、开/闭文件夹图标,子层每级缩进 14px;`file` 是带文档图标的按钮,没有大小列;`other`(符号链接、套接字、设备)是灰色、不可聚焦的 span,带 `aria-disabled` 与「不能打开」的提示,这样目录被完整报告,又不提供一个注定失败的点击。被 Host 按 `maxEntries` 上限截断的层在条目末尾以 `truncated` 标记收尾;空层显示 `empty`;进行中的列表在其目录下显示 `loading`。
+
+点文件即 `tabActions.openResource(fileAddressFor(sessionId, root, absolutePath))`:条目在树根之下的绝对路径成为每段百分号编码的 `dsh-resource://file/session/<sessionId>/<相对根的路径>` 地址。树从不指名查看器:由注册表的认领决定谁画这个地址(今天是 `fallback` 档的 `text`),一个在其上认领 `dsh-resource://file/**` 的扩展接走点击而树无需改动。打开落在点击时文件树 tab 所在的那个 pane,同地址已开着的 tab 被聚焦而不复制——两者都是导航控制器的缺省。用户明确拍过:从树里打开的文件不强制分格;它在树所在处开一个新 tab。
+
+重新读取是树唯一的控件,是根标题行右端的图标按钮(`reload`)。它重置所有层,并恰好重新列出 `expanded` 里的那些路径;曾列出后又折叠的层被丢弃,下次展开时重新拉取。控件住在体内,因为类型的控件属于它的体:pane 的 tab 条只承载布局库与面板自身的动作,不存在按类型的工具坑位。树不监听文件系统;一层只在重新读取或首次展开时变化,`changes` 流是文本查看器的事。
+
+文案是 `sidebarFiles` 命名空间,十三个键。行状态:`loading`「正在读取…」/ "Reading…",`empty`「空目录」/ "Empty directory",`truncated`「条目太多,只显示了一部分。」/ "Too many entries; showing only some of them.",`noWorkspace`「这个会话没有工作区目录。」/ "This session has no workspace directory.",`entry.other`「这不是文件或目录,没法打开。」/ "Not a file or a directory, so it cannot be opened.",`reload`「重新读取」/ "Reload"。失败行按 Host 错误码一码一句、以目录为主语:`workspace-file/not-found`「这个目录不在了。可能已被移动或删除。」/ "That directory is gone. It may have been moved or deleted.",`workspace-file/outside-workspace`「这个目录在工作区之外,侧栏不会读取它。」/ "That directory is outside the workspace, so the sidebar will not read it.",`workspace-file/not-directory`「这不是一个目录。」/ "That is not a directory.";其余任何失败,无论载体层还是未分类,显示 `error.unavailable`「读取失败:{message}」/ "Read failed: {message}" 并带上失败自身的消息,因为树对传输级错误没有什么有用的可补充。
+
+## Alternatives considered
+
+**给活跃 tab 的控件开一个按 pane 的工具坑位(`sidebar.right.pane.tab.tools`)。** 为文本预览的换行与重新读取、文件树的重新读取交付过一轮评审,随后按用户意见删除:它把类型私有的按钮放到面板 tab 条上、分栏与折叠控件旁边,读起来像面板自身的 chrome。类型的控件属于它自己的体;预览的在其路径行右端,树的在其根行右端。
+
+**保留 `Show in folder`。** 目录在 Sidebar 里没有去处,而产品决定是不给桌面打开器留次级入口。已删除,能力损失如实陈述:`openFile('.')` 命名的是目录,文本预览以 `not-regular-file` 拒绝它,于是该行什么都不提供,而不是给一个注定失败的按钮。
+
+**把内容放进资源流。** 内容可以任意大,所以 `file` 资源只携带元数据(`version`、`bytes`、`changed`),预览经 `workspaceFiles.read` 按页读内容;`changed` 是通知,不是载荷。
+
+**重新载入重取所有已加载过的页。** 相对于已交付规则(丢掉所有页、重读第 1 页)的另一条路。未采纳:重取已加载范围意味着显示任何东西之前要先做多次顺序读取,而 agent 编辑之后的已加载范围也不再描述同样的行;读者保留滚动位置,在已加载文本末尾继续要更多。先前视口在文件深处时读者的位置可能落到空白,这一点在 Consequences 里如实陈述。
+
+**文件变了就在读者眼前刷新文本。** 否决:在读者眼前重载会丢掉他的位置,而 agent 正在写的文件会反复变化。提示条等点击。
+
+**整文件读取,或可 seek 的页。** 整文件读取没有上界;可 seek 的页需要 Host 不维护的行索引。页从第 1 页起按顺序加载,导航到深处某行时逐页补到覆盖为止——代价在 Consequences 里陈述,seek 推迟。
+
+**在运行时校验 `line`。** 第一版接受 `unknown` 参数,非正整数一律视为没有请求。`params` 类型化之后否决:`file` 类型的拥有者在 `SidebarRightResourceParamsMap` 中声明 `{ line?: number }`,调用方与体相遇在同进程的类型化边界上,仓规是那里不加运行时校验。
+
+**每种地址都从坑位取读取的会话。** 第一版在体被挂载的会话下读取。只对不命名会话的 `absolute` 作用域保留:`session` 地址带着自己的会话,正是为了让同一相对路径在两个会话里是两个文件。
+
+**换行默认关。** 第一版。用户评审后反转:预览列很窄,长行横向滚动会把文字藏起来;换行默认开直到读者关掉,按 tab 记。
+
+**靠改布局库的 `.paneBody` 来撑满 pane。** pane 体是高度确定的块级滚动容器,不是 flex 容器,所以预览的 `flex: 1` 不起作用,pane 体滚动着一个 30,000px 高的预览。否决,改为在预览根上取 `height: 100%`:修复属于类型自己,布局库对其体保持无知,文件体成为唯一的滚动者,于是头部不动、跳行滚动的也是正确的元素。
+
+**store 按文件而非按 tab 分键。** 否决:同一文件的两个 tab 是两个阅读位置;页可以共享而视图不能,省下的只是一次页读取。
+
+**包内自造 `file:///` 地址,以及包内自写 basename 作树的根标签。** 否决:文件地址必须带自己的作用域——以其根解析相对路径的会话,或绝对路径本身——因此用共享的 `fileAddressFor`;一个 `workspaceTitleOf` 服务所有工作区标签面。
+
+**把整棵树建模为一个资源。** 否决:资源有一个地址和一个当前值,一棵为每个展开层钉一个资源的树,会让资源模型背上「读者展开了哪些目录」,而那是类型的事。
+
+**引导页作为链上的入口而非链的 fallback。** 否决:随包交付的引导页若是一个入口,产品的替换者与它会同为候选,胜者取决于注册顺序;作为 fallback 则永远恰有一个体,且不可能被意外投掉。
+
+**引导页在自己旁边打开被选的类型。** 否决:引导页是一扇门,一个同时持有引导页与它所打开内容的 pane 会显示一扇不再通向别处的门;`openTab(kind, { replaceTab: true })` 把 tab 交出去。
+
+## Consequences
+
+- `ui-sidebar-right` 之外写的类型有了一份完整样板:`ui-sidebar-textpreview` 演示一个查看器——由地址推出的读取、按 tab 分桶的独占 Slot store、inject face、类型化的导航参数与体内自有控件;`ui-sidebar-files` 演示一个带引导入口、懒填充 store 的页类型;引导页演示一个链 fallback。
+- 按页读取让每次请求都有界(`maxLines` 行、`maxBytes` 字节),代价是一个 **加载更多** 控件、没有总行数,以及到深处某行的顺序补页;导航到一个大文件的第 40,000 行要先读八页。
+- 只提示不应用,让读者在 agent 反复写入期间保住位置,代价是点击之前显示的是旧文本;外部编辑永不提示。
+- 重新载入只读第 1 页,所以身在文件深处的读者重载后回到文件开头再往后翻;滚动位置保留但可能指向已加载文本之外。
+- 按 tab 的视图状态跨 tab 切换与重新挂载存活,随 tab 或页面一起消失;什么都不持久化。
+- 文件树渲染 Host 列出的一切,因此大目录最多显示 `maxEntries` 行加一个标记,没有搜索或过滤,读者靠逐层展开找到深处的文件。
+- 三个类型面向用户的每条文案都由 locale 持有并列在本文中,文案评审只需读一处。
+
+## Testing
+
+文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。
+
+## Deferred
+
+- 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。
+- 文本预览的行号、语法高亮、Markdown 渲染、图片与搜索;总行数或文件末尾标记。
+- 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。
+- 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。
+- `ui-sidebar-textpreview` 与 `ui-sidebar-files` 的中文 README 对照。
+
+## Related
+
+- [右侧 Sidebar 停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)——面板、pane 与引导页每 pane 一个的规则。
+- [Sidebar tab 类型与导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)——这些类型消费的注册表、档位、`id`、`openTab` / `openResource` 与 owner props。
+- [Client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)——`useResource` 与 `file` 协议的元数据。
+- [Workspace Files 服务](../architecture/2026-09-05-workspace-files-service.zh.md)——地址语法、`stat` / `read` / `list` / `changes`,以及失败行所映射的错误码。

+ 81 - 0
packages/client/ui-sidebar-textpreview/README.md

@@ -0,0 +1,81 @@
+---
+description: "The right Sidebar's plain-text viewer tab type for the dsh web client: paged reads of one workspace file, line navigation, wrap, reload, and the fallback claim on every file resource address."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-textpreview
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The right Sidebar's plain-text viewer: one workspace text file, read one page of lines at a time, with line navigation, wrap, and reload. It is the fallback type for every `file` resource address, and the template for a tab type shipped from outside `ui-sidebar-right`: every import from the Sidebar is a type, the file's metadata comes from the shared `file` resource, the text is the type's own business, and the type's controls live in its own body.
+
+## Table of Contents
+
+- [What it registers](#what-it-registers)
+- [Addresses](#addresses)
+- [How it reads](#how-it-reads)
+- [Navigation](#navigation)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="what-it-registers"></a>
+## What it registers
+
+- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-textpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; everything else lands here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its header row shows the file's path with the type's two controls at its end: a wrap toggle (on by default; long lines wrap until the reader turns it off, per tab) and a reload button. The Sidebar's tab strip carries no controls of this type. The body takes the pane body's full height: the header row stays put and the file body below is the one scroller, so a short file leaves no unstyled space and a long file scrolls under a fixed path.
+- **One store and one face**, session-scoped and bucketed by tab id. The store holds the pages read so far (keyed by the 1-based line each starts at, with the file version they belong to), the end-of-file flag, the read in flight or its failure, and the view: scroll offset, wrap (initially on), and the navigation revision the body last answered. The face (`loadPage`, `reloadPages`) performs the reads and writes through the store's actions. The bucket is forgotten when the owner's `signal` aborts, which is when the tab record is gone.
+
+<a id="addresses"></a>
+## Addresses
+
+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 without its leading />` (a URI whose authority is the resource protocol, `file`, and whose path opens with the scope), built by `fileAddressFor` in `@deepseek-ai/dsh-util-workspace-path` and read back by `parseFileAddress`; every segment is component-encoded, and this package never splits the string itself. `hostFileOf` in `rpc.ts` turns the address into the session and path the endpoint takes: a `session` address reads under the session it names, with the relative path the Host resolves against that session's workspace root; an `absolute` address reads under the session the slot was mounted for, with the absolute path, and the Host's workspace confinement still applies. A malformed address throws, because the registry routes every `file` address to this type and a caller building one is expected to use the helper.
+
+<a id="how-it-reads"></a>
+## How it reads
+
+The body reads its record, navigation and lifetime through `useTabInfo().tab`. Metadata and content come from different places:
+
+- `useResource<'file'>(tab.contentId)`, the standard hook from `@deepseek-ai/dsh-client-resources`, yields `{ version, bytes, changed }` from the `file` provider in `@deepseek-ai/dsh-api-workspace-files`. The body reads `changed` and the failed state: when the agent wrote the file after the last `stat`, a bar announces it with a reload button, and when the resource is `failed` — the file gone, or the Host refusing it — a failure bar takes that place with the failure's line and the same reload button, ahead of any pending `changed`. Either way the pages already read stay on screen: the text is never replaced under the reader.
+- Pages come from `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`, bound in `rpc.ts` and called by the face with the session and path the address names. The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next until `eof`. Each page carries its line count (`lines`), which is how one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted — the walk restarts from the first page, so the body never mixes two versions. A failed page shows one sentence per `workspace-file/*` code (`not-found`, `outside-workspace`, `too-large` for a page over the byte cap, `not-text`, `not-regular-file`) or the transport's own message, with a retry for the same page.
+- **Reload** — the change bar's button and the header's reload control both call the resource's `reload()` (a fresh `stat`, which clears `changed`) and the face's `reloadPages` (drop the pages, read the first one again). A reload retires the reads still in flight — the face keeps a request generation per tab, and a page settling from an older generation writes nothing. The scroll offset is kept, so the reader stays where they were.
+
+Copy comes from the `sidebarTextpreview` locale namespace.
+
+<a id="navigation"></a>
+## Navigation
+
+`ctx.sidebarRight.openResource(address, { params: { line } })` — the `read` tool row passes its `offset` this way — arrives as `navigation.params`, which the body narrows to the `file` resource type's declared parameters (`SidebarRightResourceParamsMap['file']`, `{ line?: number }`, 1-based) without runtime validation: `params` is a typed same-process value. If the loaded pages do not reach that line, the body reads the next page, again, until they do or the file ends; then it scrolls the line to the top and marks it, once per `navigation.revision`. A body remounting for the same revision restores the reader's scroll offset instead. Opening the same file again without `revealIfOpened: false` focuses the existing tab and delivers the new parameters as a new revision.
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the preview is a browser-only viewer that registers no tool, prompt section, or session event.
+
+#### KV Cache effect
+
+No direct effect; what the user reads here never enters a model request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+- **Plain text only.** No syntax highlighting, images, rendered markdown, or search; a directory address fails with `not-regular-file`.
+- **Sequential pages.** A line far into a large file loads every page before it; there is no seek to an arbitrary offset.
+- **Package-local wrap glyph.** `IconWrapOutline16` lives in `src/client/icons.tsx` until the shared icon set carries one; the props contract already matches.
+- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>
+
+**Runtime invariant:** No companion is published. The type's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against.

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

@@ -0,0 +1,81 @@
+---
+description: "dsh Web 客户端右侧 Sidebar 的纯文本查看器 tab 类型:对一个工作区文件分页读取,带行导航、换行、重新读取,并兜底认领每个 file 资源地址。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-textpreview
+
+[English](README.md) | 中文
+
+## 概述
+
+右侧 Sidebar 的纯文本查看器:一个工作区文本文件,一次读一页行,带行号导航、换行与重新读取。它是每个 `file` 资源地址的兜底类型,也是 `ui-sidebar-right` 之外交付的 tab 类型的样板:来自 Sidebar 的每个 import 都是类型,文件的元数据来自共享的 `file` 资源,正文是类型自己的事,类型的控件住在自己的体里。
+
+## 目录
+
+- [注册了什么](#what-it-registers)
+- [地址](#addresses)
+- [怎么读](#how-it-reads)
+- [导航](#navigation)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="what-it-registers"></a>
+## 注册了什么
+
+- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-textpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其余一切落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。
+- **体** —— keyed 坑位 `sidebar.right.pane.tab`,键为类型的 id。它的头部行显示文件路径,末端是类型的两个控件:换行开关(默认开;长行折行直到读者关掉它,按 tab 记)与重新读取按钮。Sidebar 的 tab 条不承载这个类型的任何控件。体占满 pane 体的全部高度:头部行不动,其下的文件体是唯一的滚动者,于是短文件不留没有样式的空白,长文件在固定的路径下滚动。
+- **一个 store 与一个 face**,会话作用域、按 tab id 分桶。store 持有已读的页(以每页起始的 1 起行号为键,连同它们所属的文件版本)、文件末尾标志、进行中的读取或其失败,以及视图:滚动位置、换行(初始为开)、体最近答过的导航 revision。face(`loadPage`、`reloadPages`)执行读取并经 store 的 action 写入。owner 的 `signal` abort 时——即 tab 记录消失时——桶被忘掉。
+
+<a id="addresses"></a>
+## 地址
+
+tab 的地址是 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`(一个 URI,authority 是资源协议 `file`,路径以作用域开头),由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 构造、`parseFileAddress` 读回;每段都做 component 编码,本包从不自己拆这个串。`rpc.ts` 里的 `hostFileOf` 把地址变成端点所需的会话与路径:`session` 地址在它命名的会话下读取,相对路径由 Host 对该会话的工作区根解析;`absolute` 地址在坑位被挂载的会话下以绝对路径读取,Host 的工作区限制照样适用。畸形地址直接抛错,因为注册表把每个 `file` 地址都路由给这个类型,造地址的调用方本应使用助手。
+
+<a id="how-it-reads"></a>
+## 怎么读
+
+正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。元数据与内容来自不同的地方:
+
+- `useResource<'file'>(tab.contentId)`——来自 `@deepseek-ai/dsh-client-resources` 的标准 hook——从 `@deepseek-ai/dsh-api-workspace-files` 的 `file` 提供方得到 `{ version, bytes, changed }`。体读 `changed` 与失败态:agent 在上次 `stat` 之后写了文件时,一条提示带着重新载入按钮出现;资源为 `failed` 时——文件没了,或 Host 拒绝——一条失败条占据同一位置,显示失败句与同一个重新载入按钮,并优先于尚未处理的 `changed`。两种情况下已读的页都留在屏幕上:正文绝不在读者眼前被替换。
+- 页来自 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`,在 `rpc.ts` 绑定、由 face 以地址所命名的会话与路径调用。首次挂载读第一页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`。每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用——从第一页重新走一遍,于是体永不混合两个版本。失败的页按 `workspace-file/*` 错误码各显示一句(`not-found`、`outside-workspace`、超过字节上限的页 `too-large`、`not-text`、`not-regular-file`)或传输层自己的消息,并带一个重读同一页的重试。
+- **重新载入** —— 变更提示条的按钮与头部的重新读取控件都调用资源的 `reload()`(重新 `stat`,清掉 `changed`)与 face 的 `reloadPages`(丢掉所有页,重读第一页)。重载淘汰仍在飞的读取——face 按 tab 记请求代次,旧代次结算的页什么也不写。滚动位置保留,读者停在原处。
+
+文案来自 `sidebarTextpreview` locale 命名空间。
+
+<a id="navigation"></a>
+## 导航
+
+`ctx.sidebarRight.openResource(address, { params: { line } })`——`read` 工具行以此传它的 `offset`——以 `navigation.params` 到达,体把它收窄为 `file` 资源类型声明的参数(`SidebarRightResourceParamsMap['file']`,`{ line?: number }`,1 起),不做运行时校验:`params` 是同进程的类型化值。已加载的页够不到该行时,体读下一页,再读,直到覆盖它或文件结束;然后把该行滚到顶部并标记,每个 `navigation.revision` 一次。同一 revision 下重新挂载的体恢复读者的滚动位置而不再跳。不带 `revealIfOpened: false` 再次打开同一文件时聚焦已有 tab,并把新参数作为新 revision 送达。
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为预览是纯浏览器侧的查看器,不注册工具、提示词段或会话事件。
+
+#### KV Cache 影响
+
+无直接影响;用户在这里读到的东西永不进入模型请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+- **只有纯文本。** 没有语法高亮、图片、Markdown 渲染或搜索;目录地址以 `not-regular-file` 失败。
+- **页按顺序加载。** 大文件深处的一行要先加载它之前的每一页;没有到任意偏移的 seek。
+- **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。
+- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变量:** 不发布 companion。该类型唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。

+ 77 - 0
packages/client/ui-sidebar-textpreview/package.json

@@ -0,0 +1,77 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-sidebar-textpreview",
+  "description": "Text preview tab type for the right Sidebar: the fallback viewer for file: addresses, drawn from the file resource with line navigation, wrap, and reload",
+  "version": "0.1.3-alpha.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-sidebar-textpreview"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-api-workspace-files",
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
+        "@deepseek-ai/dsh-client-ui-session",
+        "@deepseek-ai/dsh-api-remotes"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "dependencies": {
+    "clsx": "^2.0.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-resources": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 169 - 0
packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css

@@ -0,0 +1,169 @@
+/* The pane body is a block scroller with a definite height, not a flex
+   container, so the preview takes that height outright: the header row stays
+   put and the file body below is the one scroller, however short the file. */
+.preview {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+}
+
+/* One row: the path, then the type's controls at its end. */
+.header {
+  display: flex;
+  flex: 0 0 auto;
+  gap: 2px;
+  align-items: center;
+  padding: 3px 6px 3px 10px;
+  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
+}
+
+.path {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12px;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+/* Announced, not applied: the reader keeps the text they are looking at. */
+.changed {
+  display: flex;
+  flex: 0 0 auto;
+  gap: 10px;
+  align-items: center;
+  margin: 0;
+  padding: 6px 10px;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 12px;
+  background: var(--dsw-alias-bg-layer-2);
+  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
+}
+
+.body {
+  /* Lines are positioned against the scroller, so a line's offset is its scroll target. */
+  position: relative;
+  flex: 1 1 auto;
+  min-height: 0;
+  padding: 10px 0;
+  overflow: auto;
+  /* The notice and retry surfaces in this sheet are elevated, so the file body's
+     scroller rebinds the thumb indirection in a complete pair. */
+  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+  color: var(--dsw-alias-label-primary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-family: var(--dsw-font-mono, ui-monospace, monospace);
+  line-height: 1.6;
+  white-space: pre;
+}
+
+.wrap {
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+/* One page of lines; pages abut so the file reads as one. */
+.page {
+  margin: 0;
+  font: inherit;
+  white-space: inherit;
+}
+
+.line {
+  padding: 0 10px;
+}
+
+.lineTarget {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.statusLine {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  margin: 0;
+  padding: 6px 10px;
+  color: var(--dsw-alias-label-secondary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  line-height: 1.6;
+  white-space: normal;
+}
+
+.status {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  align-items: flex-start;
+  padding: 12px 10px;
+}
+
+.action {
+  padding: 4px 10px;
+  color: var(--dsw-alias-label-primary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-family: var(--dsw-font, inherit);
+  white-space: normal;
+  background: var(--dsw-alias-bg-layer-2);
+  border: 0.5px solid var(--dsw-alias-border-l2);
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.action:hover {
+  background: var(--dsw-alias-bg-layer-3);
+}
+
+/* The next page, asked for where the loaded text ends. */
+.more {
+  display: block;
+  margin: 8px 10px;
+  padding: 4px 10px;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 12px;
+  font-family: var(--dsw-font, inherit);
+  white-space: normal;
+  background: var(--dsw-alias-bg-layer-2);
+  border: 0.5px solid var(--dsw-alias-border-l2);
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.more:hover:not(:disabled) {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-bg-layer-3);
+}
+
+.more:disabled {
+  color: var(--dsw-alias-label-tertiary);
+  cursor: default;
+}
+
+/* Controls in the header row, sized like the docking kit's own pane controls. */
+.tool {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  padding: 0;
+  color: var(--dsw-alias-label-secondary);
+  line-height: 1;
+  background: transparent;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.tool:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.toolOn {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}

+ 264 - 0
packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx

@@ -0,0 +1,264 @@
+/**
+ * The text preview's body: a file's pages, or the reason the next one is not showing.
+ *
+ * Two sources meet here. The standard `useResource` hook gives the file's
+ * metadata — its version and whether the agent wrote it since — and this type's
+ * own store holds the pages it read through its face. A Host-reported change is
+ * announced, not applied: reloading under a reader would lose their place, so
+ * the bar waits for a click. A failed metadata frame — the file gone, its
+ * workspace unknown — takes the same bar's place over the pages already loaded,
+ * with the same reload. The type's controls, wrap and reload, sit at the end of
+ * the path row; the Sidebar's strip carries none of them.
+ */
+import { useEffect, useMemo, useRef } from 'react'
+import type { ReactNode } from 'react'
+import clsx from 'clsx'
+import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { TextInjected } from './face.ts'
+import { failureLine } from './failure-line.ts'
+import { IconWrapOutline16 } from './icons.tsx'
+import { hostFileOf } from './rpc.ts'
+import type { TextPage, TextStore } from './store.ts'
+import css from './TextPreview.module.css'
+
+/** The body's composed props: the tab, its navigation, the shared store and face, and copy. */
+export type TextPreviewProps =
+  & PropsRuntime<'sidebar.right.pane.tab'>
+  & PropsStore<TextStore>
+  & InjectFace<TextInjected>
+  & PropsLocale<'sidebarTextpreview'>
+
+/**
+ * A page's lines. The Host joins a page's lines with `\n` without a terminator
+ * and counts them, so a page past the file's last line (`lines: 0`) has none
+ * and a page holding one empty line (`lines: 1`, `text: ''`) has one; a
+ * trailing `\n` ends an empty last line.
+ * @param page - the page's text and line count.
+ * @returns the lines in order.
+ */
+export function linesOf(page: TextPage): string[] {
+  return page.lines === 0 ? [] : page.text.split('\n')
+}
+
+/** One loaded page: the 1-based line it starts at, its text, and its line count. */
+export interface LoadedPage extends TextPage {
+  readonly offset: number
+}
+
+/**
+ * The loaded pages in file order.
+ * @param pages - the store's page table.
+ * @returns the pages, ascending by offset.
+ */
+export function loadedPages(pages: Record<number, TextPage>): LoadedPage[] {
+  return Object.entries(pages)
+    .map(([offset, page]) => ({ offset: Number(offset), ...page }))
+    .sort((left, right) => left.offset - right.offset)
+}
+
+/**
+ * The last line the loaded pages reach, by the Host's line counts; 0 before the first page.
+ * @param pages - the loaded pages, ascending.
+ * @returns the 1-based last loaded line.
+ */
+export function lastLineLoaded(pages: readonly LoadedPage[]): number {
+  const last = pages.at(-1)
+  return last === undefined ? 0 : last.offset + last.lines - 1
+}
+
+/**
+ * Scroll the body so one line sits at its top. A line the pages do not hold
+ * leaves the body where it is.
+ * @param body - the scrolling container; lines are positioned against it.
+ * @param line - 1-based line.
+ */
+export function scrollToLine(body: HTMLElement, line: number): void {
+  const row = body.querySelector(`[data-textpreview-line="${line}"]`)
+  if (row instanceof HTMLElement) body.scrollTop = row.offsetTop
+}
+
+/**
+ * The text type's body, registered under `sidebar.right.pane.tab` as `text`.
+ * @param props - composed slot props.
+ * @returns the pages read so far with their controls, or a progress line.
+ */
+export function TextPreview({
+  useTabInfo, sessionId, useResource, useStore, actions, loadPage, reloadPages, t,
+}: TextPreviewProps): ReactNode {
+  const { tab } = useTabInfo()
+  const { navigation, signal } = tab
+  const meta = useResource<'file'>(tab.contentId)
+  const file = useMemo(() => hostFileOf(tab.contentId, sessionId), [tab.contentId, sessionId])
+  const state = useStore(s => s.byTab[tab.id])
+  const bodyRef = useRef<HTMLDivElement>(null)
+  // 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.
+  const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined
+  const pages = state?.pages
+  const loaded = useMemo(() => loadedPages(pages ?? {}), [pages])
+  const loadedThrough = lastLineLoaded(loaded)
+  const hasPages = loaded.length > 0
+
+  // First mount reads the first page; a body coming back to a tab with pages
+  // reads nothing, because the store outlives the body.
+  const started = state !== undefined
+  useEffect(() => {
+    if (!started) loadPage(tab.id, file, 1, signal)
+  }, [started, tab.id, file, signal, loadPage])
+
+  // Come back where the reader was once there are pages to scroll: on a remount,
+  // and after a reload rebuilt the pages. Keyed on page presence only, so a
+  // scroll write never re-lands.
+  useEffect(() => {
+    const body = bodyRef.current
+    if (hasPages && body !== null && state !== undefined) body.scrollTop = state.scrollTop
+  }, [hasPages])
+
+  // Answer a navigation once: a line the pages do not reach yet loads the next
+  // page (again, until the pages cover it or the file ends); a line they hold
+  // is scrolled to and marked. The store remembers the answer, so a remount
+  // restores the reader's place instead.
+  useEffect(() => {
+    const body = bodyRef.current
+    if (state === undefined || body === null || state.revision === navigation.revision) return
+    if (line === undefined) {
+      actions.navigated(tab.id, navigation.revision)
+      return
+    }
+    if (line > loadedThrough && !state.eof) {
+      if (!state.loading && state.failure === undefined) loadPage(tab.id, file, loadedThrough + 1, signal)
+      return
+    }
+    scrollToLine(body, line)
+    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.
+    actions.scrolled(tab.id, body.scrollTop)
+  }, [navigation.revision, line, loadedThrough, state?.eof, state?.loading, state?.failure, started])
+
+  // One block per line inside one block per page, so a line has an offset to
+  // scroll to and a target can be marked. The trailing newline keeps an empty
+  // line one line tall. Memoized so a scroll write's re-render hands React the
+  // same elements back.
+  const rows = useMemo(() => loaded.map(page => (
+    <pre key={page.offset} className={css.page} data-textpreview-page={page.offset}>
+      {linesOf(page).map((content, index) => {
+        const number = page.offset + index
+        const target = number === line
+        return (
+          <div
+            key={number}
+            className={clsx(css.line, target && css.lineTarget)}
+            data-textpreview-line={number}
+            {...target ? { 'data-textpreview-target': number } : {}}
+          >
+            {content}{'\n'}
+          </div>
+        )
+      })}
+    </pre>
+  )), [loaded, line])
+
+  if (state === undefined) {
+    return (
+      <div className={css.status} data-textpreview-state="loading">
+        <p className={css.statusLine}>{t('loading')}</p>
+      </div>
+    )
+  }
+  const next = loadedThrough + 1
+  // Reload does two things at once: stat again through the resource (which
+  // clears `changed`, or a failed frame) and read the pages again through the face.
+  const reload = (): void => { meta.reload(); reloadPages(tab.id, file, signal) }
+  return (
+    <div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId}>
+      {meta.failure !== undefined
+        ? (
+          // The file's metadata failed — gone, or its workspace unknown — which
+          // outranks a pending change; the pages already read stay under it.
+          <p className={css.changed} data-textpreview-meta-failed={meta.failure.code}>
+            <span>{failureLine(t, meta.failure)}</span>
+            <button
+              type="button"
+              className={css.action}
+              data-textpreview-reload-now
+              onClick={reload}
+            >
+              {t('reloadNow')}
+            </button>
+          </p>
+        )
+        : meta.value?.changed === true && (
+          <p className={css.changed} data-textpreview-changed>
+            <span>{t('changed')}</span>
+            <button
+              type="button"
+              className={css.action}
+              data-textpreview-reload-now
+              onClick={reload}
+            >
+              {t('reloadNow')}
+            </button>
+          </p>
+        )}
+      <div className={css.header}>
+        <div className={css.path} title={file.path}>{file.path}</div>
+        <button
+          type="button"
+          className={clsx(css.tool, state.wrap && css.toolOn)}
+          aria-pressed={state.wrap}
+          aria-label={t('wrap')}
+          title={t('wrap')}
+          data-textpreview-tool="wrap"
+          onClick={() => { actions.toggledWrap(tab.id) }}
+        >
+          <IconWrapOutline16 />
+        </button>
+        <button
+          type="button"
+          className={css.tool}
+          aria-label={t('reload')}
+          title={t('reload')}
+          data-textpreview-tool="reload"
+          onClick={reload}
+        >
+          <IconRefreshOutline16 />
+        </button>
+      </div>
+      <div
+        ref={bodyRef}
+        className={clsx(css.body, state.wrap && css.wrap)}
+        data-textpreview-body
+        data-textpreview-wrap={state.wrap ? '' : undefined}
+        onScroll={(event) => { actions.scrolled(tab.id, event.currentTarget.scrollTop) }}
+      >
+        {rows}
+        {state.failure !== undefined && (
+          <p className={css.statusLine} data-textpreview-failed={state.failure.code}>
+            <span>{failureLine(t, state.failure)}</span>
+            <button
+              type="button"
+              className={css.action}
+              data-textpreview-retry
+              onClick={() => { loadPage(tab.id, file, next, signal) }}
+            >
+              {t('retry')}
+            </button>
+          </p>
+        )}
+        {!state.eof && state.failure === undefined && (
+          <button
+            type="button"
+            className={css.more}
+            disabled={state.loading}
+            data-textpreview-more
+            onClick={() => { loadPage(tab.id, file, next, signal) }}
+          >
+            {state.loading ? t('loading') : t('loadMore')}
+          </button>
+        )}
+      </div>
+    </div>
+  )
+}

+ 54 - 0
packages/client/ui-sidebar-textpreview/src/client/definition.ts

@@ -0,0 +1,54 @@
+/**
+ * Stage one of this package's registration: what the `text` tab type IS.
+ *
+ * The type claims every `dsh-resource://file/` address in either scope —
+ * `session/<sessionId>/<path>` or `absolute/<path>` — at the `fallback` band: it
+ * is the plain viewer that any more specific type for the same address should
+ * beat, the position VS Code's text editor holds among its editors. `canOpen`
+ * refuses an address `parseFileAddress` rejects at claim time, where an
+ * unclaimed address is the documented wiring error.
+ */
+import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+
+/** The tab kind this package owns. */
+export const TEXTPREVIEW_KIND = 'text'
+
+/** This implementation's identity in the tab system: the key its body registers under. */
+export const TEXTPREVIEW_ID = '@deepseek-ai/dsh-client-ui-sidebar-textpreview'
+
+/**
+ * The tab title for one `file:` address: its decoded basename.
+ *
+ * The whole address stays the content identity, so two files with one name in
+ * different directories are two tabs; only the chip text is shortened. Decoding
+ * is per segment, matching how the address was built, so a name carrying `#`,
+ * `?`, or a space reads as itself.
+ * @param address - a `file:`-shaped address.
+ * @returns the decoded last path segment, or the address itself when it has none.
+ */
+export function basenameOf(address: string): string {
+  const name = address.slice(address.lastIndexOf('/') + 1)
+  if (name === '') return address
+  try {
+    return decodeURIComponent(name)
+  } catch {
+    // A malformed percent sequence is still a name; showing it raw beats refusing the address.
+    return name
+  }
+}
+
+/**
+ * The text type's registry definition.
+ * @returns the definition to register.
+ */
+export function textDefinition(): SidebarRightTabDefinition {
+  return {
+    id: TEXTPREVIEW_ID,
+    kind: TEXTPREVIEW_KIND,
+    patterns: ['dsh-resource://file/**'],
+    priority: 'fallback',
+    canOpen: address => parseFileAddress(address) !== undefined,
+    title: basenameOf,
+  }
+}

+ 111 - 0
packages/client/ui-sidebar-textpreview/src/client/face.ts

@@ -0,0 +1,111 @@
+/**
+ * The preview's asynchronous half: reading pages into the store.
+ *
+ * The component never awaits anything. It asks for a page and this face performs
+ * the read and writes the outcome through the store's own actions — the
+ * Slot-standard `inject` form, so the write set stays the store's. The session
+ * the read runs under comes from the file's address, not from the slot's
+ * session: the address is the read's whole authority.
+ *
+ * A tab's pages are one file version walked from the first line. Dropping them
+ * — a reload, or a page of a newer version arriving past the first line, which
+ * restarts the walk — retires every read still in flight for the tab: a
+ * settlement from before the drop writes nothing. Cleanup rides the owner's
+ * `signal`, armed once per tab by its first read: the abort forgets the tab's
+ * bucket and this bookkeeping, a request is not made for a record that already
+ * ended, and a settlement arriving after the record is gone has nothing left to
+ * write to. A tab that never read has no bucket to forget.
+ */
+import type { BoundActions } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { ReadWorkspaceFilePage, SessionFile } from './rpc.ts'
+import type { TextStore } from './store.ts'
+
+/** The preview's injected business face, as the body receives it. */
+export interface TextInjected {
+  /**
+   * Read one page into the store. A page of a newer file version than the pages
+   * held, arriving past the first line, is not kept: the tab's pages are dropped
+   * and the first page read again. The tab's first read arms the abort listener
+   * that forgets its bucket when the record ends.
+   * @param tabId - the tab being drawn.
+   * @param file - the session and workspace path the tab's address names.
+   * @param offset - 1-based line the page starts at.
+   * @param signal - the tab record's lifetime.
+   */
+  readonly loadPage: (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal) => void
+  /**
+   * Drop every page and read the first one again, for a file the Host reports
+   * changed. The view is kept, so the reader stays where they were; a page read
+   * still in flight writes nothing when it settles.
+   * @param tabId - the tab being drawn.
+   * @param file - the session and workspace path the tab's address names.
+   * @param signal - the tab record's lifetime.
+   */
+  readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal) => void
+}
+
+/**
+ * What the face remembers of one tab: the read generation a settlement must
+ * match, and the version of the pages held. Created by the tab's first read,
+ * which also arms the one abort listener that forgets the tab.
+ */
+interface TabReads {
+  generation: number
+  version: string | undefined
+}
+
+/**
+ * Bind the preview's face to one paged read.
+ * @param read - the bound `workspaceFiles.read` call.
+ * @returns the Slot `inject` factory: bound actions in, face out. The slot's session id is unused because the address carries its own.
+ */
+export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, actions: BoundActions<TextStore>) => TextInjected {
+  return (_sessionId: SessionId, actions: BoundActions<TextStore>): TextInjected => {
+    const tabs = new Map<TabId, TabReads>()
+    // Reached with a live signal only: the record's end forgets the tab's
+    // bucket and this bookkeeping in one listener, however often its body mounts.
+    const readsOf = (tabId: TabId, signal: AbortSignal): TabReads => {
+      const held = tabs.get(tabId)
+      if (held !== undefined) return held
+      const created: TabReads = { generation: 0, version: undefined }
+      tabs.set(tabId, created)
+      signal.addEventListener('abort', () => {
+        tabs.delete(tabId)
+        actions.forget(tabId)
+      }, { once: true })
+      return created
+    }
+    const loadPage = (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal): void => {
+      if (signal.aborted) return
+      const reads = readsOf(tabId, signal)
+      const { generation } = reads
+      actions.loading(tabId)
+      void read(file.sessionId, file.path, offset, signal).then((result) => {
+        if (signal.aborted || reads.generation !== generation) return
+        if (!result.ok) {
+          actions.failed(tabId, result.error)
+          return
+        }
+        // Pages of two versions never meet: a newer file past the first line
+        // restarts the walk from line 1, where the store adopts the new version.
+        if (offset !== 1 && reads.version !== undefined && result.value.version !== reads.version) {
+          restart(tabId, file, signal)
+          return
+        }
+        reads.version = result.value.version
+        actions.page(tabId, result.value)
+      })
+    }
+    const restart = (tabId: TabId, file: SessionFile, signal: AbortSignal): void => {
+      if (signal.aborted) return
+      const reads = readsOf(tabId, signal)
+      reads.generation += 1
+      reads.version = undefined
+      actions.reset(tabId)
+      loadPage(tabId, file, 1, signal)
+    }
+    return { loadPage, reloadPages: restart }
+  }
+}

+ 36 - 0
packages/client/ui-sidebar-textpreview/src/client/failure-line.ts

@@ -0,0 +1,36 @@
+/**
+ * The failure line one Remote code deserves.
+ *
+ * Kept apart from the component so the mapping is testable on its own. Codes
+ * this reader does not name fall to the generic line carrying the carrier's
+ * message.
+ */
+import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+
+/** Render a byte count the way a person reads one. */
+function humanBytes(bytes: number): string {
+  if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`
+  if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
+  return `${bytes} B`
+}
+
+/**
+ * Say what went wrong, in terms of the file rather than of the transport.
+ * @param t - namespace-bound translate.
+ * @param failure - the settled Remote failure.
+ * @returns the line to show in place of the file.
+ */
+export function failureLine(t: TranslateNS<'sidebarTextpreview'>, failure: RemoteFailure): string {
+  switch (failure.code) {
+    case 'workspace-file/not-found': return t('error.notFound')
+    case 'workspace-file/outside-workspace': return t('error.outsideWorkspace')
+    case 'workspace-file/too-large':
+      return t('error.tooLarge', { limit: humanBytes(failure.details.limit) })
+    case 'workspace-file/not-text': return t('error.notText')
+    case 'workspace-file/not-regular-file': return t('error.notRegularFile')
+    // Carrier and unclassified host failures reach the reader as themselves:
+    // this panel knows nothing useful to add to a transport-level message.
+    default: return t('error.unavailable', { message: failure.message })
+  }
+}

+ 27 - 0
packages/client/ui-sidebar-textpreview/src/client/icons.tsx

@@ -0,0 +1,27 @@
+/**
+ * Glyphs this package draws that the shared icon set does not carry yet.
+ * Same props contract as `@deepseek-ai/dsh-client-ui-primitives` icons, so a
+ * shared replacement is a one-line import change.
+ */
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
+
+/** Three text lines, the middle one turning back under itself. */
+export const IconWrapOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg
+    width={size}
+    height={size}
+    className={className}
+    viewBox="0 0 16 16"
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="1.3"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path d="M2.5 4h11" />
+    <path d="M2.5 8h8.5a2.5 2.5 0 0 1 0 5H9.5" />
+    <path d="M11 11.5 9.5 13l1.5 1.5" />
+    <path d="M2.5 12h3.5" />
+  </svg>
+)

+ 73 - 0
packages/client/ui-sidebar-textpreview/src/client/index.ts

@@ -0,0 +1,73 @@
+/**
+ * Browser half: register `text` as a right-Sidebar tab type.
+ *
+ * The type reaches the Sidebar through its public path only: the definition into
+ * `ctx.sidebarRightTabs` and the body into the keyed `sidebar.right.pane.tab`
+ * seat under the definition's `id`. Nothing here reaches into the Sidebar's store, its
+ * panes, or its sequence. The file's metadata comes from the standard
+ * `useResource`, served by the `file` provider; the text is this type's own
+ * business, read one page at a time through its face. Every import from another
+ * client plugin is a type.
+ */
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-resources/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { WorkspaceFileParams } from '@deepseek-ai/dsh-api-workspace-files/client'
+import { TextPreview } from './TextPreview.tsx'
+import { TEXTPREVIEW_ID, textDefinition } from './definition.ts'
+import { textFace } from './face.ts'
+import { createReadPage } from './rpc.ts'
+import { createTextStore } from './store.ts'
+import { en, zh } from './locales.ts'
+
+// Values stay package-private unless another package needs them; the plugin
+// surface is `apply`, `inject`, and the store factory another registration may
+// share, plus the types a consumer of the seat or the store names.
+export type { SidebarTextpreviewKey } from './locales.ts'
+export type { TextPreviewProps } from './TextPreview.tsx'
+export type { TextInjected } from './face.ts'
+export type { ReadWorkspaceFilePage, SessionFile, WorkspaceFilesReadRemote } from './rpc.ts'
+export type { TextPage, TextState, TextStore, TextTabState } from './store.ts'
+
+/** This package's copy namespace. */
+const NS = 'sidebarTextpreview'
+
+declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
+  interface SidebarRightResourceParamsMap {
+    /** File line navigation supported by the text preview. */
+    file: WorkspaceFileParams
+  }
+}
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Text-preview progress, paging, change, control, and failure lines. */
+    sidebarTextpreview: import('./locales.ts').SidebarTextpreviewKey
+  }
+}
+
+/**
+ * Required browser services: the tab registry, the slot registry, copy, and the
+ * Remote carrier with its `workspaceFiles` namespace.
+ */
+export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
+
+/**
+ * Client plugin body: register the type, its dictionaries, and its body.
+ * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face.
+ */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => ctx.sidebarRightTabs.register(textDefinition()), 'ui-sidebar-textpreview: text type')
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-textpreview: dictionaries')
+
+  const store = createTextStore()
+  const face = textFace(createReadPage(ctx.remote))
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab', key: TEXTPREVIEW_ID, locale: NS, store, inject: face },
+    TextPreview,
+  )), 'ui-sidebar-textpreview: text body')
+}

+ 44 - 0
packages/client/ui-sidebar-textpreview/src/client/locales.ts

@@ -0,0 +1,44 @@
+/**
+ * `sidebarTextpreview` namespace dictionaries.
+ *
+ * The failure lines are the point of this file: a preview that cannot show a
+ * page has to say which of several different things went wrong, and each one
+ * suggests a different next step for the reader.
+ */
+
+/** Simplified Chinese dictionary and key-set source of truth. */
+export const zh = {
+  loading: '正在读取…',
+  loadMore: '加载更多',
+  changed: '文件已被修改,显示的还是旧内容。',
+  reloadNow: '重新载入',
+  reload: '重新读取文件',
+  wrap: '自动换行',
+  'error.notFound': '这个文件不在了。可能已被移动或删除。',
+  'error.outsideWorkspace': '这个文件在工作区之外,侧栏不会读取它。',
+  'error.tooLarge': '这一页太大,侧栏不读取超过 {limit} 的页。',
+  'error.notText': '这不是文本文件,没法在这里查看。',
+  'error.notRegularFile': '这不是一个普通文件,没有可显示的文本。',
+  'error.unavailable': '读取失败:{message}',
+  retry: '重试',
+} satisfies Record<string, string>
+
+/** Text-preview dictionary key union. */
+export type SidebarTextpreviewKey = keyof typeof zh
+
+/** English dictionary, checked against the Chinese key set. */
+export const en = {
+  loading: 'Reading…',
+  loadMore: 'Load more',
+  changed: 'The file has changed; this is the older text.',
+  reloadNow: 'Reload',
+  reload: 'Read the file again',
+  wrap: 'Wrap lines',
+  'error.notFound': 'That file is gone. It may have been moved or deleted.',
+  'error.outsideWorkspace': 'That file is outside the workspace, so the sidebar will not read it.',
+  'error.tooLarge': 'That page is too large; the sidebar does not read pages above {limit}.',
+  'error.notText': 'That is not a text file, so it cannot be shown here.',
+  'error.notRegularFile': 'That is not a regular file, so it has no text to show.',
+  'error.unavailable': 'Read failed: {message}',
+  retry: 'Retry',
+} satisfies Record<SidebarTextpreviewKey, string>

+ 86 - 0
packages/client/ui-sidebar-textpreview/src/client/rpc.ts

@@ -0,0 +1,86 @@
+/**
+ * The paged read this type performs, bound to the Client Remote.
+ *
+ * Content is the consumer's business: the `file` resource carries metadata only,
+ * and the text arrives here one page of lines at a time. The endpoint takes a
+ * session and a workspace path while a tab carries a `dsh-resource://file/`
+ * address in one of two scopes, so this module also owns that translation.
+ */
+import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceFileRange, WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
+import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+
+/** The slice of the Client Remote this package calls. */
+export interface WorkspaceFilesReadRemote {
+  readonly workspaceFiles: {
+    /**
+     * Read one page of lines.
+     * @param sessionId - the session whose workspace resolves `path`.
+     * @param path - workspace path, absolute or relative to the workspace root.
+     * @param range - 1-based start line; the Host's page cap applies when `limit` is absent.
+     * @param signal - cancels the call.
+     * @returns the page, or the failure the Host declares.
+     */
+    read(
+      sessionId: SessionId,
+      path: string,
+      range: WorkspaceFileRange,
+      signal?: AbortSignal,
+    ): Promise<RemoteResult<WorkspaceFileText>>
+  }
+}
+
+/**
+ * The read one page performs, injected so the face stays host-free.
+ *
+ * The session travels with the call because the endpoint resolves the workspace
+ * root from it: the same path means different files in different sessions. A
+ * Remote call does not reject: the result carries the failure.
+ */
+export type ReadWorkspaceFilePage = (
+  sessionId: SessionId,
+  path: string,
+  offset: number,
+  signal: AbortSignal,
+) => Promise<RemoteResult<WorkspaceFileText>>
+
+/** The file one tab reads: the session the read runs under and the path handed to the Host. */
+export interface SessionFile {
+  /** The session whose workspace confines the read. */
+  readonly sessionId: SessionId
+  /** The path the Host receives: workspace-relative for a `session` address, absolute for an `absolute` one. */
+  readonly path: string
+}
+
+/**
+ * The session and path one `dsh-resource://file/…` address names.
+ *
+ * A `session` address names its own session and a workspace-relative path, so
+ * a tab addressed into another session reads from that session. An `absolute`
+ * address carries no session and is read through the seat's own, which the
+ * Host confines to that session's workspace. The registry routes every
+ * parseable `file` address to this type, so an address `parseFileAddress`
+ * rejects is a programming error and throws.
+ * @param address - a tab's `dsh-resource://file/…` address.
+ * @param sessionId - the seat's session, which an `absolute` address is read through.
+ * @returns the session and the path to hand the endpoint.
+ */
+export function hostFileOf(address: string, sessionId: SessionId): SessionFile {
+  const parsed = parseFileAddress(address)
+  if (parsed === undefined) throw new Error(`ui-sidebar-textpreview: not a file address "${address}"`)
+  // The address is a string boundary: its id segment is the Session id it names.
+  return parsed.scope === 'session'
+    ? { sessionId: parsed.sessionId as SessionId, path: parsed.path }
+    : { sessionId, path: parsed.path }
+}
+
+/**
+ * Bind the paged read to one Remote face. The page length is the Host's
+ * configured cap, so no `limit` travels.
+ * @param remote - the Client Remote carrying the `workspaceFiles` namespace.
+ * @returns the read the face performs.
+ */
+export function createReadPage(remote: WorkspaceFilesReadRemote): ReadWorkspaceFilePage {
+  return (sessionId, path, offset, signal) => remote.workspaceFiles.read(sessionId, path, { offset }, signal)
+}

+ 193 - 0
packages/client/ui-sidebar-textpreview/src/client/store.ts

@@ -0,0 +1,193 @@
+/**
+ * The preview's own state: the pages it has read, and how the reader views them.
+ *
+ * The `file` resource carries metadata only, so the text is this type's to fetch
+ * and keep — page by page, keyed by the 1-based line each page starts at. The
+ * view state (scroll offset, wrap, the navigation already answered) must outlive
+ * the body: a tab switched away from unmounts its body and must come back where
+ * it was rather than re-read or jump to its opening line again. Bucketed by tab
+ * id because two tabs of one file scroll independently.
+ *
+ * A bucket lives as long as its tab record: the face's first read of a tab arms
+ * one listener on the owner's `signal` that forgets the bucket when the record
+ * ends, and a tab that never read has no bucket to forget.
+ */
+import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
+
+/**
+ * One page as the store keeps it: its text and the Host's line count, which
+ * tells a page past the file's last line (`lines: 0`) from a page holding one
+ * empty line (`lines: 1`, `text: ''`).
+ */
+export interface TextPage {
+  readonly text: string
+  readonly lines: number
+}
+
+/** One tab's pages and view. */
+export interface TextTabState {
+  /** The file version the loaded pages belong to; absent before the first page. */
+  version: string | undefined
+  /** Pages by the 1-based line each starts at. */
+  pages: Record<number, TextPage>
+  /** Whether the last loaded page reached the end of the file. */
+  eof: boolean
+  /** A page read is in flight. */
+  loading: boolean
+  /** Why the last page read failed; cleared by the next page. */
+  failure: RemoteFailure | undefined
+  /** Scroll offset of the body, in px. */
+  scrollTop: number
+  /** Whether long lines wrap instead of scrolling horizontally; on until the reader turns it off. */
+  wrap: boolean
+  /** The `navigation.revision` the body already answered; absent before the first. */
+  revision: number | undefined
+}
+
+/** Every tab's state, keyed by tab id. */
+export interface TextState {
+  byTab: Record<TabId, TextTabState>
+}
+
+/**
+ * A tab's state before it reads, scrolls, toggles, or answers anything.
+ * @returns the empty bucket.
+ */
+export function fresh(): TextTabState {
+  return {
+    version: undefined,
+    pages: {},
+    eof: false,
+    loading: false,
+    failure: undefined,
+    scrollTop: 0,
+    wrap: true,
+    revision: undefined,
+  }
+}
+
+/** The bucket for one tab, created on first write. */
+function bucket(state: TextState, tabId: TabId): TextTabState {
+  return state.byTab[tabId] ??= fresh()
+}
+
+/** The preview store's write set; every action names the tab it writes. */
+type TextActions = {
+  loading: (draft: TextState, tabId: TabId) => void
+  page: (draft: TextState, tabId: TabId, page: WorkspaceFileText) => void
+  failed: (draft: TextState, tabId: TabId, failure: RemoteFailure) => void
+  reset: (draft: TextState, tabId: TabId) => void
+  scrolled: (draft: TextState, tabId: TabId, scrollTop: number) => void
+  toggledWrap: (draft: TextState, tabId: TabId) => void
+  navigated: (draft: TextState, tabId: TabId, revision: number) => void
+  forget: (draft: TextState, tabId: TabId) => void
+}
+
+/**
+ * Declare the preview's store.
+ *
+ * Constructed once in apply and shared by the body and the tools registrations,
+ * which the slot runtime allows because both are session-scoped.
+ * @returns the store handle to declare on both registrations.
+ */
+export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
+  return defineStore({
+    init: (): TextState => ({ byTab: {} }),
+    actions: {
+      /**
+       * Mark a page read as in flight.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       */
+      loading: (d, tabId: TabId) => {
+        bucket(d, tabId).loading = true
+      },
+      /**
+       * Keep one page. A page from a newer file version invalidates the pages
+       * of the older one, so the body never shows two versions at once.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       * @param page - the page the Host returned.
+       */
+      page: (d, tabId: TabId, page: WorkspaceFileText) => {
+        const state = bucket(d, tabId)
+        if (state.version !== undefined && state.version !== page.version) state.pages = {}
+        state.version = page.version
+        state.pages[page.offset] = { text: page.text, lines: page.lines }
+        state.eof = page.eof
+        state.loading = false
+        state.failure = undefined
+      },
+      /**
+       * Record why a page read failed; the pages already held stay.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       * @param failure - the settled Remote failure.
+       */
+      failed: (d, tabId: TabId, failure: RemoteFailure) => {
+        const state = bucket(d, tabId)
+        state.loading = false
+        state.failure = failure
+      },
+      /**
+       * Drop every page, keeping the view, for a re-read from the first line.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       */
+      reset: (d, tabId: TabId) => {
+        const state = bucket(d, tabId)
+        state.pages = {}
+        state.eof = false
+        state.version = undefined
+        state.failure = undefined
+      },
+      /**
+       * Record where one tab's body is scrolled to.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       * @param scrollTop - the body's scroll offset, in px.
+       */
+      scrolled: (d, tabId: TabId, scrollTop: number) => {
+        bucket(d, tabId).scrollTop = scrollTop
+      },
+      /**
+       * Switch one tab between wrapped and unwrapped lines.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       */
+      toggledWrap: (d, tabId: TabId) => {
+        const state = bucket(d, tabId)
+        state.wrap = !state.wrap
+      },
+      /**
+       * Record that the body answered one navigation, so a remount restores the
+       * reader's position instead of jumping again.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       * @param revision - the `navigation.revision` answered.
+       */
+      navigated: (d, tabId: TabId, revision: number) => {
+        bucket(d, tabId).revision = revision
+      },
+      /**
+       * Drop one tab's state, for a tab record that is gone.
+       * @param d - draft state.
+       * @param tabId - the tab that went away.
+       */
+      forget: (d, tabId: TabId) => {
+        const byTab: TextState['byTab'] = {}
+        // Keys were written from tab ids; reading them back as ids is exact.
+        for (const [id, state] of Object.entries(d.byTab) as [TabId, TextTabState][]) {
+          if (id !== tabId) byTab[id] = state
+        }
+        d.byTab = byTab
+      },
+    },
+  })
+}
+
+/** The store handle type both registrations declare. */
+export type TextStore = ReturnType<typeof createTextStore>

+ 6 - 0
packages/client/ui-sidebar-textpreview/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
packages/client/ui-sidebar-textpreview/src/index.ts

@@ -0,0 +1,4 @@
+/** Pure host half; the whole preview lives in the browser export. */
+
+/** Host plugin body: the preview contributes nothing to the host tree. */
+export function apply(): void {}

+ 54 - 0
packages/client/ui-sidebar-textpreview/tsconfig.json

@@ -0,0 +1,54 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../resources"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-dockkit"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
+    {
+      "path": "../ui-sidebar-right"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../../api/workspace-files/tsconfig.client.json"
+    },
+    {
+      "path": "../../util/workspace-path"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-sidebar-textpreview/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-textpreview', ['lib/types/index.js'])