소스 검색

Merge pull request #2964 from deepseek-harness/worktree/localize-ui-strings-gate

fix(client): localize UI copy and gate regressions
Tianyi Cui 1 개월 전
부모
커밋
11b69490bf
100개의 변경된 파일과 886개의 추가작업 그리고 312개의 파일을 삭제
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml
  2. 7 12
      .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
  3. 7 12
      .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml
  5. 42 0
      .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md
  6. 42 0
      .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md
  7. 2 2
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
  8. 1 1
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
  9. 1 1
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
  10. 1 0
      .agents/skills/dsh-code-review/SKILL.md
  11. 1 1
      .agents/skills/dsh-prose-standard/SKILL.md
  12. 3 2
      AGENTS.md
  13. 1 1
      apps/web/tests/snapshots/access-confirmation/ui.expected.md
  14. 2 2
      apps/web/tests/snapshots/search-card/grep-card.expected.txt
  15. 1 1
      apps/web/tests/web-search-round.e2e.ts
  16. 2 2
      docs/module-graph.i18n.yaml
  17. 2 1
      docs/module-graph.md
  18. 2 1
      docs/module-graph.zh.md
  19. 1 0
      package.json
  20. 5 3
      packages/client/AGENTS.md
  21. 2 2
      packages/client/locale/README.i18n.yaml
  22. 1 2
      packages/client/locale/README.md
  23. 1 2
      packages/client/locale/README.zh.md
  24. 16 0
      packages/client/locale/src/locales/en.ts
  25. 16 0
      packages/client/locale/src/locales/zh.ts
  26. 3 2
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  27. 1 1
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  28. 1 1
      packages/client/ui-chat/src/client/chat/CompactionCommandCard.tsx
  29. 4 2
      packages/client/ui-chat/src/client/chat/CompactionItem.tsx
  30. 11 3
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  31. 1 1
      packages/client/ui-chat/src/client/chat/ReasoningRow.tsx
  32. 14 11
      packages/client/ui-chat/src/client/chat/StatsLine.tsx
  33. 4 3
      packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts
  34. 1 1
      packages/client/ui-chat/src/client/index.ts
  35. 14 0
      packages/client/ui-chat/src/client/locale.ts
  36. 16 0
      packages/client/ui-chat/src/client/markdown-labels.ts
  37. 1 1
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  38. 7 7
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  39. 4 4
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  40. 1 1
      packages/client/ui-chat/tests/coverage-tails.client.spec.tsx
  41. 2 2
      packages/client/ui-chat/tests/reasoning-row.client.spec.tsx
  42. 1 0
      packages/client/ui-commands/src/client/PopupSelectView.tsx
  43. 2 0
      packages/client/ui-conversation/src/client/contract/records.ts
  44. 2 0
      packages/client/ui-conversation/src/client/contract/request-inspection.ts
  45. 18 6
      packages/client/ui-conversation/src/client/conversation/failure-display.ts
  46. 2 1
      packages/client/ui-conversation/src/client/index.ts
  47. 82 0
      packages/client/ui-conversation/src/client/locales.ts
  48. 7 6
      packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx
  49. 9 5
      packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
  50. 23 0
      packages/client/ui-conversation/tests/failure-display.client.spec.ts
  51. 2 2
      packages/client/ui-layout/README.i18n.yaml
  52. 1 1
      packages/client/ui-layout/README.md
  53. 1 1
      packages/client/ui-layout/README.zh.md
  54. 2 0
      packages/client/ui-layout/package.json
  55. 10 2
      packages/client/ui-layout/src/client/AppFrame.tsx
  56. 3 4
      packages/client/ui-layout/src/client/DocumentTitle.tsx
  57. 3 1
      packages/client/ui-layout/src/client/index.ts
  58. 6 0
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  59. 1 1
      packages/client/ui-layout/tests/apply.client.spec.ts
  60. 5 5
      packages/client/ui-layout/tests/document-title.client.spec.tsx
  61. 2 2
      packages/client/ui-message-feedback/src/client/controller.ts
  62. 4 4
      packages/client/ui-message-feedback/tests/controller.client.spec.ts
  63. 1 0
      packages/client/ui-permission-presets/src/client/PermissionRow.tsx
  64. 2 4
      packages/client/ui-plan/src/client/PlanModeControl.tsx
  65. 4 0
      packages/client/ui-plan/src/client/locales.ts
  66. 1 1
      packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx
  67. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  68. 1 1
      packages/client/ui-primitives/README.md
  69. 1 1
      packages/client/ui-primitives/README.zh.md
  70. 2 2
      packages/client/ui-primitives/src/ConnectionBanner.tsx
  71. 23 11
      packages/client/ui-primitives/src/DiffBlock.tsx
  72. 33 0
      packages/client/ui-primitives/src/FoldToggle.tsx
  73. 5 5
      packages/client/ui-primitives/src/HoverCard.tsx
  74. 13 32
      packages/client/ui-primitives/src/JsonTree.tsx
  75. 18 13
      packages/client/ui-primitives/src/Modal.tsx
  76. 23 10
      packages/client/ui-primitives/src/ReadBlock.tsx
  77. 3 0
      packages/client/ui-primitives/src/RiskConfirmation.tsx
  78. 21 7
      packages/client/ui-primitives/src/SearchBlock.tsx
  79. 4 24
      packages/client/ui-primitives/src/TerminalBlock.tsx
  80. 21 8
      packages/client/ui-primitives/src/WebBlock.tsx
  81. 7 4
      packages/client/ui-primitives/src/index.ts
  82. 3 3
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  83. 2 7
      packages/client/ui-primitives/src/markdown/JsonBlock.tsx
  84. 18 18
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  85. 12 6
      packages/client/ui-primitives/src/markdown/render.tsx
  86. 15 4
      packages/client/ui-primitives/tests/atoms.client.spec.tsx
  87. 7 1
      packages/client/ui-primitives/tests/code-block.client.spec.tsx
  88. 7 1
      packages/client/ui-primitives/tests/diff-block.client.spec.tsx
  89. 16 2
      packages/client/ui-primitives/tests/hover-card.client.spec.tsx
  90. 9 1
      packages/client/ui-primitives/tests/json-tree.client.spec.tsx
  91. 64 0
      packages/client/ui-primitives/tests/labels.client.ts
  92. 1 1
      packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx
  93. 7 3
      packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx
  94. 3 2
      packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx
  95. 37 0
      packages/client/ui-primitives/tests/markdown-test-components.tsx
  96. 2 1
      packages/client/ui-primitives/tests/markdown.client.spec.tsx
  97. 7 1
      packages/client/ui-primitives/tests/read-block.client.spec.tsx
  98. 23 2
      packages/client/ui-primitives/tests/search-block.client.spec.tsx
  99. 7 1
      packages/client/ui-primitives/tests/terminal-block.client.spec.tsx
  100. 23 2
      packages/client/ui-primitives/tests/web-block.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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-07-30-client-locale-full-rollout.md
-2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab
-2026-07-30-client-locale-full-rollout.zh.md: 427c9e5ef9c544a49e70b6ba8450511072f53a6e
+2026-07-30-client-locale-full-rollout.md: aeb4deae28b0dfdb9ab75fd64fe3143958cd6910
+2026-07-30-client-locale-full-rollout.zh.md: a642b6062cb3dc7a2dfa22dd5d8cf7d9a02e3104

+ 7 - 12
.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md

@@ -1,4 +1,4 @@
-# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary
+# Agent Note: Full client copy rollout onto the typed locale seat
 
 Status: implemented
 
@@ -6,7 +6,7 @@ English | [中文](2026-07-30-client-locale-full-rollout.zh.md)
 
 ## Problem
 
-After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization.
+After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch, and how the zero-Cordis ui-primitives atoms receive copy without depending on the runtime.
 
 ## Decision
 
@@ -14,16 +14,11 @@ After the typed locale standard seat landed (`locale:` on register → framework
 
 **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record<string, string>` is the key source and `en satisfies Record<XxxKey, string>` locks bilingual balance.
 
-**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
+**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md).
 
-**The non-translation boundary (deliberate decisions, not debt):**
+**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation.
 
-- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim.
-- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages.
-- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately).
-- **Boot copy stays hardcoded** (the framework-free boot page runs before the locale service exists).
-
-**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure.
+**Derivation layers keep display text out of identity.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank session titles and the Ungrouped label derive from the `blank` flag / absent `workspaceId`, while internal values stay empty or stable; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter.
 
 **Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)).
 
@@ -33,7 +28,7 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
 
 - **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision.
 - **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently.
-- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text.
+- **Translate external or wire error data**: rejected because provider and protocol diagnostics are evidence searched and compared verbatim. Product-authored surrounding failure chrome is translated; externally authored data is not.
 - **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock.
 - **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic.
 
@@ -41,5 +36,5 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
 
 - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue.
 - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically.
-- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo.
+- ui-primitives require localized label props, so adding a primitive render site also adds an explicit copy owner; omission fails typechecking instead of selecting a hidden language.
 - Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md).

+ 7 - 12
.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界
+# Agent Note: client 文案全量接入 typed locale 席位
 
 Status: implemented
 
@@ -6,7 +6,7 @@ Status: implemented
 
 ## Problem
 
-typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使未来的 agent(智能体)「补完」翻译。
+typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新,以及 zero-Cordis 的 ui-primitives 原子组件如何在不依赖运行时的情况下拿到文案。
 
 ## Decision
 
@@ -14,16 +14,11 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
 
 **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record<string, string>` 为 key 源、`en satisfies Record<XxxKey, string>` 锁双语平衡。
 
-**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费方渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。
+**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。
 
-**不翻译边界(刻意决定,不是欠账):**
+**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。
 
-- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError 消息、wire 透出的 `error.message (code)` 原样呈现。
-- **设计字面量不进字典**:工具行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、整个 StatsLine——中英界面显示一致。
-- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。
-- **boot 文案保持硬编码**(不依赖框架的启动页运行早于 locale 服务可用)。
-
-**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。
+**派生层不让展示文本承担身份。** ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}`,由渲染组合字典模板;blank 会话标题和未分组 label 从 `blank` 标志/`workspaceId` 缺席派生,内部值保持为空或稳定;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数接收 `t` 参数。
 
 **测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot 快照 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。
 
@@ -33,7 +28,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
 
 - **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 已经为每个包注册一次,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。
 - **给 ui-primitives 造 locale 上下文/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费方(ui-trajectory)陪跑。props 化让每个消费方独立决定。
-- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。
+- **翻译外部或 wire 错误数据**:否决。提供方与协议诊断是需要原样搜索和比对的证据。产品编写的外围失败 chrome 会翻译,外部编写的数据不会。
 - **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。
 - **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下「看得见搜不到」;占位行本无信息量,整体排除语义最稳。
 
@@ -41,5 +36,5 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
 
 - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。
 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。
-- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。
+- ui-primitives 要求本地化 label prop,因此新增原子组件渲染点也必须新增明确的文案 owner;遗漏会在类型检查失败,而不是选择隐藏语言。
 - e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md
+2026-08-23-locale-owned-client-ui-copy.md: 7fa2d60f14253a74b2bd3df4398471905a32509b
+2026-08-23-locale-owned-client-ui-copy.zh.md: 5515699bb1702d41726c57435b19a2256ee0b896

+ 42 - 0
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md

@@ -0,0 +1,42 @@
+# Agent Note: Locale-owned client UI copy
+
+Status: implemented
+
+English | [中文](2026-08-23-locale-owned-client-ui-copy.zh.md)
+
+## Problem
+
+Typed locale namespaces and bilingual dictionary parity proved that registered dictionaries were complete, but they could not prove that presentation code used them. JSX text, accessibility attributes, formatter returns, and zero-Cordis primitive defaults could bypass `t` while every locale check remained green. The deferred and supposedly language-neutral exceptions recorded in the [initial full-rollout decision](2026-07-30-client-locale-full-rollout.md) accumulated into a mixed-language UI, especially in trajectory inspection and generic Tool cards.
+
+## Decision
+
+**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated.
+
+**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionBanner` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning.
+
+**Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state.
+
+**`verify-client-ui-i18n` enforces source ownership.** The TypeScript-AST check discovers every package `src/client` tree that contains TSX, all helper TS files under `packages/client/ui-*`, and the web app source. It rejects natural-language JSX text, copy-bearing attributes and component props, literal JSX branches, label/copy data, named copy helpers, string-returning display formatters, and destructuring defaults. Locale dictionary owners and immutable language tokens are the narrow syntactic exclusions. Discovery refuses a narrowed corpus, unit fixtures pin admitted and excluded forms, and the check runs in the static CI and `hygiene` graphs. Dictionary-key parity remains a separate check: one gate proves copy enters the locale path, while the other proves both shipped languages implement that path.
+
+The product-authored error and design-literal exclusions, primitive defaults, and trajectory deferral in the [initial rollout](2026-07-30-client-locale-full-rollout.md) are superseded by this decision. Its label-thunk, typed-seat, browser-locale, date-formatting, and search-placeholder decisions remain active.
+
+## Verification
+
+The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
+
+## Alternatives considered
+
+**Rely on review and AGENTS.md alone.** Rejected because the existing rule and typed dictionaries coexisted with hundreds of bypasses; reviewers need a source-level failure at the introducing line.
+
+**Use a text regex or ban every string literal.** Rejected because TypeScript and JSX contain imports, CSS classes, discriminants, event names, SVG data, and user/wire values. Syntax-aware contexts provide useful signal without an ever-growing file allowlist, while the minimum discovery count prevents a falsely green narrowed scan.
+
+**Keep primitive fallback copy for convenient direct use.** Rejected because a fallback is itself an implicit locale choice. Required label props keep primitives framework-free and make each product render site name its copy owner.
+
+**Translate every string that reaches the DOM.** Rejected because authored data and protocol/code tokens are not product wording. Translating them corrupts evidence, identifiers, commands, paths, URLs, and provider diagnostics; only surrounding product chrome belongs to the locale system.
+
+## Consequences
+
+- Adding or changing client UI copy requires a typed dictionary key in both locales and behavior evidence for the affected render path.
+- Pure primitives have larger explicit prop types, and tests provide deliberate label fixtures; this cost prevents hidden locale behavior.
+- The AST check catches authored literal bypasses but cannot prove that an arbitrary dynamic string prop was translated. Types, dictionary parity, component tests, and review still own that semantic distinction.
+- Boot markup that renders before the locale service and externally authored runtime data remain outside the dictionary path; product UI replaces boot copy after locale activation.

+ 42 - 0
.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md

