瀏覽代碼

Merge branch 'master' into codex/web-queue-actions

imccyu 1 月之前
父節點
當前提交
b94df7348f
共有 45 個文件被更改,包括 945 次插入191 次删除
  1. 11 8
      docs/module-graph.md
  2. 2 2
      packages/client/locale/README.i18n.yaml
  3. 3 3
      packages/client/locale/README.md
  4. 3 3
      packages/client/locale/README.zh.md
  5. 5 6
      packages/client/locale/src/client/LanguageRow.tsx
  6. 152 36
      packages/client/locale/src/client/index.ts
  7. 29 2
      packages/client/locale/src/locales/en.ts
  8. 8 0
      packages/client/locale/src/locales/index.ts
  9. 14 0
      packages/client/locale/src/locales/settings.ts
  10. 30 2
      packages/client/locale/src/locales/zh.ts
  11. 5 3
      packages/client/locale/tests/apply.spec.ts
  12. 59 0
      packages/client/locale/tests/locale.spec.ts
  13. 28 1
      packages/client/runtime/src/client/slots.ts
  14. 3 0
      packages/client/ui-model/package.json
  15. 26 20
      packages/client/ui-model/src/client/ModelSelect.tsx
  16. 39 10
      packages/client/ui-model/src/client/index.ts
  17. 46 0
      packages/client/ui-model/src/client/locales.ts
  18. 10 3
      packages/client/ui-model/tests/browser-plugin.spec.ts
  19. 20 4
      packages/client/ui-model/tests/model-select.spec.tsx
  20. 3 0
      packages/client/ui-model/tsconfig.json
  21. 3 0
      packages/client/ui-question/package.json
  22. 30 20
      packages/client/ui-question/src/client/QuestionComposer.tsx
  23. 6 5
      packages/client/ui-question/src/client/contract/slots.ts
  24. 29 10
      packages/client/ui-question/src/client/index.ts
  25. 34 0
      packages/client/ui-question/src/client/locales.ts
  26. 7 2
      packages/client/ui-question/tests/browser-plugin.spec.ts
  27. 8 1
      packages/client/ui-question/tests/question-composer.spec.tsx
  28. 3 0
      packages/client/ui-question/tsconfig.json
  29. 4 1
      packages/client/ui-sidebar/package.json
  30. 7 6
      packages/client/ui-sidebar/src/client/SidebarRoot.tsx
  31. 5 3
      packages/client/ui-sidebar/src/client/contract/slots.ts
  32. 18 1
      packages/client/ui-sidebar/src/client/index.ts
  33. 20 0
      packages/client/ui-sidebar/src/client/locales.ts
  34. 5 1
      packages/client/ui-sidebar/tests/apply.spec.tsx
  35. 6 1
      packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
  36. 7 0
      packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx
  37. 3 0
      packages/client/ui-sidebar/tsconfig.json
  38. 88 8
      packages/client/ui-slots/src/index.ts
  39. 32 1
      packages/client/ui-slots/src/renderer.ts
  40. 7 7
      packages/client/ui-theme/src/client/AppearanceRow.tsx
  41. 11 18
      packages/client/ui-theme/src/client/index.ts
  42. 20 0
      packages/client/ui-theme/src/client/locales.ts
  43. 2 1
      packages/client/ui-theme/tests/apply.spec.ts
  44. 85 2
      packages/client/web-react/src/scoped-slots.tsx
  45. 9 0
      pnpm-lock.yaml

+ 11 - 8
docs/module-graph.md

@@ -268,7 +268,6 @@ flowchart TD
   pkg_client_modules --> pkg_invariants
   pkg_client_modules --> pkg_invariants
   pkg_client_runtime --> pkg_invariants
   pkg_client_runtime --> pkg_invariants
   pkg_client_ui_primitives --> pkg_invariants
   pkg_client_ui_primitives --> pkg_invariants
-  pkg_client_ui_question --> pkg_invariants
   pkg_client_ui_slots --> pkg_invariants
   pkg_client_ui_slots --> pkg_invariants
   pkg_client_web --> pkg_invariants
   pkg_client_web --> pkg_invariants
   pkg_client_web_react --> pkg_invariants
   pkg_client_web_react --> pkg_invariants
@@ -304,10 +303,6 @@ flowchart TD
   pkg_client_ui_settings --> pkg_client_ui_primitives
   pkg_client_ui_settings --> pkg_client_ui_primitives
   pkg_client_ui_settings --> pkg_client_ui_slots
   pkg_client_ui_settings --> pkg_client_ui_slots
   pkg_client_ui_settings --> pkg_invariants
   pkg_client_ui_settings --> pkg_invariants
-  pkg_client_ui_sidebar --> pkg_client_runtime
-  pkg_client_ui_sidebar --> pkg_client_ui_primitives
-  pkg_client_ui_sidebar --> pkg_client_ui_slots
-  pkg_client_ui_sidebar --> pkg_invariants
   pkg_client_ui_trajectory --> pkg_client_ui_primitives
   pkg_client_ui_trajectory --> pkg_client_ui_primitives
   pkg_client_ui_trajectory --> pkg_invariants
   pkg_client_ui_trajectory --> pkg_invariants
   pkg_client_ui_workspace --> pkg_client_runtime
   pkg_client_ui_workspace --> pkg_client_runtime
@@ -345,12 +340,19 @@ flowchart TD
   pkg_system_prompt --> pkg_scope
   pkg_system_prompt --> pkg_scope
   pkg_web --> pkg_invariants
   pkg_web --> pkg_invariants
   pkg_web --> pkg_llm
   pkg_web --> pkg_llm
+  pkg_client_ui_question --> pkg_client_locale
+  pkg_client_ui_question --> pkg_invariants
   pkg_client_ui_settings_general --> pkg_client_locale
   pkg_client_ui_settings_general --> pkg_client_locale
   pkg_client_ui_settings_general --> pkg_client_runtime
   pkg_client_ui_settings_general --> pkg_client_runtime
   pkg_client_ui_settings_general --> pkg_client_ui_primitives
   pkg_client_ui_settings_general --> pkg_client_ui_primitives
   pkg_client_ui_settings_general --> pkg_client_ui_settings
   pkg_client_ui_settings_general --> pkg_client_ui_settings
   pkg_client_ui_settings_general --> pkg_client_ui_slots
   pkg_client_ui_settings_general --> pkg_client_ui_slots
   pkg_client_ui_settings_general --> pkg_invariants
   pkg_client_ui_settings_general --> pkg_invariants
+  pkg_client_ui_sidebar --> pkg_client_locale
+  pkg_client_ui_sidebar --> pkg_client_runtime
+  pkg_client_ui_sidebar --> pkg_client_ui_primitives
+  pkg_client_ui_sidebar --> pkg_client_ui_slots
+  pkg_client_ui_sidebar --> pkg_invariants
   pkg_client_ui_slash --> pkg_client_locale
   pkg_client_ui_slash --> pkg_client_locale
   pkg_client_ui_slash --> pkg_client_runtime
   pkg_client_ui_slash --> pkg_client_runtime
   pkg_client_ui_slash --> pkg_client_ui_primitives
   pkg_client_ui_slash --> pkg_client_ui_primitives
@@ -618,6 +620,7 @@ flowchart TD
   pkg_client_ui_goal --> pkg_goal
   pkg_client_ui_goal --> pkg_goal
   pkg_client_ui_goal --> pkg_invariants
   pkg_client_ui_goal --> pkg_invariants
   pkg_client_ui_model --> pkg_client_connection
   pkg_client_ui_model --> pkg_client_connection
+  pkg_client_ui_model --> pkg_client_locale
   pkg_client_ui_model --> pkg_client_runtime
   pkg_client_ui_model --> pkg_client_runtime
   pkg_client_ui_model --> pkg_client_ui_command
   pkg_client_ui_model --> pkg_client_ui_command
   pkg_client_ui_model --> pkg_client_ui_conversation
   pkg_client_ui_model --> pkg_client_ui_conversation
@@ -1001,7 +1004,6 @@ flowchart TD
 | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
-| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
 | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
@@ -1021,7 +1023,6 @@ flowchart TD
 | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1036,7 +1037,9 @@ flowchart TD
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
 | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
+| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
@@ -1101,7 +1104,7 @@ flowchart TD
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
 | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
 | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
-| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
 | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
 | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
 | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
 | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
 | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/locale/README.md
 #   pnpm run verify-translation-pairing --write packages/client/locale/README.md
-README.md: 9015af2b44a33771b06863ace139fe97695df616
-README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf
+README.md: c2adbcabc77def740094288da4643032873aa5b8
+README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59

+ 3 - 3
packages/client/locale/README.md

@@ -2,7 +2,7 @@
 
 
 English | [中文](README.zh.md)
 English | [中文](README.zh.md)
 
 
-Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
+Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key). The service 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).
 
 
 ## Model Experience
 ## Model Experience
 
 
@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
 
 
 ## Known Limitations and Deferred Work
 ## Known Limitations and Deferred Work
 
 
-- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
-- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
+- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
+- **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.

+ 3 - 3
packages/client/locale/README.zh.md

@@ -2,7 +2,7 @@
 
 
 [English](README.md) | 中文
 [English](README.md) | 中文
 
 
-locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
+locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
 
 
 ## 模型体验
 ## 模型体验
 
 
@@ -14,5 +14,5 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以
 
 
 ## 已知限制与暂缓事项
 ## 已知限制与暂缓事项
 
 
-- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓
-- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载
+- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移
+- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新

+ 5 - 6
packages/client/locale/src/client/LanguageRow.tsx

@@ -5,23 +5,22 @@
  * settings surface.
  * settings surface.
  */
  */
 import { useState } from 'react'
 import { useState } from 'react'
-import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {} from './settings-contract.ts'
 import type {} from './settings-contract.ts'
 import type { createLanguageRowStore } from './settings-store.ts'
 import type { createLanguageRowStore } from './settings-store.ts'
 import css from './LanguageRow.module.css'
 import css from './LanguageRow.module.css'
 
 
-/** Injected business face: namespace-bound translate + the preference write. */
+/** Injected business face: the preference write (t rides the standard locale seat). */
 export interface LanguageRowInjected {
 export interface LanguageRowInjected {
-  /** Translate a `settings.locale` dictionary key to the active-locale text. */
-  t: (key: string) => string
   /** Switch the active locale (a registered locale id). */
   /** Switch the active locale (a registered locale id). */
   setLocale: (id: string) => void
   setLocale: (id: string) => void
 }
 }
 
 
-/** Full component props: runtime share + store share + injected face. */
+/** Full component props: runtime share + store share + locale seat + injected face. */
 export type LanguageRowComponentProps =
 export type LanguageRowComponentProps =
-  PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
+  PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>>
+  & PropsLocale<'settings.locale'> & LanguageRowInjected
 
 
 /**
 /**
  * Render the Language row.
  * Render the Language row.

+ 152 - 36
packages/client/locale/src/client/index.ts

@@ -4,11 +4,21 @@
  * preference row into the settings General section — the locale feature owns
  * preference row into the settings General section — the locale feature owns
  * its own settings surface.
  * its own settings surface.
  */
  */
+/* oxlint-disable typescript/no-redundant-type-constituents --
+ * `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
+ * ui-slots): in THIS unit the map holds only this package's own merges, but
+ * consumers merge more namespaces in and the intersection keeps them
+ * string-typed. The rule fires on the narrow-map view, not real redundancy. */
 import type { Context } from 'cordis'
 import type { Context } from 'cordis'
-import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
+import {
+  deferRegistration,
+  type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
+} from '@deepseek-ai/dsh-client-ui-slots'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
-import { en } from '../locales/en.ts'
-import { zh } from '../locales/zh.ts'
+import { en, zh, type CommonKey } from '../locales/index.ts'
+import {
+  en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
+} from '../locales/settings.ts'
 import type { LanguageRowInjected } from './LanguageRow.tsx'
 import type { LanguageRowInjected } from './LanguageRow.tsx'
 import { LanguageRow } from './LanguageRow.tsx'
 import { LanguageRow } from './LanguageRow.tsx'
 import { createLanguageRowStore } from './settings-store.ts'
 import { createLanguageRowStore } from './settings-store.ts'
@@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts'
 export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
 export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
 export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
 export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
 export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
 export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
+export type { CommonKey } from '../locales/index.ts'
 
 
-/** Translate a key with optional params. */
-export type Translate = (key: string, params?: Record<string, unknown>) => string
+// The translate currency lives in ui-slots (the render machinery synthesizes
+// the seat); re-exported here so dictionary owners import one package.
+// TranslateNS<'model'> is the namespace-addressed developer-facing form.
+export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */
+    common: CommonKey
+    /** This feature's own settings-row copy (the Language row). */
+    'settings.locale': SettingsLocaleKey
+  }
+}
 
 
 /** Locale dictionary: flat key to template string ({name} placeholders). */
 /** Locale dictionary: flat key to template string ({name} placeholders). */
 export type LocaleDict = Record<string, string>
 export type LocaleDict = Record<string, string>
