Przeglądaj źródła

Merge pull request #3909 from deepseek-harness/fix/sidebar-preview-layout-polish

fix(client): refine sidebar previews, guide, and stats
imccyu 1 miesiąc temu
rodzic
commit
4cbaeb8d69
72 zmienionych plików z 683 dodań i 344 usunięć
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  5. 3 3
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  6. 3 3
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
  8. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
  9. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml
  11. 3 3
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md
  12. 3 3
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md
  13. 6 0
      .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml
  14. 29 0
      .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md
  15. 29 0
      .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md
  16. 20 9
      apps/web/tests/document-preview.e2e.ts
  17. 1 1
      apps/web/tests/sidebar-right.e2e.ts
  18. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  19. 2 2
      docs/subsystems/sidebar-right.md
  20. 2 2
      docs/subsystems/sidebar-right.zh.md
  21. 10 4
      packages/client/ui-chat/src/client/chat/StatsPills.tsx
  22. 5 1
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  23. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  24. 1 1
      packages/client/ui-dockkit/README.md
  25. 1 1
      packages/client/ui-dockkit/README.zh.md
  26. 2 0
      packages/client/ui-dockkit/src/components/dockkit.module.css
  27. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  28. 1 1
      packages/client/ui-primitives/README.md
  29. 1 1
      packages/client/ui-primitives/README.zh.md
  30. 6 0
      packages/client/ui-primitives/src/markdown/CodeBlock.module.css
  31. 6 4
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  32. 11 0
      packages/client/ui-primitives/tests/code-block.client.spec.tsx
  33. 42 37
      packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
  34. 40 35
      packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
  35. 21 19
      packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt
  36. 20 18
      packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.streaming.txt
  37. 4 3
      packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
  38. 4 3
      packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
  39. 4 3
      packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
  40. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  41. 5 5
      packages/client/ui-sidebar-documentpreview/README.md
  42. 5 5
      packages/client/ui-sidebar-documentpreview/README.zh.md
  43. 19 20
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css
  44. 26 10
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  45. 27 22
      packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css
  46. 2 1
      packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx
  47. 3 0
      packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts
  48. 1 0
      packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css
  49. 2 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css
  50. 21 17
      packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx
  51. 4 6
      packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts
  52. 7 2
      packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx
  53. 1 0
      packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx
  54. 37 12
      packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx
  55. 2 2
      packages/client/ui-sidebar-files/README.i18n.yaml
  56. 2 2
      packages/client/ui-sidebar-files/README.md
  57. 2 2
      packages/client/ui-sidebar-files/README.zh.md
  58. 8 2
      packages/client/ui-sidebar-files/src/client/definition.tsx
  59. 2 2
      packages/client/ui-sidebar-files/src/client/index.ts
  60. 2 0
      packages/client/ui-sidebar-files/src/client/locales.ts
  61. 3 2
      packages/client/ui-sidebar-files/tests/apply.client.spec.ts
  62. 11 3
      packages/client/ui-sidebar-files/tests/definition.client.spec.ts
  63. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  64. 1 1
      packages/client/ui-sidebar-right/README.md
  65. 1 1
      packages/client/ui-sidebar-right/README.zh.md
  66. 11 4
      packages/client/ui-sidebar-right/src/client/tab-registry.ts
  67. 70 19
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css
  68. 34 11
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx
  69. 40 7
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx
  70. 1 1
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  71. 28 3
      packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx
  72. 1 1
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
-2026-09-05-sidebar-tab-types-and-navigation.md: a79292eba25828287ce43ad16b7eb917dcdddcb4
-2026-09-05-sidebar-tab-types-and-navigation.zh.md: bf661948036257366714617b192525ac35b0905f
+2026-09-05-sidebar-tab-types-and-navigation.md: 24332c844d13c41e4f966f39aa7d2fe79fd8553f
+2026-09-05-sidebar-tab-types-and-navigation.zh.md: 3aeb38e2ec9c997b9aaf040ae0cb575fa08fa235

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md

@@ -36,7 +36,7 @@ interface SidebarRightTabDefinition {
 
 `priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes.
 
-`title(address)` and `guide[].title()` are thunks read on every use, so a language change needs no re-registration. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error.
+`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. [Guide start page and stat pill refinements](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the current description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error.
 
 ### Bodies and titles: keyed Slot seats under the definition's `id`
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md

@@ -36,7 +36,7 @@ interface SidebarRightTabDefinition {
 
 `priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。
 
-`title(address)` 与 `guide[].title()` 是每次使用时重读的 thunk,语言切换无需重新注册。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。
+`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[引导起始页与统计 pill 的细化](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责当前的描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。
 
 ### 正文与标题:按定义 `id` keyed 的 Slot 坑位
 

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

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

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

@@ -20,7 +20,7 @@ Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the *
 
 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 projects 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. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule 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.
 
@@ -42,13 +42,13 @@ Navigation is a `line`. The `read` tool row passes its 1-based `offset` as `open
 
 A changed file is announced, not applied. The body compares the loaded version and the observation captured at read start with later `WorkspaceFileStat.version`; a difference shows the change bar. Reload rereads only this tab through the Preview face and does not mutate shared resource metadata or another tab. A resource failure takes the same bar's place above any content already loaded.
 
-The body's header is one row: the full file path on the left and the matching-renderer menu, conditional wrap toggle, and reload button on the right. The [Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns the current controls and renderer behavior. The preview takes the pane body's full height, and its document body is the scroller beneath the fixed header and change bar.
+The body's header is one row: the full file path on the left and the matching-renderer menu, conditional wrap toggle, and reload button on the right. The [Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns the current controls, renderer behavior, and scrolling. The preview takes the pane body's full height.
 
 A failed read keeps content already shown and adds a localized failure with a retry action. The Preview names actionable file failures and falls back to the carrier message for other codes; `outside-workspace` belongs to directory listing and is not a Preview-specific failure.
 
 ### The file tree
 
-`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.
+`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: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [guide refinement](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns that presentation choice. `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.
 

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

@@ -20,7 +20,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、
 
 引导页是 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 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。
+正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。
 
 体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。
 
@@ -42,13 +42,13 @@ store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持
 
 文件变了只提示,不应用。正文把已加载版本及读取开始时捕获的观察版本与后续 `WorkspaceFileStat.version` 比较;不同则显示变更提示。重新载入只通过 Preview face 重读当前 tab,不修改共享资源元数据或其他 tab。资源失败占用同一个提示位置,已加载内容仍保留在下方。
 
-正文头部为一行:左侧显示完整文件路径,右侧放匹配渲染器菜单、按条件出现的换行开关和重新载入按钮。[Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md)负责当前控件与渲染器行为。预览占满 pane 正文的全部高度,其文档正文是固定头部与变更提示条下方的滚动区域。
+正文头部为一行:左侧显示完整文件路径,右侧放匹配渲染器菜单、按条件出现的换行开关和重新载入按钮。[Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md)负责当前控件、渲染器行为与滚动方式。预览占满 pane 正文的全部高度。
 
 读取失败时保留已显示的内容,并增加本地化失败说明与重试操作。Preview 为可处理的文件错误提供专用文案,其他代码使用载体消息兜底;`outside-workspace` 属于目录列举,不是 Preview 专用失败。
 
 ### 文件树
 
-`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` 注册,与所有注册一致。
+`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[引导页细化记录](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责这项展示选择。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。
 
 根是 Host 在会话列表里上报的会话工作目录(`useSessions().byId[sessionId].cwd`),标签由 `dsh-util-workspace-path` 的 `workspaceTitleOf` 给出——路径最后一个非空段——路径只有分隔符时用根串本身作标签。没有工作目录的会话只显示一行(`noWorkspace`),不发请求。没有根选择器,也不能往上浏览:Host 的 `list` 拒绝会话工作区根之外的路径,所以客户端能列的那一个目录就是它显示的目录。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
-2026-09-07-composer-session-stats-pills.md: 818c3c156f72816eb6540d505782be8e03dc6aa0
-2026-09-07-composer-session-stats-pills.zh.md: 8a74cddd3176026d799fb5b4223a51371ee8e253
+2026-09-07-composer-session-stats-pills.md: 541e5a363d78c4333b1b8e3927928a19754822fa
+2026-09-07-composer-session-stats-pills.zh.md: b6ef6da3c37a46d9e07e7ebdf77439e26896fddc

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md

@@ -12,7 +12,7 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
 
 `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
 
-- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, cache write, output — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually.
+- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission.
 - **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy stays on the composer's ContextMeter ring, where it already lived beside `StatsLine` — the strip never carried it.
 - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
 - **`data-composer-stats` is a cross-package attribute contract.** The pills' root carries it; ui-conversation's `InputBar.module.css` `:has([data-composer-stats])` rule tightens the composer's bottom clearance to 4px when the row is mounted. The producer side pins the attribute in unit tests, following the `data-trigger-menu` precedent.
@@ -21,7 +21,7 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
 
 - **The single-line variant (StatsLine, the A/B loser).** All figures resident in one text row, with a hover tooltip restating the full line when it truncated. Lost on crowding and reach: exact token counts appeared nowhere (the line and its tooltip both carried compact totals only), and one row gave time and billing figures no visual grouping.
 - **Three resident groups with one shared dialog.** An intermediate iteration kept counts, time, and tokens as three inline groups. Two pills won because the time/usage split matches the two underlying projections one-to-one and each pill's icon telegraphs its dialog.
-- **Extracting the dl bucket rows shared with `TurnUsagePanel`.** The session-total dialog and the per-turn panel render the same skin but different contracts (all buckets always present vs optional per-turn fields plus model routes); a shared component would be conditionals around nine lines. The mirror is marked `jscpd:ignore` with the reason inline.
+- **Extracting the dl bucket rows shared with `TurnUsagePanel`.** The session-total dialog and the per-turn panel render the same skin but different contracts (required session input, cache-read, and output rows plus a non-zero cache-write row, versus optional per-turn fields and model routes); a shared component would be conditionals around nine lines. The mirror is marked `jscpd:ignore` with the reason inline.
 
 ## Consequences
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
 
-- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、缓存写入、输出——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。
+- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。
 - **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍在输入框旁的 ContextMeter 圆环上,`StatsLine` 时代它就在那里——统计条从未承载过它。
 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
 - **`data-composer-stats` 是跨包属性契约。** pill 行根元素携带它;ui-conversation 的 `InputBar.module.css` 用 `:has([data-composer-stats])` 在该行挂载时把输入框底部留白收紧到 4px。生产方在单测里钉住该属性,沿用 `data-trigger-menu` 先例。
@@ -21,7 +21,7 @@ Status: implemented
 
 - **单行变体(StatsLine,A/B 落选方)。** 全部数字常驻一行文本,悬停提示只在截断时复述整行。败在拥挤与可达性:精确 token 计数无处可看(行内和提示里都只有紧凑总量),单行也无法给时间与计费数字分组。
 - **三个常驻分组共享一个弹层。** 中间迭代曾把计数、时间、token 保持为三个内联分组。双 pill 胜出是因为时间/用量的切分与两个底层投影一一对应,且每个 pill 的图标直接预告其弹层内容。
-- **抽出与 `TurnUsagePanel` 共享的 dl 分桶行。** 会话总量弹层与逐轮面板皮肤相同但契约不同(此处所有分桶恒在;逐轮字段可选且含模型路由);共享组件只是给九行代码包一层条件。该镜像以 `jscpd:ignore` 标注并内联说明理由。
+- **抽出与 `TurnUsagePanel` 共享的 dl 分桶行。** 会话总量弹层与逐轮面板皮肤相同但约定不同(会话输入、缓存读取与输出行始终存在,缓存写入行仅在非零时出现;逐轮字段可选且含模型路由);共享组件只是给九行代码包一层条件。该镜像以 `jscpd:ignore` 标注并内联说明理由。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md
-2026-09-09-sidebar-and-preview-interaction-polish.md: df0e768aa1aea913344c356d84c2a7d3386ccc6b
-2026-09-09-sidebar-and-preview-interaction-polish.zh.md: d3f8b00fd86ec12d867d48a917cd4a65cab18ba9
+2026-09-09-sidebar-and-preview-interaction-polish.md: eba8bd3a2314c7eaf18cd9982aa4e599665dbc62
+2026-09-09-sidebar-and-preview-interaction-polish.zh.md: 93d88d41e99f50621c80d69d615e39852b715c89

+ 3 - 3
.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md

@@ -18,7 +18,7 @@ Five small interaction defects around the right Sidebar and the document preview
 
 **Menus close on focus entering an iframe.** [Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) adds a window `blur` listener gated on `document.activeElement instanceof HTMLIFrameElement` — the focus move is the only signal a pointerdown inside a cross-origin iframe leaves, and the gate keeps app or tab switches from closing the list.
 
-**The code preview pins its banner and drops the card fill.** With wrap off, [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) sizes the renderer `max-content` so the sticky banner has the full scroll width to ride, and pins the banner `sticky; left: 0; width: 100cqw` against the document scroller (`container-type: inline-size` on the preview body). The shared CodeBlock's fill is routed through a new `--dsl-code-block-background` variable (default unchanged, so chat keeps its gray card) and the shared banner carries an inert `data-code-block-banner` hook; the preview sets the variable to `transparent` so code sits on the pane's own background.
+**The code preview separates its banner from scrolling source and drops the card fill.** The shared CodeBlock wraps its rendered source in a stable `data-code-block-content` node that defaults to `display: contents`, so existing consumers keep their layout. [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) materializes that node as a full-height inner scrollport below the banner; the code renderer reports it through a callback ref, so the document owner follows Slot replacement for position restoration, paging, and line navigation. The shared CodeBlock's fill is routed through `--dsl-code-block-background` (default unchanged, so chat keeps its gray card), while the preview sets it to `transparent` so code sits on the pane's own background.
 
 ## Alternatives considered
 
@@ -28,8 +28,8 @@ Five small interaction defects around the right Sidebar and the document preview
 
 **A bare window-blur close for menus.** Closes the list on every app or tab switch; the `activeElement` gate scopes the close to the one case the document cannot see.
 
-**An inner code scroller for the horizontal axis.** Restoring `overflow-x: auto` on the `pre` keeps the banner still, but puts the horizontal scrollbar at the bottom of the whole block — unreachable in a long file — and both axes deliberately live in the document owner's scroller.
+**Keep code in the shared document scroller.** A child banner cannot cover its parent's native scrollbar. Giving the stable source wrapper both scroll axes keeps the scrollbar at the visible viewport edge below the adjacent banner, rather than at the end of a long code block.
 
 ## Consequences
 
-`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable and `data-code-block-banner` attribute are the code block's owner-styling seam; no shared stylesheet rule targets the attribute. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules.
+`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable keeps chat's default gray card while the preview uses the pane background, and `data-code-block-content` lets an owner materialize a dedicated source viewport without changing other CodeBlock layouts. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; document-preview specs cover inner scrolling and line navigation; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules.

+ 3 - 3
.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 **焦点进入 iframe 时关闭菜单。**[Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) 增加 window `blur` 监听,以 `document.activeElement instanceof HTMLIFrameElement` 为门:焦点移动是跨源 iframe 内 pointerdown 留下的唯一信号,这道门也让应用或标签页切换不会误关列表。
 
-**代码预览钉住复制条并去掉卡片填充。**关闭折行时,[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 把渲染器设为 `max-content`,让吸附的复制条拥有完整滚动宽度可骑行,并以 `sticky; left: 0; width: 100cqw` 把它钉在文档滚动区上(预览正文设 `container-type: inline-size`)。共享 CodeBlock 的填充改经新变量 `--dsl-code-block-background`(默认值不变,会话保持灰色卡片),共享复制条带上惰性的 `data-code-block-banner` 钩子;预览把变量设为 `transparent`,代码于是坐在分栏自身的背景上。
+**代码预览把复制条与滚动源码分开,并去掉卡片填充。**共享 CodeBlock 用稳定的 `data-code-block-content` 节点包裹渲染后的源码;该节点默认使用 `display: contents`,因此既有消费者保持原布局。[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 将该节点实体化为复制条下方占满剩余高度的内部滚动区;代码渲染器通过 callback ref 报告该节点,使文档 owner 在 Slot 替换后仍能用当前节点恢复位置、分页和跳转代码行。共享 CodeBlock 的填充通过 `--dsl-code-block-background` 设置(默认值不变,会话保持灰色卡片),预览将它设为 `transparent`,代码因此直接使用分栏背景。
 
 ## Alternatives considered
 
@@ -28,8 +28,8 @@ Status: implemented
 
 **菜单用裸的 window blur 关闭。**每次应用或标签页切换都会关掉列表;`activeElement` 门把关闭收窄到父文档看不见的那一种情形。
 
-**代码横轴用内层滚动。**在 `pre` 上恢复 `overflow-x: auto` 能让复制条不动,但横向滚动条会落在整个代码块底部——长文件里够不着——而且两个轴本就有意放在文档 owner 的滚动区里。
+**让代码继续使用共享文档滚动区。**子级复制条无法覆盖父级的原生滚动条。让稳定的源码包装节点同时承载两个滚动轴,滚动条会停在复制条下方的可见视口边缘,而不是长代码块的末端。
 
 ## Consequences
 
-`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量与 `data-code-block-banner` 属性是代码块的 owner 定制接缝;共享样式表没有任何规则指向该属性。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。
+`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量保留 Chat 默认的灰色卡片,Preview 则使用分栏背景;`data-code-block-content` 允许 owner 实体化专用源码视口,而不改变其它 CodeBlock 布局。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;文档预览规格覆盖内部滚动与行跳转;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md
+2026-09-10-guide-start-page-and-stat-pill-refinements.md: 987e46fd597b11046843a943c60187fdb1a769d0
+2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md: 590f18a2963507a8918a0aca30997aac23c6e477

+ 29 - 0
.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md

@@ -0,0 +1,29 @@
+# Agent Note: Guide start page and stat pill refinements
+
+Status: implemented
+
+English | [中文](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)
+
+## Problem
+
+The right Sidebar's guide tab was a bare list of entry capsules: no visual anchor above them, a capsule could only say its title, and an entry whose type registered no glyph rendered with no icon at all, so a mixed list read as broken rather than sparse. Separately, the session token-usage dialog under the composer printed a `Cache write 0 tok` row for sessions that never wrote cache.
+
+## Decision
+
+**The guide is a compass over self-describing capsules.** This refines the guide contracts in [Right Sidebar tab types and navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) and [Sidebar text preview and file tree](2026-09-05-sidebar-text-preview-and-file-tree.md). [GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) draws a muted 56px compass hero over the entry capsules and no heading, as a browser start page shows its doors without a caption. [`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) gains an optional thunked `description` — read fresh on every render like `title`, so language changes need no re-registration. A capsule shows its description under the title only while the guide lists at most `MAX_DESCRIBED_ENTRIES` (4) entries; a longer list drops every description to stay light, so a type must stand on its title. The glyph rides the capsule's height: 22px beside a bare title, 26px beside two lines.
+
+**Icon-less entries fall back to a shipped cube placeholder.** The fallback is decided at the render site (`entry.icon ?? CubeGlyph`), not at registration, so every contributor — builtin or extension — gets it uniformly and a chain replacement of the body replaces the rule with it. `CubeGlyph` lives beside `CompassGlyph` in [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx): an isometric box in 1.1px straight strokes with rounded joins on `currentColor`, drawn on `--dsw-alias-label-tertiary` — one step quieter than a registered glyph's ink — to mark the slot as unclaimed. The files type registers a description and the shared folder glyph in [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx).
+
+**The session usage dialog drops the zero cache-write row.** This refines [Composer session stats](2026-09-07-composer-session-stats-pills.md). [StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) renders the `Cache write` row only when `cacheWriteTokens !== 0`, as the per-turn panel already drops its absent optional fields; the always-present buckets (input, cache read, output) keep their rows.
+
+## Alternatives considered
+
+**Register the cube in `ui-primitives`.** Its `icons/index.tsx` is the imported figma `ic_ds_*` set, and the cube has a single consumer; `CompassGlyph` set the precedent of package-local guide glyphs.
+
+**Default the icon at registration time.** A `?? default` inside the registry would hide the fallback from the body and make "registered no glyph" undetectable, losing the quieter placeholder ink; explicit render-site fallback keeps registrations honest.
+
+**Show `Cache write 0`.** A session on a provider that never writes cache would carry the row forever; zero here means "not a thing", not a measurement.
+
+## Consequences
+
+`description` is new pre-stable registry API; every consumer was updated (the files entry registers one). The 4-entry threshold is a shipped constant of the guide body, not configuration. Guide-body specs cover the placeholder (size and ink), the description threshold on both sides, and the registered-glyph path; chat-stats specs cover the dropped and present cache-write row. The `ui-sidebar-right` and `ui-sidebar-files` READMEs restate the guide rules.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 引导起始页与统计 pill 的细化
+
+Status: implemented
+
+[English](2026-09-10-guide-start-page-and-stat-pill-refinements.md) | 中文
+
+## Problem
+
+右侧边栏的引导 tab 只是一列光秃秃的入口胶囊:上方没有视觉锚点,胶囊只能显示标题,而类型没注册图标的入口干脆不画图标,混合列表看起来像坏了而不是稀疏。另外,输入框下方的会话 token 用量弹窗会给从未写入缓存的会话打印一行 `Cache write 0 tok`。
+
+## Decision
+
+**引导页是罗盘压着能自我说明的胶囊。** 本决议细化[右侧 Sidebar tab 类型与导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)和 [Sidebar 文本预览与文件树](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的引导页约定。[GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) 在入口胶囊上方画一枚弱化的 56px 罗盘,不加标题,如同浏览器起始页不给它的入口配说明文字。[`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) 新增可选的 thunk 化 `description`——与 `title` 一样每次渲染重新读取,语言切换无需重新注册。仅当引导页列出的入口不超过 `MAX_DESCRIBED_ENTRIES`(4)个时,胶囊才在标题下显示描述;更长的列表去掉所有描述以保持轻盈,所以类型必须靠标题立得住。图标随胶囊高度变化:单行标题旁 22px,两行旁 26px。
+
+**没有图标的入口回退到内置的立方体占位符。** 回退在渲染点决定(`entry.icon ?? CubeGlyph`)而不在注册时,因此每个贡献者——内置或扩展——得到统一的占位符,链式替换 body 时规则随之整体替换。`CubeGlyph` 与 `CompassGlyph` 一起放在 [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx):一只等距视角的盒子,1.1px 直线描边、圆角拼接、走 `currentColor`,用 `--dsw-alias-label-tertiary` 着色——比注册图标的墨色浅一档——标记这个槽位无人认领。files 类型在 [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx) 注册了描述和共享的文件夹图标。
+
+**会话用量弹窗去掉为零的缓存写入行。** 本决议细化[输入框下的会话统计](2026-09-07-composer-session-stats-pills.zh.md)。[StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) 仅当 `cacheWriteTokens !== 0` 时渲染 `Cache write` 行,与 per-turn 面板去掉缺失可选字段的做法一致;始终存在的桶(输入、缓存读取、输出)保留各自的行。
+
+## Alternatives considered
+
+**把立方体注册进 `ui-primitives`。** 其 `icons/index.tsx` 是导入的 figma `ic_ds_*` 集合,而立方体只有一个消费者;`CompassGlyph` 已开了引导图标包内自持的先例。
+
+**在注册时默认图标。** 注册表内部的 `?? default` 会对 body 隐藏回退,让"没注册图标"无法辨认,浅色占位墨色随之丢失;显式的渲染点回退让注册保持诚实。
+
+**显示 `Cache write 0`。** 用不写缓存的 provider 的会话会永远挂着这一行;这里的零意味着"没有这回事",不是一次测量。
+
+## Consequences
+
+`description` 是新的 pre-stable 注册表 API;所有消费者已同步更新(files 入口注册了一个)。4 个入口的阈值是引导 body 的内置常量,不是配置。guide-body 用例覆盖占位符(尺寸与墨色)、描述阈值两侧和已注册图标路径;chat-stats 用例覆盖缓存写入行的去除与保留。`ui-sidebar-right` 与 `ui-sidebar-files` 的 README 重述了引导页规则。

+ 20 - 9
apps/web/tests/document-preview.e2e.ts

@@ -240,6 +240,18 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const iframe = preview.locator('[data-html-preview]')
     await iframe.waitFor({ timeout: 15_000 })
     expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts')
+    expect(await iframe.evaluate((node) => {
+      const host = node.closest('[data-textpreview-body]')
+      if (!(host instanceof HTMLElement)) throw new Error('HTML preview body is unavailable')
+      const outer = host.getBoundingClientRect()
+      const frame = node.getBoundingClientRect()
+      return {
+        top: Math.round(frame.top - outer.top),
+        right: Math.round(outer.right - frame.right),
+        bottom: Math.round(outer.bottom - frame.bottom),
+        left: Math.round(frame.left - outer.left),
+      }
+    })).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
     const html = page.frameLocator('[data-html-preview]')
     await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 })
     await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
@@ -379,6 +391,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const highlightedLines = preview.locator('.shiki .line')
     await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES)
     const codeBlock = preview.locator('.md-code-block')
+    const codeScrollport = preview.locator('[data-code-block-content]')
     expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true')
     await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content))
       .not.toMatch(/^(?:none|normal)$/u)
@@ -397,33 +410,31 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const prefix = await highlightedLines.allTextContents()
     expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES))
     await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
-    await scrollForNextPage(body)
+    await scrollForNextPage(codeScrollport)
     await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length)
     const completed = await highlightedLines.allTextContents()
     expect(completed).toEqual(codeLines)
     await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
-    const scrollTop = await body.evaluate((node) => {
+    const scrollTop = await codeScrollport.evaluate((node) => {
       const target = Math.floor((node.scrollHeight - node.clientHeight) / 2)
       if (target <= 0) throw new Error('code fixture does not overflow the document body')
       node.scrollTop = target
       return target
     })
-    await expect.poll(() => body.evaluate((node) => {
-      const banner = node.querySelector('.md-code-block')?.firstElementChild
+    await expect.poll(() => codeScrollport.evaluate((node) => {
+      const codeBlock = node.parentElement
+      const banner = codeBlock?.firstElementChild
       const firstLine = node.querySelector('.shiki .line')
       if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line')
       const bounds = node.getBoundingClientRect()
       const clipTop = bounds.top + node.clientTop
       const bannerBounds = banner.getBoundingClientRect()
-      const hit = document.elementFromPoint(bounds.left + node.clientLeft + node.clientWidth / 2, clipTop + 1)
       return {
         scrollTop: node.scrollTop,
-        position: getComputedStyle(banner).position,
-        topGap: bannerBounds.top - clipTop,
+        scrollportBelowBanner: Math.abs(bannerBounds.bottom - bounds.top) < 1,
         firstLineAbove: firstLine.getBoundingClientRect().top < clipTop,
-        topCoveredByBanner: hit !== null && banner.contains(hit),
       }
-    })).toEqual({ scrollTop, position: 'sticky', topGap: 0, firstLineAbove: true, topCoveredByBanner: true })
+    })).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
     await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
     await page.evaluate(() => navigator.clipboard.writeText(''))
     await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()

+ 1 - 1
apps/web/tests/sidebar-right.e2e.ts

@@ -1061,7 +1061,7 @@ describe('web e2e: shipped right Sidebar', () => {
         expect(await width(column)).toBeGreaterThan(300)
         await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始'])
         await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText())
-          .toBe('工作区文件')
+          .toBe('工作区文件\n浏览会话工作区的文件')
         await shot(zhPage, '05-guide-copy-zh')
 
         expect(zhTripwire.pageErrors).toEqual([])

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
-sidebar-right.md: 807edc5873c97a256cb66923e776b0573af3186f
-sidebar-right.zh.md: a56ebe819e8fbc63f5e6aa7697eb08e03b8d1c46
+sidebar-right.md: 3b6e5eb08fc2c0a0bac74369bde4a48b2ee0fdb6
+sidebar-right.zh.md: 680e2ae16f8f8aeeea57b11172be8c8c9e359122

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

@@ -43,7 +43,7 @@ Tab identity is the pair `(kind, address)`: the registry's claim uses the addres
 | `priority` | One of three literal bands: `extension` (the default and the highest: a type from outside the product outranks every shipped viewer), `builtin` (types shipped with the product), `fallback` (plain-content viewers anything more specific should beat). |
 | `canOpen(address)` | Optional synchronous veto of a glob match; it runs on every routing decision. |
 | `title(address)` | The chip's text, captured into the layout record when the tab opens and never rewritten. |
-| `guide` | Optional entry boxes for the guide page: `{ order, title(), description(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. |
+| `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. |
 
 Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error.
 
@@ -130,7 +130,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name
 
 ## Shipped types
 
-- **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
+- **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
 - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
 - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
 

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

@@ -43,7 +43,7 @@ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用
 | `priority` | 三档字面量之一:`extension`(缺省且最高:产品之外的类型压过所有内置查看器)、`builtin`(随产品发布的类型)、`fallback`(任何更具体的类型都应压过的纯内容查看器)。 |
 | `canOpen(address)` | 可选的同步否决,对 glob 命中生效;每次路由决策都会调用。 |
 | `title(address)` | chip 文本,在 tab 打开时捕获进布局记录,之后不再改写。 |
-| `guide` | 可选的引导页入口框:`{ order, title(), description(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 |
+| `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 |
 
 路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。
 
@@ -130,7 +130,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
 
 ## 内置类型
 
-- **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
+- **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
 

+ 10 - 4
packages/client/ui-chat/src/client/chat/StatsPills.tsx

@@ -282,8 +282,10 @@ function UsagePill({ usage, t, dialog }: {
           </div>
           <div className={dialogCss.titleRule} aria-hidden />
           {/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
-              TurnUsagePanel's per-turn dl: same skin, different data contract (all
-              buckets always present here; per-turn fields are optional). */}
+              TurnUsagePanel's per-turn dl: same skin, different data contract (the
+              buckets are always present here; per-turn fields are optional). A
+              session that never wrote cache drops the row, as the per-turn panel
+              drops its absent fields. */}
           <dl className={dialogCss.details} data-session-stats-usage>
             {cacheHit !== null && (
               <>
@@ -295,8 +297,12 @@ function UsagePill({ usage, t, dialog }: {
             <dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
             <dt>{t('message.turnUsage.cacheRead')}</dt>
             <dd>{exactCount(usage.cacheReadTokens, t)}</dd>
-            <dt>{t('message.turnUsage.cacheWrite')}</dt>
-            <dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
+            {usage.cacheWriteTokens !== 0 && (
+              <>
+                <dt>{t('message.turnUsage.cacheWrite')}</dt>
+                <dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
+              </>
+            )}
             <dt>{t('message.turnUsage.output')}</dt>
             <dd>{exactCount(usage.outputTokens, t)}</dd>
           </dl>

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

@@ -257,7 +257,8 @@ describe('StatsPills', () => {
     expect(tokens.textContent).toContain('Cache hit90%')
     expect(tokens.textContent).toContain('Uncached input10 tok')
     expect(tokens.textContent).toContain('Cached input90 tok')
-    expect(tokens.textContent).toContain('Cache write0 tok')
+    // A session that never wrote cache drops the row rather than showing 0.
+    expect(tokens.textContent).not.toContain('Cache write')
     expect(tokens.textContent).toContain('Output5 tok')
     // The time split lives on the counts pill's own dialog, not here.
     expect(dialog.textContent).not.toContain('LLM time')
@@ -428,6 +429,9 @@ describe('StatsPills', () => {
       },
     })} />)
     expect(view.getAllByRole('button')[0]!.textContent).toBe('207 tok·Cache hit 45%')
+    // A session that did write cache keeps the row, exact.
+    fireEvent.click(view.getAllByRole('button')[0]!)
+    expect(view.getByRole('dialog').textContent).toContain('Cache write100 tok')
   })
 
   it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md
-README.md: 41100530b77f9776c768010b53ecbc6ad67b5902
-README.zh.md: 237ce5f0aedf1124e4dc3a214bf10529dea1703f
+README.md: f41668ad2eb6c16e13deb5fcc554c8717811a468
+README.zh.md: f92828b99ef5678458614261304c05d382bc34f4

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

@@ -52,7 +52,7 @@ Everything host-specific arrives through props:
 
 `DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
 
-`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
+`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
 
 A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end.
 

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

@@ -52,7 +52,7 @@ kind: "package-reference"
 
 `DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
 
-`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
+`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
 
 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。
 

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

@@ -509,6 +509,7 @@
 .dockScrim {
   position: absolute;
   inset: 0;
+  z-index: 10;
   pointer-events: none;
   /* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2`
      is a lighter bluish step than the column's `bg-base`, which tinted the
@@ -520,6 +521,7 @@
 
 .dockHint {
   position: absolute;
+  z-index: 10;
   display: flex;
   box-sizing: border-box;
   padding: 8px;

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: aebb0be860ebaba4543a8fd262599507ead64556
-README.zh.md: a545aff9837036890b6250ab1aa32fb651af0a18
+README.md: 1919e923c6edfae58e38849996f3979723198075
+README.zh.md: e0c90fd14a284aac6a2420f5974eef6ad78138c7

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

@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. |
+| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. |
 | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |

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

@@ -52,7 +52,7 @@ kind: "package-library"
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 |
+| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 |
 | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |

+ 6 - 0
packages/client/ui-primitives/src/markdown/CodeBlock.module.css

@@ -69,6 +69,12 @@
   font: inherit;
 }
 
+/* Consumers may turn the stable content node into a viewport without changing
+   the default CodeBlock layout. */
+.content {
+  display: contents;
+}
+
 .block :where(pre) {
   font: var(--dsl-code-block-content-font);
   padding: 16px;

+ 6 - 4
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -1,5 +1,5 @@
 import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
-import type { CSSProperties, ReactNode } from 'react'
+import type { CSSProperties, ReactNode, Ref } from 'react'
 import clsx from 'clsx'
 import { writeClipboard } from '../clipboard.ts'
 import {
@@ -25,6 +25,8 @@ export interface CodeBlockProps {
   streaming?: boolean | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined
+  /** Ref for the stable source-content wrapper, for owners that use it as a scrollport. */
+  contentRef?: Ref<HTMLDivElement> | undefined
   /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   lineNumbers?: boolean | undefined
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
@@ -59,7 +61,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
   )
 }
 
-export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
+export function CodeBlock({ code, lang, streaming, className, contentRef, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
@@ -177,8 +179,8 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
       style={sourceLines === undefined ? undefined : {
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
       } as CSSProperties}>
+      {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
       <div className={css.bannerWrap}>
-        {/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */}
         <div className={css.banner} data-code-block-banner>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.action}>
@@ -188,7 +190,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
           </div>
         </div>
       </div>
-      {body}
+      <div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
     </div>
   )
 }

+ 11 - 0
packages/client/ui-primitives/tests/code-block.client.spec.tsx

@@ -59,6 +59,17 @@ describe('highlightToHtml', () => {
 })
 
 describe('CodeBlock', () => {
+  it('reports the stable source-content wrapper to its owner', () => {
+    const contentRef = vi.fn<(node: HTMLDivElement | null) => void>()
+    const view = render(<CodeBlock code="plain text" contentRef={contentRef} />)
+    const content = view.container.querySelector('[data-code-block-content]')
+    expect(contentRef).toHaveBeenCalledWith(content)
+    view.rerender(<CodeBlock code="updated text" contentRef={contentRef} />)
+    expect(view.container.querySelector('[data-code-block-content]')).toBe(content)
+    view.unmount()
+    expect(contentRef).toHaveBeenLastCalledWith(null)
+  })
+
   it('renders the highlighted tree for TypeScript', () => {
     const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
     const pre = view.container.querySelector('pre.shiki')

+ 42 - 37
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt

@@ -7,22 +7,23 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <div>
-      <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
-        <code>
-          <span class="line">
-            <span style="color:var(--shiki-token-keyword)">
-              #text "const"
-            <span style="color:var(--shiki-token-constant)">
-              #text " answer"
-            <span style="color:var(--shiki-token-keyword)">
-              #text ":"
-            <span style="color:var(--shiki-token-constant)">
-              #text " number"
-            <span style="color:var(--shiki-token-keyword)">
-              #text " ="
-            <span style="color:var(--shiki-token-constant)">
-              #text " 42"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <div>
+        <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
+          <code>
+            <span class="line">
+              <span style="color:var(--shiki-token-keyword)">
+                #text "const"
+              <span style="color:var(--shiki-token-constant)">
+                #text " answer"
+              <span style="color:var(--shiki-token-keyword)">
+                #text ":"
+              <span style="color:var(--shiki-token-constant)">
+                #text " number"
+              <span style="color:var(--shiki-token-keyword)">
+                #text " ="
+              <span style="color:var(--shiki-token-constant)">
+                #text " 42"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -30,9 +31,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "no language"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "no language"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -41,9 +43,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "plain fallback"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "plain fallback"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -52,18 +55,19 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <div>
-      <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
-        <code>
-          <span class="line">
-            <span style="color:var(--shiki-token-keyword)">
-              #text "const"
-            <span style="color:var(--shiki-token-constant)">
-              #text " withMeta"
-            <span style="color:var(--shiki-token-keyword)">
-              #text " ="
-            <span style="color:var(--shiki-token-constant)">
-              #text " true"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <div>
+        <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
+          <code>
+            <span class="line">
+              <span style="color:var(--shiki-token-keyword)">
+                #text "const"
+              <span style="color:var(--shiki-token-constant)">
+                #text " withMeta"
+              <span style="color:var(--shiki-token-keyword)">
+                #text " ="
+              <span style="color:var(--shiki-token-constant)">
+                #text " true"
   <pre>
     <code>
   <div class="_block_9aea57 md-code-block">
@@ -73,6 +77,7 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "indented code block\nsecond line"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "indented code block\nsecond line"

+ 40 - 35
packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt

@@ -7,21 +7,22 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
-      <code>
-        <span class="line">
-          <span style="color: var(--shiki-token-keyword);">
-            #text "const"
-          <span style="color: var(--shiki-token-constant);">
-            #text " answer"
-          <span style="color: var(--shiki-token-keyword);">
-            #text ":"
-          <span style="color: var(--shiki-token-constant);">
-            #text " number"
-          <span style="color: var(--shiki-token-keyword);">
-            #text " ="
-          <span style="color: var(--shiki-token-constant);">
-            #text " 42"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
+        <code>
+          <span class="line">
+            <span style="color: var(--shiki-token-keyword);">
+              #text "const"
+            <span style="color: var(--shiki-token-constant);">
+              #text " answer"
+            <span style="color: var(--shiki-token-keyword);">
+              #text ":"
+            <span style="color: var(--shiki-token-constant);">
+              #text " number"
+            <span style="color: var(--shiki-token-keyword);">
+              #text " ="
+            <span style="color: var(--shiki-token-constant);">
+              #text " 42"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -29,9 +30,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "no language"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "no language"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -40,9 +42,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "plain fallback"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "plain fallback"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -51,17 +54,18 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
-      <code>
-        <span class="line">
-          <span style="color: var(--shiki-token-keyword);">
-            #text "const"
-          <span style="color: var(--shiki-token-constant);">
-            #text " withMeta"
-          <span style="color: var(--shiki-token-keyword);">
-            #text " ="
-          <span style="color: var(--shiki-token-constant);">
-            #text " true"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
+        <code>
+          <span class="line">
+            <span style="color: var(--shiki-token-keyword);">
+              #text "const"
+            <span style="color: var(--shiki-token-constant);">
+              #text " withMeta"
+            <span style="color: var(--shiki-token-keyword);">
+              #text " ="
+            <span style="color: var(--shiki-token-constant);">
+              #text " true"
   <pre>
     <code>
   <div class="_block_9aea57 md-code-block">
@@ -71,6 +75,7 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "indented code block\nsecond line"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "indented code block\nsecond line"

+ 21 - 19
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.settled.txt

@@ -6,9 +6,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "kept blank line follows\n"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "kept blank line follows\n"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -17,21 +18,22 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <div>
-      <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
-        <code>
-          <span class="line">
-            <span style="color:var(--shiki-token-keyword)">
-              #text "const"
-            <span style="color:var(--shiki-token-constant)">
-              #text " doubled"
-            <span style="color:var(--shiki-token-keyword)">
-              #text " ="
-            <span style="color:var(--shiki-token-constant)">
-              #text " true"
-          #text "\n"
-          <span class="line">
-          #text "\n"
-          <span class="line">
+    <div class="_content_9aea57" data-code-block-content="true">
+      <div>
+        <pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
+          <code>
+            <span class="line">
+              <span style="color:var(--shiki-token-keyword)">
+                #text "const"
+              <span style="color:var(--shiki-token-constant)">
+                #text " doubled"
+              <span style="color:var(--shiki-token-keyword)">
+                #text " ="
+              <span style="color:var(--shiki-token-constant)">
+                #text " true"
+            #text "\n"
+            <span class="line">
+            #text "\n"
+            <span class="line">
   <p>
     #text "after"

+ 20 - 18
packages/client/ui-primitives/tests/fixtures/markdown-dom/fence-trailing-blank-lines.streaming.txt

@@ -6,9 +6,10 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "kept blank line follows\n"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "kept blank line follows\n"
   <div class="_block_9aea57 md-code-block">
     <div class="_bannerWrap_9aea57">
       <div class="_banner_9aea57" data-code-block-banner="true">
@@ -17,20 +18,21 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
-      <code>
-        <span class="line">
-          <span style="color: var(--shiki-token-keyword);">
-            #text "const"
-          <span style="color: var(--shiki-token-constant);">
-            #text " doubled"
-          <span style="color: var(--shiki-token-keyword);">
-            #text " ="
-          <span style="color: var(--shiki-token-constant);">
-            #text " true"
-        #text "\n"
-        <span class="line">
-        #text "\n"
-        <span class="line">
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
+        <code>
+          <span class="line">
+            <span style="color: var(--shiki-token-keyword);">
+              #text "const"
+            <span style="color: var(--shiki-token-constant);">
+              #text " doubled"
+            <span style="color: var(--shiki-token-keyword);">
+              #text " ="
+            <span style="color: var(--shiki-token-constant);">
+              #text " true"
+          #text "\n"
+          <span class="line">
+          #text "\n"
+          <span class="line">
   <p>
     #text "after"

+ 4 - 3
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt

@@ -39,6 +39,7 @@
             <div class="_action_9aea57">
               <button class="_copyButton_9aea57" type="button">
                 #text "复制"
-        <pre class="_plain_9aea57">
-          <code>
-            #text "fenced inside list"
+        <div class="_content_9aea57" data-code-block-content="true">
+          <pre class="_plain_9aea57">
+            <code>
+              #text "fenced inside list"

+ 4 - 3
packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt

@@ -39,6 +39,7 @@
             <div class="_action_9aea57">
               <button class="_copyButton_9aea57" type="button">
                 #text "复制"
-        <pre class="_plain_9aea57">
-          <code>
-            #text "fenced inside list"
+        <div class="_content_9aea57" data-code-block-content="true">
+          <pre class="_plain_9aea57">
+            <code>
+              #text "fenced inside list"

+ 4 - 3
packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt

@@ -25,6 +25,7 @@
         <div class="_action_9aea57">
           <button class="_copyButton_9aea57" type="button">
             #text "复制"
-    <pre class="_plain_9aea57">
-      <code>
-        #text "\\sqrt{2}"
+    <div class="_content_9aea57" data-code-block-content="true">
+      <pre class="_plain_9aea57">
+        <code>
+          #text "\\sqrt{2}"

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

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

+ 5 - 5
packages/client/ui-sidebar-documentpreview/README.md

@@ -27,10 +27,10 @@ Preview readable files in the right Sidebar and choose among registered renderer
 ## What it registers
 
 - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land 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, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
-- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
 - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
 
-Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations.
+Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations.
 
 <a id="addresses"></a>
 ## Addresses
@@ -47,18 +47,18 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
 - **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array<ArrayBuffer>` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
 - **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice.
 
-HTML runs in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
+HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
 
 PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context. The image keeps its intrinsic CSS-pixel dimensions; a smaller image centres in the shared scroller, and larger dimensions scroll on either axis. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
 
 Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
 
-Initial reads, additional pages, and HTML/PDF/image preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill, and with wrap off both axes scroll in the shared document scroller while the copy banner stays pinned to the visible width.
+Initial reads, additional pages, and HTML/PDF/image preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
 
 <a id="navigation"></a>
 ## Navigation
 
-`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation places the target below its sticky toolbar. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
+`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation scrolls the inner source viewport directly. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
 
 <a id="model-experience"></a>
 ## Model Experience

+ 5 - 5
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -27,10 +27,10 @@ kind: "package-reference"
 ## 注册了什么
 
 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
-- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。
+- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
 
-文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
+文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
 
 <a id="addresses"></a>
 ## 地址
@@ -47,18 +47,18 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
 - **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
 - **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。
 
-HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
+HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
 
 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染。图片保持固有 CSS 像素尺寸;小图在共享滚动区内居中,大图可沿任一轴滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
 
 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
 
-首次读取、追加页及 HTML/PDF/图片准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;关闭折行时两个轴都在共享文档滚动区滚动,复制条钉在可视宽度内不随横向滚动偏移。
+首次读取、追加页及 HTML/PDF/图片准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
 
 <a id="navigation"></a>
 ## 导航
 
-`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航会把目标放在置顶工具栏下方。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
+`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
 
 <a id="model-experience"></a>
 ## 模型体验

+ 19 - 20
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css

@@ -1,6 +1,6 @@
-/* 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. */
+/* The preview takes the pane's definite height outright, so the header stays
+   put while the body below owns the remaining height. A renderer may replace
+   the body's scrolling role with an inner scrollport. */
 .preview {
   display: flex;
   flex: 1 1 auto;
@@ -81,13 +81,9 @@
   position: relative;
   flex: 1 1 auto;
   min-height: 0;
-  /* The files tree's inset and 2px scrollbar offset (ui-sidebar-files
-     FilesBody `.body`), so the two bodies start their text at one edge and
-     both bars sit 2px clear of the pane's edge. */
-  margin-right: 2px;
-  padding: 8px 0 8px 8px;
+  margin: 0;
+  padding: 0;
   overflow: auto;
-  scrollbar-gutter: stable;
   /* 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);
@@ -99,27 +95,30 @@
   white-space: pre;
 }
 
-/* The bar also stops 2px short of the body's ends, matching its 2px edge
-   offset (ui-sidebar-files FilesBody keeps the same rule). WebKit-only: the
+/* Keep the scrollbar track 2px clear of the body's ends. WebKit-only: the
    Firefox path has no track to inset. */
 .body::-webkit-scrollbar-track {
   margin: 2px;
 }
 
+.body:has([data-code-preview]) {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
 .wrap {
   white-space: pre-wrap;
   word-break: break-word;
 }
 
-/* Sticky code banners meet the scrollport edge; prose owns its initial spacing. */
-.body:has([data-code-preview], [data-document-markdown]) {
-  padding: 0;
-}
-
-/* The code renderer's banner sizes itself to this scroller's visible width
-   (100cqw in CodeBody.module.css); the container makes that width queryable. */
-.body:has([data-code-preview]) {
-  container-type: inline-size;
+/* Plain text keeps the shared document inset while edge-to-edge renderers can
+   fill the scroller themselves. */
+.textDocument {
+  box-sizing: border-box;
+  min-width: 100%;
+  min-height: 100%;
+  padding: 8px;
 }
 
 /* One page of lines; pages abut so the file reads as one. */

+ 26 - 10
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -10,7 +10,7 @@
  * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of
  * the path row; the Sidebar's strip carries none of them.
  */
-import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import type { ReactNode, RefObject } from 'react'
 import clsx from 'clsx'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -94,6 +94,8 @@ export function TextPreview({
   const mode = selected?.loading
   const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
+  const scrollportRef = useRef<HTMLElement | null>(null)
+  const storedScrollTopRef = useRef(0)
   const pathRef = useRef<HTMLDivElement | null>(null)
   const pathTextRef = useRef<HTMLSpanElement | null>(null)
   const [menuOpen, setMenuOpen] = useState(false)
@@ -106,6 +108,17 @@ export function TextPreview({
   const loaded = useMemo(() => loadedPages(pages ?? {}), [pages])
   const loadedThrough = lastLineLoaded(loaded)
   const hasContent = loaded.length > 0 || current?.complete !== undefined
+  storedScrollTopRef.current = state?.scrollTop ?? 0
+  const bindBody = useCallback((body: HTMLDivElement | null): void => {
+    const previous = bodyRef.current
+    bodyRef.current = body
+    if (scrollportRef.current === null || scrollportRef.current === previous) scrollportRef.current = body
+  }, [])
+  const bindScrollport = useCallback((scrollport: HTMLElement | null): void => {
+    const next = scrollport ?? bodyRef.current
+    scrollportRef.current = next
+    if (next !== null) next.scrollTop = storedScrollTopRef.current
+  }, [])
 
   // First mount reads the first page; a body coming back to a tab with content
   // reads nothing, because the store outlives the body.
@@ -117,19 +130,19 @@ export function TextPreview({
   }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, meta.value?.version])
 
   // Come back where the reader was once there is content to scroll: on a remount,
-  // and after a reload rebuilt the content. Keyed on content presence only, so a
-  // scroll write never re-lands.
+  // after a reload rebuilt the content, or after the selected renderer changed.
+  // Scroll writes preserve both identities, so they never re-land.
   useEffect(() => {
-    const body = bodyRef.current
+    const body = scrollportRef.current
     if (hasContent && body !== null && state !== undefined) body.scrollTop = state.scrollTop
-  }, [hasContent])
+  }, [hasContent, selected?.id])
 
   // 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
+    const body = scrollportRef.current
     if (current === undefined || body === null || current.revision === navigation.revision) return
     if (line === undefined || mode !== 'text-pages') {
       actions.navigated(tab.id, navigation.revision)
@@ -267,12 +280,15 @@ export function TextPreview({
         </Tooltip>
       </div>
       <div
-        ref={bodyRef}
+        ref={bindBody}
         className={clsx(css.body, state.wrap && css.wrap)}
         data-textpreview-body
         data-textpreview-wrap={state.wrap ? '' : undefined}
-        onScroll={(event) => {
-          const body = event.currentTarget
+        onScrollCapture={(event) => {
+          const body = scrollportRef.current
+          /* v8 ignore next -- callback refs bind the scrollport during commit, before user input. */
+          if (body === null) return
+          if (event.target !== body) return
           actions.scrolled(tab.id, body.scrollTop)
           if (mode === 'text-pages' && current?.failure === undefined && body.clientHeight > 0
             && body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext()
@@ -282,7 +298,7 @@ export function TextPreview({
           <LoadingIndicator className={css.statusLine} label={t('loading')} />
         )}
         {content !== undefined && renderSlot('sidebar.right.tab.document', {
-          resourceAddress: tab.contentId, content, wrap: state.wrap,
+          resourceAddress: tab.contentId, content, wrap: state.wrap, scrollportRef: bindScrollport,
         }, {
           entryKey: selected.id, hookContext: useTabInfo,
           fallback: <p className={css.statusLine}>{t('rendererUnavailable', { name: selected.title() })}</p>,

+ 27 - 22
packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css

@@ -1,6 +1,12 @@
 .renderer {
-  min-height: 100%;
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
   min-width: 0;
+  min-height: 0;
+  overflow: hidden;
   white-space: normal;
 }
 
@@ -10,13 +16,31 @@
   /* In a sidebar pane the code sits on the pane's own background; the chat
      card's gray fill stays a chat-only default. */
   --dsl-code-block-background: transparent;
-  /* Source lines resolve their offset against the document owner's scroller. */
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
   position: static;
   margin: 0;
   min-width: 0;
 }
 
-/* Both axes scroll in the document owner, not in an inner code window. */
+/* The banner is the scrollport's sibling, so the source scrollbar starts
+   below it. */
+.renderer .code > [data-code-block-content] {
+  position: relative;
+  display: block;
+  flex: 1 1 auto;
+  min-width: 0;
+  min-height: 0;
+  overflow: auto;
+}
+
+.renderer .code > [data-code-block-content]::-webkit-scrollbar-track {
+  margin: 2px;
+}
+
 .renderer .code pre {
   box-sizing: border-box;
   min-width: 100%;
@@ -26,25 +50,6 @@
   overflow-wrap: normal;
 }
 
-/* Unwrapped long lines widen the document owner's scroller past its viewport;
-   sizing the card to that full scroll width gives the sticky banner room to
-   ride horizontal scrolling. Wrap mode keeps the default block width:
-   max-content would defeat soft wrapping. */
-.renderer[data-wrap='false'] {
-  width: max-content;
-  min-width: 100%;
-}
-
-/* The banner sticks to the scrollport's left edge at the owner's visible
-   width (cqw resolves against the owner's .body container), so the copy
-   control stays in view while the card scrolls horizontally. */
-.renderer[data-wrap='false'] .code [data-code-block-banner] {
-  position: sticky;
-  left: 0;
-  box-sizing: border-box;
-  width: 100cqw;
-}
-
 .renderer[data-wrap='true'] .code {
   --dsl-code-block-line-white-space: pre-wrap;
 }

+ 2 - 1
packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.tsx

@@ -12,7 +12,7 @@ import css from './CodeBody.module.css'
 export type CodeBodyProps = DocumentPreviewProps & PropsLocale<'sidebarCodePreview'>
 
 /** @param props - accumulated document contents and framework props. @returns one stable CodeBlock, or no body for byte contents. */
-export function CodeBody({ resourceAddress, content, wrap, t }: CodeBodyProps): ReactNode {
+export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: CodeBodyProps): ReactNode {
   if (content.kind !== 'text') return null
   const file = parseFileAddress(resourceAddress)
   if (file === undefined) throw new Error(`ui-sidebar-documentpreview: not a file address "${resourceAddress}"`)
@@ -21,6 +21,7 @@ export function CodeBody({ resourceAddress, content, wrap, t }: CodeBodyProps):
     <div className={css.renderer} data-code-preview data-wrap={wrap}>
       <CodeBlock
         className={css.code}
+        contentRef={scrollportRef}
         code={content.text}
         lang={language}
         streaming={!content.eof}

+ 3 - 0
packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts

@@ -1,6 +1,7 @@
 /** Document renderer slot: the owner supplies shared file state, renderers own their presentation. */
 import type { PropsRuntime, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots'
 import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { RefCallback } from 'react'
 
 /** One loaded text window, retaining source line positions. */
 export interface DocumentTextPage {
@@ -30,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
         readonly content: DocumentContent
         /** The document toolbar's current wrapping preference. */
         readonly wrap: boolean
+        /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */
+        readonly scrollportRef: RefCallback<HTMLElement>
       }
       hookContext: UseSidebarRightTabInfo
       inject: {

+ 1 - 0
packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css

@@ -5,6 +5,7 @@
   min-width: 100%;
   height: max-content;
   min-height: 100%;
+  padding: 8px;
   font-family: var(--dsw-font, sans-serif);
   white-space: normal;
 }

+ 2 - 0
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css

@@ -1,8 +1,10 @@
 .body {
   display: flex;
   flex-direction: column;
+  box-sizing: border-box;
   width: 100%;
   min-height: 0;
+  padding: 8px;
   overflow-x: hidden;
   font-family: var(--dsw-font, sans-serif);
   white-space: normal;

+ 21 - 17
packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx

@@ -11,21 +11,25 @@ export function TextBody({ content, useTabInfo }: DocumentPreviewProps): ReactNo
   const params = tab.navigation.params
   const target = params !== undefined && 'line' in params ? params.line : undefined
   if (content.kind !== 'text') return null
-  return content.pages.map(page => (
-    <pre key={page.offset} className={css.page} data-textpreview-page={page.offset}>
-      {linesOf(page).map((text, index) => {
-        const number = page.offset + index
-        return (
-          <div
-            key={number}
-            className={clsx(css.line, number === target && css.lineTarget)}
-            data-textpreview-line={number}
-            {...number === target ? { 'data-textpreview-target': number } : {}}
-          >
-            {text}{'\n'}
-          </div>
-        )
-      })}
-    </pre>
-  ))
+  return (
+    <div className={css.textDocument} data-textpreview-plain>
+      {content.pages.map(page => (
+        <pre key={page.offset} className={css.page} data-textpreview-page={page.offset}>
+          {linesOf(page).map((text, index) => {
+            const number = page.offset + index
+            return (
+              <div
+                key={number}
+                className={clsx(css.line, number === target && css.lineTarget)}
+                data-textpreview-line={number}
+                {...number === target ? { 'data-textpreview-target': number } : {}}
+              >
+                {text}{'\n'}
+              </div>
+            )
+          })}
+        </pre>
+      ))}
+    </div>
+  )
 }

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

@@ -37,18 +37,16 @@ export function lastLineLoaded(pages: readonly LoadedPage[]): number {
 
 /**
  * Reveal a plain-text or highlighted source line.
- * @param body - scrolling document body.
+ * @param body - scrolling document body or code-content viewport.
  * @param line - 1-based source line to reveal.
  * @returns Whether the current renderer exposes that line.
  */
 export function scrollToLine(body: HTMLElement, line: number): boolean {
+  const innerCode = body.hasAttribute('data-code-block-content')
   const plain = body.querySelector(`[data-textpreview-line="${line}"]`)
-  const code = body.querySelectorAll('[data-code-preview] pre .line').item(line - 1)
+  const code = innerCode ? body.querySelectorAll('pre .line').item(line - 1) : null
   const row = plain ?? code
   if (!(row instanceof HTMLElement)) return false
-  const codeBlock = code === row ? row.closest<HTMLElement>('.md-code-block') : null
-  const toolbar = codeBlock?.firstElementChild
-  const toolbarHeight = toolbar instanceof HTMLElement ? toolbar.offsetHeight : 0
-  body.scrollTop = Math.max(0, row.offsetTop - toolbarHeight)
+  body.scrollTop = Math.max(0, row.offsetTop)
   return true
 }

+ 7 - 2
packages/client/ui-sidebar-documentpreview/tests/code-body.client.spec.tsx

@@ -64,6 +64,7 @@ function installStyles(): void {
     .replaceAll('.renderer', `.${css.renderer}`).replaceAll('.code', `.${css.code}`)
   const shared = readFileSync(resolve(directory, '../../ui-primitives/src/markdown/CodeBlock.module.css'), 'utf8')
     .replaceAll('.block', `.${primitiveCss.block}`)
+    .replaceAll('.content', `.${primitiveCss.content}`)
   stylesheet = document.createElement('style')
   // The shared defaults load last to prove the renderer's selectors override them.
   stylesheet.textContent = `${own}\n${shared}`
@@ -147,21 +148,25 @@ describe('CodeBody', () => {
     expect(view.container.querySelector('.md-code-block')).toBeNull()
   })
 
-  it('switches wrapping without truncating text, remounting the highlighter, or taking over scrolling', () => {
+  it('keeps a stable inner scrollport while switching wrapping without remounting the highlighter', () => {
     installStyles()
     const code = `const identifier = "${'x'.repeat(300)}";`
     const content = contents([code])
     const view = render(<CodeBody {...props(content)} />)
     const pre = element(view.container, '.shiki')
     const block = element(view.container, '.md-code-block')
+    const scrollport = element(view.container, '[data-code-block-content]')
     expect(getComputedStyle(block).marginTop).toBe('0px')
     expect(getComputedStyle(block).position).toBe('static')
-    expect(getComputedStyle(element(view.container, '[data-code-preview]')).minHeight).toBe('100%')
+    expect(getComputedStyle(element(view.container, '[data-code-preview]')).height).toBe('100%')
+    expect(getComputedStyle(scrollport).display).toBe('block')
+    expect(getComputedStyle(scrollport).overflow).toBe('auto')
     expect(getComputedStyle(pre).whiteSpace).toBe('pre')
     expect(getComputedStyle(pre).overflow).toBe('visible')
     expect(getComputedStyle(pre).wordBreak).toBe('normal')
     view.rerender(<CodeBody {...props(content, { wrap: true })} />)
     expect(element(view.container, '.shiki')).toBe(pre)
+    expect(element(view.container, '[data-code-block-content]')).toBe(scrollport)
     expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
     expect(getComputedStyle(pre).overflowWrap).toBe('anywhere')
     expect(getComputedStyle(pre).overflow).toBe('visible')

+ 1 - 0
packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx

@@ -54,6 +54,7 @@ describe('Markdown implementation registration', () => {
       resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md',
       content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true },
       wrap: false,
+      scrollportRef: vi.fn(),
     }, { entryKey: MARKDOWN_BODY_ID, hookContext: useTabInfo, fallback: <span data-missing-markdown /> }))
     const view = runtime.renderRoot()
     expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined()

+ 37 - 12
packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

@@ -23,11 +23,9 @@ import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
 import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
 
 const LINE_HEIGHT = 20
-const CODE_TOOLBAR_HEIGHT = 36
 
 const originals = {
   offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop'),
-  offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'),
   scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
 }
 
@@ -47,13 +45,6 @@ beforeAll(() => {
     get(this: HTMLElement & { __scrollTop?: number }) { return this.__scrollTop ?? 0 },
     set(this: HTMLElement & { __scrollTop?: number }, value: number) { this.__scrollTop = value },
   })
-  Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
-    configurable: true,
-    get(this: HTMLElement) {
-      return this.parentElement?.classList.contains('md-code-block') === true
-        && this.parentElement.firstElementChild === this ? CODE_TOOLBAR_HEIGHT : 0
-    },
-  })
 })
 
 afterAll(() => {
@@ -107,6 +98,10 @@ function body(container: HTMLElement): HTMLElement {
   return element
 }
 
+function scrollport(container: HTMLElement): HTMLElement {
+  return container.querySelector<HTMLElement>('[data-code-block-content]') ?? body(container)
+}
+
 function lines(container: HTMLElement): string[] {
   return Array.from(container.querySelectorAll('[data-textpreview-line]'), row => row.textContent ?? '')
 }
@@ -431,8 +426,31 @@ describe('TextPreview — the file\'s metadata', () => {
 })
 
 describe('TextPreview — navigation and view', () => {
+  it('rebinds scrolling when the selected Slot body is replaced without changing the renderer id', async () => {
+    const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
+    const code = codeProps(h, { revision: 1 })
+    const fallback: TextPreviewProps = { ...code, renderSlot: () => <div data-late-renderer /> }
+    const view = render(<TextPreview {...fallback} />)
+    await settle()
+    const outer = body(view.container)
+    fireEvent.scroll(outer, { target: { scrollTop: 120 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(120)
+
+    view.rerender(<TextPreview {...code} />)
+    const inner = scrollport(view.container)
+    expect(inner).not.toBe(outer)
+    expect(inner.scrollTop).toBe(120)
+    fireEvent.scroll(inner, { target: { scrollTop: 240 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(240)
+
+    view.rerender(<TextPreview {...fallback} />)
+    expect(outer.scrollTop).toBe(240)
+    fireEvent.scroll(outer, { target: { scrollTop: 360 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(360)
+  })
+
   it.each([
-    ['Code', 'code', 2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT],
+    ['Code', 'code', 2 * LINE_HEIGHT],
     ['Plain text', PLAIN_BODY_ID, 2 * LINE_HEIGHT],
   ])('retries a Markdown line navigation after switching to %s', async (_name, rendererId, expectedScrollTop) => {
     PendingIntersectionObserver.instances = []
@@ -460,7 +478,7 @@ describe('TextPreview — navigation and view', () => {
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBeUndefined()
     act(() => { h.instance.actions.selected(TAB_ID, rendererId) })
     await waitFor(() => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) })
-    expect(body(view.container).scrollTop).toBe(expectedScrollTop)
+    expect(scrollport(view.container).scrollTop).toBe(expectedScrollTop)
   })
 
   it('lands on code lines before and after syntax highlighting is ready', async () => {
@@ -472,13 +490,20 @@ describe('TextPreview — navigation and view', () => {
     expect(view.container.querySelector('[data-code-preview] pre.shiki')).toBeNull()
     expect(view.container.querySelectorAll('[data-code-preview] pre .line')).toHaveLength(3)
     expect(body(view.container).scrollTop).toBe(0)
+    const codeScrollport = scrollport(view.container)
+    expect(codeScrollport.scrollTop).toBe(LINE_HEIGHT)
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
+    fireEvent.scroll(body(view.container), { target: { scrollTop: 300 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(LINE_HEIGHT)
+    fireEvent.scroll(codeScrollport, { target: { scrollTop: 300 } })
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(300)
 
     const block = view.container.querySelector('[data-code-preview] .md-code-block')!
     act(() => { PendingIntersectionObserver.instances[0]!.intersect(block) })
     await waitFor(() => { expect(view.container.querySelector('[data-code-preview] pre.shiki')).not.toBeNull() })
+    expect(scrollport(view.container)).toBe(codeScrollport)
     view.rerender(<TextPreview {...codeProps(h, { params: { line: 3 }, revision: 2 })} />)
-    expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT)
+    expect(codeScrollport.scrollTop).toBe(2 * LINE_HEIGHT)
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
   })
 

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

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

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

@@ -24,11 +24,11 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree,
 <a id="what-it-registers"></a>
 ## What it registers
 
-- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type.
+- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type.
 - **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header.
 - **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet.
 
-Seven source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
+Seven source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
 
 <a id="the-tree"></a>
 ## The tree

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

@@ -24,11 +24,11 @@ kind: "package-reference"
 <a id="what-it-registers"></a>
 ## 注册了什么
 
-- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。
+- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。
 - **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。
 - **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` 坑位:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。
 
-`src/client/` 下七个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
+`src/client/` 下七个源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
 
 <a id="the-tree"></a>
 ## 树

+ 8 - 2
packages/client/ui-sidebar-files/src/client/definition.ts → packages/client/ui-sidebar-files/src/client/definition.tsx

@@ -8,7 +8,7 @@
 import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import type {} from './locales.ts'
-import { IconFolderClose16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { FileTypeIcon, type IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 
 /** The tab kind this package owns. */
 export const FILES_KIND = 'files'
@@ -16,6 +16,11 @@ export const FILES_KIND = 'files'
 /** This implementation's identity in the tab system, and the key its body registers under. */
 export const FILES_ID = '@deepseek-ai/dsh-client-ui-sidebar-files'
 
+/** The type's coloured folder sheet at the guide capsule's glyph size, as the chip title draws it. */
+function FolderSheetGlyph({ size, className }: IconProps) {
+  return <FileTypeIcon kind="folder" size={size} className={className} />
+}
+
 /**
  * The files type's registry definition.
  * @param t - namespace-bound translate, read fresh on every label call.
@@ -30,7 +35,8 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab
     guide: [{
       order: 10,
       title: () => t('guide.title'),
-      icon: IconFolderClose16,
+      description: () => t('guide.description'),
+      icon: FolderSheetGlyph,
     }],
   }
 }

+ 2 - 2
packages/client/ui-sidebar-files/src/client/index.ts

@@ -6,7 +6,7 @@
  * the keyed `sidebar.right.pane.tab.title` seat, both under the type's `id`.
  *
  * The file split is this package's layering: what the type IS
- * (`definition.ts`), what it keeps (`store.ts`), how it lists (`face.ts`), what
+ * (`definition.tsx`), what it keeps (`store.ts`), how it lists (`face.ts`), what
  * it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`),
  * and this module, which only wires them together.
  */
@@ -15,7 +15,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/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 { FILES_ID, filesDefinition } from './definition.ts'
+import { FILES_ID, filesDefinition } from './definition.tsx'
 import { createList, filesFace } from './face.ts'
 import { FilesBody } from './FilesBody.tsx'
 import { FilesTitle } from './FilesTitle.tsx'

+ 2 - 0
packages/client/ui-sidebar-files/src/client/locales.ts

@@ -22,6 +22,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 export const zh = {
   'type.label': '文件',
   'guide.title': '工作区文件',
+  'guide.description': '浏览会话工作区的文件',
   loading: '正在读取…',
   empty: '空目录',
   truncated: '条目太多,只显示了一部分。',
@@ -41,6 +42,7 @@ export type SidebarFilesKey = keyof typeof zh
 export const en = {
   'type.label': 'Files',
   'guide.title': 'Workspace files',
+  'guide.description': 'Browse files in this session\'s workspace',
   loading: 'Reading…',
   empty: 'Empty directory',
   truncated: 'Too many entries, showing only some of them.',

+ 3 - 2
packages/client/ui-sidebar-files/tests/apply.client.spec.ts

@@ -10,7 +10,7 @@
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
-import { FILES_ID, FILES_KIND } from '../src/client/definition.ts'
+import { FILES_ID, FILES_KIND } from '../src/client/definition.tsx'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { FilesBody } from '../src/client/FilesBody.tsx'
@@ -69,7 +69,8 @@ describe('ui-sidebar-files apply', () => {
     expect(definition?.id).toBe(FILES_ID)
     expect(definition?.priority).toBe('builtin')
     expect(definition?.title('sidebar://files')).toBe('type.label')
-    expect(definition?.guide?.map(entry => [entry.order, entry.title()])).toEqual([[10, 'guide.title']])
+    expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description?.()]))
+      .toEqual([[10, 'guide.title', 'guide.description']])
     expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en })
     // The seat key is the implementation's id, not the kind: an extension may
     // take the kind over, and the seat must still find this body.

+ 11 - 3
packages/client/ui-sidebar-files/tests/definition.client.spec.ts

@@ -1,20 +1,25 @@
+// @vitest-environment jsdom
 /**
  * Stage one, as the registry sees it: the type is a page that claims no
  * address, sits in the builtin band, and offers the guide page one entry that
  * opens its kind.
  */
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createElement } from 'react'
+import { cleanup, render } from '@testing-library/react'
 import { Context } from '@deepseek-ai/cordis'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
 import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
 import {
   FILES_ID, FILES_KIND, filesDefinition,
-} from '../src/client/definition.ts'
+} from '../src/client/definition.tsx'
 import { zh } from '../src/client/locales.ts'
 
 const t = makeTranslate(zh)
 
+afterEach(cleanup)
+
 describe('filesDefinition', () => {
   it('registers under its kind and id and claims no address', () => {
     const registry = new SidebarRightTabRegistry(new Context())
@@ -31,7 +36,10 @@ describe('filesDefinition', () => {
     expect(entry?.order).toBe(10)
     expect(entry?.kind).toBe(FILES_KIND)
     expect(entry?.title()).toBe(zh['guide.title'])
-    expect(entry?.icon).toBeDefined()
+    expect(entry?.description?.()).toBe(zh['guide.description'])
+    if (entry?.icon === undefined) throw new Error('expected the guide icon')
+    const icon = render(createElement(entry.icon, { size: 26 }))
+    expect(icon.container.querySelector('svg')?.getAttribute('width')).toBe('26')
   })
 
   it('sits in the builtin band and titles itself from the dictionary', () => {

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

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

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

@@ -98,7 +98,7 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi
 
 Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
 
-The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
+The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
 
 <a id="copy"></a>
 ## Copy

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

@@ -98,7 +98,7 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私
 
 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。
 
-引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
+引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
 
 <a id="copy"></a>
 ## 文案

+ 11 - 4
packages/client/ui-sidebar-right/src/client/tab-registry.ts

@@ -21,8 +21,8 @@
  * builtin resumes when the extension unregisters. Everything else colliding on
  * a kind throws, as does a second registration of an `id`.
  *
- * Thunked copy (`title`, `guide[].title`) is read again on every use, so a
- * language change needs no re-registration.
+ * Thunked copy (`title`, `guide[].title`, `guide[].description`) is read again
+ * on every use, so a language change needs no re-registration.
  */
 import type { ComponentType } from 'react'
 import type { Context } from '@deepseek-ai/cordis'
@@ -62,11 +62,18 @@ export interface SidebarRightGuideEntry {
   /** Ascending position among every registered type's entries. */
   readonly order: number
   /**
-   * The capsule's title, its only words.
+   * The capsule's title.
    * @returns the title in the current language.
    */
   readonly title: () => string
-  /** Optional glyph, drawn before the title. */
+  /**
+   * One line under the title on what picking the capsule opens. The guide shows
+   * it only while it lists few enough entries to stay light; a crowded guide
+   * falls back to titles alone, so a type must stand on its title.
+   * @returns the description in the current language.
+   */
+  readonly description?: () => string
+  /** Optional glyph, drawn before the title; without one the guide draws its cube placeholder. */
   readonly icon?: ComponentType<IconProps>
 }
 

+ 70 - 19
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css

@@ -1,45 +1,63 @@
 /* The guide body's own sheet: its domain owns these rules, so the shell's
    stylesheet does not carry them. Tokens only. */
 
-/* The capsules in one centred column, lifted above the body's middle by the
-   spacer below — the empty-state offset. */
+/* The capsules form one centred column at the column's own 14px gap, lifted
+   above the body's middle by the spacer below. The minimum height centres a
+   short guide while allowing a tall guide to grow into the pane's scroller. */
 .guide {
   display: flex;
   flex-direction: column;
-  gap: 8px;
+  gap: 14px;
   align-items: center;
   justify-content: center;
   box-sizing: border-box;
-  height: 100%;
+  min-height: 100%;
   padding: 0 24px;
 }
 
 /* The lift, as a share of the pane's height (a column flex basis resolves
-   against it): the centred column rises 6% of the pane, and a short pane
+   against it): the centred column rises 5% of the pane, and a short pane
    gives it up before the capsules clip. */
 .guide::after {
   content: '';
-  flex: 0 1 12%;
+  flex: 0 1 10%;
 }
 
-/* A 48px capsule, 360px wide by default; `max-width: 100%` shrinks it inside
-   the column's own 24px insets, so a narrow pane still keeps that gutter. A
-   long title ellipsizes inside rather than widening it. */
+/* The guide tab's own compass above the capsules, at 56px on watermark ink —
+   not a control. The ink is pinned to the pure-gray static ramp per theme
+   because the label aliases all ride the bluish ramp, whose cast shows at
+   this size. Its margin over the column's gap holds the first capsule 30px
+   below it; the capsules keep the bare gap between them. */
+.hero {
+  display: flex;
+  margin-bottom: 16px;
+  color: var(--dsw-static-neutral-200);
+}
+
+:global(body[data-ds-dark-theme]) .hero {
+  color: var(--dsw-static-neutral-700);
+}
+
+/* A filled pill, 380px wide by default, its icon and text anchored at the
+   left inset; `max-width: 100%` shrinks it inside the column's own 24px
+   insets, so a narrow pane still keeps that gutter. The 56px minimum is the
+   title-only height; a capsule with a description grows on its vertical
+   padding. Long text ellipsizes inside rather than widening it. */
 .entry {
   display: flex;
-  gap: 8px;
+  gap: 14px;
   align-items: center;
-  justify-content: center;
   box-sizing: border-box;
-  width: 360px;
+  width: 380px;
   max-width: 100%;
-  height: 48px;
-  padding: 0 20px;
+  min-height: 56px;
+  padding: 14px 20px;
   color: var(--dsw-alias-label-primary);
   font: inherit;
-  background: transparent;
+  text-align: left;
+  background: var(--dsw-alias-bg-layer-1);
   border: 0.5px solid var(--dsw-alias-border-l4);
-  border-radius: 20px;
+  border-radius: 24px;
   cursor: pointer;
 }
 
@@ -47,22 +65,55 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* A fixed 26px box — the described glyph's size — so an entry's glyph sits
+   centred and every capsule's words start at the same left edge while glyph
+   sizes differ. */
 .entryIcon {
   display: flex;
   flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 26px;
+  height: 26px;
   color: var(--dsw-alias-label-secondary);
 }
 
-.entryTitle {
+/* The capsule's placeholder cube on ink one step quieter than a registered
+   glyph's, marking the unclaimed slot. */
+.placeholderInk {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* The capsule's words in one column beside the glyph: the title, and the
+   description while the guide shows one. */
+.entryText {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
   min-width: 0;
+}
+
+.entryTitle {
+  overflow: hidden;
+  font-size: 15px;
+  line-height: 1.4;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+/* One line under the title on the quieter ink; it ellipsizes like the title. */
+.entryDescription {
   overflow: hidden;
-  font-size: var(--dsh-content-font-size, 14px);
+  color: var(--dsw-alias-label-caption);
+  font-size: 13px;
   line-height: 1.4;
   white-space: nowrap;
   text-overflow: ellipsis;
 }
 
-/* The chip title's planet, beside the label the strip lays out. */
+/* The chip title's compass, beside the label the strip lays out; the glyph
+   draws on currentColor, so this ink is the chip rendering's own. */
 .titleIcon {
   flex: none;
+  color: var(--dsw-alias-label-tertiary);
 }

+ 34 - 11
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx

@@ -8,8 +8,12 @@
  * entry of its own, so there is always exactly one body and the shipped one
  * cannot be outvoted by accident.
  *
- * The shipped guide is the entry capsules every registered type contributed,
- * centred in the body, and nothing else. Picking one opens that type as a page
+ * The shipped guide is a muted compass over the entry capsules every
+ * registered type contributed, centred in the body, and nothing else — no
+ * heading, as a browser start page shows its doors without a caption. While
+ * at most four entries are listed, a capsule with a description shows it
+ * under the title; a longer list drops every description to stay light.
+ * Picking one opens that type as a page
  * in this tab's place, so the guide is a doorway rather than a page that stays
  * open.
  */
@@ -17,6 +21,7 @@ import type { ReactNode } from 'react'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { ChainRenderOpts, HookContextOf, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SidebarRightGuideBox } from '../../tab-registry.ts'
+import { CompassGlyph, CubeGlyph } from './GuideTitle.tsx'
 import css from './GuideBody.module.css'
 
 /** What the guide body needs from its host beyond the framework shares. */
@@ -25,15 +30,23 @@ export interface GuideInjected {
   readonly hooks: { readonly guideEntries: ObservableSnapshot<readonly SidebarRightGuideBox[]> }
 }
 
-/** The guide body's composed props: the tab it draws, its chain child, and the entries. Its words are the entries' own. */
+/** The guide body's composed props: the tab it draws, its chain child, and the entries. */
 export type GuideBodyProps =
   & PropsRuntime<'sidebar.right.pane.tab'>
   & PropsRenderSlots<'sidebar.right.tab.guide'>
   & InjectFace<GuideInjected>
 
-/** One entry capsule: the contributing type's glyph and title. */
-function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (entry: SidebarRightGuideBox) => void }): ReactNode {
-  const Icon = entry.icon
+/** Entry count past which the guide drops the capsules' descriptions to stay light. */
+const MAX_DESCRIBED_ENTRIES = 4
+
+/** One entry capsule: the contributing type's glyph and title, and its description while the guide is short. */
+function EntryBox({ entry, described, onPick }: {
+  entry: SidebarRightGuideBox
+  described: boolean
+  onPick: (entry: SidebarRightGuideBox) => void
+}): ReactNode {
+  const Icon = entry.icon ?? CubeGlyph
+  const description = described ? entry.description?.() : undefined
   return (
     <button
       type="button"
@@ -41,21 +54,30 @@ function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (ent
       data-sidebar-right-guide-entry={entry.kind}
       onClick={() => { onPick(entry) }}
     >
-      {Icon !== undefined && <span className={css.entryIcon}><Icon size={16} /></span>}
-      <span className={css.entryTitle}>{entry.title()}</span>
+      {/* The glyph rides the capsule's height: 22 beside a bare title, 26 beside two lines. */}
+      <span className={css.entryIcon}>
+        <Icon size={description === undefined ? 22 : 26} className={entry.icon === undefined ? css.placeholderInk : undefined} />
+      </span>
+      <span className={css.entryText}>
+        <span className={css.entryTitle}>{entry.title()}</span>
+        {description !== undefined && <span className={css.entryDescription}>{description}</span>}
+      </span>
     </button>
   )
 }
 
-/** The shipped guide: the doors out of the column. */
+/** The shipped guide: the tab's own compass over the doors out of the column. */
 function ShippedGuide({ entries, onPick }: {
   entries: readonly SidebarRightGuideBox[]
   onPick: (entry: SidebarRightGuideBox) => void
 }): ReactNode {
   return (
     <div className={css.guide} data-sidebar-right-guide>
+      <span className={css.hero} aria-hidden="true"><CompassGlyph size={56} /></span>
       {/* Keyed by position in the ordered list: one type may contribute several capsules, and `order` is not unique. */}
-      {entries.map((entry, index) => <EntryBox key={`${entry.kind}:${index}`} entry={entry} onPick={onPick} />)}
+      {entries.map((entry, index) => (
+        <EntryBox key={`${entry.kind}:${index}`} entry={entry} described={entries.length <= MAX_DESCRIBED_ENTRIES} onPick={onPick} />
+      ))}
     </div>
   )
 }
@@ -67,7 +89,8 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: Guid
   const options = {
     hookContext: useTabInfo,
     fallback: (
-      <ShippedGuide entries={entries} onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} />
+      <ShippedGuide entries={entries}
+        onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} />
     ),
   } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> }
   return renderSlotChain('sidebar.right.tab.guide', {}, options)

+ 40 - 7
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx

@@ -1,20 +1,53 @@
 /**
  * The guide type's chip title: the compass before the type's label. Registered
  * under `sidebar.right.pane.tab.title`; without it the chip would show the
- * bare label.
+ * bare label. Both guide glyphs live here: the compass the chip and the body's
+ * hero draw, and the cube the body's icon-less capsules fall back to.
  */
 import type { ReactNode } from 'react'
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import css from './GuideBody.module.css'
 
 /**
- * The compass: a grey ring with the needle's rhombus pointing north-east.
+ * The compass: a ring with the needle's rhombus pointing north-east, on
+ * `currentColor` so each rendering picks its own ink.
+ * @param props - rendered size and class.
+ * @returns the compass glyph.
  */
-function CompassGlyph(): ReactNode {
+export function CompassGlyph({ size = 16, className }: IconProps): ReactNode {
   return (
-    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" className={css.titleIcon}>
-      <circle cx="8" cy="8" r="6" stroke="var(--dsw-alias-label-tertiary)" strokeWidth="1.4" />
-      <path d="M 10.9 5.1 L 9.1 9.1 L 5.1 10.9 L 6.9 6.9 Z" fill="var(--dsw-alias-label-tertiary)" />
+    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>
+      <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.4" />
+      <path d="M 10.9 5.1 L 9.1 9.1 L 5.1 10.9 L 6.9 6.9 Z" fill="currentColor" />
+    </svg>
+  )
+}
+
+/**
+ * The cube: an isometric box — hexagonal silhouette, the top face's two edges,
+ * and the front seam — in straight strokes with softly rounded joins, on
+ * `currentColor`. The guide body draws it in a capsule whose type registered
+ * no glyph of its own.
+ * @param props - rendered size and class.
+ * @returns the cube glyph.
+ */
+export function CubeGlyph({ size = 16, className }: IconProps): ReactNode {
+  return (
+    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>
+      <path
+        d="M 8 2.5 L 12.9 5.2 V 10.8 L 8 13.5 L 3.1 10.8 V 5.2 Z"
+        stroke="currentColor"
+        strokeWidth="1.1"
+        strokeLinejoin="round"
+      />
+      <path
+        d="M 3.1 5.2 L 8 7.9 L 12.9 5.2 M 8 7.9 V 13.5"
+        stroke="currentColor"
+        strokeWidth="1.1"
+        strokeLinejoin="round"
+        strokeLinecap="round"
+      />
     </svg>
   )
 }
@@ -28,7 +61,7 @@ export function GuideTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.
   const { tab } = useTabInfo()
   return (
     <>
-      <CompassGlyph />
+      <CompassGlyph className={css.titleIcon} />
       {tab.title}
     </>
   )

+ 1 - 1
packages/client/ui-sidebar-right/tests/apply.client.spec.ts

@@ -95,7 +95,7 @@ describe('ui-sidebar-right apply', () => {
     expect(guide?.title('sidebar://guide')).toBe('tab.guide.title')
     // Five registrations: the root and panel seats, the header's corner seat,
     // and the guide body and chip title under the guide implementation's id.
-    // The guide binds no dictionary: its only words are the entries' own.
+    // The guide draws no product copy of its own, so neither guide seat binds the dictionary.
     expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
       ['rightbar', undefined, undefined, RightbarRoot],
       ['rightbar.session', undefined, 'sidebarRight', RightbarSeat],

+ 28 - 3
packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx

@@ -16,6 +16,7 @@ import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
 import type { GuideBodyProps } from '../src/client/tabs/guide/GuideBody.tsx'
 import type { SidebarRightGuideBox } from '../src/client/tab-registry.ts'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import css from '../src/client/tabs/guide/GuideBody.module.css'
 
 afterEach(cleanup)
 
@@ -27,12 +28,13 @@ function Glyph({ size }: IconProps): ReactNode {
 }
 
 /** One entry capsule as the registry lists it. */
-function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon']): SidebarRightGuideBox {
+function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon'], description?: string): SidebarRightGuideBox {
   return {
     kind,
     order,
     title: () => `${kind} title`,
     ...icon === undefined ? {} : { icon },
+    ...description === undefined ? {} : { description: () => description },
   }
 }
 
@@ -61,15 +63,20 @@ describe('GuideBody', () => {
     expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.guide', {}, {
       hookContext: useTabInfo, fallback: expect.anything() as ReactNode,
     })
-    // The guide says nothing of its own: its words are the capsules'.
+    // The guide draws no words of its own; every word is a capsule's.
     const guide = view.container.querySelector('[data-sidebar-right-guide]')
     expect(guide?.textContent).toBe('files titleterminal title')
     // One capsule per entry, in the registry's order, each with its own title; only the first brought a glyph.
     expect(boxes()).toEqual(['files', 'terminal'])
     const [files, terminal] = [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')]
     expect(files?.textContent).toBe('files title')
-    expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('16')
+    expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('22')
     expect(terminal?.querySelector('[data-guide-glyph]')).toBeNull()
+    // The entry without a glyph falls back to the shipped cube, at the same size, on the quieter ink.
+    const placeholder = terminal?.querySelector('svg')
+    expect(placeholder?.getAttribute('width')).toBe('22')
+    expect(placeholder?.getAttribute('class')).toBe(css.placeholderInk)
+    expect(files?.querySelector('svg')).toBeNull()
     cleanup()
   })
 
@@ -91,6 +98,24 @@ describe('GuideBody', () => {
     cleanup()
   })
 
+  it('shows an entry\'s description while at most four entries are listed, and drops every description past that', () => {
+    const four = [box('a', 10, Glyph, 'a desc'), box('b', 20), box('c', 30, undefined, 'c desc'), box('d', 40)]
+    const { view, guideEntries } = mountGuide(four)
+    const capsule = (kind: string) => view.container.querySelector(`[data-sidebar-right-guide-entry="${kind}"]`)
+    // At four: a capsule with a description carries it under the title at the larger glyph; one without stays title-only.
+    expect(capsule('a')?.textContent).toBe('a titlea desc')
+    expect(capsule('a')?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('26')
+    expect(capsule('b')?.textContent).toBe('b title')
+    // The placeholder follows the described size exactly as a registered glyph does.
+    expect(capsule('c')?.querySelector('svg')?.getAttribute('width')).toBe('26')
+    // A fifth entry tips the whole guide back to titles alone, at the title-only glyph size.
+    act(() => { guideEntries.set([...four, box('e', 50)]) })
+    expect(capsule('a')?.textContent).toBe('a title')
+    expect(capsule('a')?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('22')
+    expect(capsule('c')?.textContent).toBe('c title')
+    cleanup()
+  })
+
   it('keeps two boxes of one type at the same order apart', () => {
     const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
     try {

+ 1 - 1
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -2363,7 +2363,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.document\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.document\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:23',
+    source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:24',
   },
   {
     key: 'sidebar.right.tab.guide',