@@ -0,0 +1,42 @@
+# Agent Note: locale 归属的 client UI 文案
+
+Status: implemented
+
+[English](2026-08-23-locale-owned-client-ui-copy.md) | 中文
+
+## Problem
+
+typed locale namespace 与双语字典对等性可以证明已注册字典完整,却无法证明展示代码使用了字典。JSX 文本、无障碍属性、格式化函数返回值和 zero-Cordis 原子组件默认值都可能绕过 `t`,而全部 locale 检查仍保持绿色。[最初的全量接入决策](2026-07-30-client-locale-full-rollout.zh.md)中缓做或假定为语言无关的例外逐渐形成混合语言 UI,trajectory 检查面和通用工具卡尤为明显。
+
+## Decision
+
+**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。
+
+**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionBanner` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。
+
+**本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。
+
+**`verify-client-ui-i18n` 强制源码归属。** 基于 TypeScript AST 的检查会发现每个包含 TSX 的 package `src/client` 目录树、`packages/client/ui-*` 下的所有辅助 TS 文件和 web 应用源码;它拒绝自然语言 JSX 文本、承载文案的属性与组件 prop、JSX 字面量分支、label/copy 数据、具名文案辅助函数、返回字符串的展示格式化函数和解构默认值。locale 字典 owner 与不可变语言 token 是严格的语法级排除项。发现范围缩窄会直接失败,单元 fixture 固定纳入与排除形态,检查加入静态 CI 与 `hygiene` 图。字典 key 对等性仍由独立检查负责:一道门禁证明文案进入 locale 路径,另一道门禁证明两种发布语言都实现该路径。
+
+[最初接入决策](2026-07-30-client-locale-full-rollout.zh.md)中的产品自产错误与设计字面量例外、原子组件默认文案和 trajectory 缓做均由本决定取代;其 label thunk、typed 席位、浏览器 locale、日期格式化和搜索占位行决定仍有效。
+
+## Verification
+
+AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
+
+## Alternatives considered
+
+**只依赖评审与 AGENTS.md。** 否决。既有规则和 typed 字典与数百个绕过点同时存在;评审者需要在引入行收到源码级失败。
+
+**使用文本正则,或禁止所有字符串字面量。** 否决。TypeScript 与 JSX 中包含 import、CSS class、判别值、事件名、SVG 数据和用户/wire 值。按语法上下文检查可在不扩张文件 allowlist 的情况下保持有效信号,而最小发现数量可防止扫描范围缩小后伪绿。
+
+**为方便直接使用而保留原子组件回落文案。** 否决。回落值本身就是隐式 locale 选择。必填 label prop 让原子组件保持框架无关,并迫使每个产品渲染点明确文案 owner。
+
+**翻译所有进入 DOM 的字符串。** 否决。外部编写的数据和协议/代码 token 并非产品措辞。翻译会破坏证据、标识符、命令、路径、URL 和提供方诊断;只有其周围的产品 chrome 属于 locale 系统。
+
+## Consequences
+
+- 新增或修改 client UI 文案时,必须在两种 locale 中添加 typed 字典 key,并为受影响渲染路径提供行为证据。
+- 纯原子组件的显式 prop 类型变大,测试需提供有意选择的 label fixture;这项成本换来无隐藏 locale 行为。
+- AST 检查可以抓到产品编写的字面量绕过,却无法证明任意动态字符串 prop 已翻译。类型、字典对等性、组件测试和评审仍共同负责这一语义区分。
+- locale 服务之前渲染的 boot 标记和外部编写的运行时数据仍在字典路径之外;locale 激活后,产品 UI 会替换 boot 文案。

+ 2 - 2
.agents/notes/implemented/process/2026-07-20-gui-testing-system.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-20-gui-testing-system.md
-2026-07-20-gui-testing-system.md: 20e2709d070439f33664d07c6424c864cd84e850
-2026-07-20-gui-testing-system.zh.md: 88490dc90b34488391a80c155f423336d64adc3d
+2026-07-20-gui-testing-system.md: 8656507ba85b187fea333b100762d4250256845f
+2026-07-20-gui-testing-system.zh.md: d9dee0d531bfebafca02bbe765a271ee39e6e6a0

+ 1 - 1
.agents/notes/implemented/process/2026-07-20-gui-testing-system.md

@@ -2,7 +2,7 @@
 
 Status: implemented
 
-> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership/registry semantics are tier-2 territory (`runtime` + `ui-slots` suites), not component specs.
+> Path update (2026-08-23, Controller split): the three-tier philosophy and golden-path method here remain current; object-layer specs now live across `packages/api/session-controller/tests/` and `packages/test-support/client-runtime/tests/`, while wire specs remain in `packages/client/connection/tests/`. Component specs are per-plugin jsdom suites under each `packages/client/*/tests/`. Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership and registry semantics are tier-2 territory (`ui-renderer` + `ui-slots` suites), not component specs.
 
 English | [中文](2026-07-20-gui-testing-system.zh.md)
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md

@@ -2,7 +2,7 @@
 
 Status: implemented
 
-> 路径更新(2026-07-22,插件体系重构):本文三层理念与黄金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.zh.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的无额外机制路径),框架钩子用普通桩;无渲染机制、不挂载提供方。slot 归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec。
+> 路径更新(2026-08-23,Controller 拆分):本文三层理念与黄金路径方法仍为现行;对象层 spec 现分布于 `packages/api/session-controller/tests/` 和 `packages/test-support/client-runtime/tests/`,wire spec 仍位于 `packages/client/connection/tests/`。组件 spec 是各 `packages/client/*/tests/` 下的插件级 jsdom 套件。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.zh.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的无额外机制路径),框架钩子用普通桩;无渲染机制、不挂载提供方。slot 归属和注册表语义归 2 层地界(`ui-renderer` + `ui-slots` 套件),不归组件 spec。
 
 [English](2026-07-20-gui-testing-system.md) | 中文
 

+ 1 - 0
.agents/skills/dsh-code-review/SKILL.md

@@ -25,6 +25,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
 4. **Registrations clean up.** Verify each new registry contribution passes the disposal tests required by [packages/AGENTS.md](../../../packages/AGENTS.md).
 5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at the point where that package can observe it; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package invariant rules](../../../packages/AGENTS.md)).
 6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect.
+7. **Client UI copy is locale-owned.** Reject product text embedded in JSX, templates, helper returns, accessibility attributes, or primitive defaults. Require typed dictionary keys, the standard `t` seat or explicit localized props, `verify-client-ui-i18n`, and behavior evidence in each affected locale; preserve user/model/wire data and code tokens verbatim.
 
 ## Manual checks
 

+ 1 - 1
.agents/skills/dsh-prose-standard/SKILL.md

@@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an
 - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality.
 - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth.
 - **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows.
-- **Prompts and visible strings:** treat wording as behavior. Update the owning runnable snapshot for model-visible text and the repository-required behavior evidence for GUI text. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
+- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
 - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration.
 
 Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only.

+ 3 - 2
AGENTS.md

@@ -122,11 +122,12 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
 - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
 - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
-- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
+- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
+- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
 - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
 - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change.
 - **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)).
-- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
+- **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
 - **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)).
 - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
 - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.

+ 1 - 1
apps/web/tests/snapshots/access-confirmation/ui.expected.md

@@ -1,6 +1,6 @@
 - dialog "确认启用 Full access?":
   - heading "确认启用 Full access?" [level=2]
-  - button "Close":
+  - button "关闭":
     - img
   - img
   - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。

+ 2 - 2
apps/web/tests/snapshots/search-card/grep-card.expected.txt

@@ -1,5 +1,5 @@
 kind=matches
-summary=显示 9 / 共 42 处匹配 · 3 个文件
+summary=Showing 9 of 42 matches · 3 files
 file=packages/client/ui-primitives/src/SearchBlock.tsx3
 file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4
 line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
@@ -8,7 +8,7 @@ line=141:   const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() =
 line=36:   const search = searchCardModel(block)
 line=56:       search={search}
 line=78:       yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
-expand=… 其余 4 行
+expand=… 4 more lines
 recovery=Found 9 of 42 matches
 
 packages/client/ui-primitives/src/SearchBlock.tsx

+ 1 - 1
apps/web/tests/web-search-round.e2e.ts

@@ -280,7 +280,7 @@ describe('web e2e: shipped default web search', () => {
     expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS)
     // The list is complete in the DOM, so the card carries no expand control.
     expect(await card.locator('button').count()).toBe(0)
-    expect(await card.getByText('来源列表已截断').isVisible()).toBe(true)
+    expect(await card.getByText('Source list truncated').isVisible()).toBe(true)
 
     const geometry = await sources.evaluate((element) => {
       const computed = getComputedStyle(element)

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 9bbbb81dc1e5b4aa65619702d380eeb126cfdea3
-module-graph.zh.md: 86298ce1ed68aa550f5b0b489ee6dccd49771638
+module-graph.md: 130e7d204e613591f2b6ab31136083cc49d3bdd0
+module-graph.zh.md: 673c3f2f9d6117d200205d0127e87711f043ed85

+ 2 - 1
docs/module-graph.md

@@ -1321,6 +1321,7 @@ flowchart TD
   pkg_client_ui_theme --> pkg_host_webserver
   pkg_client_ui_theme --> pkg_invariants
   pkg_client_ui_theme --> pkg_settings
+  pkg_client_ui_layout --> pkg_client_locale
   pkg_client_ui_layout --> pkg_client_ui_renderer
   pkg_client_ui_layout --> pkg_client_ui_session
   pkg_client_ui_layout --> pkg_client_ui_theme
@@ -1841,7 +1842,7 @@ flowchart TD
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |

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

@@ -1323,6 +1323,7 @@ flowchart TD
   pkg_client_ui_theme --> pkg_host_webserver
   pkg_client_ui_theme --> pkg_invariants
   pkg_client_ui_theme --> pkg_settings
+  pkg_client_ui_layout --> pkg_client_locale
   pkg_client_ui_layout --> pkg_client_ui_renderer
   pkg_client_ui_layout --> pkg_client_ui_session
   pkg_client_ui_layout --> pkg_client_ui_theme
@@ -1843,7 +1844,7 @@ flowchart TD
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 1 - 0
package.json

@@ -103,6 +103,7 @@
     "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
     "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts",
     "verify-client-packages": "tsx scripts/verify-client-packages.ts",
+    "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
     "verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
     "verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
     "rescope-vendor": "tsx scripts/rescope-vendor.ts",

+ 5 - 3
packages/client/AGENTS.md

@@ -106,9 +106,11 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent
 
 One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
 
-## Styling
+## Styling and localization
 
-[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English.
+[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English.
+
+Every product-visible string—including text, accessibility names, tooltips, placeholders, status/unit formatters, and primitive chrome—lives in a typed locale dictionary and reaches components through the standard `t` seat or an already-localized prop. Cordis-free primitives require complete label props and own no fallback copy. Keep user/model/wire data and code tokens verbatim; internal matching uses discriminants or stable ids, never localized text. `pnpm run verify-client-ui-i18n` enforces source ownership ([decision](../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
 
 ## Testing and coverage
 
@@ -145,6 +147,6 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com
 1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
 3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery.
-4. Tokens only in CSS; Chinese product copy; English comments.
+4. Tokens only in CSS; product copy follows the localization rule above; English comments.
 5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.

+ 2 - 2
packages/client/locale/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/locale/README.md
-README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9
-README.zh.md: 10fb3547376c8e960165a04fb4ea64ec8dd6f982
+README.md: 4f54a9a2c5aa9d39ee3c93a4d8f2c6deb538b792
+README.zh.md: d4c4833b2334c1b21f31e9b6f4d5116bbbb8591d

+ 1 - 2
packages/client/locale/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
+Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
 
 ## Model Experience
 
@@ -14,5 +14,4 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly.
 - **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.

+ 1 - 2
packages/client/locale/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
+locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
 
 ## 模型体验
 
@@ -14,5 +14,4 @@ locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存
 
 ## 已知限制与暂缓事项
 
-- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。
 - **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。

+ 16 - 0
packages/client/locale/src/locales/en.ts

@@ -7,6 +7,13 @@ export const en = {
   'close': 'Close',
   'copy': 'Copy',
   'copied': 'Copied',
+  'copy.failed': 'Copy failed',
+  'copy.value': 'Copy value',
+  'copy.json': 'Copy JSON',
+  'copy.path': 'Copy property path',
+  'copy.prettyJson': 'Copy pretty JSON',
+  'copy.compactJson': 'Copy compact JSON',
+  'copy.optionsHint': '{action}; right-click for copy options',
   'retry': 'Retry',
   'loading': 'Loading…',
   'load.failed': 'Failed to load',
@@ -23,7 +30,16 @@ export const en = {
   'collapse': 'Collapse',
   'expand': 'Expand',
   'back': 'Back',
+  'brand.localBuild': 'DSH Local Build',
   'unknown': 'Unknown',
   'none': 'None',
   'truncated': 'Truncated',
+  'connection.reconnecting': 'Connection lost; reconnecting…',
+  'json.collapseNode': 'Collapse JSON node',
+  'json.expandNode': 'Expand JSON node',
+  'json.label': 'JSON',
+  'markdown.footnotes': 'Footnotes',
+  'markdown.truncatedCharacters': '… truncated at {total} characters',
+  'number.thousand': '{value}K',
+  'number.million': '{value}M',
 } satisfies Record<CommonKey, string>

+ 16 - 0
packages/client/locale/src/locales/zh.ts

@@ -5,6 +5,13 @@ export const zh = {
   'close': '关闭',
   'copy': '复制',
   'copied': '复制成功',
+  'copy.failed': '复制失败',
+  'copy.value': '复制值',
+  'copy.json': '复制 JSON',
+  'copy.path': '复制属性路径',
+  'copy.prettyJson': '复制格式化 JSON',
+  'copy.compactJson': '复制紧凑 JSON',
+  'copy.optionsHint': '{action};右键点击可选择复制方式',
   'retry': '重试',
   'loading': '加载中…',
   'load.failed': '加载失败',
@@ -21,9 +28,18 @@ export const zh = {
   'collapse': '收起',
   'expand': '展开',
   'back': '返回',
+  'brand.localBuild': 'DSH 本地构建',
   'unknown': '未知',
   'none': '无',
   'truncated': '已截断',
+  'connection.reconnecting': '连接已断开,正在重连…',
+  'json.collapseNode': '收起 JSON 节点',
+  'json.expandNode': '展开 JSON 节点',
+  'json.label': 'JSON',
+  'markdown.footnotes': '脚注',
+  'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',
+  'number.thousand': '{value}K',
+  'number.million': '{value}M',
 } satisfies Record<string, string>
 
 /** The common vocabulary key union (zh is the key-set source of truth). */

+ 3 - 2
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -4,6 +4,7 @@ import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { AssistantBlock } from '../contract/snapshot.ts'
+import { markdownLabels } from '../markdown-labels.ts'
 import { ReasoningRow } from './ReasoningRow.tsx'
 import css from './AssistantMarkdown.module.css'
 
@@ -26,7 +27,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
 }: AssistantMarkdownProps) {
   // Stable per locale revision (t identity changes on switch): a fresh object
   // per render would rebuild MarkdownText's component table every chunk.
-  const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
+  const labels = useMemo(() => markdownLabels(t), [t])
   const last = blocks.length - 1
   // Tool-call heads render as tool rows in the chat view's grouping pass, so
   // a node that is only those heads (or empty) would paint an empty root
@@ -46,7 +47,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
             key={i}
             text={block.text}
             streaming={streaming}
-            codeLabels={codeLabels}
+            labels={labels}
             fileMentions={mentions}
           />,
         )

+ 1 - 1
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -129,7 +129,7 @@ function TurnStatus({ startTime, t }: {
   const showClock = elapsedMs >= 15_000
   return (
     <div className={css.turnStatus} role="status" aria-live="polite">
-      Deep diving...
+      {t('chat.deepDiving')}
       {showClock && (
         <span className={css.turnStatusClock} aria-hidden>
           {formatRunDuration(elapsedMs, t)}

+ 1 - 1
packages/client/ui-chat/src/client/chat/CompactionCommandCard.tsx

@@ -15,7 +15,7 @@ export function CompactionCommandCard({ node, compaction, t }: CompactionCommand
     return (
       <CompactionItem
         node={compaction}
-        title="compact"
+        title={t('message.compaction.commandTitle')}
         fallbackSummary={node.outcome?.text ?? null}
         t={t}
       />

+ 4 - 2
packages/client/ui-chat/src/client/chat/CompactionItem.tsx

@@ -1,7 +1,7 @@
 // A compaction marker does not replace shadowed transcript rows. It is
 // expandable only when the current window includes its cited summary.
 
-import { memo, useState } from 'react'
+import { memo, useMemo, useState } from 'react'
 import {
   IconApiOutline14,
   IconChevronDownOutline14,
@@ -9,6 +9,7 @@ import {
   MarkdownText,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
+import { markdownLabels } from '../markdown-labels.ts'
 import type { CompactionSummaryNode } from '../contract/snapshot.ts'
 import css from './MessageItem.module.css'
 
@@ -34,6 +35,7 @@ export const CompactionItem = memo(function CompactionItem({
   t,
 }: CompactionItemProps) {
   const [expanded, setExpanded] = useState(false)
+  const labels = useMemo(() => markdownLabels(t), [t])
   const expandable = node.summary !== null
   const open = expandable && expanded
   const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null
@@ -68,7 +70,7 @@ export const CompactionItem = memo(function CompactionItem({
         <span className={css.compactionSummary}>{summary}</span>
       </button>
       {open && node.summary !== null
-        && <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
+        && <div className={css.compactionBody}><MarkdownText text={node.summary} labels={labels} /></div>}
     </div>
   )
 })

+ 11 - 3
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -39,6 +39,14 @@ interface RetryCountdown {
   seconds: number
 }
 
+function failureMessage(
+  message: string,
+  code: unknown,
+  t: ChatViewSlotProps['t'],
+): string {
+  return code === 'AUTH' ? t('message.failure.auth') : message
+}
+
 function ModelRetryItem({ node, active, t }: {
   node: ModelRetryNode
   active: boolean
@@ -94,11 +102,11 @@ function ModelRetryItem({ node, active, t }: {
       <div className={css.retryDetails}>
         <div>
           <span className={css.retryDetailLabel}>{t('message.retry.delay')}</span>
-          {Math.round(node.delayMs)}ms
+          {t('duration.milliseconds', { milliseconds: Math.round(node.delayMs) })}
         </div>
         <div>
           <span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
-          {node.failure.message}
+          {failureMessage(node.failure.message, node.failure.code, t)}
         </div>
       </div>
     </details>
@@ -115,7 +123,7 @@ function TurnErrorItem({ node, t }: {
       <StateDot state="error" className={css.turnErrorDot} />
       <div className={css.turnErrorCopy}>
         <span className={css.turnErrorTitle}>{t('message.turnError')}</span>
-        <span className={css.turnErrorMessage}>{node.message}</span>
+        <span className={css.turnErrorMessage}>{failureMessage(node.message, node.code, t)}</span>
       </div>
       {node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
     </div>

+ 1 - 1
packages/client/ui-chat/src/client/chat/ReasoningRow.tsx

@@ -46,7 +46,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
         titleClassName={css.title}
         chevronClassName={css.chevron}
         icon={<IconThinkOutline14 size={14} />}
-        title="Think"
+        title={t('message.think')}
         open={expanded}
         expandable
         expandOnRowClick

+ 14 - 11
packages/client/ui-chat/src/client/chat/StatsLine.tsx

@@ -85,12 +85,12 @@ export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats
  * @param n - token count.
  * @returns display string.
  */
-export function formatTokens(n: number): string {
+export function formatTokens(n: number, t: ChatViewSlotProps['t']): string {
   const scaled = (v: number): string =>
     v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
   if (n < 1_000) return String(n)
-  if (n < 1_000_000) return `${scaled(n / 1_000)}K`
-  return `${scaled(n / 1_000_000)}M`
+  if (n < 1_000_000) return t('number.thousand', { value: scaled(n / 1_000) })
+  return t('number.million', { value: scaled(n / 1_000_000) })
 }
 
 /**
@@ -98,11 +98,14 @@ export function formatTokens(n: number): string {
  * @param ms - duration in milliseconds.
  * @returns display string.
  */
-export function formatDuration(ms: number): string {
+export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
   const s = ms / 1_000
-  if (s < 60) return `${Math.round(s * 10) / 10}s`
+  if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 })
   const whole = Math.round(s)
-  return `${Math.floor(whole / 60)}m${whole % 60}s`
+  return t('duration.compactMinutes', {
+    minutes: Math.floor(whole / 60),
+    seconds: whole % 60,
+  })
 }
 
 /** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */
@@ -196,12 +199,12 @@ export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }:
   if (stats.steps > 0) {
     groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
     const durations: string[] = []
-    if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
-    if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) }))
+    if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) }))
+    if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) }))
     if (durations.length > 0) groups.push(durations.join(' · '))
     const speeds: string[] = []
     if (stats.ttftSteps > 0) {
-      speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
+      speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) }))
     }
     if (stats.decodeMs > 0) {
       speeds.push(t('stats.tokensPerSecond', {
@@ -221,8 +224,8 @@ export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }:
     const cacheHit = cacheHitPercent(usage)
     if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
     groups.push(t('stats.tokens', {
-      input: formatTokens(billedInputTokens(usage)),
-      output: formatTokens(usage.outputTokens),
+      input: formatTokens(billedInputTokens(usage), t),
+      output: formatTokens(usage.outputTokens, t),
     }))
   }
   const line = groups.join(' | ')

+ 4 - 3
packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts

@@ -3,7 +3,7 @@ import type {
   ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { TurnErrorNode } from '../contract/snapshot.ts'
-import { displayFailureMessage } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { displayFailure } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { chatNode } from './common.ts'
 
 declare module '../contract/chat-nodes.ts' {
@@ -32,11 +32,12 @@ function lastStep(context: ConversationNodeContext<TurnErrorState>): number {
 function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined {
   if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined
   const failure = match.event.data.reason.error
+  const display = displayFailure(failure)
   return {
     seq: match.event.seq,
     time: match.event.time,
-    message: displayFailureMessage(failure),
-    code: failure.code,
+    message: display.message,
+    ...(display.code === undefined ? {} : { code: display.code }),
   }
 }
 

+ 1 - 1
packages/client/ui-chat/src/client/index.ts

@@ -43,7 +43,7 @@ export type {
 export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts'
 export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts'
 export {
-  contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta,
+  contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 /** Public merge surface for Chat renderer payloads contributed by other plugins. */

+ 14 - 0
packages/client/ui-chat/src/client/locale.ts

@@ -6,6 +6,9 @@ export const NS = 'chat'
 /** Simplified Chinese dictionary and key-set source of truth. */
 export const zh = {
   'view.chat': '对话',
+  'duration.compactSeconds': '{seconds}秒',
+  'duration.compactMinutes': '{minutes}分{seconds}秒',
+  'duration.milliseconds': '{milliseconds}毫秒',
   'stats.counts': '{turns} 轮 · {steps} 步',
   'stats.llm': 'LLM {duration}',
   'stats.toolCall': '工具调用 {duration}',
@@ -24,6 +27,7 @@ export const zh = {
   'chat.loadError': '历史加载失败:{message}({code})',
   'chat.loadOlder': '加载更早',
   'chat.toBottom': '回到底部',
+  'chat.deepDiving': '正在深入处理…',
   'fileOpen.title': '无法打开文件',
   'fileOpen.unknown': '无法打开此文件',
   'fileOpen.folderTitle': '无法打开文件夹',
@@ -48,6 +52,8 @@ export const zh = {
   'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
   'message.compaction.expand': '点击查看压缩摘要',
   'message.compaction.unavailable': '压缩摘要不可用',
+  'message.compaction.commandTitle': 'compact',
+  'message.think': '思考',
   'message.unknownSurface': '未知 surface 事件:{type}',
   'message.unknownBlock': '未知内容块',
   'message.stopped': '已停止',
@@ -60,6 +66,7 @@ export const zh = {
   'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
   'message.retry.delay': '重试延迟:',
   'message.retry.failure': '失败原因:',
+  'message.failure.auth': 'API 密钥无效',
   'message.turnError': '本轮运行失败',
   'message.maxTokens': '已达到输出 token 上限',
   'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
@@ -85,6 +92,9 @@ export type ChatKey = keyof typeof zh
 /** English dictionary, checked against the Chinese key set. */
 export const en = {
   'view.chat': 'Chat',
+  'duration.compactSeconds': '{seconds}s',
+  'duration.compactMinutes': '{minutes}m{seconds}s',
+  'duration.milliseconds': '{milliseconds}ms',
   'stats.counts': '{turns} turns · {steps} steps',
   'stats.llm': 'LLM {duration}',
   'stats.toolCall': 'Tool call {duration}',
@@ -103,6 +113,7 @@ export const en = {
   'chat.loadError': 'Failed to load history: {message} ({code})',
   'chat.loadOlder': 'Load earlier',
   'chat.toBottom': 'Back to bottom',
+  'chat.deepDiving': 'Deep diving...',
   'fileOpen.title': 'Couldn’t open file',
   'fileOpen.unknown': 'Couldn’t open this file',
   'fileOpen.folderTitle': 'Couldn’t open folder',
@@ -127,6 +138,8 @@ export const en = {
   'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
   'message.compaction.expand': 'View compaction summary',
   'message.compaction.unavailable': 'Compaction summary unavailable',
+  'message.compaction.commandTitle': 'compact',
+  'message.think': 'Think',
   'message.unknownSurface': 'Unknown surface event: {type}',
   'message.unknownBlock': 'Unknown content block',
   'message.stopped': 'Stopped',
@@ -139,6 +152,7 @@ export const en = {
   'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
   'message.retry.delay': 'Retry delay: ',
   'message.retry.failure': 'Failure reason: ',
+  'message.failure.auth': 'API key is invalid',
   'message.turnError': 'This turn failed',
   'message.maxTokens': 'Output token limit reached',
   'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',

+ 16 - 0
packages/client/ui-chat/src/client/markdown-labels.ts

@@ -0,0 +1,16 @@
+/** Localized copy adapters for Cordis-free Markdown primitives. */
+
+import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ChatViewSlotProps } from './contract/slots.ts'
+
+/**
+ * Build the complete Markdown chrome copy for one locale revision.
+ * @param t - Chat locale seat.
+ * @returns Labels for code fences and footnotes.
+ */
+export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
+  return {
+    code: { copyLabel: t('copy'), copiedLabel: t('copied') },
+    footnotes: t('markdown.footnotes'),
+  }
+}

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

@@ -852,7 +852,7 @@ describe('MessageItem arms', () => {
     expect(details?.open).toBe(false)
     expect(details?.dataset.active).toBe('true')
     expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s')
-    expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms')
+    expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500毫秒')
     expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
 
     act(() => { vi.advanceTimersByTime(1_100) })

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

@@ -123,15 +123,15 @@ describe('deriveStats', () => {
 
 describe('formatters', () => {
   it('formats token counts compactly', () => {
-    expect(formatTokens(517)).toBe('517')
-    expect(formatTokens(12_240)).toBe('12.2K')
-    expect(formatTokens(517_000)).toBe('517K')
-    expect(formatTokens(1_230_000)).toBe('1.2M')
+    expect(formatTokens(517, tEn)).toBe('517')
+    expect(formatTokens(12_240, tEn)).toBe('12.2K')
+    expect(formatTokens(517_000, tEn)).toBe('517K')
+    expect(formatTokens(1_230_000, tEn)).toBe('1.2M')
   })
 
   it('formats durations under and over a minute', () => {
-    expect(formatDuration(45_230)).toBe('45.2s')
-    expect(formatDuration(162_000)).toBe('2m42s')
+    expect(formatDuration(45_230, tEn)).toBe('45.2s')
+    expect(formatDuration(162_000, tEn)).toBe('2m42s')
   })
 })
 
@@ -242,7 +242,7 @@ describe('StatsLine', () => {
     const { source } = makeSource({ nodes: [timed] })
     const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
     expect(view.container.textContent)
-      .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
+      .toBe('1 轮 · 1 步| LLM 3.8秒| 首 token 平均 0.8秒 · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
   })
 
   it('renders without ResizeObserver support', () => {

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

@@ -625,7 +625,7 @@ describe('ChatView', () => {
     const view = render(<h.ChatView {...h.props} />)
     const statuses = view.getAllByRole('status')
     expect(statuses.map(status => status.textContent)).toEqual([
-      '本轮运行失败API key is invalidAUTH',
+      '本轮运行失败API 密钥无效AUTH',
       '本轮运行失败plugin exploded',
     ])
   })
@@ -917,7 +917,7 @@ describe('ChatView', () => {
     const view = render(<h.ChatView {...h.props} />)
     expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
     expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
-    expect(view.getByRole('status').textContent).toBe('Deep diving...')
+    expect(view.getByRole('status').textContent).toBe('正在深入处理…')
   })
 
   it('keeps the Tool renderer mounted when a running call settles into log order', () => {
@@ -977,7 +977,7 @@ describe('ChatView', () => {
     const view = render(<h.ChatView {...h.props} />)
     // Freshly mounted (as after a reload) yet already past the 15s gate.
     const status = view.getByRole('status')
-    expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
+    expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/)
     expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
     act(() => {
       h.setSession({ queue: [{
@@ -989,7 +989,7 @@ describe('ChatView', () => {
         text: 'also',
       }] })
     })
-    expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
+    expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/)
   })
 
   it('hands each ordered root call to the keyed business-node slot', () => {

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

@@ -26,7 +26,7 @@ describe('tails', () => {
         renderMessageImages={renderMessageImages}
       />,
     )
-    expect(view.getByText('Think')).toBeTruthy()
+    expect(view.getByText('思考')).toBeTruthy()
     expect(view.getByText('thinking hard')).toBeTruthy()
     expect(view.getByText(/未知内容块/)).toBeTruthy()
     const stopped = render(

+ 2 - 2
packages/client/ui-chat/tests/reasoning-row.client.spec.tsx

@@ -101,7 +101,7 @@ describe('ReasoningRow', () => {
     expect(row.getAttribute('aria-expanded')).toBe('true')
     expect(view.getByText(/Check persistence/)).toBeTruthy()
 
-    fireEvent.click(view.getByText('Think'))
+    fireEvent.click(view.getByText('思考'))
     expect(row.getAttribute('aria-expanded')).toBe('false')
   })
 
@@ -114,7 +114,7 @@ describe('ReasoningRow', () => {
         renderMessageImages={renderMessageImages}
       />,
     )
-    fireEvent.click(view.getByText('Think'))
+    fireEvent.click(view.getByText('思考'))
     expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
     expect(view.queryByText('IN')).toBeNull()
     expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()

+ 1 - 0
packages/client/ui-commands/src/client/PopupSelectView.tsx

@@ -164,6 +164,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
           description={confirmation.description}
           acknowledgeLabel={confirmation.acknowledgeLabel}
           cancelLabel={confirmation.cancelLabel}
+          closeLabel={t('close')}
           confirmLabel={confirmation.confirmLabel}
           acknowledged={state.acknowledged}
           onAcknowledgedChange={(value) => { popup.acknowledge(value) }}

+ 2 - 0
packages/client/ui-conversation/src/client/contract/records.ts

@@ -176,7 +176,9 @@ export interface TurnErrorNode {
   time: number
   turn: number
   step: number
+  /** Sanitized provider message; empty when a known code owns localized copy. */
   message: string
+  /** Stable provider failure code, when recorded. */
   code?: string
 }
 

+ 2 - 0
packages/client/ui-conversation/src/client/contract/request-inspection.ts

@@ -37,6 +37,8 @@ interface RequestViewBase {
   completedAt: number | null
   status: 'running' | 'complete' | 'error'
   error?: string
+  /** Stable provider code for localized presentation of known failures. */
+  errorCode?: string
   provenance?: AssistantProvenanceView
   requestConfig?: AssistantRequestConfig
   usage?: unknown

+ 18 - 6
packages/client/ui-conversation/src/client/conversation/failure-display.ts

@@ -1,13 +1,25 @@
+/** Display-safe failure fields retained by locale-independent projections. */
+export interface DisplayFailure {
+  /** Stable provider failure code used for localized known-error copy. */
+  code?: string
+  /** Sanitized provider message; empty when the code owns the display copy. */
+  message: string
+}
+
 /**
- * Convert a durable failure into copy that is safe to expose in the GUI.
+ * Convert a durable failure into locale-independent fields safe for GUI projections.
  * @param failure - Failure value preserved by the session event.
- * @returns Display-safe copy for client projections.
+ * @returns Sanitized message and optional stable provider code.
  */
-export function displayFailureMessage(failure: unknown): string {
-  if (failure === null || typeof failure !== 'object') return String(failure)
+export function displayFailure(failure: unknown): DisplayFailure {
+  if (failure === null || typeof failure !== 'object') return { message: String(failure) }
   const record = failure as { code?: unknown; message?: unknown }
+  const code = typeof record.code === 'string' ? record.code : undefined
   // Provider AUTH messages may echo a masked or partially preserved credential.
   // Keep the raw diagnostic in the session log, but never project it into UI state.
-  if (record.code === 'AUTH') return 'API key is invalid'
-  return typeof record.message === 'string' ? record.message : JSON.stringify(failure)
+  if (code === 'AUTH') return { code, message: '' }
+  return {
+    ...(code === undefined ? {} : { code }),
+    message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
+  }
 }

+ 2 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -48,7 +48,8 @@ export type { AssistantStepMetadata } from './conversation/assistant-timing.ts'
 export {
   assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming,
 } from './conversation/assistant-timing.ts'
-export { displayFailureMessage } from './conversation/failure-display.ts'
+export { displayFailure } from './conversation/failure-display.ts'
+export type { DisplayFailure } from './conversation/failure-display.ts'
 export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts'
 
 export { ConversationNodeAssembler } from './conversation/assembler.ts'

+ 82 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -61,6 +61,7 @@ export const zh = {
   'access.confirm.acknowledge': '我已了解风险,并愿意继续',
   'access.confirm.cancel': '取消',
   'access.confirm.enable': '启用 Full access',
+  'access.fullLabel': 'Full access',
   'hero.headline': '探索未至之境',
   'hero.preview': '预览版',
   'hero.chooseWorkspace': '选择工作区',
@@ -83,6 +84,46 @@ export const zh = {
   'row.running': '运行中',
   'row.failed': '失败',
   'row.stopped': '已停止',
+  'row.input': '输入',
+  'row.output': '输出',
+  'row.inspect': '查看',
+  'tool.title.search': '搜索',
+  'tool.title.read': '读取',
+  'tool.title.bash': 'Bash',
+  'tool.title.write': '写入',
+  'tool.title.edit': '编辑',
+  'tool.title.code': '代码',
+  'tool.title.generic': '工具调用',
+  'tool.title.inspect': '查看',
+  'tool.title.runCordis': '运行 Cordis 插件',
+  'tool.title.stopCordis': '停止 Cordis 插件',
+  'tool.title.removeCordis': '移除 Cordis 插件',
+  'tool.title.pwsh': 'Pwsh',
+  'tool.title.grep': 'Grep',
+  'tool.title.glob': 'Glob',
+  'tool.title.webSearch': '网页搜索',
+  'tool.title.webFetch': '网页获取',
+  'diff.files.one': '{count} 个文件',
+  'diff.files.other': '{count} 个文件',
+  'diff.collapseAria': '收起差异',
+  'diff.expandAria': '展开其余 {count} 行差异',
+  'diff.expandRest': '… 其余 {count} 行',
+  'read.window': '显示 {shown} / {total} 行',
+  'read.collapseAria': '收起内容',
+  'read.expandAria': '展开其余 {count} 行',
+  'read.expandRest': '… 其余 {count} 行',
+  'search.paths': '{shown} 个路径',
+  'search.paths.truncated': '显示 {shown} / 共 {total} 个路径',
+  'search.matches': '{shown} 处匹配 · {files} 个文件',
+  'search.matches.truncated': '显示 {shown} / 共 {total} 处匹配 · {files} 个文件',
+  'search.noResults': '无结果',
+  'search.collapseAria': '收起结果',
+  'search.expandAria': '展开其余 {count} 行结果',
+  'search.expandRest': '… 其余 {count} 行',
+  'web.noResults': '未找到结果',
+  'web.sourcesTruncated': '来源列表已截断',
+  'web.http': 'HTTP',
+  'web.contentTruncated': '内容已截断',
   'details.running': '运行中…',
   'queue.count': '{n} 条排队消息',
   'queue.edit': '编辑排队消息',
@@ -163,6 +204,7 @@ export const en = {
   'access.confirm.acknowledge': 'I understand the risks and want to continue',
   'access.confirm.cancel': 'Cancel',
   'access.confirm.enable': 'Enable Full access',
+  'access.fullLabel': 'Full access',
   'hero.headline': 'Into the Unknown',
   'hero.preview': 'Preview',
   'hero.chooseWorkspace': 'Choose workspace',
@@ -185,6 +227,46 @@ export const en = {
   'row.running': 'Running',
   'row.failed': 'Failed',
   'row.stopped': 'Stopped',
+  'row.input': 'IN',
+  'row.output': 'OUT',
+  'row.inspect': 'Inspect',
+  'tool.title.search': 'Search',
+  'tool.title.read': 'Read',
+  'tool.title.bash': 'Bash',
+  'tool.title.write': 'Write',
+  'tool.title.edit': 'Edit',
+  'tool.title.code': 'Code',
+  'tool.title.generic': 'Tool call',
+  'tool.title.inspect': 'Inspect',
+  'tool.title.runCordis': 'Run Cordis Plugin',
+  'tool.title.stopCordis': 'Stop Cordis Plugin',
+  'tool.title.removeCordis': 'Remove Cordis Plugin',
+  'tool.title.pwsh': 'Pwsh',
+  'tool.title.grep': 'Grep',
+  'tool.title.glob': 'Glob',
+  'tool.title.webSearch': 'Search',
+  'tool.title.webFetch': 'Fetch',
+  'diff.files.one': '{count} file',
+  'diff.files.other': '{count} files',
+  'diff.collapseAria': 'Collapse diff',
+  'diff.expandAria': 'Expand {count} more diff lines',
+  'diff.expandRest': '… {count} more lines',
+  'read.window': 'Showing {shown} of {total} lines',
+  'read.collapseAria': 'Collapse content',
+  'read.expandAria': 'Expand {count} more lines',
+  'read.expandRest': '… {count} more lines',
+  'search.paths': '{shown} paths',
+  'search.paths.truncated': 'Showing {shown} of {total} paths',
+  'search.matches': '{shown} matches · {files} files',
+  'search.matches.truncated': 'Showing {shown} of {total} matches · {files} files',
+  'search.noResults': 'No results',
+  'search.collapseAria': 'Collapse results',
+  'search.expandAria': 'Expand {count} more result lines',
+  'search.expandRest': '… {count} more lines',
+  'web.noResults': 'No results found',
+  'web.sourcesTruncated': 'Source list truncated',
+  'web.http': 'HTTP',
+  'web.contentTruncated': 'Content truncated',
   'details.running': 'Running…',
   'queue.count': '{n} queued messages',
   'queue.edit': 'Edit queued message',

+ 7 - 6
packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx

@@ -34,15 +34,16 @@ const ROWS = [
 /**
  * Format a token count for the compact context panel.
  * @param value - token count.
- * @returns compact count using K or M when needed.
+ * @param t - Conversation locale seat with shared compact-number templates.
+ * @returns Compact localized count using K or M when needed.
  */
-function formatTokens(value: number): string {
+function formatTokens(value: number, t: ComposerBarProps['t']): string {
   const scaled = (candidate: number): string => candidate >= 100
     ? String(Math.round(candidate))
     : String(Math.round(candidate * 10) / 10)
   if (value < 1_000) return String(value)
-  if (value < 1_000_000) return `${scaled(value / 1_000)}K`
-  return `${scaled(value / 1_000_000)}M`
+  if (value < 1_000_000) return t('number.thousand', { value: scaled(value / 1_000) })
+  return t('number.million', { value: scaled(value / 1_000_000) })
 }
 
 export interface ContextMeterProps {
@@ -135,7 +136,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
             <span className={css.percent}>{reading}</span>
             <span className={css.headline}>{headAfter}</span>
             <span className={css.figures}>
-              {`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`}
+              {`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
             </span>
           </div>
           <div className={css.bar}>