@@ -50,7 +72,10 @@ declare module 'cordis' {
   }
   }
   interface Events {
   interface Events {
     /**
     /**
-     * Locale state changed (active locale switched or registry updated).
+     * The active locale switched. Dictionary registrations do NOT emit this
+     * event (listeners may re-register slots in response, and boot registers
+     * one namespace per package); continuous render refresh rides the
+     * LocaleFace revision instead.
      * @param snapshot - Current immutable locale snapshot.
      * @param snapshot - Current immutable locale snapshot.
      * @mode emit
      * @mode emit
      */
      */
@@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
 ])
 ])
 
 
 /**
 /**
- * Dictionary registry plus locale preference. Lookup chain per key: active
- * locale -> zh fallback -> the key itself (missing text stays visible, fail
- * loud in the UI rather than blank). Reads go through {@link getLocale};
- * writes only through {@link setLocale}; continuous sync only through the
- * `locale/change` event.
+ * Dictionary registry plus locale preference. Lookup chain per key: the
+ * entry's namespace in the active locale -> that namespace's zh fallback ->
+ * the shared common namespace (active, then zh) -> the key itself (missing
+ * text stays visible, fail loud in the UI rather than blank). Reads go
+ * through {@link getLocale}; writes only through {@link setLocale};
+ * continuous sync through the `locale/change` event, or through the
+ * LocaleFace getSnapshot/subscribe pair the render machinery consumes
+ * (installed via `ctx.slots.installLocale`).
  */
  */
 export class LocaleService {
 export class LocaleService {
   private dicts = new Map<string, Map<string, LocaleDict>>()
   private dicts = new Map<string, Map<string, LocaleDict>>()
   private bound = new Map<string, Translate>()
   private bound = new Map<string, Translate>()
   private snapshot: LocaleSnapshot
   private snapshot: LocaleSnapshot
+  private listeners = new Set<() => void>()
   private readonly ctx: Context
   private readonly ctx: Context
 
 
   /**
   /**
@@ -105,6 +134,27 @@ export class LocaleService {
     return this.snapshot
     return this.snapshot
   }
   }
 
 
+  /**
+   * LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
+   * reference between changes, uSES-safe).
+   * @returns the current snapshot.
+   */
+  getSnapshot(): LocaleSnapshot {
+    return this.snapshot
+  }
+
+  /**
+   * LocaleFace subscribe: notified on every snapshot change (locale switch
+   * or dictionary registration — registrations bump the revision so already
+   * rendered outlets pick up late-arriving dictionaries).
+   * @param fn - change callback.
+   * @returns unsubscribe.
+   */
+  subscribe(fn: () => void): () => void {
+    this.listeners.add(fn)
+    return () => { this.listeners.delete(fn) }
+  }
+
   /**
   /**
    * Switch the active locale — the only preference write entry. Persists the
    * Switch the active locale — the only preference write entry. Persists the
    * id and emits `locale/change`.
    * id and emits `locale/change`.
@@ -114,44 +164,80 @@ export class LocaleService {
     const match = this.snapshot.locales.find(l => l.id === id)
     const match = this.snapshot.locales.find(l => l.id === id)
     if (match === undefined) throw new Error(`locale "${id}" is not registered`)
     if (match === undefined) throw new Error(`locale "${id}" is not registered`)
     if (this.snapshot.active === match.id) return
     if (this.snapshot.active === match.id) return
-    this.snapshot = Object.freeze({
-      active: match.id,
-      locales: this.snapshot.locales,
-      revision: this.snapshot.revision + 1,
-    })
     persistPreference(match.id)
     persistPreference(match.id)
-    this.ctx.emit('locale/change', this.snapshot)
+    this.publish(match.id, true)
   }
   }
 
 
   /**
   /**
-   * Register a dictionary for a namespace and locale. Duplicate (ns, locale)
-   * throws (single occupant; a namespace's texts have one owner).
+   * Register a declared namespace's dictionaries, all locales in one call —
+   * the typed form: each dictionary is checked against the namespace's
+   * {@link LocaleNamespaceMap} key union (a missing or extra key is a
+   * compile error), and every shipped locale is required (bilingual balance
+   * enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
+   * namespace's texts have one owner). Registration bumps the revision so
+   * mounted outlets pick up late-arriving dictionaries.
+   * @param ns - a namespace merged into LocaleNamespaceMap.
+   * @param dicts - complete dictionaries keyed by locale id.
+   * @returns disposer removing every locale registered by this call (idempotent).
+   */
+  register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
+  /**
+   * Single-locale untyped form for namespaces outside the merge table
+   * (dynamic composition, tests).
    * @param ns - namespace.
    * @param ns - namespace.
-   * @param locale - locale tag (zh/en to start).
+   * @param locale - locale tag.
    * @param dict - dictionary.
    * @param dict - dictionary.
    * @returns disposer (idempotent).
    * @returns disposer (idempotent).
    */
    */
-  register(ns: string, locale: string, dict: LocaleDict): () => void {
+  register(ns: string, locale: string, dict: LocaleDict): () => void
+  register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
+    const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string'
+      // Overload guarantees dict on the single-locale arm.
+      ? [[localeOrDicts, dict as LocaleDict]]
+      : Object.entries(localeOrDicts)
     let locales = this.dicts.get(ns)
     let locales = this.dicts.get(ns)
     if (!locales) {
     if (!locales) {
       locales = new Map()
       locales = new Map()
       this.dicts.set(ns, locales)
       this.dicts.set(ns, locales)
     }
     }
-    if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
-    locales.set(locale, dict)
+    for (const [locale] of pairs) {
+      if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
+    }
+    for (const [locale, entries] of pairs) locales.set(locale, entries)
+    this.publish(this.snapshot.active, false)
     return () => {
     return () => {
       const owner = this.dicts.get(ns)
       const owner = this.dicts.get(ns)
-      if (owner?.get(locale) === dict) owner.delete(locale)
+      /* v8 ignore next -- defensive: a namespace's locales map is created on
+       * first register and never removed, so the disposer always finds it. */
+      if (!owner) return
+      let removed = false
+      for (const [locale, entries] of pairs) {
+        if (owner.get(locale) === entries) {
+          owner.delete(locale)
+          removed = true
+        }
+      }
+      if (removed) this.publish(this.snapshot.active, false)
     }
     }
   }
   }
 
 
   /**
   /**
-   * Bind a namespace to a translate function. The returned reference is
-   * stable per namespace (repeat binds return the same function), so it can
-   * ride inject surfaces without breaking memoization.
+   * Bind a declared namespace to a translate function typed to its
+   * dictionary key union (plus the shared common vocabulary) — the same key
+   * domain the framework-injected `t` seat carries. The returned reference
+   * is stable per namespace (repeat binds return the same function), so it
+   * can ride inject surfaces without breaking memoization.
+   * @param ns - a namespace merged into LocaleNamespaceMap.
+   * @returns the typed translate function (reads the active locale at call time).
+   */
+  bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
+  /**
+   * Untyped form for namespaces outside the merge table (dynamic
+   * composition, tests).
    * @param ns - namespace.
    * @param ns - namespace.
-   * @returns the translate function (reads the active locale at call time).
+   * @returns the translate function.
    */
    */
+  bind(ns: string): Translate
   bind(ns: string): Translate {
   bind(ns: string): Translate {
     let t = this.bound.get(ns)
     let t = this.bound.get(ns)
     if (!t) {
     if (!t) {
@@ -163,14 +249,43 @@ export class LocaleService {
   }
   }
 
 
   private translate(ns: string, key: string, params?: Record<string, unknown>): string {
   private translate(ns: string, key: string, params?: Record<string, unknown>): string {
-    const locales = this.dicts.get(ns)
-    const template = locales?.get(this.snapshot.active)?.[key]
-      ?? locales?.get(FALLBACK_LOCALE)?.[key]
+    const template = this.lookup(ns, key)
+      ?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
       ?? key
       ?? key
     if (!params) return template
     if (!params) return template
     return template.replace(/\{(\w+)\}/g, (match, name: string) =>
     return template.replace(/\{(\w+)\}/g, (match, name: string) =>
       name in params ? String(params[name]) : match)
       name in params ? String(params[name]) : match)
   }
   }
+
+  private lookup(ns: string, key: string): string | undefined {
+    const locales = this.dicts.get(ns)
+    return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
+  }
+
+  /**
+   * Advance the snapshot revision and notify LocaleFace subscribers (render
+   * refresh). Only an active-locale switch additionally emits
+   * `locale/change` — dictionary registrations stay off the event so
+   * registration-heavy boot cannot storm event listeners (which may
+   * re-register slots in response).
+   */
+  private publish(active: LocaleId, localeChanged: boolean): void {
+    this.snapshot = Object.freeze({
+      active,
+      locales: this.snapshot.locales,
+      revision: this.snapshot.revision + 1,
+    })
+    if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
+    for (const fn of [...this.listeners]) {
+      try {
+        fn()
+      } catch (error) {
+        // One throwing subscriber must not strand the rest on a stale
+        // revision (outlets would keep the previous language).
+        console.error('locale subscriber crashed:', error)
+      }
+    }
+  }
 }
 }
 
 
 /** Read the persisted locale id; unknown or unreadable values fall back to zh. */
 /** Read the persisted locale id; unknown or unreadable values fall back to zh. */
@@ -208,11 +323,12 @@ export const inject = ['slots']
  */
  */
 export function apply(ctx: ClientContext): void {
 export function apply(ctx: ClientContext): void {
   const locale = new LocaleService(ctx)
   const locale = new LocaleService(ctx)
-  locale.register(COMMON_NS, 'zh', zh)
-  locale.register(COMMON_NS, 'en', en)
-  locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
-  locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
+  locale.register(COMMON_NS, { zh, en })
+  locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
   ctx.provide('locale', locale)
   ctx.provide('locale', locale)
+  // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
+  // so the render machinery can synthesize the `t` standard seat.
+  ctx.slots.installLocale(locale)
 
 
   const store = createLanguageRowStore()
   const store = createLanguageRowStore()
   let bound: BoundActions<typeof store> | undefined
   let bound: BoundActions<typeof store> | undefined
@@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void {
     // first render (the store's revision guard drops stale duplicates).
     // first render (the store's revision guard drops stale duplicates).
     sync(locale.getLocale())
     sync(locale.getLocale())
     return {
     return {
-      t: locale.bind(SETTINGS_NS),
       setLocale: (id) => { locale.setLocale(id) },
       setLocale: (id) => { locale.setLocale(id) },
     }
     }
   }
   }
@@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void {
         id: 'language',
         id: 'language',
         order: 0,
         order: 0,
         store,
         store,
+        locale: SETTINGS_NS,
         inject: injected,
         inject: injected,
       }, LanguageRow))
       }, LanguageRow))
     return () => { deferred.dispose() }
     return () => { deferred.dispose() }

+ 29 - 2
packages/client/locale/src/locales/en.ts

@@ -1,2 +1,29 @@
-/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
-export const en: Record<string, string> = {}
+import type { CommonKey } from './zh.ts'
+
+/** en base dictionary for the common namespace, checked complete against the zh key set. */
+export const en = {
+  'ok': 'OK',
+  'cancel': 'Cancel',
+  'close': 'Close',
+  'copy': 'Copy',
+  'copied': 'Copied',
+  'retry': 'Retry',
+  'loading': 'Loading…',
+  'load.failed': 'Failed to load',
+  'submit': 'Submit',
+  'submitting': 'Submitting…',
+  'next': 'Next',
+  'previous': 'Previous',
+  'skip': 'Skip',
+  'delete': 'Delete',
+  'edit': 'Edit',
+  'save': 'Save',
+  'search': 'Search',
+  'more': 'More',
+  'collapse': 'Collapse',
+  'expand': 'Expand',
+  'back': 'Back',
+  'unknown': 'Unknown',
+  'none': 'None',
+  'truncated': 'Truncated',
+} satisfies Record<CommonKey, string>

+ 8 - 0
packages/client/locale/src/locales/index.ts

@@ -0,0 +1,8 @@
+/**
+ * The common-namespace dictionary pair. zh is the source of truth for the
+ * key set (Chinese-first repo convention); en is checked complete against it
+ * — a missing or extra en key is a compile error.
+ */
+export { zh } from './zh.ts'
+export { en } from './en.ts'
+export type { CommonKey } from './zh.ts'

+ 14 - 0
packages/client/locale/src/locales/settings.ts

@@ -0,0 +1,14 @@
+/** `settings.locale` namespace dictionaries (the Language row's copy). */
+
+/** Simplified Chinese dictionary (the key-set source of truth). */
+export const zh = {
+  'language.title': '语言',
+} satisfies Record<string, string>
+
+/** The settings.locale namespace key union. */
+export type SettingsLocaleKey = keyof typeof zh
+
+/** English dictionary, checked complete against the zh key set. */
+export const en = {
+  'language.title': 'Language',
+} satisfies Record<SettingsLocaleKey, string>

+ 30 - 2
packages/client/locale/src/locales/zh.ts

@@ -1,2 +1,30 @@
-/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
-export const zh: Record<string, string> = {}
+/** zh base dictionary for the common namespace: cross-feature standard words. */
+export const zh = {
+  'ok': '确定',
+  'cancel': '取消',
+  'close': '关闭',
+  'copy': '复制',
+  'copied': '复制成功',
+  'retry': '重试',
+  'loading': '加载中…',
+  'load.failed': '加载失败',
+  'submit': '提交',
+  'submitting': '正在提交…',
+  'next': '下一步',
+  'previous': '上一步',
+  'skip': '跳过',
+  'delete': '删除',
+  'edit': '编辑',
+  'save': '保存',
+  'search': '搜索',
+  'more': '更多',
+  'collapse': '收起',
+  'expand': '展开',
+  'back': '返回',
+  'unknown': '未知',
+  'none': '无',
+  'truncated': '已截断',
+} satisfies Record<string, string>
+
+/** The common vocabulary key union (zh is the key-set source of truth). */
+export type CommonKey = keyof typeof zh

+ 5 - 3
packages/client/locale/tests/apply.spec.ts

@@ -69,16 +69,18 @@ describe('locale apply', () => {
     // An event ahead of any inject hits the unbound-actions arm.
     // An event ahead of any inject hits the unbound-actions arm.
     locale.setLocale('en')
     locale.setLocale('en')
 
 
-    const { instance, face } = faceOf(b.slots)
+    const { entry, instance, face } = faceOf(b.slots)
     // The inject-time re-sync sealed the init window: the mirror is current.
     // The inject-time re-sync sealed the init window: the mirror is current.
     expect(instance.getSnapshot().active).toBe('en')
     expect(instance.getSnapshot().active).toBe('en')
     expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
     expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
-    expect(face.t('language.title')).toBe('Language')
+    // Copy rides the standard locale seat: the entry declares the namespace.
+    expect(entry.locale).toBe(SETTINGS_NS)
+    expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
 
 
     face.setLocale('zh')
     face.setLocale('zh')
     expect(locale.getLocale().active).toBe('zh')
     expect(locale.getLocale().active).toBe('zh')
     expect(instance.getSnapshot().active).toBe('zh')
     expect(instance.getSnapshot().active).toBe('zh')
-    expect(face.t('language.title')).toBe('语言')
+    expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
   })
   })
 
 
   it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
   it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {

+ 59 - 0
packages/client/locale/tests/locale.spec.ts

@@ -29,6 +29,24 @@ describe('LocaleService', () => {
     expect(t('missing.key')).toBe('missing.key')
     expect(t('missing.key')).toBe('missing.key')
   })
   })
 
 
+  it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
+    const { svc } = make()
+    // The shipped common pair is registered by apply; the bench registers it
+    // directly to pin the production chain: ns -> common -> zh -> key.
+    svc.register('common', 'zh', { retry: '重试' })
+    svc.register('common', 'en', { retry: 'Retry' })
+    svc.register('ns', 'zh', { own: '自有' })
+    const t = svc.bind('ns')
+    expect(t('retry')).toBe('重试')
+    svc.setLocale('en')
+    expect(t('retry')).toBe('Retry')
+    expect(t('own')).toBe('自有')
+    // common itself must not recurse: a miss inside common echoes the key.
+    // (Wide-string ns hits the untyped bind overload — the typed one rejects
+    // unknown keys at compile time, which is the point of the seam.)
+    expect(svc.bind('common' as string)('nope')).toBe('nope')
+  })
+
   it('interpolates {name} params and leaves unknown placeholders intact', () => {
   it('interpolates {name} params and leaves unknown placeholders intact', () => {
     const { svc } = make()
     const { svc } = make()
     svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
     svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
@@ -56,6 +74,47 @@ describe('LocaleService', () => {
     expect(t('k')).toBe('v2')
     expect(t('k')).toBe('v2')
   })
   })
 
 
+  it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => {
+    const { svc } = make()
+    const seen: number[] = []
+    const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
+    expect(svc.getSnapshot()).toBe(svc.getLocale())
+    const r0 = svc.getSnapshot().revision
+    svc.register('ns', 'zh', { k: 'v' })
+    expect(svc.getSnapshot().revision).toBe(r0 + 1)
+    svc.setLocale('en')
+    expect(seen).toEqual([r0 + 1, r0 + 2])
+    off()
+    svc.setLocale('zh')
+    expect(seen).toHaveLength(2)
+  })
+
+  it('isolates a throwing subscriber: the rest still see the new revision', () => {
+    const { svc } = make()
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    try {
+      const seen: number[] = []
+      svc.subscribe(() => { throw new Error('boom') })
+      svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
+      svc.setLocale('en')
+      expect(seen).toEqual([1])
+      expect(spy).toHaveBeenCalledOnce()
+    } finally {
+      spy.mockRestore()
+    }
+  })
+
+  it('register disposer republishes (mounted outlets drop the dead dictionary)', () => {
+    const { svc } = make()
+    const dispose = svc.register('ns', 'zh', { k: 'v' })
+    const before = svc.getSnapshot().revision
+    dispose()
+    expect(svc.getSnapshot().revision).toBe(before + 1)
+    // Second run hits the idempotent arm: nothing removed, no republish.
+    dispose()
+    expect(svc.getSnapshot().revision).toBe(before + 1)
+  })
+
   it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
   it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
     const { svc, events } = make()
     const { svc, events } = make()
     svc.setLocale('en')
     svc.setLocale('en')

+ 28 - 1
packages/client/runtime/src/client/slots.ts

@@ -18,7 +18,7 @@ import { Service } from 'cordis'
 import type { Context } from 'cordis'
 import type { Context } from 'cordis'
 import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
 import type {
-  OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
+  LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
   SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
   SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-slots'
 } from '@deepseek-ai/dsh-client-ui-slots'
 
 
@@ -70,6 +70,8 @@ interface ErasedRegisterOptions {
   select?: (owner: never) => unknown
   select?: (owner: never) => unknown
   /** Chain-slot explicit ordering override (ascending; registration order otherwise). */
   /** Chain-slot explicit ordering override (ascending; registration order otherwise). */
   priority?: number
   priority?: number
+  /** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */
+  locale?: string
   registrant?: string
   registrant?: string
 }
 }
 
 
@@ -82,6 +84,7 @@ export class SlotsService extends Service {
   /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
   /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
   private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
   private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
   private _renderer: SlotRenderer | undefined
   private _renderer: SlotRenderer | undefined
+  private _locale: LocaleFace | undefined
   private _host: SlotRendererHost | undefined
   private _host: SlotRendererHost | undefined
 
 
   /**
   /**
@@ -127,6 +130,23 @@ export class SlotsService extends Service {
     }, 'slots.install()')
     }, 'slots.install()')
   }
   }
 
 
+  /**
+   * Install the locale face backing the `t` standard seat (the locale
+   * plugin's product; same boot-once discipline as the renderer install).
+   * Runs through the caller's ctx.effect, so the installing fiber's unload
+   * uninstalls the face.
+   * @param face - namespace binder + revision observable.
+   */
+  installLocale(face: LocaleFace): void {
+    if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)')
+    this.ctx.effect(() => {
+      this._locale = face
+      return () => {
+        if (this._locale === face) this._locale = undefined
+      }
+    }, 'slots.installLocale()')
+  }
+
   /**
   /**
    * The single ctx-level render entry: the shell renders 'root'; every other
    * The single ctx-level render entry: the shell renders 'root'; every other
    * key renders inside components through the props renderSlot face. All
    * key renders inside components through the props renderSlot face. All
@@ -246,6 +266,12 @@ export class SlotsService extends Service {
     if (workspaces === undefined) {
     if (workspaces === undefined) {
       throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
       throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
     }
     }
+    // `locale` is a live getter: the face installs (and, under HMR, swaps)
+    // on the locale plugin's own fiber lifetime, while this host object is
+    // built once — a captured value would strand renders on a dead face. The
+    // alias is required: `this` inside the getter is the host literal.
+    // oxlint-disable-next-line typescript/no-this-alias
+    const service = this
     this._host = {
     this._host = {
       subscribe: (key, fn) => this._core.subscribe(key, fn),
       subscribe: (key, fn) => this._core.subscribe(key, fn),
       getVersion: key => this._core.getVersion(key),
       getVersion: key => this._core.getVersion(key),
@@ -259,6 +285,7 @@ export class SlotsService extends Service {
         provideInfo: sessions.currentProvideInfo,
         provideInfo: sessions.currentProvideInfo,
       },
       },
       workspaces: { list: workspaces.list },
       workspaces: { list: workspaces.list },
+      get locale() { return service._locale },
     }
     }
     return this._host
     return this._host
   }
   }

+ 3 - 0
packages/client/ui-model/package.json

@@ -24,6 +24,7 @@
   },
   },
   "dshClient": {
   "dshClient": {
     "inject": [
     "inject": [
+      "@deepseek-ai/dsh-client-locale",
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-ui-command"
       "@deepseek-ai/dsh-client-ui-command"
     ],
     ],
@@ -36,6 +37,7 @@
   "license": "BSD-3-Clause",
   "license": "BSD-3-Clause",
   "peerDependencies": {
   "peerDependencies": {
     "@deepseek-ai/dsh-client-connection": "^0.0.1",
     "@deepseek-ai/dsh-client-connection": "^0.0.1",
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-command": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-command": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
@@ -49,6 +51,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-command": "workspace:^",
     "@deepseek-ai/dsh-client-ui-command": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",

+ 26 - 20
packages/client/ui-model/src/client/ModelSelect.tsx

@@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-
 import {
 import {
   IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
   IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ModelSelectInjected } from './slots.ts'
 import type { ModelSelectInjected } from './slots.ts'
 import css from './ModelSelect.module.css'
 import css from './ModelSelect.module.css'
 
 
@@ -34,10 +35,13 @@ interface EffortChoice {
 
 
 /**
 /**
  * Render the composer model seat.
  * Render the composer model seat.
- * @param props - owner share (locked) + injected face (shared directory store/verbs).
+ * @param props - owner share (locked) + injected face (shared directory
+ * store/verbs) + the standard locale seat.
  * @returns the trigger and, while open, the two-level menu.
  * @returns the trigger and, while open, the two-level menu.
  */
  */
-export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
+export function ModelSelect(
+  { locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
+) {
   const state = useSyncExternalStore(
   const state = useSyncExternalStore(
     fn => directory.subscribe(fn),
     fn => directory.subscribe(fn),
     () => directory.getSnapshot(),
     () => directory.getSnapshot(),
@@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
   const effortLabel = reasoning === undefined
   const effortLabel = reasoning === undefined
     ? undefined
     ? undefined
     : effectiveEffort === undefined
     : effectiveEffort === undefined
-      ? 'Provider default'
+      ? t('effort.providerDefault')
       : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
       : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
   const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
   const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
     ? []
     ? []
     : [
     : [
       ...reasoning.defaultEffort === undefined
       ...reasoning.defaultEffort === undefined
-        ? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
+        ? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
         : [],
         : [],
       ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
       ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
         key: `effort:${effort.id}`,
         key: `effort:${effort.id}`,
@@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
         label: effort.name,
         label: effort.name,
         ...effort.description === undefined ? {} : { description: effort.description },
         ...effort.description === undefined ? {} : { description: effort.description },
       })),
       })),
-    ], [reasoning])
+    ], [reasoning, t])
   const busy = state.status === 'selecting'
   const busy = state.status === 'selecting'
 
 
   // Mount-time load resolves the trigger label; every open refreshes.
   // Mount-time load resolves the trigger label; every open refreshes.
@@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
     })
     })
   }
   }
 
 
-  const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
+  const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
   const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
   const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
   itemRefs.current = []
   itemRefs.current = []
   let itemIndex = 0
   let itemIndex = 0
@@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
         ref={triggerRef}
         ref={triggerRef}
         type="button"
         type="button"
         className={css.trigger}
         className={css.trigger}
-        aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
+        aria-label={effortLabel === undefined
+          ? t('trigger.aria', { model: modelLabel })
+          : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
         aria-haspopup="menu"
         aria-haspopup="menu"
         aria-expanded={open}
         aria-expanded={open}
         aria-controls={open ? `${id}-menu` : undefined}
         aria-controls={open ? `${id}-menu` : undefined}
@@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
           id={`${id}-menu`}
           id={`${id}-menu`}
           className={css.menu}
           className={css.menu}
           role="menu"
           role="menu"
-          aria-label="模型与推理等级"
+          aria-label={t('menu.aria')}
           aria-busy={state.status === 'loading' || busy}
           aria-busy={state.status === 'loading' || busy}
         >
         >
           {pane === 'root' && (
           {pane === 'root' && (
             <>
             <>
               <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
               <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
-                <span className={css.cellLabel}>Model</span>
+                <span className={css.cellLabel}>{t('menu.model')}</span>
                 <span className={css.cellValue}>{modelLabel}</span>
                 <span className={css.cellValue}>{modelLabel}</span>
                 <IconChevronRightOutline14 className={css.cellChevron} />
                 <IconChevronRightOutline14 className={css.cellChevron} />
               </button>
               </button>
               {reasoning !== undefined && (
               {reasoning !== undefined && (
                 <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
                 <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
-                  <span className={css.cellLabel}>Effort</span>
+                  <span className={css.cellLabel}>{t('menu.effort')}</span>
                   <span className={css.cellValue}>{effortLabel}</span>
                   <span className={css.cellValue}>{effortLabel}</span>
                   <IconChevronRightOutline14 className={css.cellChevron} />
                   <IconChevronRightOutline14 className={css.cellChevron} />
                 </button>
                 </button>
@@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
           {pane === 'model' && (
           {pane === 'model' && (
             <>
             <>
               {state.status === 'loading' && (
               {state.status === 'loading' && (
-                <div className={css.status}>正在刷新模型列表…</div>
+                <div className={css.status}>{t('status.loading')}</div>
               )}
               )}
               {state.error !== null && (
               {state.error !== null && (
                 <div className={css.error}>
                 <div className={css.error}>
-                  <span>模型操作失败:{state.error}</span>
-                  <button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
+                  <span>{t('error.action', { message: state.error })}</span>
+                  <button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
                 </div>
                 </div>
               )}
               )}
               {state.failures.map(failure => (
               {state.failures.map(failure => (
                 <div className={css.warning} key={failure.id}>
                 <div className={css.warning} key={failure.id}>
-                  <span>{failure.name} 加载失败:{failure.message}</span>
-                  <button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
+                  <span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
+                  <button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
                 </div>
                 </div>
               ))}
               ))}
               <div className={clsx(css.groups, 'scrollable')}>
               <div className={clsx(css.groups, 'scrollable')}>
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
                                 <span className={css.description}>{model.description}</span>
                                 <span className={css.description}>{model.description}</span>
                               )}
                               )}
                               {model.unlisted === true && (
                               {model.unlisted === true && (
-                                <span className={css.unlisted}>当前模型 · 未列入目录</span>
+                                <span className={css.unlisted}>{t('option.currentUnlisted')}</span>
                               )}
                               )}
                             </span>
                             </span>
                             <span className={css.check}>
                             <span className={css.check}>
@@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
                 })}
                 })}
               </div>
               </div>
               {state.status === 'ready' && choices.length === 0 && (
               {state.status === 'ready' && choices.length === 0 && (
-                <div className={css.empty}>没有可用的模型。</div>
+                <div className={css.empty}>{t('empty.models')}</div>
               )}
               )}
             </>
             </>
           )}
           )}
@@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
             <>
             <>
               {state.error !== null && (
               {state.error !== null && (
                 <div className={css.error}>
                 <div className={css.error}>
-                  <span>模型操作失败:{state.error}</span>
-                  <button type="button" className={css.retry} onClick={() => { load() }}>重新加载</button>
+                  <span>{t('error.action', { message: state.error })}</span>
+                  <button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
                 </div>
                 </div>
               )}
               )}
               {effortChoices.length === 0
               {effortChoices.length === 0
-                ? <div className={css.empty}>当前模型未提供推理等级。</div>
+                ? <div className={css.empty}>{t('empty.efforts')}</div>
                 : effortChoices.map(level => (
                 : effortChoices.map(level => (
                   <button
                   <button
                     ref={itemRef()}
                     ref={itemRef()}

+ 39 - 10
packages/client/ui-model/src/client/index.ts

@@ -14,15 +14,27 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
 import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
 // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
 // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ModelDirectoryState } from './directory.ts'
 import type { ModelDirectoryState } from './directory.ts'
 import { ModelService } from './service.ts'
 import { ModelService } from './service.ts'
 import type { ModelSelectInjected } from './slots.ts'
 import type { ModelSelectInjected } from './slots.ts'
 import { ModelSelect } from './ModelSelect.tsx'
 import { ModelSelect } from './ModelSelect.tsx'
+import { en, zh, type ModelKey } from './locales.ts'
 
 
 export { ModelDirectory } from './directory.ts'
 export { ModelDirectory } from './directory.ts'
 export type { ModelDirectoryState } from './directory.ts'
 export type { ModelDirectoryState } from './directory.ts'
 export { ModelService } from './service.ts'
 export { ModelService } from './service.ts'
 export type { ModelSelectInjected } from './slots.ts'
 export type { ModelSelectInjected } from './slots.ts'
+export type { ModelKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** The model selection surfaces' copy (/model popup + composer seat). */
+    model: ModelKey
+  }
+}
 
 
 /** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
 /** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
 function rowId(providerId: string, modelId: string): string {
 function rowId(providerId: string, modelId: string): string {
@@ -30,7 +42,7 @@ function rowId(providerId: string, modelId: string): string {
 }
 }
 
 
 /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
 /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
-function optionsOf(directory: SessionModels): SelectOption[] {
+function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
   const rows: SelectOption[] = []
   const rows: SelectOption[] = []
   for (const group of directory.groups) {
   for (const group of directory.groups) {
     for (const model of group.models) {
     for (const model of group.models) {
@@ -38,7 +50,7 @@ function optionsOf(directory: SessionModels): SelectOption[] {
         id: rowId(group.id, model.id),
         id: rowId(group.id, model.id),
         label: model.name,
         label: model.name,
         detail: model.unlisted === true
         detail: model.unlisted === true
-          ? `${group.name} · 未列入目录`
+          ? t('option.unlisted', { group: group.name })
           : model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
           : model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
         ...(directory.current.provider === group.id && directory.current.model === model.id
         ...(directory.current.provider === group.id && directory.current.model === model.id
           ? { active: true } : {}),
           ? { active: true } : {}),
@@ -46,7 +58,11 @@ function optionsOf(directory: SessionModels): SelectOption[] {
     }
     }
   }
   }
   for (const failure of directory.failures) {
   for (const failure of directory.failures) {
-    rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` })
+    rows.push({
+      id: `failure/${failure.id}`,
+      label: failure.name,
+      detail: t('option.loadError', { message: failure.message }),
+    })
   }
   }
   return rows
   return rows
 }
 }
@@ -76,28 +92,40 @@ function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefin
   return undefined
   return undefined
 }
 }
 
 
-/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */
-export const inject = ['command', 'connection', 'sessions', 'slots']
+/** Dictionary namespace owned by this plugin. */
+const NS = 'model'
+
+/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
+export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
 
 
 /**
 /**
- * Client plugin body: mount ModelService, then register the /model popup
- * contribution and the composer model seat over it.
+ * Client plugin body: mount ModelService, register the `model` dictionaries,
+ * then register the /model popup contribution and the composer model seat
+ * over the service.
  * @param ctx - client root context.
  * @param ctx - client root context.
  */
  */
 export function apply(ctx: ClientContext): void {
 export function apply(ctx: ClientContext): void {
   ctx.plugin(ModelService)
   ctx.plugin(ModelService)
 
 
-  // Entry 1: the /model popupSelect over the shared directory.
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
+
+  // Non-slot faces (the command description, the popup option builder) read
+  // through the bound translate; the seat component reads the standard seat.
+  const t = ctx.locale.bind(NS)
+
+  // Entry 1: the /model popupSelect over the shared directory. The command
+  // description is registry-held text: it reads t() once at registration and
+  // refreshes only on re-registration, not on locale change.
   ctx.inject(['command', 'models'], (scope: ClientContext) => {
   ctx.inject(['command', 'models'], (scope: ClientContext) => {
     const command = scope.get('command') as CommandServiceContract
     const command = scope.get('command') as CommandServiceContract
     const models = scope.models
     const models = scope.models
     scope.effect(() => command.register({
     scope.effect(() => command.register({
       name: 'model',
       name: 'model',
-      description: 'Select the model for this conversation',
+      description: t('command.description'),
       available: () => true,
       available: () => true,
       ui: {
       ui: {
         kind: 'popupSelect',
         kind: 'popupSelect',
-        options: async session => optionsOf(await models.directoryFor(session.sessionId).load()),
+        options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
         onSelect: async (option, session) => {
         onSelect: async (option, session) => {
           const directory = models.directoryFor(session.sessionId)
           const directory = models.directoryFor(session.sessionId)
           const target = targetOf(directory.store.getSnapshot(), option.id)
           const target = targetOf(directory.store.getSnapshot(), option.id)
@@ -117,6 +145,7 @@ export function apply(ctx: ClientContext): void {
     const models = scope.models
     const models = scope.models
     scope.effect(() => scope.slots.register({
     scope.effect(() => scope.slots.register({
       name: 'conversation.input.model',
       name: 'conversation.input.model',
+      locale: NS,
       inject: (sessionId): ModelSelectInjected => {
       inject: (sessionId): ModelSelectInjected => {
         const directory = models.directoryFor(sessionId)
         const directory = models.directoryFor(sessionId)
         return {
         return {

+ 46 - 0
packages/client/ui-model/src/client/locales.ts

@@ -0,0 +1,46 @@
+/** `model` namespace dictionaries. */
+
+/** Simplified Chinese dictionary (the key-set source of truth). */
+export const zh = {
+  'command.description': '选择本会话使用的模型',
+  'option.unlisted': '{group} · 未列入目录',
+  'option.loadError': '目录加载失败:{message}',
+  'trigger.fallback': '选择模型',
+  'trigger.aria': '选择模型,当前 {model}',
+  'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
+  'menu.aria': '模型与推理等级',
+  'menu.model': '模型',
+  'menu.effort': '推理等级',
+  'effort.providerDefault': '服务商默认',
+  'status.loading': '正在刷新模型列表…',
+  'error.action': '模型操作失败:{message}',
+  'action.reload': '重新加载',
+  'warning.groupLoad': '{name} 加载失败:{message}',
+  'option.currentUnlisted': '当前模型 · 未列入目录',
+  'empty.models': '没有可用的模型。',
+  'empty.efforts': '当前模型未提供推理等级。',
+} satisfies Record<string, string>
+
+/** The model namespace key union. */
+export type ModelKey = keyof typeof zh
+
+/** English dictionary, checked complete against the zh key set. */
+export const en = {
+  'command.description': 'Select the model for this conversation',
+  'option.unlisted': '{group} · Not in catalog',
+  'option.loadError': 'Catalog failed to load: {message}',
+  'trigger.fallback': 'Select model',
+  'trigger.aria': 'Select model, current {model}',
+  'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
+  'menu.aria': 'Model and reasoning effort',
+  'menu.model': 'Model',
+  'menu.effort': 'Effort',
+  'effort.providerDefault': 'Provider default',
+  'status.loading': 'Refreshing model list…',
+  'error.action': 'Model operation failed: {message}',
+  'action.reload': 'Reload',
+  'warning.groupLoad': '{name} failed to load: {message}',
+  'option.currentUnlisted': 'Current model · Not in catalog',
+  'empty.models': 'No models available.',
+  'empty.efforts': 'This model provides no reasoning effort levels.',
+} satisfies Record<ModelKey, string>

+ 10 - 3
packages/client/ui-model/tests/browser-plugin.spec.ts

@@ -12,6 +12,7 @@ import { Context } from 'cordis'
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
 import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
 import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
 import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
 import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
 import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
 import type { ModelSelectInjected } from '../src/client/slots.ts'
 import type { ModelSelectInjected } from '../src/client/slots.ts'
@@ -79,14 +80,18 @@ async function bench() {
       return () => { contribution = undefined }
       return () => { contribution = undefined }
     },
     },
   })
   })
-  const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
+  const seats = new Map<string, {
+    inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined
+    locale: string | undefined
+  }>()
   ctx.provide('slots', {
   ctx.provide('slots', {
-    register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
-      seats.set(options.name, { inject: options.inject })
+    register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
+      seats.set(options.name, { inject: options.inject, locale: options.locale })
       return () => { seats.delete(options.name) }
       return () => { seats.delete(options.name) }
     },
     },
   })
   })
   ctx.provide('conversation', {})
   ctx.provide('conversation', {})
+  ctx.provide('locale', new LocaleService(ctx))
   const scopes = new Map<SessionId, Context>()
   const scopes = new Map<SessionId, Context>()
   ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
   ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
   const fiber = ctx.plugin({ inject: [...inject], apply })
   const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -114,6 +119,8 @@ describe('ui-model dual entry', () => {
     expect(b.contribution().name).toBe('model')
     expect(b.contribution().name).toBe('model')
     expect(b.contribution().ui.kind).toBe('popupSelect')
     expect(b.contribution().ui.kind).toBe('popupSelect')
     expect(b.seat().inject).toBeTypeOf('function')
     expect(b.seat().inject).toBeTypeOf('function')
+    // Copy rides the standard locale seat.
+    expect(b.seat().locale).toBe('model')
   })
   })
 
 
   it('popup options mark the host current active with the provider group in the detail', async () => {
   it('popup options mark the host current active with the provider group in the detail', async () => {

+ 20 - 4
packages/client/ui-model/tests/model-select.spec.tsx

@@ -3,8 +3,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
 import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ComponentProps } from 'react'
 import type { ModelDirectoryState } from '../src/client/directory.ts'
 import type { ModelDirectoryState } from '../src/client/directory.ts'
 import { ModelSelect } from '../src/client/ModelSelect.tsx'
 import { ModelSelect } from '../src/client/ModelSelect.tsx'
+import { zh } from '../src/client/locales.ts'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+
+// The seat's key domain is model ∪ common; the stub mirrors the real lookup
+// chain: package dictionary, then common vocabulary, then the key.
+const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
+  const template = (zh as Record<string, string>)[key]
+    ?? (commonZh as Record<string, string>)[key]
+    ?? key
+  return params === undefined
+    ? template
+    : template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
+}
 
 
 const reasoning = {
 const reasoning = {
   efforts: [
   efforts: [
@@ -44,13 +58,14 @@ describe('ModelSelect reasoning effort', () => {
       directory={directory}
       directory={directory}
       load={vi.fn()}
       load={vi.fn()}
       select={select}
       select={select}
+      t={t}
     />)
     />)
 
 
     const trigger = screen.getByRole('button', {
     const trigger = screen.getByRole('button', {
       name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
       name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
     })
     })
     fireEvent.click(trigger)
     fireEvent.click(trigger)
-    fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
     expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
     expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
       .toEqual(['Off', 'High', 'MaxLargest budget'])
       .toEqual(['Off', 'High', 'MaxLargest budget'])
 
 
@@ -83,13 +98,14 @@ describe('ModelSelect reasoning effort', () => {
       directory={directory}
       directory={directory}
       load={vi.fn()}
       load={vi.fn()}
       select={vi.fn().mockResolvedValue(true)}
       select={vi.fn().mockResolvedValue(true)}
+      t={t}
     />)
     />)
 
 
     fireEvent.click(screen.getByRole('button', {
     fireEvent.click(screen.getByRole('button', {
-      name: '选择模型,当前 Model,推理等级 Provider default',
+      name: '选择模型,当前 Model,推理等级 服务商默认',
     }))
     }))
-    fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
     expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
     expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
-      .toEqual(['Provider default', 'Standard'])
+      .toEqual(['服务商默认', 'Standard'])
   })
   })
 })
 })

+ 3 - 0
packages/client/ui-model/tsconfig.json

@@ -14,6 +14,9 @@
     {
     {
       "path": "../connection"
       "path": "../connection"
     },
     },
+    {
+      "path": "../locale"
+    },
     {
     {
       "path": "../runtime"
       "path": "../runtime"
     },
     },

+ 3 - 0
packages/client/ui-question/package.json

@@ -24,6 +24,7 @@
   },
   },
   "dshClient": {
   "dshClient": {
     "inject": [
     "inject": [
+      "@deepseek-ai/dsh-client-locale",
       "@deepseek-ai/dsh-client-ui-conversation"
       "@deepseek-ai/dsh-client-ui-conversation"
     ],
     ],
     "platform": "web"
     "platform": "web"
@@ -44,11 +45,13 @@
     "react": "^18.2.0"
     "react": "^18.2.0"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
     "cordis": "^4.0.0-rc.7"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 30 - 20
packages/client/ui-question/src/client/QuestionComposer.tsx

@@ -4,7 +4,10 @@ import {
   Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
   Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
   IconCloseOutline16, IconEditOutline16, MarkdownText,
   IconCloseOutline16, IconEditOutline16, MarkdownText,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
+import {
+  PendingQuestion,
+  type QuestionAnswer, type QuestionComposerProps,
+} from './contract/slots.ts'
 import css from './QuestionComposer.module.css'
 import css from './QuestionComposer.module.css'
 
 
 interface DraftAnswer {
 interface DraftAnswer {
@@ -52,17 +55,20 @@ export function QuestionComposer(props: QuestionComposerProps) {
   // Domain-face mint rides the carrier's stable identity (never minted in a
   // Domain-face mint rides the carrier's stable identity (never minted in a
   // select/render dispatch — per-dispatch minting would churn memo identity).
   // select/render dispatch — per-dispatch minting would churn memo identity).
   const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
   const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
-  return <QuestionFlow key={question.key} pending={question} />
+  return <QuestionFlow key={question.key} pending={question} t={props.t} />
 }
 }
 
 
-function QuestionFlow({ pending }: { pending: PendingQuestion }) {
+function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
   const questions = pending.questions
   const questions = pending.questions
   const [index, setIndex] = useState(0)
   const [index, setIndex] = useState(0)
   const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
   const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
     selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
     selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
   })))
   })))
   const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
   const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
-  const [error, setError] = useState<string | null>(null)
+  // Validation feedback is stored as a dictionary KEY and translated at
+  // render, so already-shown feedback follows a locale switch; runtime
+  // failure messages (finished strings from the wire) pass through verbatim.
+  const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null)
   // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
   // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
   // oxlint-disable-next-line typescript/no-non-null-assertion
   // oxlint-disable-next-line typescript/no-non-null-assertion
   const question = questions[index]!
   const question = questions[index]!
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
     setError(null)
     setError(null)
     void pending.cancel().catch((cause: unknown) => {
     void pending.cancel().catch((cause: unknown) => {
       setBusy(null)
       setBusy(null)
-      setError(cause instanceof Error ? cause.message : String(cause))
+      setError({ text: cause instanceof Error ? cause.message : String(cause) })
     })
     })
   }
   }
 
 
@@ -111,7 +117,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
     const missing = values.findIndex(item => !completed(item))
     const missing = values.findIndex(item => !completed(item))
     if (missing >= 0) {
     if (missing >= 0) {
       setIndex(missing)
       setIndex(missing)
-      setError('请先完成这道问题。')
+      setError({ key: 'error.incomplete' })
       return
       return
     }
     }
     const answer: QuestionAnswer = {
     const answer: QuestionAnswer = {
@@ -130,13 +136,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
     setError(null)
     setError(null)
     void pending.answer(answer).catch((cause: unknown) => {
     void pending.answer(answer).catch((cause: unknown) => {
       setBusy(null)
       setBusy(null)
-      setError(cause instanceof Error ? cause.message : String(cause))
+      setError({ text: cause instanceof Error ? cause.message : String(cause) })
     })
     })
   }
   }
 
 
   const continueFlow = (): void => {
   const continueFlow = (): void => {
     if (!answered(draft)) {
     if (!answered(draft)) {
-      setError('请选择一个选项或填写自定义答案。')
+      setError({ key: 'error.unanswered' })
       return
       return
     }
     }
     if (index < questions.length - 1) {
     if (index < questions.length - 1) {
@@ -174,28 +180,30 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
               <span>{question.multiSelect === true
               <span>{question.multiSelect === true
                 ? parseQuestionTitle(question.question)
                 ? parseQuestionTitle(question.question)
                 : question.question}</span>
                 : question.question}</span>
-              {question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
+              {question.multiSelect === true && (
+                <span className={css.multiSelectHint}>{t('title.multi')}</span>
+              )}
             </h2>
             </h2>
           </div>
           </div>
           <div className={css.headerActions}>
           <div className={css.headerActions}>
             <span className={css.progress}>{index + 1} / {questions.length}</span>
             <span className={css.progress}>{index + 1} / {questions.length}</span>
             <button
             <button
-              type="button" className={css.iconButton} aria-label="上一题"
+              type="button" className={css.iconButton} aria-label={t('nav.prev')}
               disabled={index === 0 || busy !== null}
               disabled={index === 0 || busy !== null}
               onClick={() => { setIndex(index - 1); setError(null) }}
               onClick={() => { setIndex(index - 1); setError(null) }}
             >
             >
               <IconChevronLeftOutline14 />
               <IconChevronLeftOutline14 />
             </button>
             </button>
             <button
             <button
-              type="button" className={css.iconButton} aria-label="下一题"
+              type="button" className={css.iconButton} aria-label={t('nav.next')}
               disabled={index === questions.length - 1 || busy !== null}
               disabled={index === questions.length - 1 || busy !== null}
               onClick={() => { setIndex(index + 1); setError(null) }}
               onClick={() => { setIndex(index + 1); setError(null) }}
             >
             >
               <IconChevronRightOutline14 />
               <IconChevronRightOutline14 />
             </button>
             </button>
             <button
             <button
-              type="button" className={css.iconButton} aria-label="放弃整组问题"
-              title="放弃整组问题"
+              type="button" className={css.iconButton} aria-label={t('nav.cancel')}
+              title={t('nav.cancel')}
               disabled={busy !== null} onClick={cancelFlow}
               disabled={busy !== null} onClick={cancelFlow}
             >
             >
               <IconCloseOutline16 />
               <IconCloseOutline16 />
@@ -230,7 +238,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
                   <span className={css.optionCopy}>
                   <span className={css.optionCopy}>
                     <span className={css.optionLine}>
                     <span className={css.optionLine}>
                       <span className={css.optionLabel}>{display.label}</span>
                       <span className={css.optionLabel}>{display.label}</span>
-                      {display.recommended && <span className={css.badge}>推荐</span>}
+                      {display.recommended && (
+                        <span className={css.badge}>{t('option.recommended')}</span>
+                      )}
                       {option.description !== undefined && (
                       {option.description !== undefined && (
                         <span className={css.description}>{option.description}</span>
                         <span className={css.description}>{option.description}</span>
                       )}
                       )}
@@ -255,7 +265,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
                   aria-expanded={draft.customOpen}
                   aria-expanded={draft.customOpen}
                 >
                 >
                   <span className={css.number}><IconEditOutline16 /></span>
                   <span className={css.number}><IconEditOutline16 /></span>
-                  <span>其他,请填写自定义答案</span>
+                  <span>{t('option.custom')}</span>
                 </button>
                 </button>
               )}
               )}
               {draft.customOpen && (
               {draft.customOpen && (
@@ -265,7 +275,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
                   value={draft.custom}
                   value={draft.custom}
                   disabled={busy !== null}
                   disabled={busy !== null}
                   rows={2}
                   rows={2}
-                  placeholder="输入你的答案"
+                  placeholder={t('custom.placeholder')}
                   onChange={(event) => {
                   onChange={(event) => {
                     const value = event.target.value
                     const value = event.target.value
                     updateDraft(current => ({
                     updateDraft(current => ({
@@ -285,18 +295,18 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
         </div>
         </div>
 
 
         <footer className={css.footer}>
         <footer className={css.footer}>
-          <div className={css.feedback} role="status">{error}</div>
+          <div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
           <div className={css.footerActions}>
           <div className={css.footerActions}>
             <Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
             <Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
-              跳过本题
+              {t('action.skip')}
             </Button>
             </Button>
             <Button
             <Button
               variant="primary" size="sm"
               variant="primary" size="sm"
               disabled={busy !== null || !answered(draft)} onClick={continueFlow}
               disabled={busy !== null || !answered(draft)} onClick={continueFlow}
             >
             >
               {busy === 'answer'
               {busy === 'answer'
-                ? '正在提交…'
-                : index === questions.length - 1 ? '提交' : '下一题'}
+                ? t('submitting')
+                : index === questions.length - 1 ? t('submit') : t('action.next')}
             </Button>
             </Button>
           </div>
           </div>
         </footer>
         </footer>

+ 6 - 5
packages/client/ui-question/src/client/contract/slots.ts

@@ -6,7 +6,7 @@
  * cancelled error encoding, receipt checks — lives HERE, with the package
  * cancelled error encoding, receipt checks — lives HERE, with the package
  * that consumes it.
  * that consumes it.
  */
  */
-import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 // Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
 // Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
 // entry) into every program that sees this contract, so PropsRuntime resolves.
 // entry) into every program that sees this contract, so PropsRuntime resolves.
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -70,8 +70,9 @@ export class PendingQuestion {
 /**
 /**
  * Full component props: the framework runtime share (chain currency +
  * Full component props: the framework runtime share (chain currency +
  * session/global standard kit) plus the chain `matched` share — the entry's
  * session/global standard kit) plus the chain `matched` share — the entry's
- * selector result, already narrowed to the question carrier. No injected
- * share: the carrier plus the domain face above carry the whole behavior
- * surface.
+ * selector result, already narrowed to the question carrier — plus the
+ * standard locale seat; the carrier plus the domain face above carry the
+ * whole behavior surface.
  */
  */
-export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
+export type QuestionComposerProps =
+  PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>

+ 29 - 10
packages/client/ui-question/src/client/index.ts

@@ -1,18 +1,32 @@
 /**
 /**
  * Web question plugin, browser half: QuestionComposer registered as a
  * Web question plugin, browser half: QuestionComposer registered as a
- * selector-routed entry of the conversation-declared composer chain. Pure
- * consumer — the selector narrows the owner's currency to the question
- * carrier (matched prop), and the whole behavior surface rides the carrier
- * (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
- * service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
+ * selector-routed entry of the conversation-declared composer chain, plus the
+ * `question` dictionaries. The selector narrows the owner's currency to the
+ * question carrier (matched prop), and the whole behavior surface rides the
+ * carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
+ * the standard locale seat. Export discipline: packages/client/AGENTS.md.
  */
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type { QuestionWait } from './contract/slots.ts'
 import type { QuestionWait } from './contract/slots.ts'
 import { QuestionComposer } from './QuestionComposer.tsx'
 import { QuestionComposer } from './QuestionComposer.tsx'
+import { en, zh, type QuestionKey } from './locales.ts'
 
 
 export { PendingQuestion } from './contract/slots.ts'
 export { PendingQuestion } from './contract/slots.ts'
 export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
 export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
+export type { QuestionKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** The question composer's copy. */
+    question: QuestionKey
+  }
+}
+
+/** Dictionary namespace owned by this plugin. */
+const NS = 'question'
 
 
 /**
 /**
  * Required services (cordis fiber inject). 'conversation' is an ordering
  * Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont
  * declared by ui-conversation's apply, and register() into an undeclared
  * declared by ui-conversation's apply, and register() into an undeclared
  * slot throws — service waiting orders this apply after the declaring one.
  * slot throws — service waiting orders this apply after the declaring one.
  */
  */
-export const inject = ['slots', 'conversation']
+export const inject = ['slots', 'conversation', 'locale']
 
 
 /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
 /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
 function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
 function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
@@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
 }
 }
 
 
 /**
 /**
- * Client plugin body: register the question composer into the composer chain.
- * Zero business face — data and verbs both live on the matched carrier.
+ * Client plugin body: register the `question` dictionaries and the question
+ * composer into the composer chain. Zero business face — data and verbs live
+ * on the matched carrier; t rides the standard locale seat.
  * @param ctx - client root context.
  * @param ctx - client root context.
  */
  */
 export function apply(ctx: ClientContext): void {
 export function apply(ctx: ClientContext): void {
-  const slots = ctx.slots
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
+
   ctx.effect(
   ctx.effect(
-    () => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
+    () => ctx.slots.register(
+      { name: 'conversation.composer', select: selectQuestion, locale: NS },
+      QuestionComposer,
+    ),
     'ui-question: composer chain registration',
     'ui-question: composer chain registration',
   )
   )
 }
 }

+ 34 - 0
packages/client/ui-question/src/client/locales.ts

@@ -0,0 +1,34 @@
+/** `question` namespace dictionaries. */
+
+/** Simplified Chinese dictionary (the key-set source of truth). */
+export const zh = {
+  'error.incomplete': '请先完成这道问题。',
+  'error.unanswered': '请选择一个选项或填写自定义答案。',
+  'title.multi': '可多选',
+  'nav.prev': '上一题',
+  'nav.next': '下一题',
+  'nav.cancel': '放弃整组问题',
+  'option.recommended': '推荐',
+  'option.custom': '其他,请填写自定义答案',
+  'custom.placeholder': '输入你的答案',
+  'action.skip': '跳过本题',
+  'action.next': '下一题',
+} satisfies Record<string, string>
+
+/** The question namespace key union. */
+export type QuestionKey = keyof typeof zh
+
+/** English dictionary, checked complete against the zh key set. */
+export const en = {
+  'error.incomplete': 'Please complete this question first.',
+  'error.unanswered': 'Please select an option or enter a custom answer.',
+  'title.multi': 'Multi-select',
+  'nav.prev': 'Previous question',
+  'nav.next': 'Next question',
+  'nav.cancel': 'Dismiss all questions',
+  'option.recommended': 'Recommended',
+  'option.custom': 'Other — enter a custom answer',
+  'custom.placeholder': 'Type your answer',
+  'action.skip': 'Skip this question',
+  'action.next': 'Next',
+} satisfies Record<QuestionKey, string>

+ 7 - 2
packages/client/ui-question/tests/browser-plugin.spec.ts

@@ -9,6 +9,7 @@
 import { Context } from 'cordis'
 import { Context } from 'cordis'
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
 import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
 import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
 import { apply, inject } from '../src/client/index.ts'
 import { apply, inject } from '../src/client/index.ts'
 
 
@@ -24,12 +25,13 @@ async function bench() {
   // 'conversation' inject is an ordering edge (the declaring plugin provides
   // 'conversation' inject is an ordering edge (the declaring plugin provides
   // it after declaring the chain); the bench declares the chain itself.
   // it after declaring the chain); the bench declares the chain itself.
   ctx.provide('conversation', {})
   ctx.provide('conversation', {})
+  ctx.provide('locale', new LocaleService(ctx))
   return { ctx, slots }
   return { ctx, slots }
 }
 }
 
 
 describe('apply', () => {
 describe('apply', () => {
   it('declares the services it binds', () => {
   it('declares the services it binds', () => {
-    expect(inject).toEqual(['slots', 'conversation'])
+    expect(inject).toEqual(['slots', 'conversation', 'locale'])
   })
   })
 
 
   it('fails loud when no live entry has declared the composer slot', async () => {
   it('fails loud when no live entry has declared the composer slot', async () => {
@@ -38,6 +40,7 @@ describe('apply', () => {
     // Satisfy the ordering inject without declaring the chain: apply must
     // Satisfy the ordering inject without declaring the chain: apply must
     // then hit the undeclared-slot throw, not sit waiting on the service.
     // then hit the undeclared-slot throw, not sit waiting on the service.
     ctx.provide('conversation', {})
     ctx.provide('conversation', {})
+    ctx.provide('locale', new LocaleService(ctx))
     await expect(ctx.plugin({ inject: [...inject], apply }))
     await expect(ctx.plugin({ inject: [...inject], apply }))
       .rejects.toThrow(/slot "conversation.composer" is not declared/)
       .rejects.toThrow(/slot "conversation.composer" is not declared/)
   })
   })
@@ -47,8 +50,10 @@ describe('apply', () => {
     await ctx.plugin({ inject: [...inject], apply }).await()
     await ctx.plugin({ inject: [...inject], apply }).await()
     const entry = slots.entries('conversation.composer')[0]!
     const entry = slots.entries('conversation.composer')[0]!
     expect(entry.component).toBe(QuestionComposer)
     expect(entry.component).toBe(QuestionComposer)
-    // The whole behavior surface rides the matched carrier: no business face.
+    // The whole behavior surface rides the matched carrier: no business face;
+    // copy rides the standard locale seat.
     expect(entry.inject).toBeUndefined()
     expect(entry.inject).toBeUndefined()
+    expect(entry.locale).toBe('question')
     // The selector narrows the chain currency: question wait in → that wait; none → null.
     // The selector narrows the chain currency: question wait in → that wait; none → null.
     const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
     const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
     const question = { kind: 'question' }
     const question = { kind: 'question' }

+ 8 - 1
packages/client/ui-question/tests/question-composer.spec.tsx

@@ -8,10 +8,12 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
 import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
 import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
 import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
-import { PendingQuestion } from '../src/client/contract/slots.ts'
+import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
 import {
 import {
   QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
   QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
 } from '../src/client/QuestionComposer.tsx'
 } from '../src/client/QuestionComposer.tsx'
+import { zh } from '../src/client/locales.ts'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 
 
 afterEach(cleanup)
 afterEach(cleanup)
 
 
@@ -28,6 +30,11 @@ const kit = {
   useProjection: (() => undefined) as never,
   useProjection: (() => undefined) as never,
   useInput: (() => { throw new Error('unused') }) as never,
   useInput: (() => { throw new Error('unused') }) as never,
   inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
   inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
+  // The seat's key domain is question ∪ common; the stub mirrors the real
+  // lookup chain: package dictionary, then common vocabulary, then the key.
+  t: (key => (zh as Record<string, string>)[key]
+    ?? (commonZh as Record<string, string>)[key]
+    ?? key) as QuestionComposerProps['t'],
 }
 }
 
 
 const QUESTIONS = [
 const QUESTIONS = [

+ 3 - 0
packages/client/ui-question/tsconfig.json

@@ -14,6 +14,9 @@
     {
     {
       "path": "../connection"
       "path": "../connection"
     },
     },
+    {
+      "path": "../locale"
+    },
     {
     {
       "path": "../runtime"
       "path": "../runtime"
     },
     },

+ 4 - 1
packages/client/ui-sidebar/package.json

@@ -25,7 +25,8 @@
   "dshClient": {
   "dshClient": {
     "inject": [
     "inject": [
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-runtime",
-      "@deepseek-ai/dsh-client-ui-layout"
+      "@deepseek-ai/dsh-client-ui-layout",
+      "@deepseek-ai/dsh-client-locale"
     ],
     ],
     "platform": "web"
     "platform": "web"
   },
   },
@@ -38,6 +39,7 @@
     "clsx": "^2.0.0"
     "clsx": "^2.0.0"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -46,6 +48,7 @@
     "react": "^18.2.0"
     "react": "^18.2.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",

+ 7 - 6
packages/client/ui-sidebar/src/client/SidebarRoot.tsx

@@ -32,6 +32,7 @@ export function SidebarRoot({
   width,
   width,
   startSession,
   startSession,
   toggleSidebar,
   toggleSidebar,
+  t,
   renderSlot,
   renderSlot,
 }: SidebarRootComponentProps) {
 }: SidebarRootComponentProps) {
   // Wide content stays mounted while the collapse animates (fading via
   // Wide content stays mounted while the collapse animates (fading via
@@ -67,7 +68,7 @@ export function SidebarRoot({
           <button
           <button
             type="button"
             type="button"
             className={clsx(css.brand, css.wide)}
             className={clsx(css.brand, css.wide)}
-            aria-label="New session"
+            aria-label={t('session.new.label')}
             onClick={() => { startSession() }}
             onClick={() => { startSession() }}
           >
           >
             <BrandWordmark />
             <BrandWordmark />
@@ -75,11 +76,11 @@ export function SidebarRoot({
         )}
         )}
         {/* Rail resting state is the whale mark; hovering swaps in the panel
         {/* Rail resting state is the whale mark; hovering swaps in the panel
             icon (the expand affordance, figma sidebar-hover flow). */}
             icon (the expand affordance, figma sidebar-hover flow). */}
-        <Tooltip label="Open sidebar" disabled={wide}>
+        <Tooltip label={t('toggle.open')} disabled={wide}>
           <button
           <button
             type="button"
             type="button"
             className={clsx(css.iconButton, css.toggle)}
             className={clsx(css.iconButton, css.toggle)}
-            aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
+            aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')}
             onClick={() => { toggleSidebar() }}
             onClick={() => { toggleSidebar() }}
           >
           >
             {!wide && <FishLogo className={css.railFish} size={24} />}
             {!wide && <FishLogo className={css.railFish} size={24} />}
@@ -89,15 +90,15 @@ export function SidebarRoot({
         </Tooltip>
         </Tooltip>
       </div>
       </div>
 
 
-      <Tooltip label="New session" disabled={wide}>
+      <Tooltip label={t('session.new.label')} disabled={wide}>
         <button
         <button
           type="button"
           type="button"
           className={css.newSession}
           className={css.newSession}
-          aria-label="New session"
+          aria-label={t('session.new.label')}
           onClick={() => { startSession() }}
           onClick={() => { startSession() }}
         >
         >
           <IconNewChatOutline16 size={wide ? 14 : 18} />
           <IconNewChatOutline16 size={wide ? 14 : 18} />
-          {wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
+          {wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
         </button>
         </button>
       </Tooltip>
       </Tooltip>
 
 

+ 5 - 3
packages/client/ui-sidebar/src/client/contract/slots.ts

@@ -6,7 +6,7 @@
  * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
  * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
  * `sidebar.settings` registrant's (ui-settings).
  * `sidebar.settings` registrant's (ui-settings).
  */
  */
-import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
 // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
 // program that sees this contract, so PropsRuntime<'sidebar'> resolves.
 // program that sees this contract, so PropsRuntime<'sidebar'> resolves.
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -68,7 +68,9 @@ export type SidebarRootInjected = {
 
 
 /**
 /**
  * Full component props: layout owner state/actions plus the declared holes'
  * Full component props: layout owner state/actions plus the declared holes'
- * render shares and this package's injected callbacks. No store is registered.
+ * render shares, this package's injected callbacks, and the standard locale
+ * seat. No store is registered.
  */
  */
 export type SidebarRootComponentProps =
 export type SidebarRootComponentProps =
-  PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected
+  PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'>
+  & SidebarRootInjected & PropsLocale<'sidebar'>

+ 18 - 1
packages/client/ui-sidebar/src/client/index.ts

@@ -1,17 +1,33 @@
 /** Registers the sidebar shell into the layout-owned slot. */
 /** Registers the sidebar shell into the layout-owned slot. */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type { SidebarRootInjected } from './contract/slots.ts'
 import type { SidebarRootInjected } from './contract/slots.ts'
 import { SidebarRoot } from './SidebarRoot.tsx'
 import { SidebarRoot } from './SidebarRoot.tsx'
+import { en, zh, type SidebarKey } from './locales.ts'
 
 
 export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts'
 export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts'
+export type { SidebarKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Sidebar shell controls copy. */
+    sidebar: SidebarKey
+  }
+}
+
+/** Dictionary namespace owned by this plugin (shell controls copy). */
+const NS = 'sidebar'
 
 
 /** Services required by the sidebar plugin. */
 /** Services required by the sidebar plugin. */
-export const inject = ['slots', 'layout', 'sessions', 'workspaces']
+export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
 
 
 /** Registers the sidebar shell and its service callbacks.
 /** Registers the sidebar shell and its service callbacks.
  * @param ctx - Client root context.
  * @param ctx - Client root context.
  */
  */
 export function apply(ctx: ClientContext): void {
 export function apply(ctx: ClientContext): void {
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')
+
   const injectProps = (): SidebarRootInjected => ({
   const injectProps = (): SidebarRootInjected => ({
     // The shell's New Session button rides the runtime's shared action
     // The shell's New Session button rides the runtime's shared action
     // (recent-Workspace targeting; explicit Workspace wins for scoped actions).
     // (recent-Workspace targeting; explicit Workspace wins for scoped actions).
@@ -21,6 +37,7 @@ export function apply(ctx: ClientContext): void {
   ctx.effect(
   ctx.effect(
     () => ctx.slots.register({
     () => ctx.slots.register({
       name: 'sidebar',
       name: 'sidebar',
+      locale: NS,
       // The shell owns geometry; ui-workspace registers the whole browsing
       // The shell owns geometry; ui-workspace registers the whole browsing
       // region (header, search, session list, workspace dialogs), ui-settings
       // region (header, search, session list, workspace dialogs), ui-settings
       // registers the foot trigger + settings panel.
       // registers the foot trigger + settings panel.

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

@@ -0,0 +1,20 @@
+/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */
+
+/** Simplified Chinese dictionary (the key-set source of truth). */
+export const zh = {
+  'session.new': '新会话',
+  'session.new.label': '新建会话',
+  'toggle.open': '打开侧边栏',
+  'toggle.collapse': '收起侧边栏',
+} satisfies Record<string, string>
+
+/** The sidebar namespace key union. */
+export type SidebarKey = keyof typeof zh
+
+/** English dictionary, checked complete against the zh key set. */
+export const en = {
+  'session.new': 'New Session',
+  'session.new.label': 'New session',
+  'toggle.open': 'Open sidebar',
+  'toggle.collapse': 'Collapse sidebar',
+} satisfies Record<SidebarKey, string>

+ 5 - 1
packages/client/ui-sidebar/tests/apply.spec.tsx

@@ -2,6 +2,7 @@
 import { Context } from 'cordis'
 import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { describe, expect, it, vi } from 'vitest'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 
 
@@ -14,6 +15,7 @@ async function bench(declare = true) {
   ctx.provide('layout', layout)
   ctx.provide('layout', layout)
   ctx.provide('sessions', sessions as never)
   ctx.provide('sessions', sessions as never)
   ctx.provide('workspaces', workspaces as never)
   ctx.provide('workspaces', workspaces as never)
+  ctx.provide('locale', new LocaleService(ctx))
   const slots = ctx.get('slots') as SlotsService
   const slots = ctx.get('slots') as SlotsService
   if (declare) {
   if (declare) {
     slots.register(
     slots.register(
@@ -26,7 +28,7 @@ async function bench(declare = true) {
 
 
 describe('ui-sidebar apply', () => {
 describe('ui-sidebar apply', () => {
   it('declares only the services it uses', () => {
   it('declares only the services it uses', () => {
-    expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
+    expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
   })
   })
 
 
   it('registers the shell and declares the browsing-region hole', async () => {
   it('registers the shell and declares the browsing-region hole', async () => {
@@ -34,6 +36,8 @@ describe('ui-sidebar apply', () => {
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     expect(b.slots.entries('sidebar')).toHaveLength(1)
     expect(b.slots.entries('sidebar')).toHaveLength(1)
     expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
     expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
+    // Copy rides the standard locale seat, not the inject face.
+    expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
     const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
     const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
     expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
     expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
     // Both arms delegate to the runtime's shared New Session action.
     // Both arms delegate to the runtime's shared New Session action.

+ 6 - 1
packages/client/ui-sidebar/tests/sidebar-root.spec.tsx

@@ -3,6 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
 import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
 import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
 import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
+import { en } from '../src/client/locales.ts'
+
+// English-dictionary translate stub: the shell renders the same copy the
+// assertions below query by accessible name.
+const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
 
 
 afterEach(() => {
 afterEach(() => {
   cleanup()
   cleanup()
@@ -23,7 +28,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
     <SidebarRoot
     <SidebarRoot
       collapsed={current.collapsed} width={current.width}
       collapsed={current.collapsed} width={current.width}
       useSessions={neverHook} useWorkspaces={neverHook}
       useSessions={neverHook} useWorkspaces={neverHook}
-      startSession={startSession} toggleSidebar={toggleSidebar}
+      startSession={startSession} toggleSidebar={toggleSidebar} t={t}
       renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
       renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
         if (key === 'sidebar.settings') {
         if (key === 'sidebar.settings') {
           settingsOwner = owner
           settingsOwner = owner

+ 7 - 0
packages/client/ui-sidebar/tests/sidebar-snapshot.spec.tsx

@@ -11,6 +11,7 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, waitFor } from '@testing-library/react'
 import { cleanup, waitFor } from '@testing-library/react'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 
 
 afterEach(cleanup)
 afterEach(cleanup)
@@ -18,6 +19,12 @@ afterEach(cleanup)
 async function bench() {
 async function bench() {
   const runtime = await SlotTestRuntime.create()
   const runtime = await SlotTestRuntime.create()
   runtime.provide('layout', { toggleSidebar: vi.fn() })
   runtime.provide('layout', { toggleSidebar: vi.fn() })
+  // English locale pins the snapshots to the copy they were recorded with;
+  // the installed face backs the entry's standard `t` seat.
+  const locale = new LocaleService(runtime.ctx)
+  locale.setLocale('en')
+  runtime.provide('locale', locale)
+  runtime.slots.installLocale(locale)
   await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
   await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
   await runtime.mount({ inject: [...inject], apply })
   await runtime.mount({ inject: [...inject], apply })
   return runtime
   return runtime

+ 3 - 0
packages/client/ui-sidebar/tsconfig.json

@@ -26,6 +26,9 @@
     {
     {
       "path": "../ui-layout"
       "path": "../ui-layout"
     },
     },
+    {
+      "path": "../locale"
+    },
     {
     {
       "path": "../../support/invariants"
       "path": "../../support/invariants"
     }
     }

+ 88 - 8
packages/client/ui-slots/src/index.ts

@@ -24,6 +24,67 @@ export * from './deferred.ts'
 /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
 /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
 export interface SlotMap {}
 export interface SlotMap {}
 
 
+/**
+ * Locale namespace table. Dictionary owners extend via declaration merging
+ * (exactly like {@link SlotMap}, and declared in this entry module for the
+ * same lexical-merge reason): the key is the namespace string, the value is
+ * the union of its dictionary keys. Register sites declare one of these
+ * namespaces (`locale:`), which puts the typed `t` standard seat on the
+ * component props.
+ */
+export interface LocaleNamespaceMap {}
+
+/**
+ * Translate a dictionary key with optional `{name}` template params.
+ * `K` narrows the accepted keys to the owning namespace's dictionary union
+ * (plus the shared common vocabulary where composed).
+ */
+export type Translate<K extends string = string> =
+  (key: K, params?: Record<string, unknown>) => string
+
+/**
+ * The shared `common` vocabulary keys as merged by the locale plugin;
+ * resolves to `never` in programs without the merge (this package's tests),
+ * keeping the union collapse harmless.
+ */
+export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never
+
+/**
+ * Key domain of a namespace-bound translate: the namespace's own dictionary
+ * union plus the shared common vocabulary (the lookup chain consults common
+ * after the namespace misses).
+ */
+export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> =
+  (LocaleNamespaceMap[N] & string) | CommonKeyOf
+
+/**
+ * Namespace-addressed translate — the developer-facing alias over
+ * {@link Translate}: `TranslateNS<'model'>` is the translate function of the
+ * `model` namespace (key domain = its dictionary union plus the shared
+ * common vocabulary), the exact type of the framework-injected `t` seat and
+ * of the locale service's typed `bind`.
+ */
+export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>
+
+/**
+ * Dictionary shape for a declared namespace: exactly the keys the namespace
+ * merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed
+ * registration site is a compile error.
+ */
+export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> =
+  Record<LocaleNamespaceMap[N] & string, string>
+
+/**
+ * Locale share of the composed component props: the framework-injected `t`
+ * seat, present exactly on entries whose registration declares `locale:`.
+ */
+export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string
+  ? {
+    /** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */
+    t: TranslateNS<N>
+  }
+  : object
+
 /** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */
 /** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */
 export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
 export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
 
 
@@ -244,10 +305,11 @@ export type InjectFace<I extends object> =
   I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
   I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
 
 
 /**
 /**
- * The four-share component props intersection: runtime share (SlotMap) +
+ * The composed component props intersection: runtime share (SlotMap) +
  * child-render share (children declaration) + store share (declared handle) +
  * child-render share (children declaration) + store share (declared handle) +
  * the registrant's injected business face (its hooks compartment bound, see
  * the registrant's injected business face (its hooks compartment bound, see
- * {@link InjectFace}). Each share derives from its single source of truth;
+ * {@link InjectFace}) + the locale `t` seat (declared namespace, see
+ * {@link PropsLocale}). Each share derives from its single source of truth;
  * components reference this composition, never re-type it.
  * components reference this composition, never re-type it.
  */
  */
 export type ComposedProps<
 export type ComposedProps<
@@ -256,7 +318,8 @@ export type ComposedProps<
   H,
   H,
   I extends object,
   I extends object,
   M = never,
   M = never,
-> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
+  N = undefined,
+> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
 
 
 /**
 /**
  * Inject factory parameter list, derived from the registration's declaration:
  * Inject factory parameter list, derived from the registration's declaration:
@@ -303,13 +366,20 @@ type RendersCheck<C, D> =
       : unknown
       : unknown
 
 
 /** Common register options share (see {@link SlotCore.register} for semantics). */
 /** Common register options share (see {@link SlotCore.register} for semantics). */
-type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never> = {
+type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never, N = undefined> = {
   /** Target slot key (the entry contributes INTO this slot). */
   /** Target slot key (the entry contributes INTO this slot). */
   name: K
   name: K
   /** Child-slot declaration + render authorization + runtime spec, in one table. */
   /** Child-slot declaration + render authorization + runtime spec, in one table. */
   children?: D
   children?: D
   /** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
   /** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
   store?: H
   store?: H
+  /**
+   * Dictionary namespace of this entry's copy. Declaring it puts the
+   * framework-synthesized `t` seat (typed to the namespace's dictionary
+   * union) on the component props; rendering requires an installed locale
+   * face — fails loud otherwise.
+   */
+  locale?: N
   /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
   /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
   registrant?: string
   registrant?: string
 } & KindOptions<SlotMap[K], M>
 } & KindOptions<SlotMap[K], M>
@@ -330,6 +400,8 @@ export interface StoredEntry {
   children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
   children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
   /** Declared store seat (instance resolution and lifecycle live with the host machinery). */
   /** Declared store seat (instance resolution and lifecycle live with the host machinery). */
   store?: StoreDecl | undefined
   store?: StoreDecl | undefined
+  /** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */
+  locale?: string | undefined
   /** Diagnostics label of who registered. */
   /** Diagnostics label of who registered. */
   registrant?: string | undefined
   registrant?: string | undefined
 }
 }
@@ -350,6 +422,7 @@ interface ErasedOptions {
   priority?: number | undefined
   priority?: number | undefined
   children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
   children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
   store?: StoreDecl | undefined
   store?: StoreDecl | undefined
+  locale?: string | undefined
   /* oxlint-disable-next-line typescript/no-explicit-any --
   /* oxlint-disable-next-line typescript/no-explicit-any --
    * implementation-signature position only (both public overloads type inject
    * implementation-signature position only (both public overloads type inject
    * exactly); `never[]` would fail overload-to-implementation compatibility
    * exactly); `never[]` would fail overload-to-implementation compatibility
@@ -427,16 +500,20 @@ export class SlotCore {
    * @returns disposer removing the registration and its declarations
    * @returns disposer removing the registration and its declarations
    * (idempotent; stale disposers after a cascade are no-ops).
    * (idempotent; stale disposers after a cascade are no-ops).
    */
    */
+  /* jscpd:ignore-start -- the two register overloads are deliberately
+   * parallel declarations differing only in the inject share; folding them
+   * would lose the per-overload inference of I. */
   register<
   register<
     K extends keyof SlotMap & string,
     K extends keyof SlotMap & string,
     const D extends ChildrenDecl = Record<never, never>,
     const D extends ChildrenDecl = Record<never, never>,
     H extends StoreDecl | undefined = undefined,
     H extends StoreDecl | undefined = undefined,
     M = never,
     M = never,
+    N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
     C extends SlotComponent<never> = SlotComponent<never>,
     C extends SlotComponent<never> = SlotComponent<never>,
   >(
   >(
-    options: BaseOptions<K, D, H, M> & { inject?: undefined },
+    options: BaseOptions<K, D, H, M, N> & { inject?: undefined },
     component: C
     component: C
-      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>>>
+      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
       & RendersCheck<C, D>,
       & RendersCheck<C, D>,
   ): () => void
   ): () => void
   /**
   /**
@@ -455,13 +532,15 @@ export class SlotCore {
     const D extends ChildrenDecl = Record<never, never>,
     const D extends ChildrenDecl = Record<never, never>,
     H extends StoreDecl | undefined = undefined,
     H extends StoreDecl | undefined = undefined,
     M = never,
     M = never,
+    N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
     C extends SlotComponent<never> = SlotComponent<never>,
     C extends SlotComponent<never> = SlotComponent<never>,
   >(
   >(
-    options: BaseOptions<K, D, H, M> & { inject: (...args: InjectParams<K, H>) => I },
+    options: BaseOptions<K, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
     component: C
     component: C
-      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>>>
+      & SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
       & RendersCheck<C, D>,
       & RendersCheck<C, D>,
   ): () => void
   ): () => void
+  /* jscpd:ignore-end */
   register(options: ErasedOptions, component: unknown): () => void {
   register(options: ErasedOptions, component: unknown): () => void {
     const rec = this.records.get(options.name)
     const rec = this.records.get(options.name)
     if (!rec?.spec) {
     if (!rec?.spec) {
@@ -523,6 +602,7 @@ export class SlotCore {
       ...(options.inject !== undefined ? { inject: options.inject } : {}),
       ...(options.inject !== undefined ? { inject: options.inject } : {}),
       ...(options.children !== undefined ? { children: options.children } : {}),
       ...(options.children !== undefined ? { children: options.children } : {}),
       ...(options.store !== undefined ? { store: options.store } : {}),
       ...(options.store !== undefined ? { store: options.store } : {}),
+      ...(options.locale !== undefined ? { locale: options.locale } : {}),
       ...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
       ...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
     }
     }
     const next = [...rec.entries, entry]
     const next = [...rec.entries, entry]

+ 32 - 1
packages/client/ui-slots/src/renderer.ts

@@ -1,6 +1,31 @@
 /** React-free contracts between the slot host and an installed renderer. */
 /** React-free contracts between the slot host and an installed renderer. */
 import type { ReactNode } from 'react'
 import type { ReactNode } from 'react'
-import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
+import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
+
+/**
+ * The locale face the render machinery consumes: namespace binding plus an
+ * observable revision (getSnapshot/subscribe pair — the same HostObservable
+ * currency as every other standard-kit source). The revision moves on every
+ * active-locale or registry change; the renderer re-derives each entry's `t`
+ * from (namespace, revision), so a locale switch hands out NEW function
+ * references and memoized components re-render naturally. Implemented by the
+ * locale plugin, installed through the runtime SlotsService (installLocale).
+ * Install before the first render that needs the seat: outlets bind their
+ * revision subscription at mount, and a face appearing later has no channel
+ * to notify already-mounted outlets (the locale plugin is immediately-tier
+ * infrastructure, so normal compositions install during boot).
+ */
+export interface LocaleFace extends HostObservable<{ revision: number }> {
+  /**
+   * Bind a namespace to a translate function reading the active locale at
+   * call time. Identity may be stable per namespace — freshness of rendered
+   * text is carried by the renderer's (ns, revision) seat derivation, not by
+   * this binding.
+   * @param ns - dictionary namespace.
+   * @returns the namespace-bound translate function.
+   */
+  bind(ns: string): Translate
+}
 
 
 /** Minimal observable surface for host-provided standard-kit data sources. */
 /** Minimal observable surface for host-provided standard-kit data sources. */
 export interface HostObservable<T> {
 export interface HostObservable<T> {
@@ -128,6 +153,12 @@ export interface SlotRendererHost {
     /** Workspace list source backing the useWorkspaces standard hook. */
     /** Workspace list source backing the useWorkspaces standard hook. */
     list: HostObservable<unknown>
     list: HostObservable<unknown>
   }
   }
+  /**
+   * Installed locale face backing the `t` standard seat (absent until the
+   * locale plugin installs one; rendering an entry that declared `locale:`
+   * without it is an assembly failure).
+   */
+  locale?: LocaleFace | undefined
 }
 }
 
 
 /** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
 /** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */

+ 7 - 7
packages/client/ui-theme/src/client/AppearanceRow.tsx

@@ -9,26 +9,26 @@ import clsx from 'clsx'
 import {
 import {
   IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
   IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ThemePreference } from './index.ts'
 import type { ThemePreference } from './index.ts'
+import type { ThemeKey } from './locales.ts'
 import type {} from './settings-contract.ts'
 import type {} from './settings-contract.ts'
 import type { createAppearanceRowStore } from './settings-store.ts'
 import type { createAppearanceRowStore } from './settings-store.ts'
 import css from './AppearanceRow.module.css'
 import css from './AppearanceRow.module.css'
 
 
-/** Injected business face: namespace-bound translate + the preference write. */
+/** Injected business face: the preference write (t rides the standard locale seat). */
 export interface AppearanceRowInjected {
 export interface AppearanceRowInjected {
-  /** Translate a `settings.theme` dictionary key to the active-locale text. */
-  t: (key: string) => string
   /** Switch the theme preference. */
   /** Switch the theme preference. */
   setTheme: (id: ThemePreference) => void
   setTheme: (id: ThemePreference) => void
 }
 }
 
 
-/** Full component props: runtime share + store share + injected face. */
+/** Full component props: runtime share + store share + locale seat + injected face. */
 export type AppearanceRowComponentProps =
 export type AppearanceRowComponentProps =
-  PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected
+  PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>>
+  & PropsLocale<'settings.theme'> & AppearanceRowInjected
 
 
 /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
 /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
-const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
+const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [
   { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
   { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
   { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
   { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
   { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
   { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },

+ 11 - 18
packages/client/ui-theme/src/client/index.ts

@@ -14,13 +14,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type { AppearanceRowInjected } from './AppearanceRow.tsx'
 import type { AppearanceRowInjected } from './AppearanceRow.tsx'
 import { AppearanceRow } from './AppearanceRow.tsx'
 import { AppearanceRow } from './AppearanceRow.tsx'
 import { createAppearanceRowStore } from './settings-store.ts'
 import { createAppearanceRowStore } from './settings-store.ts'
+import { en, zh, type ThemeKey } from './locales.ts'
 
 
 export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
 export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
 export type { AppearanceRowState } from './settings-store.ts'
 export type { AppearanceRowState } from './settings-store.ts'
+export type { ThemeKey } from './locales.ts'
 
 
 /** Namespace owning this feature's settings-row copy. */
 /** Namespace owning this feature's settings-row copy. */
 export const SETTINGS_NS = 'settings.theme'
 export const SETTINGS_NS = 'settings.theme'
 
 
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** The Appearance settings row's copy. */
+    'settings.theme': ThemeKey
+  }
+}
+
 /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
 /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
 export type ThemeTokens = Record<string, string>
 export type ThemeTokens = Record<string, string>
 
 
@@ -228,23 +237,7 @@ export function apply(ctx: ClientContext): void {
   const theme = new ThemeService(ctx)
   const theme = new ThemeService(ctx)
   ctx.provide('theme', theme)
   ctx.provide('theme', theme)
 
 
-  ctx.effect(() => {
-    const disposers = [
-      ctx.locale.register(SETTINGS_NS, 'zh', {
-        'appearance.title': '外观',
-        'appearance.light': '浅色',
-        'appearance.dark': '深色',
-        'appearance.system': '跟随系统',
-      }),
-      ctx.locale.register(SETTINGS_NS, 'en', {
-        'appearance.title': 'Appearance',
-        'appearance.light': 'Light',
-        'appearance.dark': 'Dark',
-        'appearance.system': 'System',
-      }),
-    ]
-    return () => { for (const dispose of disposers) dispose() }
-  }, 'ui-theme: settings row dictionaries')
+  ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
 
 
   const store = createAppearanceRowStore()
   const store = createAppearanceRowStore()
   let bound: BoundActions<typeof store> | undefined
   let bound: BoundActions<typeof store> | undefined
@@ -258,7 +251,6 @@ export function apply(ctx: ClientContext): void {
     // first render (the store's revision guard drops stale duplicates).
     // first render (the store's revision guard drops stale duplicates).
     sync(theme.getTheme())
     sync(theme.getTheme())
     return {
     return {
-      t: ctx.locale.bind(SETTINGS_NS),
       setTheme: (id) => { theme.setTheme(id) },
       setTheme: (id) => { theme.setTheme(id) },
     }
     }
   }
   }
@@ -269,6 +261,7 @@ export function apply(ctx: ClientContext): void {
         id: 'appearance',
         id: 'appearance',
         order: 10,
         order: 10,
         store,
         store,
+        locale: SETTINGS_NS,
         inject: injected,
         inject: injected,
       }, AppearanceRow))
       }, AppearanceRow))
     return () => { deferred.dispose() }
     return () => { deferred.dispose() }

+ 20 - 0
packages/client/ui-theme/src/client/locales.ts

@@ -0,0 +1,20 @@
+/** `settings.theme` namespace dictionaries (the Appearance row's copy). */
+
+/** Simplified Chinese dictionary (the key-set source of truth). */
+export const zh = {
+  'appearance.title': '外观',
+  'appearance.light': '浅色',
+  'appearance.dark': '深色',
+  'appearance.system': '跟随系统',
+} satisfies Record<string, string>
+
+/** The settings.theme namespace key union. */
+export type ThemeKey = keyof typeof zh
+
+/** English dictionary, checked complete against the zh key set. */
+export const en = {
+  'appearance.title': 'Appearance',
+  'appearance.light': 'Light',
+  'appearance.dark': 'Dark',
+  'appearance.system': 'System',
+} satisfies Record<ThemeKey, string>

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

@@ -73,7 +73,8 @@ describe('ui-theme apply', () => {
     const { instance, face } = faceOf(b.slots)
     const { instance, face } = faceOf(b.slots)
     // The inject-time re-sync sealed the init window: the mirror is current.
     // The inject-time re-sync sealed the init window: the mirror is current.
     expect(instance.getSnapshot().preference).toBe('dark')
     expect(instance.getSnapshot().preference).toBe('dark')
-    expect(face.t('appearance.dark')).toBe('深色')
+    // Copy rides the standard locale seat: the entry declares the namespace.
+    expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS)
 
 
     face.setTheme('system')
     face.setTheme('system')
     expect(theme.getTheme().preference).toBe('system')
     expect(theme.getTheme().preference).toBe('system')

+ 85 - 2
packages/client/web-react/src/scoped-slots.tsx

@@ -5,8 +5,9 @@
 import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
 import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
 import {
 import {
   SlotOwnershipError, StaleAuthorizationError,
   SlotOwnershipError, StaleAuthorizationError,
-  type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
-  type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
+  type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
+  type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
+  type SlotScope, type StoredEntry, type Translate,
 } from '@deepseek-ai/dsh-client-ui-slots'
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
 import {
   HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
   HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -159,6 +160,74 @@ function cachedSessionMaybeInject(
   return props
   return props
 }
 }
 
 
+/**
+ * Locale `t` seat bindings, cached per (face, namespace, revision). The
+ * revision is part of the cache key ON PURPOSE: a locale switch mints a NEW
+ * function reference per namespace, so `React.memo` components taking `t`
+ * re-render through ordinary shallow comparison — freshness rides identity,
+ * no extra invalidation channel. Within one revision the reference is stable
+ * (memoized children do not churn on unrelated re-renders).
+ */
+const localeSeatCache = new WeakMap<LocaleFace, Map<string, { revision: number; t: Translate }>>()
+
+function localeSeat(face: LocaleFace, ns: string): Translate {
+  let perNs = localeSeatCache.get(face)
+  if (!perNs) {
+    perNs = new Map()
+    localeSeatCache.set(face, perNs)
+  }
+  const revision = face.getSnapshot().revision
+  const cached = perNs.get(ns)
+  if (cached && cached.revision === revision) return cached.t
+  const bound = face.bind(ns)
+  // Fresh wrapper per revision: bind() itself may return a stable reference.
+  const t: Translate = (key, params) => bound(key, params)
+  perNs.set(ns, { revision, t })
+  return t
+}
+
+const noopSubscribe = (): (() => void) => () => {}
+const zeroRevision = (): number => 0
+
+/**
+ * Per-face subscribe/getSnapshot closure pair. Cached by face identity: the
+ * face is one global source shared by every outlet, and uSES resubscribes
+ * whenever the subscribe reference changes — fresh closures per render would
+ * churn one unsubscribe/resubscribe pair per outlet per render.
+ */
+const localeSubscriptionCache = new WeakMap<LocaleFace, {
+  subscribe: (fn: () => void) => () => void
+  getRevision: () => number
+}>()
+
+function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } {
+  let cached = localeSubscriptionCache.get(face)
+  if (!cached) {
+    cached = {
+      subscribe: fn => face.subscribe(fn),
+      getRevision: () => face.getSnapshot().revision,
+    }
+    localeSubscriptionCache.set(face, cached)
+  }
+  return cached
+}
+
+/**
+ * Subscribe an outlet to the installed locale face's revision (0 while none
+ * is installed — exactly one uSES call either way, keeping hook order
+ * stable). Every outlet re-renders on a locale switch; entry bodies then
+ * re-derive their `t` seat at the new revision. The face must be installed
+ * before the first render that needs it — a face appearing later has no
+ * notification channel to already-mounted outlets.
+ */
+function useLocaleRevision(face: LocaleFace | undefined): number {
+  const subscription = face !== undefined ? localeSubscription(face) : undefined
+  return useSyncExternalStore(
+    subscription?.subscribe ?? noopSubscribe,
+    subscription?.getRevision ?? zeroRevision,
+  )
+}
+
 /**
 /**
  * Entry-identity React keys for chain boundaries. A chain outlet renders ONE
  * Entry-identity React keys for chain boundaries. A chain outlet renders ONE
  * elected entry through an error boundary; without a key, a boundary that
  * elected entry through an error boundary; without a key, a boundary that
@@ -242,6 +311,16 @@ function standardKit(
     // reader, bound per provide bundle (cached by info identity).
     // reader, bound per provide bundle (cached by info identity).
     kit['useProjection'] = projectionHook(info)
     kit['useProjection'] = projectionHook(info)
   }
   }
+  if (entry.locale !== undefined) {
+    const face = host.locale
+    // Loud assembly failure: locale is immediately-tier infrastructure; a
+    // declared namespace with no installed face is a miswired composition.
+    if (face === undefined) {
+      throw new SlotAssemblyError(
+        `entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`)
+    }
+    kit['t'] = localeSeat(face, entry.locale)
+  }
   const store = scope === 'session-maybe' && info?.sessionId === undefined
   const store = scope === 'session-maybe' && info?.sessionId === undefined
     ? undefined
     ? undefined
     : host.storeOf(entry, info?.sessionId)
     : host.storeOf(entry, info?.sessionId)
@@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
     fn => host.subscribe(slotKey, fn),
     fn => host.subscribe(slotKey, fn),
     () => host.getVersion(slotKey),
     () => host.getVersion(slotKey),
   )
   )
+  // Locale revision tick: a locale switch re-renders every outlet, and entry
+  // bodies re-derive their `t` seat at the new revision (fresh identity).
+  useLocaleRevision(host.locale)
   const sessionInfo = useSessionMaybeProvideInfo()
   const sessionInfo = useSessionMaybeProvideInfo()
   const spec = host.specOf(slotKey)
   const spec = host.specOf(slotKey)
   // Undeclared (or no-longer-declared) keys render empty: a declaring entry's
   // Undeclared (or no-longer-declared) keys render empty: a declaring entry's
@@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
     fn => host.subscribe('root', fn),
     fn => host.subscribe('root', fn),
     () => host.getVersion('root'),
     () => host.getVersion('root'),
   )
   )
+  useLocaleRevision(host.locale)
   const entry = host.entriesOf('root')[0]
   const entry = host.entriesOf('root')[0]
   if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
   if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
   return (
   return (

+ 9 - 0
pnpm-lock.yaml

@@ -1172,6 +1172,9 @@ importers:
       '@deepseek-ai/dsh-client-connection':
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../connection
         version: link:../connection
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
       '@deepseek-ai/dsh-client-runtime':
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../runtime
         version: link:../runtime
@@ -1366,6 +1369,9 @@ importers:
       '@deepseek-ai/dsh-agent':
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../core/agent
         version: link:../../core/agent
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
       '@deepseek-ai/dsh-invariants':
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../support/invariants
         version: link:../../support/invariants
@@ -1452,6 +1458,9 @@ importers:
         specifier: ^2.0.0
         specifier: ^2.0.0
         version: 2.1.1
         version: 2.1.1
     devDependencies:
     devDependencies:
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
       '@deepseek-ai/dsh-client-runtime':
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../runtime
         version: link:../runtime