Explorar o código

Merge remote-tracking branch 'origin/master' into worktree/web-sidebar-terminal

# Conflicts:
#	packages/client/ui-sidebar-right/README.i18n.yaml
#	packages/client/ui-sidebar-right/README.md
#	packages/client/ui-sidebar-right/README.zh.md
Yichen Jiang hai 4 semanas
pai
achega
12d3be3514
Modificáronse 100 ficheiros con 504 adicións e 198 borrados
  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/architecture/2026-09-08-document-preview-operations.i18n.yaml
  5. 4 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  6. 4 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md
  9. 1 1
      .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  11. 5 5
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  12. 5 5
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
  14. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
  15. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml
  17. 3 3
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md
  18. 3 3
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md
  19. 6 0
      .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml
  20. 29 0
      .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md
  21. 29 0
      .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md
  22. 2 2
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
  23. 3 7
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
  24. 3 7
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
  25. 2 2
      .agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml
  26. 1 1
      .agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md
  27. 1 1
      .agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md
  28. 6 0
      .agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.i18n.yaml
  29. 39 0
      .agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md
  30. 39 0
      .agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md
  31. 2 2
      .agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml
  32. 1 1
      .agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md
  33. 1 1
      .agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md
  34. 2 1
      .github/workflows/build-exe-for-python-sdk.yml
  35. 3 6
      .github/workflows/ci-master.yml
  36. 4 6
      .github/workflows/ci.yml
  37. 3 3
      .github/workflows/e2e.yml
  38. 1 1
      .github/workflows/release-vendor.yml
  39. 1 1
      .github/workflows/release.yml
  40. 1 1
      apps/cli/package.json
  41. 1 1
      apps/desktop-host/package.json
  42. 1 1
      apps/desktop/package.json
  43. 1 1
      apps/web/package.json
  44. 76 10
      apps/web/tests/document-preview.e2e.ts
  45. 86 0
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  46. 5 25
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  47. 34 14
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  48. 4 0
      apps/web/tests/scaffold.ts
  49. 1 1
      apps/web/tests/sidebar-right.e2e.ts
  50. 2 2
      docs/config-catalog.i18n.yaml
  51. 2 2
      docs/config-catalog.md
  52. 2 2
      docs/config-catalog.zh.md
  53. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  54. 2 2
      docs/subsystems/sidebar-right.md
  55. 2 2
      docs/subsystems/sidebar-right.zh.md
  56. 2 2
      docs/user/guide/providers.i18n.yaml
  57. 1 1
      docs/user/guide/providers.md
  58. 1 1
      docs/user/guide/providers.zh.md
  59. 1 1
      package.json
  60. 1 1
      packages/acp/acp/package.json
  61. 1 1
      packages/api/gateway/package.json
  62. 1 1
      packages/api/remotes/package.json
  63. 1 1
      packages/api/session-controller/package.json
  64. 1 1
      packages/api/settings-controller/package.json
  65. 1 1
      packages/api/terminal-controller/package.json
  66. 1 1
      packages/api/workspace-controller/package.json
  67. 1 1
      packages/api/workspace-files/package.json
  68. 1 1
      packages/attachment/attachment-local/package.json
  69. 1 1
      packages/attachment/attachment/package.json
  70. 1 1
      packages/boot/app-boot/package.json
  71. 1 1
      packages/boot/cmdline/package.json
  72. 1 1
      packages/bundle/acp-app/package.json
  73. 1 1
      packages/bundle/base/cordis.patch.yml
  74. 1 1
      packages/bundle/base/package.json
  75. 1 1
      packages/bundle/headless/package.json
  76. 1 1
      packages/bundle/sdk-app/package.json
  77. 1 1
      packages/bundle/sdk-minimal/package.json
  78. 1 1
      packages/bundle/web-app/package.json
  79. 1 1
      packages/client/connection/package.json
  80. 1 1
      packages/client/file-upload/package.json
  81. 1 1
      packages/client/hmr/package.json
  82. 1 1
      packages/client/locale/package.json
  83. 1 1
      packages/client/modules/package.json
  84. 1 1
      packages/client/resources/package.json
  85. 1 1
      packages/client/store/package.json
  86. 1 1
      packages/client/ui-agent-preset/package.json
  87. 1 1
      packages/client/ui-approval/package.json
  88. 1 1
      packages/client/ui-attachment/package.json
  89. 1 1
      packages/client/ui-brand-official/package.json
  90. 1 1
      packages/client/ui-chat/package.json
  91. 10 4
      packages/client/ui-chat/src/client/chat/StatsPills.tsx
  92. 5 1
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  93. 1 1
      packages/client/ui-commands/package.json
  94. 1 1
      packages/client/ui-conversation/package.json
  95. 1 1
      packages/client/ui-deliverables/package.json
  96. 1 1
      packages/client/ui-directory-picker-browse/package.json
  97. 1 1
      packages/client/ui-directory-picker-native/package.json
  98. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  99. 1 1
      packages/client/ui-dockkit/README.md
  100. 1 1
      packages/client/ui-dockkit/README.zh.md

+ 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/architecture/2026-09-08-document-preview-operations.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-08-document-preview-operations.md
-2026-09-08-document-preview-operations.md: 316f33371d393846cce5b598ee23289cecd6c178
-2026-09-08-document-preview-operations.zh.md: 97759db32d139a44fe33fd2c5e2eb7ec0c8960fd
+2026-09-08-document-preview-operations.md: 3703933273e743c8df32bf0352fc276fe21dcb93
+2026-09-08-document-preview-operations.zh.md: b4896e95959d0f276ee69dfeaee9528319981714

+ 4 - 2
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md