@@ -155,7 +156,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
                     <span className={`${css.swatch} ${row.color}`} aria-hidden />
                     {t(row.label)}
                   </dt>
-                  <dd>{`~${formatTokens(breakdown[row.key])}`}</dd>
+                  <dd>{`~${formatTokens(breakdown[row.key], t)}`}</dd>
                 </div>
               ))}
             </dl>

+ 9 - 5
packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx

@@ -57,8 +57,11 @@ function displayName(name: string): string {
   return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
 }
 
-function optionLabel(option: PermissionSelectValue['options'][number]): string {
-  return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
+function optionLabel(
+  option: PermissionSelectValue['options'][number],
+  t: ComposerBarProps['t'],
+): string {
+  return option.value === FULL_ACCESS ? t('access.fullLabel') : displayName(option.name)
 }
 
 export interface PermissionSelectProps {
@@ -92,7 +95,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
     .filter(o => o.value !== 'custom')
     .map((option) => {
       const icon = permissionGlyph(option.value)
-      return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } }
+      return { id: option.value, label: optionLabel(option, t), ...icon === undefined ? {} : { icon } }
     })
 
   const submit = (id: string): void => {
@@ -138,7 +141,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
           <button
             type="button"
             className={css.trigger}
-            aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
+            aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current, t) })}
             title={current?.description}
             disabled={locked || busy}
             onClick={() => { setOpen(!open) }}
@@ -146,7 +149,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
             {permissionGlyph(currentValue) !== undefined && (
               <span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
             )}
-            <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
+            <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current, t)}</span>
             <span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
               <IconChevronDownOutline14 />
             </span>
@@ -159,6 +162,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
         description={t('access.confirm.description')}
         acknowledgeLabel={t('access.confirm.acknowledge')}
         cancelLabel={t('access.confirm.cancel')}
+        closeLabel={t('close')}
         confirmLabel={t('access.confirm.enable')}
         acknowledged={acknowledged}
         disabled={locked}

+ 23 - 0
packages/client/ui-conversation/tests/failure-display.client.spec.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { displayFailure } from '../src/client/conversation/failure-display.ts'
+
+describe('displayFailure', () => {
+  it('keeps ordinary diagnostics and stable provider codes', () => {
+    expect(displayFailure(null)).toEqual({ message: 'null' })
+    expect(displayFailure('disconnected')).toEqual({ message: 'disconnected' })
+    expect(displayFailure({ code: 'RATE_LIMIT', message: 'try later' })).toEqual({
+      code: 'RATE_LIMIT',
+      message: 'try later',
+    })
+    expect(displayFailure({ detail: 'unknown' })).toEqual({
+      message: '{"detail":"unknown"}',
+    })
+  })
+
+  it('removes the provider message when AUTH owns localized display copy', () => {
+    expect(displayFailure({ code: 'AUTH', message: 'credential sk-secret failed' })).toEqual({
+      code: 'AUTH',
+      message: '',
+    })
+  })
+})

+ 2 - 2
packages/client/ui-layout/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-layout/README.md
-README.md: 7c9d64b9ca7a5fada492c37200a283904466c36d
-README.zh.md: a2604d865977c495d2a3d2362b25f05e34142c97
+README.md: e0e7b33170ee61ca7a0d8ca5a087b9c40100fcee
+README.zh.md: 7ea650bf7613a8a0e0ca4e303364cf8b8de926f7

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

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens as inline variables on body, and one owned `<meta name="theme-color">` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes.
 
-AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
+AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. It also projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the rest of the root entry. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
 
 The `/client` exports are the plugin body (`apply`/`inject`), `LayoutController`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal.
 

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

@@ -4,7 +4,7 @@
 
 外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量,同时拥有一个 `<meta name="theme-color">`,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景成为唯一的颜色依据;呈现器在 dispose(资源释放)时会移除其自有的元数据节点,并一并清除其写入的其他全局状态。
 
-AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用约定默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。
+AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。它还会把选中 Session 的标题投影到 build 配置的产品标题或已本地化的 `common.brand.localBuild` 回退之上,因此 locale revision 会与 root entry 的其余内容一起更新文档元数据。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用约定默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。
 
 `/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutController` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部。
 

+ 2 - 0
packages/client/ui-layout/package.json

@@ -32,6 +32,7 @@
   "dsh": {
     "client": {
       "inject": [
+        "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-session",
         "@deepseek-ai/dsh-client-ui-theme"
@@ -45,6 +46,7 @@
   },
   "license": "MIT",
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",

+ 10 - 2
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -12,7 +12,9 @@
  */
 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
 import type { ReactNode } from 'react'
-import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
+} from '@deepseek-ai/dsh-client-ui-slots'
 import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts'
 import { DocumentTitle } from './DocumentTitle.tsx'
 import type { createLayoutStore } from './stores.ts'
@@ -23,6 +25,7 @@ export type AppFrameProps =
   & PropsRuntime<'root'>
   & PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'shell.overlay'>
   & PropsStore<ReturnType<typeof createLayoutStore>>
+  & PropsLocale<'common'>
 
 /** Center column grid item (session-body building block). */
 function CenterColumn(props: { children?: ReactNode }) {
@@ -91,6 +94,7 @@ export function AppFrame({
   actions,
   renderSlot,
   SessionProvider,
+  t,
 }: AppFrameProps) {
   const panels = useStore(s => s)
   const detailsSession = useSessions((s) => {
@@ -166,6 +170,7 @@ export function AppFrame({
   const onDetailsDrag = useCallback((dx: number) => {
     actions.setDetails(detailsBase.current - dx)
   }, [actions])
+  const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild')
 
   return (
     <div
@@ -176,7 +181,10 @@ export function AppFrame({
       data-details-collapsed={cols.details === 0 || undefined}
       data-dragging={dragging || undefined}
     >
-      <DocumentTitle {...documentTitle === undefined ? {} : { title: documentTitle }} />
+      <DocumentTitle
+        productTitle={productTitle}
+        {...documentTitle === undefined ? {} : { title: documentTitle }}
+      />
       <div className={css.sidebarCol}>
         {/* Render-site slot call with live concession output: a closed
             sidebar keeps the mounted slot at the compact-rail width, and the

+ 3 - 4
packages/client/ui-layout/src/client/DocumentTitle.tsx

@@ -1,11 +1,11 @@
 import { useEffect } from 'react'
 
-const DEFAULT_CLIENT_TITLE = 'DSH Local Build'
-
 /** Props for the browser title projection. */
 export interface DocumentTitleProps {
   /** Durable title of the selected session, or undefined for the product title. */
   title?: string
+  /** Build-configured or localized product title. */
+  productTitle: string
 }
 
 /**
@@ -14,8 +14,7 @@ export interface DocumentTitleProps {
  * @param props - Selected session title projection.
  * @returns No rendered content.
  */
-export function DocumentTitle({ title }: DocumentTitleProps): null {
-  const productTitle = process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE
+export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null {
   useEffect(() => {
     document.title = title === undefined ? productTitle : `${title} — ${productTitle}`
     return () => { document.title = productTitle }

+ 3 - 1
packages/client/ui-layout/src/client/index.ts

@@ -8,6 +8,7 @@
  * presenter, which projects ctx.theme snapshots onto document.body.
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
@@ -107,7 +108,7 @@ export interface ConvOwnerProps {}
 export interface DetailsOwnerProps {}
 
 /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
-export const inject = ['slots', 'theme']
+export const inject = ['slots', 'theme', 'locale']
 
 /**
  * Client plugin body: provide ctx.layout, then one register() call — AppFrame
@@ -121,6 +122,7 @@ export function apply(ctx: ClientContext): void {
     const disposeService = ctx.reflect.provide('layout', layout)
     const disposeRegistration = ctx.slots.register({
       name: 'root',
+      locale: 'common',
       children: {
         'sidebar': { kind: 'single', scope: 'root' },
         'conversation': { kind: 'single', scope: 'session-maybe' },

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

@@ -100,6 +100,7 @@ function mountFrame() {
       useSessionPendingInteraction={useSessionPendingInteraction}
       useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as never}
       SessionProvider={SessionProviderStub}
+      t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}
     />
   )
   const utils = render(element())
@@ -152,6 +153,11 @@ afterEach(() => {
 })
 
 describe('AppFrame', () => {
+  it('localizes the product title when the build does not supply one', () => {
+    mountFrame()
+    expect(document.title).toBe('DSH Local Build')
+  })
+
   it('projects the selected durable Session title', () => {
     vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
     selectedSessionTitle.current = 'First'

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

@@ -31,7 +31,7 @@ async function bench() {
 
 describe('ui-layout client apply', () => {
   it('declares its service dependencies', () => {
-    expect(inject).toEqual(['slots', 'theme'])
+    expect(inject).toEqual(['slots', 'theme', 'locale'])
   })
 
   it('provides ctx.layout and registers AppFrame into root with the three child declarations', async () => {

+ 5 - 5
packages/client/ui-layout/tests/document-title.client.spec.tsx

@@ -13,13 +13,13 @@ describe('DocumentTitle', () => {
   it('projects a durable title and restores the product title', () => {
     vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness')
     document.title = 'stale title'
-    const mounted = render(<DocumentTitle />)
+    const mounted = render(<DocumentTitle productTitle="DeepSeek Harness" />)
     expect(document.title).toBe('DeepSeek Harness')
-    mounted.rerender(<DocumentTitle title="First title" />)
+    mounted.rerender(<DocumentTitle title="First title" productTitle="DeepSeek Harness" />)
     expect(document.title).toBe('First title — DeepSeek Harness')
-    mounted.rerender(<DocumentTitle title="Revised title" />)
+    mounted.rerender(<DocumentTitle title="Revised title" productTitle="DeepSeek Harness" />)
     expect(document.title).toBe('Revised title — DeepSeek Harness')
-    mounted.rerender(<DocumentTitle />)
+    mounted.rerender(<DocumentTitle productTitle="DeepSeek Harness" />)
     expect(document.title).toBe('DeepSeek Harness')
     mounted.unmount()
     expect(document.title).toBe('DeepSeek Harness')
@@ -28,7 +28,7 @@ describe('DocumentTitle', () => {
   it('uses the generic title when the build provides no title', () => {
     vi.stubEnv('DSH_CLIENT_TITLE', '')
     delete process.env.DSH_CLIENT_TITLE
-    const mounted = render(<DocumentTitle title="First title" />)
+    const mounted = render(<DocumentTitle title="First title" productTitle="DSH Local Build" />)
     expect(document.title).toBe('First title — DSH Local Build')
     mounted.unmount()
     expect(document.title).toBe('DSH Local Build')

+ 2 - 2
packages/client/ui-message-feedback/src/client/controller.ts

@@ -306,7 +306,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
       return OK
     } catch (error) {
       if (this.disposed) return OK
-      const message = error instanceof Error ? error.message : 'message feedback list failed'
+      const message = error instanceof Error ? error.message : String(error)
       this.publish({ status: 'error', items: this.view.items, error: message })
       return { ok: false, error: { code: 'transport', message } }
     }
@@ -338,7 +338,7 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
           ok: false,
           error: {
             code: 'transport',
-            message: error instanceof Error ? error.message : 'message feedback mutation failed',
+            message: error instanceof Error ? error.message : String(error),
           },
         }
       }

+ 4 - 4
packages/client/ui-message-feedback/tests/controller.client.spec.ts

@@ -344,25 +344,25 @@ describe('MessageFeedbackController', () => {
     expect(controller.getSnapshot().status).not.toBe('error')
   })
 
-  it('describes a non-Error list rejection with a stable message', async () => {
+  it('preserves a non-Error list rejection as a diagnostic string', async () => {
     // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
     const { remote } = fakeRemote({ list: () => Promise.reject('socket string') })
     const controller = new MessageFeedbackController(remote, SESSION)
 
     expect(await controller.ensure()).toEqual({
       ok: false,
-      error: { code: 'transport', message: 'message feedback list failed' },
+      error: { code: 'transport', message: 'socket string' },
     })
   })
 
-  it('describes a non-Error mutation rejection with a stable message', async () => {
+  it('preserves a non-Error mutation rejection as a diagnostic string', async () => {
     // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
     const { remote } = fakeRemote({ put: () => Promise.reject('nope') })
     const controller = new MessageFeedbackController(remote, SESSION)
 
     expect(await controller.rate(MSG, 'positive')).toEqual({
       ok: false,
-      error: { code: 'transport', message: 'message feedback mutation failed' },
+      error: { code: 'transport', message: 'nope' },
     })
   })
 

+ 1 - 0
packages/client/ui-permission-presets/src/client/PermissionRow.tsx

@@ -107,6 +107,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
         description={t('confirm.description')}
         acknowledgeLabel={t('confirm.acknowledge')}
         cancelLabel={t('confirm.cancel')}
+        closeLabel={t('close')}
         confirmLabel={t('confirm.enable')}
         acknowledged={acknowledged}
         disabled={!state.writable || state.status === 'saving'}

+ 2 - 4
packages/client/ui-plan/src/client/PlanModeControl.tsx

@@ -58,14 +58,12 @@ export function PlanChip({ useProjection, locked, exitPlanMode, t }: PlanChipPro
         disabled={locked || leaving}
         onClick={off}
       >
-        {/* Design literal, not copy: the chip wordmark stays 'Plan' in every locale. */}
-        Plan
+        {t('chip.label')}
         <span className={css.close} aria-hidden>
           <IconCloseFill14 size={12} />
         </span>
       </button>
-      {/* Failure copy stays English (error-surface policy: not localized). */}
-      {error !== null && <span className={css.error} role="status" title={error}>failed to exit plan mode</span>}
+      {error !== null && <span className={css.error} role="status" title={error}>{t('chip.exitFailed')}</span>}
     </span>
   )
 }

+ 4 - 0
packages/client/ui-plan/src/client/locales.ts

@@ -2,10 +2,12 @@
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
+  'chip.label': 'Plan',
   'chip.on.aria': 'plan mode 已开启,按下关闭',
   'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)',
   'chip.off.aria': 'plan mode 已关闭,按下开启',
   'chip.off.title': 'plan mode 已关闭 — 点击开启(/plan)',
+  'chip.exitFailed': '退出 plan mode 失败',
 } satisfies Record<string, string>
 
 /** The plan namespace key union. */
@@ -13,8 +15,10 @@ export type PlanKey = keyof typeof zh
 
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
+  'chip.label': 'Plan',
   'chip.on.aria': 'Plan mode on, press to turn off',
   'chip.on.title': 'Plan mode on — click to turn off (/plan off)',
   'chip.off.aria': 'Plan mode off, press to turn on',
   'chip.off.title': 'Plan mode off — click to turn on (/plan)',
+  'chip.exitFailed': 'Failed to exit plan mode',
 } satisfies Record<PlanKey, string>

+ 1 - 1
packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx

@@ -82,7 +82,7 @@ describe('PlanChip', () => {
       .mockRejectedValueOnce('socket closed')
     setup({ active: true, pending: false }, exitPlanMode)
     fireEvent.click(chip())
-    expect((await screen.findByText('failed to exit plan mode')).getAttribute('title')).toBe('host said no')
+    expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
     expect(chip()).toBeTruthy()
 
     fireEvent.click(chip())

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81
-README.zh.md: 8de384f276e8be5b78d2659a1e14e4afe12c030e
+README.md: c1c40e39710d46fae240f0b3281c36d660855010
+README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36

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

@@ -50,5 +50,5 @@ None; this package neither assembles nor sends a provider request.
 - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
 - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
 - **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error.
-- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing gets those defaults. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment.
+- **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`, so `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionBanner`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` receive complete localized labels through props. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
 - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.

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

@@ -50,5 +50,5 @@
 - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
 - **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。
-- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费方得到的就是这些默认值。`WebBlock` 尚未跟进这一模式:它的来源列表截断提示与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
+- **面向用户的文案必须由渲染点传入**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionBanner`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 和 `WebBlock` 都通过 prop 接收完整的本地化 label。本包不持有任何语言回落值;遗漏会导致类型检查失败,各功能把自己的 typed `t` 席位映射到原子组件 label 接口([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。
 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。

+ 2 - 2
packages/client/ui-primitives/src/ConnectionBanner.tsx

@@ -7,9 +7,9 @@ import css from './ConnectionBanner.module.css'
  * package is cordis-free, so copy arrives via props).
  * @returns the banner, or null when connected.
  */
-export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: {
+export function ConnectionBanner({ reconnecting, label }: {
   reconnecting: boolean
-  label?: string | undefined
+  label: string
 }) {
   if (!reconnecting) return null
   return <div className={css.banner}>{label}</div>

+ 23 - 11
packages/client/ui-primitives/src/DiffBlock.tsx

@@ -1,5 +1,6 @@
 import { useCallback, useMemo, useState } from 'react'
 import clsx from 'clsx'
+import { FoldToggle } from './FoldToggle.tsx'
 import { writeClipboard } from './clipboard.ts'
 import css from './DiffBlock.module.css'
 
@@ -22,12 +23,25 @@ export interface DiffHunk {
 export interface DiffBlockProps {
   /** One entry per applied hunk, in file order; empty renders nothing. */
   diffs: DiffHunk[]
+  /** Localized chrome supplied by the owning render site. */
+  labels: DiffBlockLabels
   /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
   maxLines?: number | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined
 }
 
+/** Localized chrome for {@link DiffBlock}. */
+export interface DiffBlockLabels {
+  copy: string
+  copied: string
+  collapseAria: string
+  expandAria: (hidden: number) => string
+  collapse: string
+  expand: (hidden: number) => string
+  files: (count: number) => string
+}
+
 /** A single rendered body line and its role, so the height cap slices a flat list. */
 interface DiffRow {
   kind: 'path' | 'del' | 'add' | 'gap'
@@ -123,7 +137,7 @@ function copyText(rows: DiffRow[]): string {
  * @param props - see {@link DiffBlockProps}.
  * @returns the diff block element.
  */
-export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
+export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
   const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
   const [expanded, setExpanded] = useState(false)
   const [copied, setCopied] = useState(false)
@@ -153,28 +167,26 @@ export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className
   return (
     <div className={clsx(css.block, className)} data-diff="">
       <button type="button" className={css.copyButton} onClick={onCopy}>
-        {copied ? '复制成功' : '复制'}
+        {copied ? labels.copied : labels.copy}
       </button>
       <div className={css.body}>
         {head.map((row, index) => (
           <div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
         ))}
         {hidden > 0 && (
-          <button
-            type="button"
+          <FoldToggle
             className={css.expand}
-            aria-expanded={expanded}
-            aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`}
-            onClick={onToggle}
-          >
-            {expanded ? '收起' : `… 其余 ${hidden} 行`}
-          </button>
+            expanded={expanded}
+            hidden={hidden}
+            labels={labels}
+            onToggle={onToggle}
+          />
         )}
         {tail.map((row, index) => (
           <div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
         ))}
       </div>
-      <div className={css.footer}>└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
+      <div className={css.footer}>└ +{added} -{removed} · {labels.files(files)}</div>
     </div>
   )
 }

+ 33 - 0
packages/client/ui-primitives/src/FoldToggle.tsx

@@ -0,0 +1,33 @@
+interface FoldToggleProps {
+  className: string | undefined
+  expanded: boolean
+  hidden: number
+  labels: {
+    collapseAria: string
+    expandAria: (hidden: number) => string
+    collapse: string
+    expand: (hidden: number) => string
+  }
+  onToggle: () => void
+}
+
+/**
+ * Render the shared head-tail fold control with caller-owned localized copy.
+ * @param props - Fold state, localized labels, and toggle callback.
+ * @returns The accessible expand or collapse button.
+ */
+export function FoldToggle({
+  className, expanded, hidden, labels, onToggle,
+}: FoldToggleProps) {
+  return (
+    <button
+      type="button"
+      className={className}
+      aria-expanded={expanded}
+      aria-label={expanded ? labels.collapseAria : labels.expandAria(hidden)}
+      onClick={onToggle}
+    >
+      {expanded ? labels.collapse : labels.expand(hidden)}
+    </button>
+  )
+}

+ 5 - 5
packages/client/ui-primitives/src/HoverCard.tsx

@@ -14,21 +14,21 @@ import css from './HoverCard.module.css'
  * @param props.disabled - suppress opening; turning true closes an open card.
  * @param props.copyText - optional primary value copied by activation and
  * included in the card's accessible name.
- * @param props.copyLabel - accessible activation-label prefix (default "复制").
- * @param props.copiedLabel - visible success label (default "复制成功").
+ * @param props.copyLabel - localized accessible activation-label prefix.
+ * @param props.copiedLabel - localized visible success label.
  * @returns anchor wrapper with the conditional portaled card.
  */
 export function HoverCard({
   anchor, content, openDelayMs = 500, disabled = false,
-  copyText, copyLabel = '复制', copiedLabel = '复制成功',
+  copyText, copyLabel, copiedLabel,
 }: {
   anchor: ReactNode
   content: ReactNode
   openDelayMs?: number
   disabled?: boolean
   copyText?: string | undefined
-  copyLabel?: string | undefined
-  copiedLabel?: string | undefined
+  copyLabel: string
+  copiedLabel: string
 }) {
   const rootRef = useRef<HTMLSpanElement>(null)
   const cardRef = useRef<HTMLDivElement>(null)

+ 13 - 32
packages/client/ui-primitives/src/JsonTree.tsx

@@ -1,5 +1,5 @@
 import clsx from 'clsx'
-import { useEffect, useId, useMemo, useRef, useState } from 'react'
+import { useEffect, useId, useRef, useState } from 'react'
 import type {
   KeyboardEvent as ReactKeyboardEvent,
   MouseEvent as ReactMouseEvent,
@@ -17,9 +17,7 @@ const PREVIEW_DEPTH_LIMIT = 2
 
 /**
  * Display copy for the tree's copy affordance; the owner passes localized
- * labels (this package is cordis-free, so copy arrives via props). Every
- * field defaults to the current built-in value, so existing consumers render
- * unchanged.
+ * labels (this package is cordis-free, so copy arrives via props).
  */
 export interface JsonTreeLabels {
   /** Menu item: copy the raw primitive value. */
@@ -44,19 +42,6 @@ export interface JsonTreeLabels {
   copyButtonTitle: (action: string) => string
 }
 
-const DEFAULT_LABELS: JsonTreeLabels = {
-  copyValue: 'Copy value',
-  copyJson: 'Copy JSON',
-  copyPath: 'Copy property path',
-  copyPrettyJson: 'Copy pretty JSON',
-  copyCompactJson: 'Copy compact JSON',
-  copied: 'Copied',
-  copyFailed: 'Copy failed',
-  collapseNode: 'Collapse JSON node',
-  expandNode: 'Expand JSON node',
-  copyButtonTitle: action => `${action}; right-click for copy options`,
-}
-
 function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
   return [
     { id: 'value', label: labels.copyValue },
@@ -388,15 +373,15 @@ export interface JsonTreeProps {
   /** Parsed JSON object or array. */
   data: object | unknown[]
   /** Accessible label for the tree. */
-  label?: string
+  label: string
   /** Optional positioning class owned by the caller. */
   className?: string | undefined
   /** Whether JSON rows expose copy actions. */
   copyable?: boolean
   /** Whether the top-level object or array is always expanded. */
   expandTopLevel?: boolean
-  /** Localized display copy; omitted fields keep the built-in defaults. */
-  labels?: Partial<JsonTreeLabels> | undefined
+  /** Localized display copy supplied by the owning render site. */
+  labels: JsonTreeLabels
 }
 
 /**
@@ -406,16 +391,12 @@ export interface JsonTreeProps {
  */
 export function JsonTree({
   data,
-  label = 'JSON',
+  label,
   className,
   copyable = true,
   expandTopLevel = true,
   labels,
 }: JsonTreeProps) {
-  const copyLabels = useMemo<JsonTreeLabels>(
-    () => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
-    [labels],
-  )
   const rootEntries = entriesOf(data)
   const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
     isExpandableValue(value) && entriesOf(value).length > 0
@@ -544,10 +525,10 @@ export function JsonTree({
   const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
   const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
   const copyTitle = copyState === 'copied'
-    ? copyLabels.copied
+    ? labels.copied
     : copyState === 'failed'
-      ? copyLabels.copyFailed
-      : copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue
+      ? labels.copyFailed
+      : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue
 
   return (
     <div
@@ -583,7 +564,7 @@ export function JsonTree({
                   field={key}
                   value={value}
                   path={[Array.isArray(data) ? index : key]}
-                  labels={copyLabels}
+                  labels={labels}
                   lastElement={index === rootEntries.length - 1}
                   initialExpanded={false}
                   tabStopId={tabStopId}
@@ -602,7 +583,7 @@ export function JsonTree({
             <JsonTreeNode
               value={data}
               path={[]}
-              labels={copyLabels}
+              labels={labels}
               lastElement
               initialExpanded
               tabStopId={tabStopId}
@@ -630,7 +611,7 @@ export function JsonTree({
                 data-json-copy-button
                 data-state={copyState}
                 aria-label={copyTitle}
-                title={copyLabels.copyButtonTitle(copyTitle)}
+                title={labels.copyButtonTitle(copyTitle)}
                 onClick={() => void copy(defaultCopyMode)}
                 onContextMenu={(event) => {
                   event.preventDefault()
@@ -644,7 +625,7 @@ export function JsonTree({
                   : <IconCopyOutline16 size={12} />}
               </button>
             )}
-            items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)}
+            items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
             onSelect={(id) => {
               void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
               copyMenuOpenRef.current = false

+ 18 - 13
packages/client/ui-primitives/src/Modal.tsx

@@ -5,6 +5,22 @@ import clsx from 'clsx'
 import { IconCloseOutline16 } from './icons/index.tsx'
 import css from './Modal.module.css'
 
+interface ModalBaseProps {
+  open: boolean
+  onClose: () => void
+  title: string
+  description?: string
+  children?: ReactNode
+  footer?: ReactNode
+  className?: string
+  contentClassName?: string
+}
+
+type ModalProps = ModalBaseProps & (
+  | { headless: true; closeLabel?: never }
+  | { headless?: false; closeLabel: string }
+)
+
 /**
  * Render a centered, body-portaled modal over a blurred page mask.
  * @param props.open - whether the dialog is showing.
@@ -20,19 +36,8 @@ import css from './Modal.module.css'
  * @returns null when closed; otherwise the overlay tree.
  */
 export function Modal({
-  open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
-}: {
-  open: boolean
-  onClose: () => void
-  title: string
-  closeLabel?: string
-  description?: string
-  children?: ReactNode
-  footer?: ReactNode
-  className?: string
-  contentClassName?: string
-  headless?: boolean
-}) {
+  open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false,
+}: ModalProps) {
   useEffect(() => {
     if (!open) return
     const onKeyDown = (e: KeyboardEvent) => {

+ 23 - 10
packages/client/ui-primitives/src/ReadBlock.tsx

@@ -1,5 +1,6 @@
 import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
 import clsx from 'clsx'
+import { FoldToggle } from './FoldToggle.tsx'
 import { writeClipboard } from './clipboard.ts'
 import {
   grammarLoadCount,
@@ -29,6 +30,8 @@ export interface ReadBlockProps {
   label?: string | undefined
   /** The returned window's lines, in file order, each keeping its file line number. */
   lines: readonly ReadBlockLine[]
+  /** Localized chrome supplied by the owning render site. */
+  labels: ReadBlockLabels
   /** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
   totalLines: number
   /** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
@@ -39,6 +42,17 @@ export interface ReadBlockProps {
   className?: string | undefined
 }
 
+/** Localized chrome for {@link ReadBlock}. */
+export interface ReadBlockLabels {
+  window: (shown: number, total: number) => string
+  copy: string
+  copied: string
+  collapseAria: string
+  expandAria: (hidden: number) => string
+  collapse: string
+  expand: (hidden: number) => string
+}
+
 function renderSpans(spans: readonly HighlightSpan[]) {
   return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
 }
@@ -51,6 +65,7 @@ function renderSpans(spans: readonly HighlightSpan[]) {
  */
 export function ReadBlock({
   label,
+  labels,
   lines,
   totalLines,
   lang,
@@ -104,13 +119,13 @@ export function ReadBlock({
         <div className={css.label}>{label ?? ''}</div>
         <div className={css.action}>
           {windowed && (
-            <span className={css.count}>{`显示 ${lines.length} / ${totalLines} 行`}</span>
+            <span className={css.count}>{labels.window(lines.length, totalLines)}</span>
           )}
           <span className={css.lang}>{lang ?? ''}</span>
           {/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */}
           {lines.length > 0 && (
             <button type="button" className={css.copyButton} onClick={onCopy}>
-              {copied ? '复制成功' : '复制'}
+              {copied ? labels.copied : labels.copy}
             </button>
           )}
         </div>
@@ -118,15 +133,13 @@ export function ReadBlock({
       <div className={css.body}>
         {rows(capped ? paired.slice(0, headLines) : paired)}
         {hidden > 0 && (
-          <button
-            type="button"
+          <FoldToggle
             className={css.expand}
-            aria-expanded={expanded}
-            aria-label={expanded ? '收起内容' : `展开其余 ${hidden} 行`}
-            onClick={onToggle}
-          >
-            {expanded ? '收起' : `… 其余 ${hidden} 行`}
-          </button>
+            expanded={expanded}
+            hidden={hidden}
+            labels={labels}
+            onToggle={onToggle}
+          />
         )}
         {capped && rows(paired.slice(paired.length - tailLines))}
       </div>

+ 3 - 0
packages/client/ui-primitives/src/RiskConfirmation.tsx

@@ -13,6 +13,7 @@ export interface RiskConfirmationProps {
   description: string
   acknowledgeLabel: string
   cancelLabel: string
+  closeLabel: string
   confirmLabel: string
   acknowledged: boolean
   disabled?: boolean
@@ -31,6 +32,7 @@ export function RiskConfirmation({
   description,
   acknowledgeLabel,
   cancelLabel,
+  closeLabel,
   confirmLabel,
   acknowledged,
   disabled = false,
@@ -43,6 +45,7 @@ export function RiskConfirmation({
       open={open}
       onClose={onCancel}
       title={title}
+      closeLabel={closeLabel}
       className={css.confirmation ?? ''}
       contentClassName={css.confirmationContent ?? ''}
       footer={(

+ 21 - 7
packages/client/ui-primitives/src/SearchBlock.tsx

@@ -29,6 +29,8 @@ export interface SearchFileGroup {
 
 /** Fields both search shapes carry (the render site positions; this component draws). */
 interface SearchBlockCommon {
+  /** Localized chrome supplied by the owning render site. */
+  labels: SearchBlockLabels
   /**
    * Whether the tool capped the inline result: the shape carries only the
    * retained results, not every result the search found. The banner summary
@@ -44,6 +46,19 @@ interface SearchBlockCommon {
   className?: string | undefined
 }
 
+/** Localized chrome for {@link SearchBlock}. */
+export interface SearchBlockLabels {
+  pathsSummary: (shown: number, total: number, truncated: boolean) => string
+  matchesSummary: (shown: number, total: number, files: number, truncated: boolean) => string
+  copy: string
+  copied: string
+  noResults: string
+  collapseAria: string
+  expandAria: (hidden: number) => string
+  collapse: string
+  expand: (hidden: number) => string
+}
+
 /** Props for the grouped-matches (`grep`) shape. */
 export interface SearchMatchesBlockProps extends SearchBlockCommon {
   kind: 'matches'
@@ -113,10 +128,9 @@ function shownCount(props: SearchBlockProps): number {
  * @returns the summary text.
  */
 function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string {
-  const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}`
   return props.kind === 'paths'
-    ? `${count} 个路径`
-    : `${count} 处匹配 · ${props.files.length} 个文件`
+    ? props.labels.pathsSummary(shown, total, truncated)
+    : props.labels.matchesSummary(shown, total, props.files.length, truncated)
 }
 
 /**
@@ -232,12 +246,12 @@ export function SearchBlock(props: SearchBlockProps) {
         <span className={css.summary}>{summaryText(props, shown, truncated, total)}</span>
         {!empty && (
           <button type="button" className={css.copyButton} onClick={onCopy}>
-            {copied ? '复制成功' : '复制'}
+            {copied ? props.labels.copied : props.labels.copy}
           </button>
         )}
       </div>
       {empty
-        ? <div className={css.empty}>无结果</div>
+        ? <div className={css.empty}>{props.labels.noResults}</div>
         : (
           <div className={css.body}>
             {head.map(row => (
@@ -248,10 +262,10 @@ export function SearchBlock(props: SearchBlockProps) {
                 type="button"
                 className={css.expand}
                 aria-expanded={expanded}
-                aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`}
+                aria-label={expanded ? props.labels.collapseAria : props.labels.expandAria(hidden)}
                 onClick={onToggle}
               >
-                {expanded ? '收起' : `… 其余 ${hidden} 行`}
+                {expanded ? props.labels.collapse : props.labels.expand(hidden)}
               </button>
             )}
             {tailHeader !== undefined && (

+ 4 - 24
packages/client/ui-primitives/src/TerminalBlock.tsx

@@ -12,9 +12,7 @@ export const DEFAULT_TERMINAL_MAX_LINES = 16
 
 /**
  * Display copy for the terminal surface; the owner passes localized labels
- * (this package is cordis-free, so copy arrives via props). Every field
- * defaults to the current built-in value, so existing consumers render
- * unchanged.
+ * (this package is cordis-free, so copy arrives via props).
  */
 export interface TerminalBlockLabels {
   /** Status pill text for a signal-terminated command. */
@@ -43,21 +41,6 @@ export interface TerminalBlockLabels {
   expand: (hidden: number) => string
 }
 
-const DEFAULT_LABELS: TerminalBlockLabels = {
-  signal: signal => `信号 ${signal}`,
-  exitCode: exitCode => `退出码 ${exitCode}`,
-  running: '运行中',
-  failed: '失败',
-  done: '已完成',
-  copy: '复制',
-  copied: '复制成功',
-  noOutput: '无输出',
-  collapseAria: '收起输出',
-  collapse: '收起',
-  expandAria: hidden => `展开其余 ${hidden} 行输出`,
-  expand: hidden => `… 其余 ${hidden} 行`,
-}
-
 export interface TerminalBlockProps {
   /** The command line, rendered verbatim after the prompt label. */
   command: string
@@ -77,8 +60,8 @@ export interface TerminalBlockProps {
   maxLines?: number | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined
-  /** Localized display copy; omitted fields keep the built-in defaults. */
-  labels?: Partial<TerminalBlockLabels> | undefined
+  /** Localized display copy supplied by the owning render site. */
+  labels: TerminalBlockLabels
 }
 
 /**
@@ -172,10 +155,7 @@ export function TerminalBlock({
   className,
   labels,
 }: TerminalBlockProps) {
-  const copy = useMemo<TerminalBlockLabels>(
-    () => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
-    [labels],
-  )
+  const copy = labels
   const text = output ?? ''
   // A command's output ends with a newline; that terminator is not an extra
   // blank line to draw or to count against the height cap. The check runs on the

+ 21 - 8
packages/client/ui-primitives/src/WebBlock.tsx

@@ -1,5 +1,5 @@
 import clsx from 'clsx'
-import { MarkdownText } from './markdown/MarkdownText.tsx'
+import { MarkdownText, type MarkdownLabels } from './markdown/MarkdownText.tsx'
 import css from './WebBlock.module.css'
 
 /**
@@ -21,6 +21,8 @@ export interface WebSourceView {
 /** A `web_search` card: an optional answer over a capped citation list. */
 export interface WebSearchBlockProps {
   kind: 'search'
+  /** Localized chrome supplied by the owning render site. */
+  labels: WebBlockLabels
   /** The provider-generated answer, rendered as markdown above the sources. */
   answer?: string | undefined
   /** The cited sources, in provider order. */
@@ -34,6 +36,8 @@ export interface WebSearchBlockProps {
 /** A `web_fetch` card: the retrieval summary for one fetched URL. */
 export interface WebFetchBlockProps {
   kind: 'fetch'
+  /** Localized chrome supplied by the owning render site. */
+  labels: WebBlockLabels
   /** The final URL after allowed redirects; becomes a safe external link when http(s). */
   url: string
   /** HTTP status code of the fetched response. */
@@ -47,6 +51,15 @@ export interface WebFetchBlockProps {
 /** A completed web retrieval card, discriminated by `kind`. */
 export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
 
+/** Localized chrome for {@link WebBlock}. */
+export interface WebBlockLabels {
+  noResults: string
+  sourcesTruncated: string
+  http: string
+  contentTruncated: string
+  markdown: MarkdownLabels
+}
+
 /**
  * The URL to link to, or undefined when the URL must render as plain text. Only
  * http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:`
@@ -132,7 +145,7 @@ function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: numbe
  * @param props - see {@link WebSearchBlockProps}.
  * @returns the search card element.
  */
-function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBlockProps) {
+function WebSearchBlock({ answer, sources, truncated, labels, className }: WebSearchBlockProps) {
   // A provider may legitimately return no answer and no sources; the chat WebRow
   // does not show the raw result content, so without this the user would see an
   // empty card. Mirror the backend's `No results found.` render text.
@@ -140,16 +153,16 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc
   return (
     <div className={clsx(css.block, className)} data-web="search">
       {answer !== undefined && answer !== '' && (
-        <div className={css.answer}><MarkdownText text={answer} /></div>
+        <div className={css.answer}><MarkdownText text={answer} labels={labels.markdown} /></div>
       )}
       {empty ? (
-        <div className={css.empty}>未找到结果</div>
+        <div className={css.empty}>{labels.noResults}</div>
       ) : (
         <ol className={css.sources}>
           {sources.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
         </ol>
       )}
-      {truncated && <div className={css.truncated}>来源列表已截断</div>}
+      {truncated && <div className={css.truncated}>{labels.sourcesTruncated}</div>}
     </div>
   )
 }
@@ -159,13 +172,13 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc
  * @param props - see {@link WebFetchBlockProps}.
  * @returns the fetch card element.
  */
-function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) {
+function WebFetchBlock({ url, statusCode, truncated, labels, className }: WebFetchBlockProps) {
   return (
     <div className={clsx(css.block, css.fetch, className)} data-web="fetch">
       <SafeLink url={url} label={url} className={css.fetchUrl} />
       <div className={css.fetchMeta}>
-        <span className={css.status}>HTTP {statusCode}</span>
-        {truncated && <span className={css.truncated}>内容已截断</span>}
+        <span className={css.status}>{labels.http} {statusCode}</span>
+        {truncated && <span className={css.truncated}>{labels.contentTruncated}</span>}
       </div>
     </div>
   )

+ 7 - 4
packages/client/ui-primitives/src/index.ts

@@ -34,20 +34,23 @@ export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
 export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
 export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
 export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
-export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
+export type { ReadBlockProps, ReadBlockLine, ReadBlockLabels } from './ReadBlock.tsx'
 export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
-export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
+export type { DiffBlockProps, DiffHunk, DiffBlockLabels } from './DiffBlock.tsx'
 export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
 export type {
   SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
+  SearchBlockLabels,
 } from './SearchBlock.tsx'
 export { WebBlock } from './WebBlock.tsx'
-export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
+export type {
+  WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView, WebBlockLabels,
+} from './WebBlock.tsx'
 export { CodeBlock } from './markdown/CodeBlock.tsx'
 export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
-export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx'
+export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx'
 export { MessageText } from './markdown/MessageText.tsx'
 export { extractMarkdownPlainText } from './markdown/plain-text.ts'
 export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'

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

@@ -12,12 +12,12 @@ export interface CodeBlockProps {
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
-  copyLabel?: string | undefined
+  copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
-  copiedLabel?: string | undefined
+  copiedLabel: string
 }
 
-export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
+export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   // Re-render when a lazy grammar finishes loading, so a fence that showed plain
   // text while its language's grammar imported picks up highlighting. The

+ 2 - 7
packages/client/ui-primitives/src/markdown/JsonBlock.tsx

@@ -5,17 +5,12 @@ import css from './JsonBlock.module.css'
 
 const MAX_CHARS = 20_000
 
-/** Default truncation footer; the owner passes a localized formatter. */
-function defaultTruncatedLabel(total: number): string {
-  return `… 已截断,共 ${total} 字符`
-}
-
-export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: {
+export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel }: {
   label: string
   payload: unknown
   defaultOpen?: boolean
   /** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */
-  truncatedLabel?: ((total: number) => string) | undefined
+  truncatedLabel: (total: number) => string
 }) {
   const [open, setOpen] = useState(defaultOpen)
   const body = useMemo(() => {

+ 18 - 18
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -19,16 +19,16 @@ import {
   collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
   wrapBlockChildren,
 } from './render.tsx'
-import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
+import type { MarkdownFileMentions, MarkdownLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
 import 'katex/dist/katex.min.css'
 import css from './MarkdownText.module.css'
 
-export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx'
+export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './render.tsx'
 
 /** One settled full render: parse with math, resolve references, append the footnote section. */
 function renderSettled(
   text: string,
-  codeLabels: MarkdownCodeLabels | undefined,
+  labels: MarkdownLabels,
   fileMentions: MarkdownFileMentions | undefined,
 ): ReactNode[] {
   const root = parseGfmWithMath(text)
@@ -36,7 +36,7 @@ function renderSettled(
   collectReferenceTargets(root.children, targets)
   const context: MarkdownRenderContext = {
     streaming: false,
-    codeLabels,
+    labels,
     fileMentions,
     targets,
     footnoteOrder: [],
@@ -67,8 +67,8 @@ class StreamingRenderer {
   private lastText: string | null = null
   private lastRendered: ReactNode[] = []
 
-  /** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
-  constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {}
+  /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */
+  constructor(private readonly labels: MarkdownLabels) {}
 
   /**
    * Render the current accumulated text. Idempotent per text value, so React
@@ -100,7 +100,7 @@ class StreamingRenderer {
     if (newlyFrozen.length > 0) {
       const frozenContext: MarkdownRenderContext = {
         streaming: true,
-        codeLabels: this.codeLabels,
+        labels: this.labels,
         fileMentions: undefined,
         targets: frameTargets,
         footnoteOrder: this.frozenFootnoteOrder,
@@ -118,7 +118,7 @@ class StreamingRenderer {
     }
     const tailContext: MarkdownRenderContext = {
       streaming: true,
-      codeLabels: this.codeLabels,
+      labels: this.labels,
       fileMentions: undefined,
       targets: frameTargets,
       footnoteOrder: [...this.frozenFootnoteOrder],
@@ -141,8 +141,8 @@ class StreamingRenderer {
  * Render untrusted assistant-authored Markdown as semantic React elements.
  * @param props - Markdown source text preserved by the session projection;
  * `streaming` renders fences and TeX plain (highlighting and KaTeX land on
- * the finalize swap) and parses incrementally across chunks; `codeLabels`
- * forwards localized copy-button labels to fence CodeBlocks — pass a
+ * the finalize swap) and parses incrementally across chunks; `labels`
+ * forwards localized fence and footnote chrome — pass a
  * reference-stable object (memoized per locale revision), because a new
  * identity discards the streaming render cache mid-message. `fileMentions`
  * links inline-code tokens its resolver recognizes as real files; this is
@@ -153,24 +153,24 @@ class StreamingRenderer {
  * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
  * images render directly.
  */
-export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: {
+export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions }: {
   text: string
   streaming?: boolean
-  codeLabels?: MarkdownCodeLabels | undefined
+  labels: MarkdownLabels
   fileMentions?: MarkdownFileMentions | undefined
 }) {
   const streamRef = useRef<StreamingRenderer | null>(null)
-  const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
+  const streamLabelsRef = useRef<MarkdownLabels>(labels)
   const children = useMemo(() => {
     if (!streaming) {
       streamRef.current = null
-      return renderSettled(text, codeLabels, fileMentions)
+      return renderSettled(text, labels, fileMentions)
     }
-    if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
-      streamRef.current = new StreamingRenderer(codeLabels)
-      streamLabelsRef.current = codeLabels
+    if (streamRef.current === null || streamLabelsRef.current !== labels) {
+      streamRef.current = new StreamingRenderer(labels)
+      streamLabelsRef.current = labels
     }
     return streamRef.current.render(text)
-  }, [text, streaming, codeLabels, fileMentions])
+  }, [text, streaming, labels, fileMentions])
   return <div className={css.markdown}>{children}</div>
 })

+ 12 - 6
packages/client/ui-primitives/src/markdown/render.tsx

@@ -30,9 +30,15 @@ import css from './MarkdownText.module.css'
 /** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
 export interface MarkdownCodeLabels {
   /** Copy-button idle label. */
-  copyLabel?: string | undefined
+  copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
-  copiedLabel?: string | undefined
+  copiedLabel: string
+}
+
+/** Localized chrome for a Markdown document. */
+export interface MarkdownLabels {
+  code: MarkdownCodeLabels
+  footnotes: string
 }
 
 function sanitizeUrl(url: string): string {
@@ -123,7 +129,7 @@ export interface MarkdownRenderContext {
   /** Streaming arm: fences render plain and TeX stays literal. */
   readonly streaming: boolean
   /** Localized fence copy-button labels. */
-  readonly codeLabels: MarkdownCodeLabels | undefined
+  readonly labels: MarkdownLabels
   /** Inside a blockquote's children: tables there always fill the quote's width. */
   readonly inBlockquote?: boolean
   /** Inline-code file mentions; absent wherever no opener vocabulary exists. */
@@ -329,8 +335,8 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
       // that trim eat a REAL trailing blank line inside the fence instead.
       code={`${node.value}\n`}
       lang={context.streaming ? undefined : lang}
-      copyLabel={context.codeLabels?.copyLabel}
-      copiedLabel={context.codeLabels?.copiedLabel}
+      copyLabel={context.labels.code.copyLabel}
+      copiedLabel={context.labels.code.copiedLabel}
     />
   )
 }
@@ -597,7 +603,7 @@ export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode
   if (items.length === 0) return null
   return (
     <section key="footnotes" data-footnotes className="footnotes">
-      <h2 id="footnote-label" className="sr-only">Footnotes</h2>
+      <h2 id="footnote-label" className="sr-only">{context.labels.footnotes}</h2>
       <ol>{items}</ol>
     </section>
   )

+ 15 - 4
packages/client/ui-primitives/tests/atoms.client.spec.tsx

@@ -383,7 +383,7 @@ describe('Modal', () => {
   it('is absent while closed; Escape and mask click call onClose', () => {
     const onClose = vi.fn()
     const { rerender } = render(
-      <Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
+      <Modal open={false} onClose={onClose} title="Create new workspace" closeLabel="Close">body</Modal>)
     expect(screen.queryByRole('dialog')).toBeNull()
     rerender(
       <Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
@@ -406,13 +406,24 @@ describe('Modal', () => {
     fireEvent.click(mask)
     expect(onClose).toHaveBeenCalledTimes(2)
   })
+
+  it('renders headless content without the default close chrome', () => {
+    render(
+      <Modal open onClose={() => {}} title="Custom surface" headless>
+        <span>Custom body</span>
+      </Modal>,
+    )
+    expect(screen.getByRole('dialog', { name: 'Custom surface' })).toBeDefined()
+    expect(screen.getByText('Custom body')).toBeDefined()
+    expect(screen.queryByRole('button')).toBeNull()
+  })
 })
 
 describe('ConnectionBanner', () => {
   it('renders only while reconnecting', () => {
-    const { container, rerender } = render(<ConnectionBanner reconnecting={false} />)
+    const { container, rerender } = render(<ConnectionBanner reconnecting={false} label="Reconnecting" />)
     expect(container.firstChild).toBeNull()
-    rerender(<ConnectionBanner reconnecting />)
-    expect(container.textContent).toContain('重连')
+    rerender(<ConnectionBanner reconnecting label="Reconnecting" />)
+    expect(container.textContent).toContain('Reconnecting')
   })
 })

+ 7 - 1
packages/client/ui-primitives/tests/code-block.client.spec.tsx

@@ -2,8 +2,14 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
+import type { ComponentProps } from 'react'
+import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx'
 import { highlightToHtml } from '../src/markdown/highlight.ts'
+import { markdownLabels } from './labels.client.ts'
+
+function CodeBlock(props: Omit<ComponentProps<typeof LocalizedCodeBlock>, 'copyLabel' | 'copiedLabel'>) {
+  return <LocalizedCodeBlock {...props} {...markdownLabels.code} />
+}
 
 afterEach(cleanup)
 

+ 7 - 1
packages/client/ui-primitives/tests/diff-block.client.spec.tsx

@@ -2,7 +2,13 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
+import type { ComponentProps } from 'react'
+import { DEFAULT_DIFF_MAX_LINES, DiffBlock as LocalizedDiffBlock, type DiffHunk } from '../src/index.ts'
+import { diffBlockLabels } from './labels.client.ts'
+
+function DiffBlock(props: Omit<ComponentProps<typeof LocalizedDiffBlock>, 'labels'>) {
+  return <LocalizedDiffBlock {...props} labels={diffBlockLabels} />
+}
 
 afterEach(cleanup)
 

+ 16 - 2
packages/client/ui-primitives/tests/hover-card.client.spec.tsx

@@ -25,7 +25,13 @@ function mount(props: {
   copiedLabel?: string
 } = {}) {
   const view = render(
-    <HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
+    <HoverCard
+      anchor={<span>row</span>}
+      content={<div>card body</div>}
+      copyLabel={props.copyLabel ?? 'Copy'}
+      copiedLabel={props.copiedLabel ?? 'Copied'}
+      {...props}
+    />,
   )
   const anchor = screen.getByText('row')
   stubAnchorRect(anchor, { top: 40, right: 200 })
@@ -370,7 +376,15 @@ describe('HoverCard', () => {
     fireEvent.pointerEnter(wrapper)
     act(() => { vi.advanceTimersByTime(500) })
     expect(screen.getByText('card body')).toBeTruthy()
-    view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />)
+    view.rerender(
+      <HoverCard
+        anchor={<span>row</span>}
+        content={<div>card body</div>}
+        copyLabel="Copy"
+        copiedLabel="Copied"
+        disabled
+      />,
+    )
     expect(screen.queryByText('card body')).toBeNull()
   })
 

+ 9 - 1
packages/client/ui-primitives/tests/json-tree.client.spec.tsx

@@ -2,7 +2,15 @@
 
 import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { JsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ComponentProps } from 'react'
+import { JsonTree as LocalizedJsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
+import { jsonTreeLabels } from './labels.client.ts'
+
+function JsonTree(props: Omit<ComponentProps<typeof LocalizedJsonTree>, 'label' | 'labels'> & {
+  label?: string
+}) {
+  return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
+}
 
 let writeText: ReturnType<typeof vi.fn>
 

+ 64 - 0
packages/client/ui-primitives/tests/labels.client.ts

@@ -0,0 +1,64 @@
+import type {
+  DiffBlockLabels,
+  JsonTreeLabels,
+  MarkdownLabels,
+  ReadBlockLabels,
+  SearchBlockLabels,
+  TerminalBlockLabels,
+  WebBlockLabels,
+} from '../src/index.ts'
+
+export const markdownLabels: MarkdownLabels = {
+  code: { copyLabel: '复制', copiedLabel: '复制成功' },
+  footnotes: 'Footnotes',
+}
+
+export const diffBlockLabels: DiffBlockLabels = {
+  copy: '复制', copied: '复制成功', collapseAria: '收起差异',
+  expandAria: hidden => `展开其余 ${hidden} 行差异`,
+  collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
+  files: count => `${count} ${count === 1 ? 'file' : 'files'}`,
+}
+
+export const readBlockLabels: ReadBlockLabels = {
+  window: (shown, total) => `显示 ${shown} / ${total} 行`,
+  copy: '复制', copied: '复制成功', collapseAria: '收起内容',
+  expandAria: hidden => `展开其余 ${hidden} 行`,
+  collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
+}
+
+export const searchBlockLabels: SearchBlockLabels = {
+  pathsSummary: (shown, total, truncated) => truncated
+    ? `显示 ${shown} / 共 ${total} 个路径`
+    : `${shown} 个路径`,
+  matchesSummary: (shown, total, files, truncated) => truncated
+    ? `显示 ${shown} / 共 ${total} 处匹配 · ${files} 个文件`
+    : `${shown} 处匹配 · ${files} 个文件`,
+  copy: '复制', copied: '复制成功', noResults: '无结果',
+  collapseAria: '收起结果',
+  expandAria: hidden => `展开其余 ${hidden} 行结果`,
+  collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
+}
+
+export const terminalBlockLabels: TerminalBlockLabels = {
+  signal: signal => `信号 ${signal}`,
+  exitCode: code => `退出码 ${code}`,
+  running: '运行中', failed: '失败', done: '已完成',
+  copy: '复制', copied: '复制成功', noOutput: '无输出',
+  collapseAria: '收起输出', collapse: '收起',
+  expandAria: hidden => `展开其余 ${hidden} 行输出`,
+  expand: hidden => `… 其余 ${hidden} 行`,
+}
+
+export const jsonTreeLabels: JsonTreeLabels = {
+  copyValue: 'Copy value', copyJson: 'Copy JSON', copyPath: 'Copy property path',
+  copyPrettyJson: 'Copy pretty JSON', copyCompactJson: 'Copy compact JSON',
+  copied: 'Copied', copyFailed: 'Copy failed',
+  collapseNode: 'Collapse JSON node', expandNode: 'Expand JSON node',
+  copyButtonTitle: action => `${action}; right-click for copy options`,
+}
+
+export const webBlockLabels: WebBlockLabels = {
+  noResults: '未找到结果', sourcesTruncated: '来源列表已截断',
+  http: 'HTTP', contentTruncated: '内容已截断', markdown: markdownLabels,
+}

+ 1 - 1
packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx

@@ -3,7 +3,7 @@
 // user-visible Markdown changes rather than regenerating them for refactors.
 import { cleanup, render } from '@testing-library/react'
 import { afterEach, describe, expect, it } from 'vitest'
-import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { MarkdownText } from './markdown-test-components.tsx'
 
 afterEach(cleanup)
 

+ 7 - 3
packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx

@@ -6,7 +6,7 @@
 import { cleanup, render } from '@testing-library/react'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { Root, RootContent } from 'mdast'
-import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { MarkdownText } from './markdown-test-components.tsx'
 import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts'
 import { parseGfm } from '../src/markdown/parse.ts'
 
@@ -95,9 +95,13 @@ describe('incremental streaming rendering', () => {
 
   it('drops the streaming cache when the copy labels change identity', () => {
     const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
-    const live = render(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy' }} />)
+    const live = render(
+      <MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy', copiedLabel: 'Copied' }} />,
+    )
     expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
-    live.rerender(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren' }} />)
+    live.rerender(
+      <MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren', copiedLabel: 'Kopiert' }} />,
+    )
     expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
     live.unmount()
   })

+ 3 - 2
packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx

@@ -8,7 +8,8 @@ import { StrictMode } from 'react'
 import { cleanup, render } from '@testing-library/react'
 import { afterEach, describe, expect, it } from 'vitest'
 import type * as Md from 'mdast'
-import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { MarkdownText } from './markdown-test-components.tsx'
+import { markdownLabels } from './labels.client.ts'
 import {
   collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
 } from '../src/markdown/render.tsx'
@@ -19,7 +20,7 @@ afterEach(cleanup)
 function makeContext(): MarkdownRenderContext {
   return {
     streaming: false,
-    codeLabels: undefined,
+    labels: markdownLabels,
     fileMentions: undefined,
     targets: createReferenceTargets(),
     footnoteOrder: [],

+ 37 - 0
packages/client/ui-primitives/tests/markdown-test-components.tsx

@@ -0,0 +1,37 @@
+import type { ComponentProps } from 'react'
+import {
+  JsonBlock as LocalizedJsonBlock,
+  MarkdownText as LocalizedMarkdownText,
+  type MarkdownCodeLabels,
+  type MarkdownLabels,
+} from '../src/index.ts'
+import { markdownLabels as defaultMarkdownLabels } from './labels.client.ts'
+
+type MarkdownTextProps = Omit<ComponentProps<typeof LocalizedMarkdownText>, 'labels'> & {
+  labels?: MarkdownLabels
+  codeLabels?: MarkdownCodeLabels
+}
+
+export function MarkdownText({
+  labels,
+  codeLabels,
+  ...props
+}: MarkdownTextProps) {
+  const resolved = labels ?? (codeLabels === undefined
+    ? defaultMarkdownLabels
+    : { ...defaultMarkdownLabels, code: codeLabels })
+  return <LocalizedMarkdownText {...props} labels={resolved} />
+}
+
+type JsonBlockProps = Omit<ComponentProps<typeof LocalizedJsonBlock>, 'truncatedLabel'> & {
+  truncatedLabel?: (total: number) => string
+}
+
+export function JsonBlock({ truncatedLabel, ...props }: JsonBlockProps) {
+  return (
+    <LocalizedJsonBlock
+      {...props}
+      truncatedLabel={truncatedLabel ?? (total => `… 已截断,共 ${total} 字符`)}
+    />
+  )
+}

+ 2 - 1
packages/client/ui-primitives/tests/markdown.client.spec.tsx

@@ -1,7 +1,8 @@
 // @vitest-environment jsdom
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it } from 'vitest'
-import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
 import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
 import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
 

+ 7 - 1
packages/client/ui-primitives/tests/read-block.client.spec.tsx

@@ -2,8 +2,14 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
+import type { ComponentProps } from 'react'
+import { DEFAULT_READ_MAX_LINES, ReadBlock as LocalizedReadBlock, type ReadBlockLine } from '../src/index.ts'
 import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
+import { readBlockLabels } from './labels.client.ts'
+
+function ReadBlock(props: Omit<ComponentProps<typeof LocalizedReadBlock>, 'labels'>) {
+  return <LocalizedReadBlock {...props} labels={readBlockLabels} />
+}
 
 afterEach(cleanup)
 

+ 23 - 2
packages/client/ui-primitives/tests/search-block.client.spec.tsx

@@ -2,8 +2,29 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts'
-import type { SearchFileGroup } from '../src/index.ts'
+import { DEFAULT_SEARCH_MAX_LINES, SearchBlock as LocalizedSearchBlock } from '../src/index.ts'
+import type {
+  SearchFileGroup, SearchMatchesBlockProps, SearchPathsBlockProps,
+} from '../src/index.ts'
+import { searchBlockLabels } from './labels.client.ts'
+
+type SearchBlockProps =
+  | Omit<SearchMatchesBlockProps, 'labels'>
+  | Omit<SearchPathsBlockProps, 'labels'>
+
+function SearchMatchesBlock(props: Omit<SearchMatchesBlockProps, 'labels'>) {
+  return <LocalizedSearchBlock {...props} labels={searchBlockLabels} />
+}
+
+function SearchPathsBlock(props: Omit<SearchPathsBlockProps, 'labels'>) {
+  return <LocalizedSearchBlock {...props} labels={searchBlockLabels} />
+}
+
+function SearchBlock(props: SearchBlockProps) {
+  return props.kind === 'matches'
+    ? <SearchMatchesBlock {...props} />
+    : <SearchPathsBlock {...props} />
+}
 
 afterEach(cleanup)
 

+ 7 - 1
packages/client/ui-primitives/tests/terminal-block.client.spec.tsx

@@ -2,8 +2,14 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts'
+import type { ComponentProps } from 'react'
+import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock as LocalizedTerminalBlock } from '../src/index.ts'
 import { writeClipboard } from '../src/clipboard.ts'
+import { terminalBlockLabels } from './labels.client.ts'
+
+function TerminalBlock(props: Omit<ComponentProps<typeof LocalizedTerminalBlock>, 'labels'>) {
+  return <LocalizedTerminalBlock {...props} labels={terminalBlockLabels} />
+}
 
 const ESC = '\u001b'
 

+ 23 - 2
packages/client/ui-primitives/tests/web-block.client.spec.tsx

@@ -2,8 +2,29 @@
 
 import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import { WebBlock } from '../src/index.ts'
-import type { WebSourceView } from '../src/index.ts'
+import { WebBlock as LocalizedWebBlock } from '../src/index.ts'
+import type {
+  WebFetchBlockProps, WebSearchBlockProps, WebSourceView,
+} from '../src/index.ts'
+import { webBlockLabels } from './labels.client.ts'
+
+type WebBlockProps =
+  | Omit<WebSearchBlockProps, 'labels'>
+  | Omit<WebFetchBlockProps, 'labels'>
+
+function WebSearchBlock(props: Omit<WebSearchBlockProps, 'labels'>) {
+  return <LocalizedWebBlock {...props} labels={webBlockLabels} />
+}
+
+function WebFetchBlock(props: Omit<WebFetchBlockProps, 'labels'>) {
+  return <LocalizedWebBlock {...props} labels={webBlockLabels} />
+}
+
+function WebBlock(props: WebBlockProps) {
+  return props.kind === 'search'
+    ? <WebSearchBlock {...props} />
+    : <WebFetchBlock {...props} />
+}
 
 afterEach(cleanup)
 

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.