@@ -18,7 +18,7 @@ Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path ma
 
 [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
 
-Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob.
+Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. They retain intrinsic CSS-pixel dimensions; auto margins centre images smaller than the shared scroller, while larger dimensions extend its horizontal or vertical scroll range. The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
 
 ## Alternatives considered
 
@@ -34,6 +34,8 @@ Markdown and code reuse the incremental primitives with cumulative paged text. H
 
 **A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support.
 
+**Sanitize SVG into the application DOM or an iframe.** A sanitizer would add a second SVG parser and an evolving active-content policy before placing untrusted markup in an interactive document. The `<img>` static-image context preserves native SVG rendering and intrinsic dimensions without giving the markup a script-capable DOM.
+
 ## Consequences
 
-Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and lazy continuous PDF Worker rendering.
+Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, intrinsic raster and SVG rendering with two-axis scrolling, inert SVG scripts, and lazy continuous PDF Worker rendering.

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

@@ -18,7 +18,7 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
 
 [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
 
-Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob。
+Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。它们保留固有 CSS 像素尺寸;auto margin 让小于共享滚动区的图片居中,较大的尺寸则扩展横向或纵向滚动范围。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
 
 ## 考虑过的替代方案
 
@@ -34,6 +34,8 @@ Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和
 
 **本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。
 
+**清理 SVG 后放入应用 DOM 或 iframe。** sanitizer 会增加第二套 SVG parser 和一套持续演进的主动内容策略,之后仍要把不可信标记放进可交互文档。`<img>` 静态图片上下文保留浏览器原生 SVG 渲染与固有尺寸,同时不给标记一个能运行脚本的 DOM。
+
 ## 影响
 
-替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及惰性连续 PDF Worker 渲染。
+替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、可双轴滚动的固有尺寸位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.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-02-in-history-system-prompt-replacement.md
-2026-09-02-in-history-system-prompt-replacement.md: b34a0d2b3591c1b62aba16d79963940be787f373
-2026-09-02-in-history-system-prompt-replacement.zh.md: 296932a977ae852c4ef32de1c23b2c5d9e4a1bbf
+2026-09-02-in-history-system-prompt-replacement.md: 8e5fa65ec4649537889cefc75459d2a4958165e5
+2026-09-02-in-history-system-prompt-replacement.zh.md: 73003b04de82f45154f9c673654cc492eb62a4fd

+ 1 - 1
.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md

@@ -18,7 +18,7 @@ For a model route that declares the capability, the loop appends a new `system/m
 
 ### Capability
 
-`dsh-llm` defines `SystemPromptUpdate = 'in-history'` and carries it as an optional sibling field, `systemPromptUpdate`, on `LlmResolvedModelInfo` and `PreparedLlmCall`; `normalizeModelInfo` rejects any other value with an `LlmError` whose code is `INVALID_MODEL_INFO`. The DeepSeek adapter's catalog model (`DeepSeekCatalogModel.systemPromptUpdate`, validated by zod at load) and the replay provider's `ReplayModelConfig.systemPromptUpdate` declare it per model; absence means the model needs message 0 rewritten. No default catalog entry declares it; a deployment enables it through the `models` list in `cordis.yml`, and every `dsh-llm-pi-ai` route keeps the replace behaviour.
+`dsh-llm` defines `SystemPromptUpdate = 'in-history'` and carries it as an optional sibling field, `systemPromptUpdate`, on `LlmResolvedModelInfo` and `PreparedLlmCall`; `normalizeModelInfo` rejects any other value with an `LlmError` whose code is `INVALID_MODEL_INFO`. The DeepSeek adapter's catalog model (`DeepSeekCatalogModel.systemPromptUpdate`, validated by zod at load) and the replay provider's `ReplayModelConfig.systemPromptUpdate` declare it per model; absence means the model needs message 0 rewritten. The sole built-in entry in `dsh-llm-deepseek`, `deepseek-flash`, declares it alongside text and image input. This exact catalog entry records the model capability; names and protocol families do not imply support for other models. A deployment can replace the catalog through the `models` list in `cordis.yml`, and every `dsh-llm-pi-ai` route keeps the replace behaviour.
 
 The loop records the mode in the session: `RequestContext.systemPromptUpdate` joins provider, model, and capacity as a `request/context` field, logged whenever any of them differs from the latest snapshot. Admission reads `PreparedLlmCall.systemPromptUpdate` from the actual call prepared after `agent/request`; the preceding snapshot is not an admission input. First requests, resumed sessions, route changes, and same-route capability changes therefore use the capability of the bound adapter that will serve the call.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 ### 能力
 
-`dsh-llm` 定义 `SystemPromptUpdate = 'in-history'`,并把它作为可选的并列字段 `systemPromptUpdate` 放在 `LlmResolvedModelInfo` 与 `PreparedLlmCall` 上;`normalizeModelInfo` 用代码为 `INVALID_MODEL_INFO` 的 `LlmError` 拒绝任何其他值。DeepSeek 适配器的目录模型(`DeepSeekCatalogModel.systemPromptUpdate`,加载时由 zod 校验)与回放提供者的 `ReplayModelConfig.systemPromptUpdate` 逐模型声明它;缺省表示该模型需要重写消息 0。没有默认目录条目声明它;部署方通过 `cordis.yml` 的 `models` 列表启用,所有 `dsh-llm-pi-ai` 路由保持替换行为。
+`dsh-llm` 定义 `SystemPromptUpdate = 'in-history'`,并把它作为可选的并列字段 `systemPromptUpdate` 放在 `LlmResolvedModelInfo` 与 `PreparedLlmCall` 上;`normalizeModelInfo` 用代码为 `INVALID_MODEL_INFO` 的 `LlmError` 拒绝任何其他值。DeepSeek 适配器的目录模型(`DeepSeekCatalogModel.systemPromptUpdate`,加载时由 zod 校验)与回放提供者的 `ReplayModelConfig.systemPromptUpdate` 逐模型声明它;缺省表示该模型需要重写消息 0。`dsh-llm-deepseek` 仅内置 `deepseek-flash` 条目,在该条目上声明它,同时声明文本和图片输入。该精确目录条目记录模型能力;名称和协议类别不能推导其他模型是否支持。部署方可以通过 `cordis.yml` 的 `models` 列表替换目录,所有 `dsh-llm-pi-ai` 路由保持替换行为。
 
 循环把该模式记录进会话:`RequestContext.systemPromptUpdate` 与 provider、model、容量并列成为 `request/context` 的字段,其中任一项与最新快照不同时就记录一次。准入读取 `agent/request` 之后实际准备调用的 `PreparedLlmCall.systemPromptUpdate`;先前快照不是准入输入。因此首次请求、恢复的会话、路由变更以及同一路由的能力变更,都使用将服务该调用的绑定适配器的能力。
 

+ 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: 9c0b6db832f19ba758ba2d3851db72f9a1fdb785
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: a5173c20b5203942df3c6f86b2bef5961946fa08
+2026-09-05-sidebar-text-preview-and-file-tree.md: 0142b325004a1fc018e944ed908d600e75f0ab0d
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: d40ca8c790c9a7208a2243422c0973fc293a5915

+ 5 - 5
.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.
 
@@ -108,12 +108,12 @@ Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「
 
 ## Testing
 
-The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier.
+The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier. `apps/web/tests/document-preview.e2e.ts` covers centred intrinsic-size images, two-axis image scrolling, and inert SVG scripts.
 
 ## Deferred
 
 - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`.
-- Images, search, a total line count, and an end-of-file marker.
+- Search, a total line count, and an end-of-file marker.
 - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree.
 - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries.
 

+ 5 - 5
.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` 拒绝会话工作区根之外的路径,所以客户端能列的那一个目录就是它显示的目录。
 
@@ -108,12 +108,12 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 
 ## Testing
 
-文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。
+文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。`apps/web/tests/document-preview.e2e.ts` 覆盖居中的固有尺寸图片、双轴图片滚动和不可执行的 SVG 脚本。
 
 ## Deferred
 
 - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。
-- 图片、搜索、总行数与文件末尾标记。
+- 搜索、总行数与文件末尾标记。
 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。
 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。
 

+ 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 重述了引导页规则。

+ 2 - 2
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md
-2026-07-26-ci-failover-runbook.md: f24cb8b8239141cd1ccf468a566dba620dd3cfdd
-2026-07-26-ci-failover-runbook.zh.md: 57c4a92a3af720d9b11b7a1ce7a1515b83c77339
+2026-07-26-ci-failover-runbook.md: 559fa61bcf416bed3bd58b3ffcbc145038cdce22
+2026-07-26-ci-failover-runbook.zh.md: e2098b928b1a1f158bcbfabd940ca23a5cd0e28e

+ 3 - 7
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md

@@ -12,11 +12,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym
 
 The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; otherwise they retain their workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain `ubuntu-latest` fallback. Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
 
-`ci-master.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check.
-
-The exemption is narrower than "a drill always finishes", in two ways. GitHub keeps a single pending entry per group, so a newer pending run displaces an older one and intermediate push runs still end as `cancelled` during busy periods. And the expression is evaluated against the *newly triggered* run, so a run whose own event is not `push` — a benchmark dispatched on master within `ci-master.yml`, sharing its group `CI master-<ref>` — evaluates to `true` and does cancel a drill that is mid-flight. That is a rare manual action and the next master push restores the evidence, so it does not warrant further mechanism. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence.
-
-The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level `concurrency` group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming `pull_request` alone would also stop cancelling `workflow_dispatch`, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push in `ci-master.yml` carries the [post-merge runtime and Wine checks](2026-09-06-master-only-platform-ci.md) and these two drills; the pull-request jobs live in the separate `ci.yml` (which does not see `push`), and the benchmarks are `workflow_dispatch`-gated within `ci-master.yml`. `scripts/ci-workflow.spec.ts` pins that push-reachable set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs.
+The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness.
 
 ### Release rehearsals share the Linux switch
 
@@ -44,7 +40,7 @@ The two switches are independent: flip only the one whose platform is degraded.
 
 ## Capacity during failover
 
-Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds three Node compatibility jobs at gate concurrency one, including the build-backed Node 22 leg and cold temporary runtime downloads. The release workflows do not cancel running rehearsals when another run arrives, so overlapping refs can add sustained build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory.
+Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds three Node compatibility jobs at gate concurrency one, including the build-backed Node 22 leg and cold temporary runtime downloads. The release rehearsal workflows cancel superseded runs within each workflow/ref group under the [cancellation policy](2026-09-09-cancel-superseded-ci.md); different refs can still add concurrent build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory.
 
 
 ### Switch back
@@ -63,4 +59,4 @@ The variables are writer-manageable repository state; a pull request event itsel
 
 ## Consequences
 
-Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform.
+Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: master pushes schedule the standby lanes, but only completed verdicts establish readiness under the [cancellation policy](2026-09-09-cancel-superseded-ci.md), and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform.

+ 3 - 7
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md

@@ -12,11 +12,7 @@ Status: implemented
 
 三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;否则保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并保留 `ubuntu-latest` 回退。Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
 
-`ci-master.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。
-
-这项豁免比「演练总能跑完」要窄,有两点限制。其一,GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。其二,该表达式是针对**新触发的运行**求值的,因此自身事件不是 `push` 的运行——例如在 `ci-master.yml` 内的 master 上派发的基准测试,与其演练共用 `CI master-<ref>` 组——求值为 `true`,会取消正在运行中的演练。这属于罕见的手动操作,且下一次 master 推送即可恢复证据,因此不值得为它再加机制。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。
-
-这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 `concurrency` 组并不能豁免其所属作业。采用否定式写法而非仅指名 `pull_request`,是有实质作用的:后者会连 `workflow_dispatch` 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为 `ci-master.yml` 中一次 master 推送承载[合并后的运行时与 Wine 检查](2026-09-06-master-only-platform-ci.zh.md)和这两条演练;拉取请求作业位于独立的 `ci.yml`(不监听 `push`),而基准测试在 `ci-master.yml` 内受 `workflow_dispatch` 门控。`scripts/ci-workflow.spec.ts` 会锁定这个推送可达集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。
+[被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。
 
 ### 发布演练共用 Linux 开关
 
@@ -44,7 +40,7 @@ Status: implemented
 
 ## 切换期间的容量
 
-Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加三个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 22 条目和冷临时运行时下载。发布工作流不会因为新运行到来而取消正在执行的演练,因此不同引用的重叠运行会增加持续的构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。
+Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加三个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 22 条目和冷临时运行时下载。发布演练工作流依据[取消策略](2026-09-09-cancel-superseded-ci.zh.md)取消各工作流/引用组内被取代的运行;不同引用仍可能增加并发构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。
 
 
 ### 切回
@@ -63,4 +59,4 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以
 
 ## 后果
 
-从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。
+从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:master 推送会调度热备通道,但依据[取消策略](2026-09-09-cancel-superseded-ci.zh.md),只有已完成的结论才能证明就绪状态;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。

+ 2 - 2
.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.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/process/2026-09-06-master-only-platform-ci.md
-2026-09-06-master-only-platform-ci.md: 28284206c8c6d3fbb5de8ecadbcdf2035a5bb8c0
-2026-09-06-master-only-platform-ci.zh.md: eed843b0d235c80256343890e91b1de84f482174
+2026-09-06-master-only-platform-ci.md: d32864a4eafa81433c6d4fd0e47892b4d17091b1
+2026-09-06-master-only-platform-ci.zh.md: 4fb55d5e95f8fe75e6d1efb8b57295b8e8469e8d

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md

@@ -14,7 +14,7 @@ Python runtime builds on macOS Intel and ARM and Linux ARM64, plus Windows build
 
 Wine runs once as an independent hosted Ubuntu master job. Its existing image-keyed apt cache restore/save also supplies default-branch cache production, so it needs no separate cache-seeding job. The native Linux and Windows serial aggregates do not invoke Wine. Keeping Wine hosted avoids shared-host apt transactions and shared Wine-prefix cleanup on the persistent Linux VM. The script owns a scratch snapshot, a checkout-local Wine prefix, and a checksum-verified Windows Node cache; provisioning, failure propagation, and always-run cleanup remain intact.
 
-The parent and reusable runtime workflows preserve running master-push checks against subsequent master pushes. GitHub concurrency still permits replacement of pending runs; manual benchmarks can cancel the parent run. A master push schedules all three selected carriers but does not guarantee every intermediate commit reaches a result. PR, manual, and release cancellation retain their existing behavior.
+The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) applies to the parent and reusable runtime workflows: newer master pushes or manual runs cancel older validation in the same workflow/ref group, while release-owned builds remain protected. A master push schedules all three selected carriers but does not guarantee every intermediate commit reaches a result.
 
 This decision partially supersedes scheduling in the [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md), [native Windows CI](2026-08-08-native-windows-pull-request-ci.md), [serial references](2026-07-21-serial-cross-platform-ci-reference.md), and [failover runbook](2026-07-26-ci-failover-runbook.md). Those notes remain active for artifact provenance, platform fidelity, serial completeness, and trust rules.
 

+ 1 - 1
.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md

@@ -14,7 +14,7 @@ macOS Intel、ARM 与 Linux ARM64 上的 Python 运行时构建,以及通过 W
 
 Wine 作为独立的托管 Ubuntu master 作业运行一次。其现有的按镜像标识的 apt 缓存恢复和保存也负责生成默认分支缓存,因此不需要单独的缓存预热作业。原生 Linux 与 Windows 串行聚合不调用 Wine。Wine 保持托管运行,避免在持久 Linux VM 上执行共享宿主机 apt 事务和共享 Wine prefix 清理。脚本负责临时快照、checkout 内的 Wine prefix 和经过校验和验证的 Windows Node 缓存;环境准备、失败传播及始终执行的清理保持不变。
 
-父工作流与可复用运行时工作流均保留正在执行的 master 推送检查,不被后续 master 推送取消。GitHub 并发机制仍允许替换待执行的运行;手动基准测试可以取消父工作流。master 推送会调度全部三个选定载体,但不保证每个中间提交都得到结果。PR(Pull Request)、手动和发布运行的取消行为保持不变。
+[被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 适用于父工作流与可复用运行时工作流:更新的 master 推送或手动运行会取消同一工作流/引用组内的旧验证,而发布所属的构建仍受保护。master 推送会调度全部三个选定载体,但不保证每个中间提交都得到结果。
 
 本决策部分取代[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)、[原生 Windows CI](2026-08-08-native-windows-pull-request-ci.zh.md)、[串行参考](2026-07-21-serial-cross-platform-ci-reference.zh.md)和[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)中的调度策略。这些记录仍保留产物来源、平台保真度、串行完整性与信任规则的决策价值。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.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/process/2026-09-09-cancel-superseded-ci.md
+2026-09-09-cancel-superseded-ci.md: 8de1f757870a2b18852a224ec2f1cc33d9f6a95e
+2026-09-09-cancel-superseded-ci.zh.md: 3ae6ec6ad58b0dfc94e803a33ba92ded4e239485

+ 39 - 0
.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md

@@ -0,0 +1,39 @@
+# Agent Note: Cancel superseded CI validation
+
+Status: implemented
+
+English | [中文](2026-09-09-cancel-superseded-ci.zh.md)
+
+## Problem
+
+Validation of an obsolete PR revision or master commit consumes runner capacity without establishing the newest revision’s status. Unconditional aggregate verdicts and coverage-history uploads can also keep cancelled runs doing bookkeeping. Preserving older post-merge runs favors historical completion over current validation, especially on the shared self-hosted pools.
+
+## Decision
+
+Validation favors the newest run within each workflow/ref group. [CI](../../../../.github/workflows/ci.yml), [CI master](../../../../.github/workflows/ci-master.yml), [real-API e2e](../../../../.github/workflows/e2e.yml), and the credential-free [dsh](../../../../.github/workflows/release.yml) and [vendor](../../../../.github/workflows/release-vendor.yml) pack validations use `cancel-in-progress: true` with `${{ github.workflow }}-${{ github.ref }}`. Different PR refs and different workflows do not cancel each other. Event type is not part of the group: master pushes and manual benchmarks can supersede each other in CI master, and e2e pushes, scheduled runs, and manual runs can supersede each other on the same ref.
+
+The [reusable Python runtime builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) uses `${{ !inputs.release }}`. Its `build-single-exe-${{ github.workflow }}-${{ github.ref }}` group remains distinct from its caller’s group, and the caller workflow name isolates ordinary CI from release-owned builds. Release-owned builds are exempt because they belong to an intentional publication transaction. Publication, deployment, and metadata workflows retain their own policies; this decision does not apply cancellation indiscriminately across workflows.
+
+The PR aggregate uses `${{ !cancelled() && github.event_name == 'pull_request' }}`. The explicit status function preserves evaluation after failed or skipped dependencies rather than accepting GitHub’s default success-only condition. The aggregate still fails on any failure, cancellation, or skip among its dependencies when the workflow itself is not cancelled; cancellation of the whole workflow suppresses its obsolete verdict. Coverage duration history uses `!cancelled()` too: failed coverage can still save useful measurements, but cancelled coverage does not upload them. Wine’s `always()` cleanup remains necessary resource cleanup rather than optional bookkeeping.
+
+This reverses the cancellation exemption in the [failover runbook](2026-07-26-ci-failover-runbook.md), [master-only platform CI](2026-09-06-master-only-platform-ci.md), and [real-API e2e decision](../testing/2026-06-19-real-api-e2e-ci.md). Those notes retain independent value for pool trust and switching, platform coverage, and secret exposure. The [release rehearsal decision](2026-09-06-release-rehearsal-selfhosted.md) retains runner selection and isolation ownership. None is fully superseded or archived.
+
+## Alternatives considered
+
+**Preserve running master-push drills.** The former `${{ github.event_name != 'push' }}` exemption favored periodic readiness evidence: each standby executes its complete unsharded aggregate with one gate worker and can outlast the interval between master merges. Even that policy did not guarantee every drill completed. GitHub retains one pending run per group, replacing intermediate pending pushes; cancellation is evaluated on the newly triggered run, so a manual benchmark sharing the master group could still cancel a drill. That rare manual interruption was accepted on the expectation of evidence from a subsequent push. The exemption’s cost was bounded by the master-only runtime checks, Wine, and two drills; PR jobs remained in a separate workflow, and exact-condition regression checks pinned the push-reachable job set. This policy is rejected in favor of freeing capacity for current validation, explicitly accepting standby starvation.
+
+**Protect a drill with job-level concurrency, or cancel only PR events.** A job-level group cannot exempt a job from cancellation of its entire workflow. A PR-only cancellation condition also exempts manual dispatch: a repeated runner benchmark can occupy twelve larger runners for up to fifteen minutes rather than replacing an obsolete measurement. Workflow-level cancellation covers both pushes and manual runs.
+
+**Keep every post-merge, nightly, and pack run.** Historical completion provides more per-commit and per-trigger evidence, but obsolete validation competes with the newest run. These validations do not publish packages, so preserving every run is not the same requirement as protecting an intentional publication transaction.
+
+**Replace every `always()` condition.** Failure aggregation and resource cleanup have different obligations. A success-only aggregate can hide failed dependencies behind a skipped required check; removing unconditional Wine cleanup can leave resources running. Only cancelled-run bookkeeping is suppressed.
+
+## Consequences
+
+Rapid master updates can repeatedly cancel the longer standby drills before they produce a verdict. Operators use the latest completed standby verdict, checking its age and commit before relying on it for failover readiness; a scheduled, running, or cancelled drill is not readiness evidence. The policy does not guarantee that every intermediate commit, nightly trigger, or benchmark completes. Different refs can still compete for shared host capacity.
+
+Cancellation is a request handled by GitHub Actions and its runners, not a guarantee of immediate termination or bounded queue delay. Cleanup can still take time. The policy makes obsolete validation cancellable; it does not promise a fixed runtime or cancellation latency.
+
+## Verification
+
+[Workflow regressions](../../../../scripts/ci-workflow.spec.ts) pin workflow/ref isolation, release-owned exemptions, aggregate status conditions, coverage-history cancellation, and retained Wine cleanup. [Platform routing regressions](../../../../scripts/tests/ci-master-platforms.spec.ts) preserve the master/PR target split and release matrix; [release rehearsal regressions](../../../../scripts/tests/ci-release-selfhosted.spec.ts) preserve cancellation alongside runner eligibility and publication isolation. These configuration checks do not reproduce GitHub scheduling or runner shutdown. Live supersession and completed standby evidence remain CI verification responsibilities.

+ 39 - 0
.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 取消被取代的 CI 验证
+
+Status: implemented
+
+[English](2026-09-09-cancel-superseded-ci.md) | 中文
+
+## 问题
+
+验证已被取代的 PR(Pull Request)修订或 master 提交会消耗运行器容量,却不能确定最新修订的状态。无条件执行的聚合判定和覆盖率耗时历史上传,还可能让已取消的运行继续处理记账任务。保留旧的合并后运行,意味着优先完成历史验证而非当前验证,在共享自托管池上尤其如此。
+
+## 决策
+
+验证优先保留各工作流/引用组内最新的运行。[CI](../../../../.github/workflows/ci.yml)、[CI master](../../../../.github/workflows/ci-master.yml)、[真实 API e2e](../../../../.github/workflows/e2e.yml),以及无凭据的 [dsh](../../../../.github/workflows/release.yml) 和 [vendor](../../../../.github/workflows/release-vendor.yml) 打包验证,均在 `${{ github.workflow }}-${{ github.ref }}` 组中使用 `cancel-in-progress: true`。不同 PR 引用和不同工作流不会相互取消。事件类型不参与分组:CI master 中的 master 推送与手动基准测试可以相互取代,e2e 的推送、定时运行和手动运行也可以在同一引用上相互取代。
+
+[可复用 Python 运行时构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)使用 `${{ !inputs.release }}`。其 `build-single-exe-${{ github.workflow }}-${{ github.ref }}` 组与调用方的组保持区分,调用方工作流名称将普通 CI 与发布所属的构建隔离。发布所属的构建获得豁免,因为它们属于一次有意发起的发布事务。发布、部署和元数据工作流保留各自的策略;本决策不会不加区分地对所有工作流应用取消。
+
+PR 聚合使用 `${{ !cancelled() && github.event_name == 'pull_request' }}`。显式状态函数使其在依赖失败或跳过后仍然求值,而非采用 GitHub 默认的仅成功条件。当工作流本身未被取消时,聚合仍会因任意依赖失败、取消或跳过而失败;整个工作流被取消时则抑制其已失去用途的判定。覆盖率耗时历史也使用 `!cancelled()`:覆盖率失败时仍可保存有用的测量数据,但被取消的覆盖率运行不上传。Wine 的 `always()` 清理仍是必要的资源清理,而非可选记账任务。
+
+本决策推翻[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)、[仅 master 执行的平台 CI](2026-09-06-master-only-platform-ci.zh.md) 和[真实 API e2e 决策](../testing/2026-06-19-real-api-e2e-ci.zh.md)中的取消豁免。这些记录对运行器池信任与切换、平台覆盖和密钥暴露仍有独立价值。[发布演练决策](2026-09-06-release-rehearsal-selfhosted.zh.md)仍负责运行器选择与隔离。没有记录被完全取代或归档。
+
+## 曾考虑的替代方案
+
+**保留正在执行的 master 推送演练。** 原有 `${{ github.event_name != 'push' }}` 豁免优先保证周期性就绪证据:每条热备以单门禁工作进程执行完整的未分片聚合流程,耗时可能长于 master 合并间隔。即便该策略也不保证每次演练完成。GitHub 每个组仅保留一个待运行条目,会替换中间的待执行推送;取消条件针对新触发的运行求值,因此共享 master 组的手动基准测试仍可取消演练。当时接受了这种罕见的手动中断,期望后续推送提供证据。豁免成本被限定为仅 master 执行的运行时检查、Wine 和两条演练;PR 作业仍在独立工作流中,按精确条件匹配的回归检查固定推送可达作业集合。为释放容量给当前验证而否决该策略,明确接受热备因反复被取消而无法完成。
+
+**用作业级并发保护演练,或仅取消 PR 事件。** 作业级分组不能让作业免于整个工作流的取消。仅针对 PR 的取消条件还会豁免手动触发:重复派发的运行器基准测试可能占用十二台大型运行器长达十五分钟,而非替换陈旧的测量。工作流级取消同时覆盖推送和手动运行。
+
+**保留每次合并后、每夜和打包运行。** 完成历史运行能提供更多按提交和触发划分的证据,但已被取代的验证会与最新运行竞争。这些验证不发布包,因此保留每次运行与保护有意发起的发布事务并不是同一项要求。
+
+**替换每个 `always()` 条件。** 失败聚合与资源清理承担不同义务。仅成功时执行的聚合可能把依赖失败隐藏为跳过的必需检查;移除无条件 Wine 清理则可能留下仍在运行的资源。只有已取消运行的记账任务被抑制。
+
+## 后果
+
+master 快速更新可能反复取消耗时更长的热备演练,使其无法产出结论。运维人员使用最近一次已完成的热备结论,并在依赖它判断故障切换就绪状态前核对其时间和提交;已调度、正在执行或已取消的演练都不构成就绪证据。该策略不保证每个中间提交、每夜触发或基准测试都能完成。不同引用仍会竞争共享主机容量。
+
+取消是由 GitHub Actions 及其运行器处理的请求,不保证立即终止或限定排队时长。清理仍可能耗时。该策略让已被取代的验证可以被取消,但不承诺固定运行时长或取消延迟。
+
+## 验证
+
+[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)固定工作流/引用隔离、发布所属构建豁免、聚合状态条件、覆盖率历史取消及保留的 Wine 清理。[平台路由回归测试](../../../../scripts/tests/ci-master-platforms.spec.ts)保留 master/PR 目标划分与发布矩阵;[发布演练回归测试](../../../../scripts/tests/ci-release-selfhosted.spec.ts)在验证取消策略的同时保留运行器准入与发布隔离。这些配置检查不重现 GitHub 调度或运行器停止过程。真实运行取代行为与已完成热备证据仍由 CI 负责验证。

+ 2 - 2
.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.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/testing/2026-06-19-real-api-e2e-ci.md
-2026-06-19-real-api-e2e-ci.md: 4f51032d90b6773d01db7bbfdced6328bd882ad0
-2026-06-19-real-api-e2e-ci.zh.md: 390df23aebec3a6a54bc48eb52022fe02e872164
+2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8
+2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808

+ 1 - 1
.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md

@@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
 
 ### Scope, runtime shape
 
-The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. Superseded PR runs are cancelled, while push and scheduled runs complete for post-merge signal.
+The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. The [superseded-CI cancellation policy](../process/2026-09-09-cancel-superseded-ci.md) cancels older runs in the same workflow/ref group across PR, push, schedule, and manual triggers; a post-merge or nightly trigger does not guarantee completion.
 
 The DeepSeek native `web_search` probe is registered but skipped. The live Anthropic-compatible endpoint can return a successful response without structured source blocks, so its positive-source assertion is not a reliable merge signal; unit coverage still pins response parsing, but CI does not prove the live source-block wire shape.
 

+ 1 - 1
.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md

@@ -54,7 +54,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试
 
 ### 范围与运行时形态
 
-job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和 schedule 运行完整执行以提供合并后信号。
+job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。[被取代 CI 的取消策略](../process/2026-09-09-cancel-superseded-ci.zh.md)会在 PR、push、schedule 和手动触发之间取消同一工作流/引用组内的旧运行;合并后或每夜触发并不保证完成。
 
 DeepSeek 原生 `web_search` 探测已注册但会跳过。线上 Anthropic 兼容端点可能返回成功响应却没有结构化来源块,因此对来源存在性的正向断言不是可靠的合并信号;单元测试仍会锁定响应解析行为,但 CI 不会验证线上端点返回的来源块协议格式(wire format)。
 

+ 2 - 1
.github/workflows/build-exe-for-python-sdk.yml

@@ -46,7 +46,8 @@ concurrency:
   # github.workflow identifies the caller inside a reusable workflow and keeps
   # an ordinary CI run from cancelling a full release validation on the same ref.
   group: build-single-exe-${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: ${{ github.event_name != 'push' || github.ref != 'refs/heads/master' }}
+  # Release-owned builds are part of an intentional publication transaction.
+  cancel-in-progress: ${{ !inputs.release }}
 
 permissions:
   contents: read

+ 3 - 6
.github/workflows/ci-master.yml

@@ -14,14 +14,11 @@ on:
           - larger-runner-benchmark
           - consolidated-runner-benchmark
 
-# Master runs platform runtime checks, Wine, and two self-hosted standby drills.
-# The drills outlast the interval between master merges, so
-# push is exempt from cancellation (see ci-failover-runbook). workflow_dispatch
-# keeps cancelling: a re-dispatched runner benchmark holds up to 12 larger
-# runners for 15 minutes in this same group.
+# New master pushes and manual runs replace obsolete checks in this workflow/ref.
+# Standby drills share cancellation; use completed runs as readiness evidence.
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: ${{ github.event_name != 'push' }}
+  cancel-in-progress: true
 
 permissions:
   contents: read

+ 4 - 6
.github/workflows/ci.yml

@@ -562,7 +562,7 @@ jobs:
         # Coverage flakes must not prevent the cache from building; the
         # measured durations remain useful even when a partition failed. The
         # per-run key keeps every save a fresh immutable cache entry.
-        if: always()
+        if: ${{ !cancelled() }}
         uses: actions/cache/save@v4
         with:
           path: .coverage-times.json
@@ -666,10 +666,8 @@ jobs:
   # `needs`. Native Windows build and process checks are required; Wine and
   # the deferred Python runtime targets live in ci-master.yml and do not
   # participate in this PR verdict. `needs` cannot cross workflow files.
-  # `if: always()` is load-bearing: without it a failed dependency
-  # would SKIP this job, and GitHub counts a skipped required check as passing
-  # — so this job always runs and fails on any non-success result, including
-  # 'cancelled' and 'skipped'.
+  # An explicit status function runs the verdict after failed/skipped needs
+  # without keeping a cancelled workflow alive for an obsolete verdict.
   all-checks-passed:
     name: all checks passed
     # This bookkeeping-only verdict must not depend on custom-pool
@@ -684,7 +682,7 @@ jobs:
           && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
           || 'ubuntu-latest' }}
     needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests]
-    if: always() && github.event_name == 'pull_request'
+    if: ${{ !cancelled() && github.event_name == 'pull_request' }}
     steps:
       - name: Fail if any needed job did not succeed
         if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')

+ 3 - 3
.github/workflows/e2e.yml

@@ -36,11 +36,11 @@ on:
     # 00:17 UTC nightly = 08:17 Asia/Shanghai — off the top-of-hour cron stampede.
     - cron: '17 0 * * *'
 
-# Cancel a superseded PR run (it is on a stale commit); never cancel a
-# push/schedule run — it is already producing the post-merge/nightly signal.
+# Keep only the newest validation in each workflow/ref, including master
+# pushes, nightly runs, and manual dispatches.
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+  cancel-in-progress: true
 
 # Least privilege: this job only reads the repo to run tests.
 permissions:

+ 1 - 1
.github/workflows/release-vendor.yml

@@ -20,7 +20,7 @@ permissions:
 concurrency:
   # Pack runs per ref so concurrent pull requests never displace each other.
   group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: false
+  cancel-in-progress: true
 
 env:
   PRIMARY_NODE_VERSION: '24'

+ 1 - 1
.github/workflows/release.yml

@@ -19,7 +19,7 @@ permissions:
 concurrency:
   # Pack runs per ref so concurrent pull requests never displace each other.
   group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: false
+  cancel-in-progress: true
 
 env:
   PRIMARY_NODE_VERSION: '24'

+ 1 - 1
apps/cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh",
   "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
apps/desktop-host/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-desktop-host",
   "description": "Private upstream-Node host process for the Electron desktop application",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "private": true,
   "license": "MIT",
   "type": "module",

+ 1 - 1
apps/desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-desktop",
   "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "private": true,
   "license": "MIT",
   "type": "module",

+ 1 - 1
apps/web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-frontend",
   "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 76 - 10
apps/web/tests/document-preview.e2e.ts

@@ -18,6 +18,10 @@ const PAGE_LINES = 64
 const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url))
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
 const MODE = webSnapshotMode()
+const TINY_PNG = Buffer.from(
+  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
+  'base64',
+)
 
 /** Successful render evidence stays outside the committed snapshot inventory. */
 async function successShot(page: Page, name: string): Promise<void> {
@@ -76,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     }
   })
 
-  it('opens Markdown, isolated HTML, and a rendered PDF from the Session workspace', async () => {
+  it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => {
     onTestFailed(async () => {
       await mkdir(SHOT_DIR, { recursive: true })
       await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
@@ -118,6 +122,13 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'),
       writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'),
       writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'),
+      writeFile(join(cwd, 'tiny.png'), TINY_PNG),
+      writeFile(join(cwd, 'large.svg'), [
+        '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1600" viewBox="0 0 1200 1600">',
+        '<script>parent.document.documentElement.setAttribute("data-image-preview-escape","true")</script>',
+        '<rect width="1200" height="1600" fill="#2463eb"/>',
+        '</svg>',
+      ].join('')),
       writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
     ])
 
@@ -230,6 +241,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')
@@ -293,6 +316,50 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
     ].join('\n'))
 
+    await openFile('tiny.png')
+    await expect.poll(() => viewer.innerText()).toBe('Image')
+    const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
+    await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
+    expect(await tinyImage.evaluate(node => ({
+      width: (node as HTMLImageElement).naturalWidth,
+      height: (node as HTMLImageElement).naturalHeight,
+      draggable: (node as HTMLImageElement).draggable,
+    }))).toEqual({ width: 1, height: 1, draggable: false })
+    const centering = await tinyImage.evaluate((node) => {
+      const image = node.getBoundingClientRect()
+      const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
+      if (scroller === undefined) throw new Error('image document scroller is unavailable')
+      return {
+        horizontal: Math.abs((image.left + image.width / 2) - (scroller.left + scroller.width / 2)),
+        vertical: Math.abs((image.top + image.height / 2) - (scroller.top + scroller.height / 2)),
+      }
+    })
+    expect(centering.horizontal).toBeLessThan(10)
+    expect(centering.vertical).toBeLessThan(10)
+
+    await openFile('large.svg')
+    await expect.poll(() => viewer.innerText()).toBe('Image')
+    const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true })
+    await largeImage.waitFor({ state: 'visible', timeout: 15_000 })
+    expect(await largeImage.evaluate(node => ({
+      naturalWidth: (node as HTMLImageElement).naturalWidth,
+      naturalHeight: (node as HTMLImageElement).naturalHeight,
+      width: getComputedStyle(node).width,
+      height: getComputedStyle(node).height,
+    }))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' })
+    expect(await body.evaluate(node => ({
+      horizontal: node.scrollWidth > node.clientWidth,
+      vertical: node.scrollHeight > node.clientHeight,
+    }))).toEqual({ horizontal: true, vertical: true })
+    const scrolled = await body.evaluate((node) => {
+      node.scrollLeft = node.scrollWidth
+      node.scrollTop = node.scrollHeight
+      return { left: node.scrollLeft, top: node.scrollTop }
+    })
+    expect(scrolled.left).toBeGreaterThan(0)
+    expect(scrolled.top).toBeGreaterThan(0)
+    expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
+
     const releaseRead = Promise.withResolvers<undefined>()
     let waitingForRead = false
     const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles)
@@ -325,6 +392,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)
@@ -343,33 +411,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()

+ 86 - 0
apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md

@@ -0,0 +1,86 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "插件":
+      - img
+      - text: 插件
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: DeepSeek
+      - img "API 密钥已配置"
+      - button "编辑 DeepSeek (deepseek-official)": 编辑
+      - text: DeepSeek deepseek-official API 密钥
+      - textbox "API 密钥":
+        - /placeholder: 已配置——输入新值可替换
+      - group:
+        - text: 自定义设置 API 地址
+        - textbox "API 地址":
+          - /placeholder: https://api.deepseek.com
+        - region "模型目录":
+          - text: 模型目录 正在使用适配器默认模型
+          - textbox "模型 ID 1":
+            - /placeholder: 模型 ID
+            - text: deepseek-flash
+          - textbox "显示名称 1":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V41-Flash
+          - button "容量 1":
+            - img
+          - button "删除模型 1":
+            - img
+          - textbox "模型 ID 2":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-flash
+          - textbox "显示名称 2":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Flash
+          - button "容量 2":
+            - img
+          - button "删除模型 2":
+            - img
+          - textbox "模型 ID 3":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-pro
+          - textbox "显示名称 3":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Pro
+          - button "容量 3":
+            - img
+          - button "删除模型 3":
+            - img
+          - textbox "模型 ID 4":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-flash-vision-exp
+          - textbox "显示名称 4":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Flash-Vision-Exp
+          - button "容量 4":
+            - img
+          - button "删除模型 4":
+            - img
+          - button "添加模型":
+            - img
+            - text: 添加模型
+      - button "取消"
+      - button "保存"
+  - button "添加提供方":
+    - img
+    - text: 添加提供方
+  - button "添加自定义提供方":
+    - img
+    - text: 添加自定义提供方

+ 5 - 25
apps/web/tests/expected/onboarding-deepseek-config/models.expected.md

@@ -35,41 +35,21 @@
           - text: 模型目录 已自定义模型目录
           - button "恢复默认模型"
           - textbox "模型 ID 1":
-            - /placeholder: 模型 ID
-            - text: deepseek-v4-pro
-          - textbox "显示名称 1":
-            - /placeholder: 显示名称
-            - text: DeepSeek-V4-Pro
-          - button "容量 1":
-            - img
-          - button "删除模型 1":
-            - img
-          - textbox "模型 ID 2":
-            - /placeholder: 模型 ID
-            - text: deepseek-v4-flash-vision-exp
-          - textbox "显示名称 2":
-            - /placeholder: 显示名称
-            - text: DeepSeek-V4-Flash-Vision-Exp
-          - button "容量 2":
-            - img
-          - button "删除模型 2":
-            - img
-          - textbox "模型 ID 3":
             - /placeholder: 模型 ID
             - text: private-preview
-          - textbox "显示名称 3":
+          - textbox "显示名称 1":
             - /placeholder: 显示名称
             - text: Private Preview
-          - button "容量 3" [expanded]:
+          - button "容量 1" [expanded]:
             - img
-          - button "删除模型 3":
+          - button "删除模型 1":
             - img
           - text: 上下文窗口
-          - textbox "上下文窗口 3":
+          - textbox "上下文窗口 1":
             - /placeholder: 1M
             - text: "131072"
           - text: 最大输出 token 数
-          - textbox "最大输出 token 数 3":
+          - textbox "最大输出 token 数 1":
             - /placeholder: 256K
             - text: 64K
           - button "添加模型":

+ 34 - 14
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -21,6 +21,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-deepseek-confi
 const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
 const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
 const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
+const DEFAULT_MODELS_EXPECTED = join(SNAPSHOT_DIR, 'default-models.expected.md')
 const MODE = webSnapshotMode()
 
 describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
@@ -202,15 +203,39 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await deepSeek.waitFor({ timeout: 10_000 })
     await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
     await settings.getByText('自定义设置').click()
-    await settings.getByRole('button', { name: /删除模型/ }).first().click()
+    expect(await settings.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
+    expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V41-Flash')
+    expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-flash')
+    expect(await settings.getByLabel('模型 ID 3').inputValue()).toBe('deepseek-v4-pro')
+    expect(await settings.getByLabel('模型 ID 4').inputValue()).toBe('deepseek-v4-flash-vision-exp')
+    expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(4)
+    const defaultModels = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(DEFAULT_MODELS_EXPECTED, defaultModels, MODE)
+    await settings.getByLabel('显示名称 1').fill('Configured Flash')
+    await settings.getByRole('button', { name: '保存', exact: true }).click()
+    await settings.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 15_000 })
+    const savedDefaults = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(savedDefaults).toContain('id: deepseek-flash')
+    expect(savedDefaults).toContain('inputModalities:')
+    expect(savedDefaults).toContain('- text')
+    expect(savedDefaults).toContain('- image')
+    expect(savedDefaults).toContain('systemPromptUpdate: in-history')
+    await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
+      name: 'Configured Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
+    })
+    await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
+    await settings.getByText('自定义设置').click()
+    for (let index = 0; index < 4; index++) {
+      await settings.getByRole('button', { name: /删除模型/ }).first().click()
+    }
     await settings.getByRole('button', { name: '添加模型' }).click()
-    const customModelId = settings.getByLabel('模型 ID 3')
+    const customModelId = settings.getByLabel('模型 ID 1')
     await customModelId.fill('private-preview')
-    await settings.getByLabel('显示名称 3').fill('Private Preview')
+    await settings.getByLabel('显示名称 1').fill('Private Preview')
     // Capacities live behind the row's own disclosure, as in the pi-ai form.
-    await settings.getByRole('button', { name: '容量 3' }).click()
-    await settings.getByLabel('上下文窗口 3').fill('131072')
-    await settings.getByLabel('最大输出 token 数 3').fill('64K')
+    await settings.getByRole('button', { name: '容量 1' }).click()
+    await settings.getByLabel('上下文窗口 1').fill('131072')
+    await settings.getByLabel('最大输出 token 数 1').fill('64K')
 
     await expect.poll(
       () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
@@ -222,15 +247,11 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await customModelId.waitFor({ state: 'detached', timeout: 15_000 })
 
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
-    expect(document).toContain('id: deepseek-v4-pro')
-    expect(document).toContain('id: deepseek-v4-flash-vision-exp')
-    expect(document).toContain('inputModalities:')
-    expect(document).toContain('- image')
     expect(document).toContain('id: private-preview')
     expect(document).toContain('name: Private Preview')
     expect(document).toContain('contextWindow: 131072')
     expect(document).toContain('maxTokens: 64000')
-    expect(document).not.toMatch(/^\s*- id: deepseek-v4-flash$/m)
+    expect(document).not.toContain('id: deepseek-flash')
 
     await page.keyboard.press('Escape')
     // A connected Workspace is what puts a live composer — and its model
@@ -241,8 +262,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await modelTrigger.waitFor({ timeout: 10_000 })
     await modelTrigger.click()
     await page.getByRole('menuitem', { name: /模型/ }).click()
-    expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0)
-    await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).waitFor({ timeout: 10_000 })
+    expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0)
     await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
@@ -251,7 +271,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
   it('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(
       SNAPSHOT_DIR,
-      ['welcome.expected.md', 'missing.expected.md', 'models.expected.md'],
+      ['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'],
     )
   })
 })

+ 4 - 0
apps/web/tests/scaffold.ts

@@ -515,6 +515,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   const patches: PatchOptions[] = [
     ...basePatches,
     ...surfacePatches,
+    // Keyless scenarios retain the recorded default; explicit scenario overlays win.
+    ...mode === 'record' || options.deepSeekMissingCredential === true
+      ? []
+      : [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
     ...extraOverlayPatches,
     // The roster's shipped presets are the plugin's own, bundled inside
     // `dsh-agent-presets` and prepended by it. Pin only the machine-local

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

@@ -1071,7 +1071,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/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 0994c30c9d11f3b505d89df1e7e2bb7709d40144
-config-catalog.zh.md: 449e6b3521ebc189de9e787350d8f5e4dce7a046
+config-catalog.md: fbe4f1a11bf15118251d8225e2a648e49723ac53
+config-catalog.zh.md: 005a84d4e5782eac5678e8b6b6af4de82ad34f23

+ 2 - 2
docs/config-catalog.md

@@ -1074,7 +1074,7 @@ export interface Config {
   maxTokens?: number
   /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
   defaultContextWindow?: number
-  /** Advisory models shown by discovery consumers; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
+  /** Advisory models shown by discovery consumers; defaults to V41 Flash, V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number
@@ -1131,7 +1131,7 @@ export interface DeepSeekCatalogModel {
 
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts)
+Source: [`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

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

@@ -1076,7 +1076,7 @@ export interface Config {
   maxTokens?: number
   /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
   defaultContextWindow?: number
-  /** Advisory models shown by discovery consumers; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
+  /** Advisory models shown by discovery consumers; defaults to V41 Flash, V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number
@@ -1133,7 +1133,7 @@ export interface DeepSeekCatalogModel {
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/llm/llm-deepseek/src/index.ts:125`](../packages/llm/llm-deepseek/src/index.ts)
+来源:[`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 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))。
 

+ 2 - 2
docs/user/guide/providers.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/user/guide/providers.md
-providers.md: 0db3280760c487c8469399bc9668b0685213e375
-providers.zh.md: 3af9096f5a77ee40afe2ee788aa22a0758dd0e86
+providers.md: 90d3bb2c96a80185fbc5e3e70f1ff577f6923acc
+providers.zh.md: 2937d676253ee45dad48655398736d36fc37dff2

+ 1 - 1
docs/user/guide/providers.md

@@ -186,5 +186,5 @@ Every switch, its accepted values, and the protocols that take it are listed und
 - **The Effort menu does not appear for a model you entered by hand** — It declares no levels. Add `reasoningEfforts` to the model in `settings.yaml`.
 - **`off` does not stop a DeepSeek model from thinking** — An empty `off` sends no reasoning field at all, and an endpoint that thinks by default keeps thinking. Set `compat.thinkingFormat: deepseek` on the model or the route.
 - **A compat switch is refused as having no value** — A key written with nothing after the colon. Give it a value, or remove the key to keep the installed catalog's.
-- **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; on DeepSeek's own route, select `deepseek-v4-flash-vision-exp`, the model that declares images.
+- **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; on DeepSeek's own route, select an image-capable entry from the configured catalog (`deepseek-flash` by default) and confirm that your gateway serves that model with image input.
 - **The provider rejects a request carrying an image** — The model declares images its endpoint does not actually serve. Remove `image` from whichever list granted it — the model's `input`, or the route's `defaultInput` — then start a new session: the attached image stays in the session log, so the same request repeats until the session moves off it.

+ 1 - 1
docs/user/guide/providers.zh.md

@@ -186,5 +186,5 @@ llm-pi-ai:
 - **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。在 `settings.yaml` 中给该模型加上 `reasoningEfforts`。
 - **`off` 无法让 DeepSeek 模型停止思考**:留空的 `off` 不发送任何推理字段,默认思考的端点就继续思考。请在模型或路由上设置 `compat.thinkingFormat: deepseek`。
 - **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。
-- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请选择声明了图片能力的模型 `deepseek-v4-flash-vision-exp`。
+- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。
 - **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-root",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "license": "MIT",
   "private": true,
   "type": "module",

+ 1 - 1
packages/acp/acp/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp",
   "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/gateway/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-gateway",
   "description": "Typert Remote Host dispatcher and Client API endpoint",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/remotes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-remotes",
   "description": "Remote BFF assembly for application-selected Host capabilities",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/session-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-session-controller",
   "description": "Session Remote commands, cold reads, and live control transport",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/settings-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-settings-controller",
   "description": "Remote owner for the configuration surfaces over the settings-domain seams",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/terminal-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-terminal-controller",
   "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/workspace-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-controller",
   "description": "Workspace Remote commands and reconnect-safe state transport",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/workspace-files/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-files",
   "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment-local/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment-local",
   "description": "Private content-addressed DSH_HOME attachment storage",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment",
   "description": "Durable immutable attachment storage seam for the DeepSeek Harness",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/app-boot/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-app-boot",
   "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/cmdline/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-cmdline",
   "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp-app",
   "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/base/cordis.patch.yml

@@ -76,7 +76,7 @@
       name: '@deepseek-ai/dsh-agent-default-model'
       config:
         provider: deepseek-official
-        model: deepseek-v4-flash
+        model: deepseek-flash
 
     - id: jobs
       name: '@deepseek-ai/dsh-jobs-local'

+ 1 - 1
packages/bundle/base/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-base",
   "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/headless/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-headless",
   "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-app",
   "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/sdk-minimal/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-minimal",
   "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-app",
   "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/connection/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-connection",
   "description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/file-upload/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-file-upload",
   "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/hmr/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-hmr",
   "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/locale/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-locale",
   "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/modules/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-modules",
   "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/resources/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-resources",
   "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/store/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-store",
   "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-agent-preset/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-agent-preset",
   "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-approval",
   "description": "Approval composer takeover over the scoped Remote Event waterfall",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-attachment",
   "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-brand-official/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-brand-official",
   "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-chat",
   "description": "Chat Conversation target, node definitions, renderers, and details surface",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 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)', () => {

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-commands",
   "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-conversation",
   "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

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

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-deliverables",
   "description": "Produced-files turn tail and clickable final-response file references for Web",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-directory-picker-browse/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse",
   "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-directory-picker-native/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
   "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 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 条的某个位置而非末尾。
 

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio