From 1e10966ef65fbb5beb2283e36c9cdbc65b13bfb8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:56:41 +0800 Subject: [PATCH] wip fix: docs --- ...07-30-client-locale-full-rollout.i18n.yaml | 6 + .../2026-07-30-client-locale-full-rollout.md | 45 +++++ ...026-07-30-client-locale-full-rollout.zh.md | 45 +++++ ...-25-client-settings-locale-theme.i18n.yaml | 6 +- ...2026-07-25-client-settings-locale-theme.md | 2 +- ...6-07-25-client-settings-locale-theme.zh.md | 2 +- apps/web/tests/built-boot.snapshot.ts | 3 + .../tests/details-session-lifecycle.e2e.ts | 8 +- apps/web/tests/message-actions.e2e.ts | 10 +- apps/web/tests/navigation-panes.e2e.ts | 4 +- apps/web/tests/queue-actions.e2e.ts | 14 +- apps/web/tests/seeded-history.e2e.ts | 4 +- .../snapshots/code-mode-round/ui.expected.md | 10 +- .../cordis-tool-round/ui.expected.md | 12 +- .../snapshots/fresh-round-trip/ui.expected.md | 14 +- .../lifecycle-chrome/hero.expected.md | 6 +- .../lifecycle-chrome/reloaded.expected.md | 10 +- .../live-interactions/cancel.expected.md | 12 +- .../live-interactions/error-auth.expected.md | 6 +- .../live-interactions/retry.expected.md | 10 +- .../snapshots/message-actions/ui.expected.md | 16 +- .../terminal-card.expected.md | 4 +- .../plan-review/approved.expected.md | 10 +- .../question-composer/answered.expected.md | 10 +- .../queue-actions/collapsed.expected.md | 8 +- .../queue-actions/editing.expected.md | 18 +- .../snapshots/queue-actions/ui.expected.md | 10 +- .../snapshots/seeded-history/ui.expected.md | 18 +- .../snapshots/steering/mid-steer.expected.md | 6 +- .../snapshots/steering/settled.expected.md | 12 +- apps/web/tests/steering.e2e.ts | 6 +- docs/module-graph.md | 54 +++--- packages/client/test-runtime/src/index.ts | 1 + packages/client/test-runtime/src/translate.ts | 32 ++++ packages/client/ui-command/package.json | 4 + .../ui-command/src/client/PopupSelectView.tsx | 24 +-- .../client/ui-command/src/client/index.ts | 22 ++- .../client/ui-command/src/client/locales.ts | 26 +++ .../ui-command/tests/browser-plugin.spec.ts | 4 +- .../ui-command/tests/popup-view.spec.tsx | 28 +-- packages/client/ui-command/tsconfig.json | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 55 +++--- .../src/client/chat/AssistantMarkdown.tsx | 33 +++- .../src/client/chat/ChatView.tsx | 50 ++++-- .../src/client/chat/ContextInjectionRow.tsx | 11 +- .../src/client/chat/GenericCommandCard.tsx | 16 +- .../src/client/chat/GenericToolCard.tsx | 10 +- .../src/client/chat/MessageIconActions.tsx | 19 +- .../src/client/chat/MessageItem.tsx | 17 +- .../src/client/chat/ToolRow.tsx | 17 +- .../src/client/chat/message-chrome.ts | 21 ++- .../src/client/contract/slots.ts | 32 ++-- .../client/contract/terminal-card-model.ts | 28 ++- .../ui-conversation/src/client/index.ts | 1 + .../ui-conversation/src/client/locales.ts | 170 ++++++++++++++++++ .../src/client/queue/QueueDock.tsx | 34 ++-- .../src/client/skeleton/ApprovalPanel.tsx | 18 +- .../src/client/skeleton/ConversationRoot.tsx | 9 +- .../client/skeleton/ConversationSession.tsx | 4 +- .../src/client/skeleton/DetailsPanel.tsx | 27 +-- .../src/client/skeleton/EmptyHero.tsx | 17 +- .../src/client/skeleton/InputBar.tsx | 24 +-- .../src/client/skeleton/PermissionSelect.tsx | 7 +- .../src/client/skeleton/TodoPanel.tsx | 31 ++-- .../src/client/toolviews/ask-question-row.tsx | 26 +-- .../src/client/toolviews/bash-sample.tsx | 28 ++- .../src/client/toolviews/todo-row.tsx | 22 ++- .../tests/apply-inject.spec.tsx | 6 +- .../tests/ask-question-row.spec.tsx | 32 ++-- .../tests/assembly-surfaces.spec.tsx | 18 +- .../ui-conversation/tests/chat-apply.spec.tsx | 8 +- .../tests/chat-branch-tails.spec.tsx | 36 ++-- .../tests/chat-code-subcalls.spec.tsx | 4 +- .../tests/chat-stats-bash-sample.spec.tsx | 14 +- .../tests/chat-tool-row.spec.tsx | 15 +- .../tests/chat-toolview-slot.spec.tsx | 8 +- .../ui-conversation/tests/chat-view.spec.tsx | 5 + .../tests/coverage-tails.spec.tsx | 26 ++- .../tests/gate-branch-tails.spec.tsx | 13 +- .../ui-conversation/tests/input-bar.spec.tsx | 56 +++--- .../tests/input-matrix.spec.tsx | 11 +- .../tests/input-scenarios.spec.tsx | 9 +- .../ui-conversation/tests/queue-dock.spec.tsx | 9 +- .../ui-conversation/tests/skeleton.spec.tsx | 20 ++- .../tests/terminal-card.spec.tsx | 31 ++-- .../ui-conversation/tests/todo-panel.spec.tsx | 40 +++-- packages/client/ui-goal/package.json | 4 + .../client/ui-goal/src/client/GoalBar.tsx | 43 ++--- packages/client/ui-goal/src/client/index.ts | 21 ++- packages/client/ui-goal/src/client/locales.ts | 32 ++++ .../ui-goal/tests/browser-plugin.spec.tsx | 14 +- .../client/ui-goal/tests/goalbar.spec.tsx | 118 ++++++------ packages/client/ui-goal/tsconfig.json | 3 + packages/client/ui-models/src/client/index.ts | 34 ++-- .../client/ui-models/src/client/locales.ts | 5 +- packages/client/ui-models/tests/apply.spec.ts | 15 +- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- packages/client/ui-plan/package.json | 4 + .../ui-plan/src/client/PlanModeControl.tsx | 18 +- packages/client/ui-plan/src/client/index.ts | 30 +++- packages/client/ui-plan/src/client/locales.ts | 20 +++ .../ui-plan/tests/browser-plugin.spec.ts | 9 +- .../ui-plan/tests/plan-mode-control.spec.tsx | 16 +- packages/client/ui-plan/tsconfig.json | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/ConnectionBanner.tsx | 9 +- .../client/ui-primitives/src/JsonTree.tsx | 94 ++++++++-- packages/client/ui-primitives/src/Modal.tsx | 2 + .../ui-primitives/src/TerminalBlock.tsx | 86 +++++++-- packages/client/ui-primitives/src/index.ts | 6 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 8 +- .../ui-primitives/src/markdown/JsonBlock.tsx | 13 +- .../src/markdown/MarkdownText.tsx | 41 ++++- .../src/client/GeneralSection.tsx | 12 +- .../ui-settings-general/src/client/chrome.tsx | 20 +-- .../ui-settings-general/src/client/index.ts | 54 +++--- .../ui-settings-general/src/client/locales.ts | 19 +- .../ui-settings-general/tests/apply.spec.ts | 40 ++--- .../tests/components.spec.tsx | 4 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- packages/client/ui-settings/package.json | 1 + .../client/ui-settings/src/client/index.ts | 25 ++- packages/client/ui-settings/tsconfig.json | 3 + .../sidebar-snapshot.spec.tsx.snap | 70 ++++++++ .../tests/sidebar-snapshot.spec.tsx | 40 ++++- packages/client/ui-slash/package.json | 1 + .../client/ui-slash/src/client/MenuView.tsx | 16 +- packages/client/ui-slash/src/client/index.ts | 22 ++- .../client/ui-slash/src/client/locales.ts | 26 +++ packages/client/ui-slash/src/client/slots.ts | 9 +- packages/client/ui-slash/tests/apply.spec.ts | 4 +- .../client/ui-slash/tests/menu-view.spec.tsx | 15 +- packages/client/ui-slots/src/index.ts | 24 ++- .../ui-trajectory/tests/client-bundle.spec.ts | 9 +- .../client/ui-trajectory/tests/views.spec.tsx | 11 +- packages/client/ui-workspace/package.json | 4 + .../src/client/WorkspaceBrowser.tsx | 91 ++++++---- .../src/client/WorkspacePicker.tsx | 37 ++-- .../ui-workspace/src/client/contract/slots.ts | 12 +- .../client/ui-workspace/src/client/index.ts | 20 ++- .../client/ui-workspace/src/client/locales.ts | 118 ++++++++++++ .../ui-workspace/src/client/rows/Rows.tsx | 98 ++++++---- .../client/ui-workspace/src/client/tree.ts | 45 +++-- packages/client/ui-workspace/src/invariant.ts | 8 +- .../client/ui-workspace/tests/apply.spec.ts | 11 +- .../tests/rename-assembly.spec.tsx | 34 ++-- .../client/ui-workspace/tests/rows.spec.tsx | 88 ++++----- .../client/ui-workspace/tests/tree.spec.ts | 48 +++-- .../tests/workspace-browser.spec.tsx | 168 +++++++++-------- .../tests/workspace-picker.spec.tsx | 106 ++++++----- pnpm-lock.yaml | 30 ++++ 160 files changed, 2521 insertions(+), 1135 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md create mode 100644 packages/client/test-runtime/src/translate.ts create mode 100644 packages/client/ui-command/src/client/locales.ts create mode 100644 packages/client/ui-conversation/src/client/locales.ts create mode 100644 packages/client/ui-goal/src/client/locales.ts create mode 100644 packages/client/ui-plan/src/client/locales.ts create mode 100644 packages/client/ui-slash/src/client/locales.ts create mode 100644 packages/client/ui-workspace/src/client/locales.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml new file mode 100644 index 0000000000..a56a91c980 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425 +2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md new file mode 100644 index 0000000000..c080d9f240 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -0,0 +1,45 @@ +# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary + +Status: implemented + +English | [中文](2026-07-30-client-locale-full-rollout.zh.md) + +## Problem + +After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization. + +## Decision + +**Registration-time text rides a label thunk.** A list registration's `label` accepts `SlotLabel = string | (() => string)`; owners projecting ledger rows resolve through `resolveSlotLabel` (never reading `options.label` raw) and make the read point follow the locale revision (outlets subscribe to the revision themselves; off-ledger projections such as the ui-settings nav fold the revision into their cache key and subscribe to both sources). Thunks evaluate per read, so a language switch causes zero ledger churn — no re-registration, versions stay put, and every `locale/change` re-registration wiring is deleted. + +**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. + +**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). + +**The non-translation boundary (deliberate decisions, not debt):** + +- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim. +- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages. +- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately). +- **Boot copy stays hardcoded** (AppRoot renders before the locale service exists). + +**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. + +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario deliberately bypasses the helper to cover the zh default. + +The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). + +## Alternatives considered + +- **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision. +- **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently. +- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text. +- **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock. +- **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic. + +## Consequences + +- A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. +- Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. +- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. +- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md new file mode 100644 index 0000000000..062d982e3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -0,0 +1,45 @@ +# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界 + +Status: implemented + +[English](2026-07-30-client-locale-full-rollout.md) | 中文 + +## Problem + +typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使后来者"补完"翻译。 + +## Decision + +**注册期文本走 label thunk。** ui-slots 的 list 注册项 `label` 接受 `SlotLabel = string | (() => string)`;owner 投影 ledger 行时必须经 `resolveSlotLabel` 解析(不裸读 `options.label`),并让读取点跟随 locale revision(outlet 自身订阅 revision;ledger 外的投影如 ui-settings 导航把 revision 并进缓存键、订阅双源)。thunk 每次读取时求值,语言切换零 ledger churn——没有重注册、version 不动,`locale/change` 重注册接线全部删除。 + +**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 + +**zero-cordis 原子组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 + +**不翻译边界(刻意决定,不是欠账):** + +- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError message、wire 透出的 `error.message (code)` 原样呈现。 +- **设计字面量不进字典**:tool 行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、StatsLine 全部指标——中英界面显示一致。 +- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。 +- **boot 文案保持硬编码**(AppRoot 渲染早于 locale 服务可用)。 + +**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 + +**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`(boot 前钉 `dsh.locale=en`),built-boot snapshot 同样钉 en——golden 对语言迁移免疫;settings 语言切换用例刻意绕开该 helper 覆盖 zh 默认态。 + +[settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` 重注册刷新 label"的机制已被本决定取代(thunk + revision 生命周期)。 + +## Alternatives considered + +- **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 每包一次注册已很重,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。 +- **给 ui-primitives 造 locale context/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费者(ui-trajectory)陪跑。props 化让每个消费者独立决定。 +- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。 +- **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。 +- **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下"看得见搜不到";占位行本无信息量,整体排除语义最稳。 + +## Consequences + +- 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 +- 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 拿到函数);类型上 `SlotLabel` 已挡住多数误用。 +- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费点传 label**——未迁移包(ui-trajectory 的 JsonTree)显示英文默认恰好符合其整包英文现状。 +- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index ad6c575d1e..96dc47f9f7 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-client-settings-locale-theme.md: 87077b3fd3f0bd8a3375a71aebf947cbd9961799 -2026-07-25-client-settings-locale-theme.zh.md: a64a4afdf6565a527a25136694aa79305eeabb3c +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +2026-07-25-client-settings-locale-theme.md: c86d6ac053f7bb87ce758613a5f3a0a34951e428 +2026-07-25-client-settings-locale-theme.zh.md: 05edbb3c550828832a390e3cf4fad3262b5be196 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 87077b3fd3..c86d6ac053 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -55,7 +55,7 @@ root └─ models (order 10) ui-models 注册 ``` -Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. +Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, one-call disposal; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md), not `refresh()`) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. ### Future work: promote slot declarations to first-class injectable waits diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index a64a4afdf6..05edbb3c55 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -55,7 +55,7 @@ root └─ models (order 10) ui-models 注册 ``` -section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 +section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、一键 dispose;本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk,不再 `refresh()`),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 ### Future work:坑位声明升格为可 inject 的一等等待物 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..0bac8a2eb8 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -60,6 +60,9 @@ let unmount: (() => void) | undefined beforeEach(() => { localStorage.clear() + // English pinned before boot: role/text locators stay deterministic across + // localized component migrations (the newEnglishPage e2e convention). + localStorage.setItem('dsh.locale', 'en') document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 8105519338..ce97cfb2a1 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE) const sidebarBefore = await sidebarTrack(page) @@ -118,18 +118,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await appFrame(page).waitFor({ timeout: 30_000 }) await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() await original.click() await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) const ungrouped = page.getByText('Ungrouped', { exact: true }) const ungroupedRow = ungrouped.locator('..').locator('..') diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 3af8c14b58..4d798e11ed 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -66,12 +66,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => { // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). User has three actions; each turn's last content // assistant has copy + branch. - const copyButtons = page.getByRole('button', { name: '复制' }) + const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) await copyButtons.first().focus() - await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 }) + await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) .toBeGreaterThanOrEqual(2) - await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1) + await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(1) }, 60_000) it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { @@ -81,7 +81,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { }).waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. - await page.getByRole('button', { name: '复制' }).first().focus() + await page.getByRole('button', { name: 'Copy' }).first().focus() const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) @@ -91,7 +91,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) // Exercise the assistant action specifically; package coverage pins the // user action separately at its own event seq. - await page.getByRole('button', { name: '在新对话中分支' }).last().click() + await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), { timeout: 15_000 }, diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index e88cfa8857..12a2c362ab 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -253,7 +253,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } }) expect(dot.state).toBe('done') - expect(dot.label).toBe('已完成') + expect(dot.label).toBe('Done') expect(dot.beforePrompt).toBe(true) expect(dot.insideCard).toBe(true) expect(dot.leftOfPrompt).toBe(true) @@ -270,7 +270,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await card.locator('[class*="_copyButton_"]').first().click() await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 }) - .toBe('复制成功') + .toBe('Copied') expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK') }, 60_000) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 1c2ca271aa..42e44c14ca 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -81,7 +81,7 @@ describe('web e2e: queue row actions', () => { await input.fill(text) await input.press('Enter') } - const queueHeader = page.getByRole('button', { name: '2 条排队消息' }) + const queueHeader = page.getByRole('button', { name: '2 queued messages' }) await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 }) .toBe('false') const collapsedSnapshot = await captureStableAria( @@ -92,21 +92,21 @@ describe('web e2e: queue row actions', () => { await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE) await queueHeader.click() await expect.poll( - () => page.getByRole('button', { name: '删除排队消息' }).count(), + () => page.getByRole('button', { name: 'Remove queued message' }).count(), { timeout: 10_000 }, ).toBe(2) const editRow = page.getByText(EDIT, { exact: true }).locator('..') - await editRow.getByRole('button', { name: '编辑排队消息' }).click() - const editor = page.getByRole('textbox', { name: '编辑排队消息' }) + await editRow.getByRole('button', { name: 'Edit queued message' }).click() + const editor = page.getByRole('textbox', { name: 'Edit queued message' }) await editor.fill(EDITED) const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) - await page.getByRole('button', { name: '保存排队消息' }).click() + await page.getByRole('button', { name: 'Save queued message' }).click() await page.getByText(EDITED, { exact: true }).waitFor() const removeRow = page.getByText(REMOVE, { exact: true }).locator('..') - await removeRow.getByRole('button', { name: '删除排队消息' }).click() + await removeRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) @@ -116,7 +116,7 @@ describe('web e2e: queue row actions', () => { expect(tripwire.warnings).toEqual([]) const editedRow = page.getByText(EDITED, { exact: true }).locator('..') - await editedRow.getByRole('button', { name: '删除排队消息' }).click() + await editedRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0) await page.getByRole('button', { name: 'Stop generating' }).click() await settled diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 4d7825963a..3437c41e18 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -147,7 +147,7 @@ describe('web e2e: seeded history renders through cold resume', () => { }], }, })) - await page.getByRole('button', { name: '上下文注入' }).waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 }) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -165,7 +165,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: '上下文注入' }) + const disclosure = page.getByRole('button', { name: 'Context injection' }) expect(await disclosure.getAttribute('aria-expanded')).toBe('false') const collapsedIcon = disclosure.locator('svg').first() const collapsedIconBox = await collapsedIcon.boundingBox() diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..afc722db14 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - 'button "Think The user wants me to write a single `run_code` program that:"': - img @@ -27,9 +27,9 @@ - img - text: Think The program ran successfully. Let me now reply DONE as instructed. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..297915e52f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to:": - img @@ -26,7 +26,7 @@ - button [expanded]: - img - text: Mount temporary Plugin typescript -- button "复制" +- button "Copy" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': - img @@ -41,9 +41,9 @@ - img - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. - paragraph: CORDIS_UI_DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..089a9e8efe 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -5,28 +5,28 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". - img -- text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK -- button "复制" +- text: Bash Echo the test string Done workspace echo WEB_E2E_OK +- button "Copy" - text: WEB_E2E_OK - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - img - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 65abda0dba..70424a4c8c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -34,6 +34,6 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 详情 -- button "关闭详情" -- text: 点击消息流中的工具行查看详情 +- text: Details +- button "Close details" +- text: Click a tool row in the message flow to view its details diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 45e3514fa4..52e43a54f7 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -5,20 +5,20 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to reply with a single word. Let me comply.": - img - img - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..eab492b96e 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -5,17 +5,17 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial -- text: 已停止 -- button "复制": +- text: Stopped +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1d78e91c73..b214ad80d5 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - textbox "Message the agent" - button "Add attachment": diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..572d77b22e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -5,20 +5,20 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..098fa20807 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -4,13 +4,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" -- button "复制": +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" +- button "Copy": - img -- tooltip "复制" -- button "在新对话中分支": +- tooltip "Copy" +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img @@ -27,11 +27,11 @@ - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- text: {{clock}} +- text: 7/25 {{clock}} - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md index 298bf764b6..464e48628e 100644 --- a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md @@ -1,3 +1,3 @@ -- text: 已完成 {{workspace}} echo NAVIGATION_OK -- button "复制" +- text: Done {{workspace}} echo NAVIGATION_OK +- button "Copy" - text: NAVIGATION_OK diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index dd62e265ee..0aa340fa77 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -6,11 +6,11 @@ - tab "Trajectory" - img - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."': - img @@ -29,9 +29,9 @@ - img - text: "Think The plan was approved. The user's last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop." - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..20603ed2f2 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img @@ -24,9 +24,9 @@ - img - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdbf6fc64b..0b92df9a16 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -5,14 +5,14 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial -- button "2 条排队消息" +- button "2 queued messages" - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index cf287b5006..5ced1f6f4e 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -5,26 +5,26 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial -- button "2 条排队消息" [disabled] [expanded] +- button "2 queued messages" [disabled] [expanded] - list: - listitem: - text: Queue item to remove - - button "编辑排队消息": + - button "Edit queued message": - img - - button "删除排队消息": + - button "Remove queued message": - img - listitem: - - textbox "编辑排队消息": Edited queue item - - button "保存排队消息": + - textbox "Edit queued message": Edited queue item + - button "Save queued message": - img - - button "取消编辑": + - button "Cancel editing": - img - textbox "Message the agent" - button "Add attachment": diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 919617bdab..343ecc0fe2 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -5,19 +5,19 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - paragraph: partial - list: - listitem: - text: Edited queue item - - button "编辑排队消息": + - button "Edit queued message": - img - - button "删除排队消息": + - button "Remove queued message": - img - textbox "Message the agent" - button "Add attachment": diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..3204da2d59 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -4,12 +4,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" -- button "复制": +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img @@ -26,15 +26,15 @@ - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- text: {{clock}} -- button "上下文注入": +- text: 7/25 {{clock}} +- button "Context injection": - img - img - - text: 上下文注入 + - text: Context injection - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 28127fd73b..9cac976f9e 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..aa51d2c489 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -5,11 +5,11 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img -- button "编辑": +- button "Edit": - img - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img @@ -18,15 +18,15 @@ - button: - img - img -- text: "Ask question 1/1 answered 插话 Interjection: include the word BANANA in your final reply." +- text: "Ask question 1/1 answered Interjection Interjection: include the word BANANA in your final reply." - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer. - paragraph: Great, let's move forward. BANANA! -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index b36e001dd1..3cbae0b9ee 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -121,9 +121,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // exists yet and no interjection bubble renders — the composer still // blocks, alone. The DOM is stable here (no further SSE frames can // arrive until the question is answered), making this state capturable. - expect(await page.getByText('插话').count()).toBe(0) + expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0) expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) - expect(await page.getByRole('button', { name: '编辑排队消息' }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) } @@ -157,7 +157,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // Visible: the badged interjection bubble plus the reply that obeys it // (steer text + final reply each contain the marker word). - await expect.poll(() => page.getByText('插话').count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Interjection', { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.locator('[data-question-key]').count()).toBe(0) diff --git a/docs/module-graph.md b/docs/module-graph.md index 8e8b3130ab..2949cb778d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -316,10 +316,6 @@ flowchart TD pkg_client_ui_settings --> pkg_invariants pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_helper --> pkg_brand @@ -389,20 +385,15 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -479,10 +470,16 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -560,6 +557,7 @@ flowchart TD pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime pkg_client_ui_command --> pkg_client_ui_conversation pkg_client_ui_command --> pkg_client_ui_primitives @@ -573,6 +571,10 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -651,6 +653,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_user_approval pkg_client_ui_goal --> pkg_client_connection + pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -925,6 +928,7 @@ flowchart TD pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_runtime pkg_client_ui_plan --> pkg_client_ui_conversation pkg_client_ui_plan --> pkg_client_ui_slots @@ -1056,7 +1060,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-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-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) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | @@ -1077,9 +1080,8 @@ flowchart TD | [`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-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-workspace`](../packages/client/ui-workspace) | `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) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`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-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | @@ -1102,7 +1104,8 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`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-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -1122,9 +1125,10 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-command`](../packages/client/ui-command) | `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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`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) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1141,7 +1145,7 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`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) | -| [`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-locale`](../packages/client/locale), [`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-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) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -1183,7 +1187,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 987039b385..7100e1595e 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -37,6 +37,7 @@ export { FixtureSession, TestSessions } from './sessions.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' +export { makeTranslate } from './translate.ts' /** Erased register face for the internal root call (the public declare seam holds the typing). */ type ErasedRegister = (options: object, component: unknown) => () => void diff --git a/packages/client/test-runtime/src/translate.ts b/packages/client/test-runtime/src/translate.ts new file mode 100644 index 0000000000..65c06d5cee --- /dev/null +++ b/packages/client/test-runtime/src/translate.ts @@ -0,0 +1,32 @@ +/** + * Test double of the locale lookup chain: a translate stub over plain + * dictionaries, mirroring LocaleService's resolution order (first dictionary + * that owns the key wins, then the key itself stays visible) and its + * `{name}` template interpolation. Specs stub the framework-injected `t` + * seat with `makeTranslate(zh, commonZh)` instead of re-implementing the + * chain per suite. + */ + +/** + * Build a translate stub resolving through `dicts` in order (namespace + * first, then the shared common vocabulary), falling back to the key. + * @param dicts - dictionaries consulted in order. + * @returns the translate function (assignable to any `XxxProps['t']` seat). + */ +export function makeTranslate( + ...dicts: readonly Record[] +): (key: string, params?: Record) => string { + return (key, params) => { + let template = key + for (const dict of dicts) { + const hit = dict[key] + if (hit !== undefined) { + template = hit + break + } + } + if (!params) return template + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } +} diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index c34f34dc18..8144aea6c8 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -25,6 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slash", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -40,6 +41,7 @@ }, "peerDependencies": { "@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-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -51,7 +53,9 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index ec0bbdd2bb..7fe5edcb86 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -13,6 +13,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' @@ -26,12 +27,15 @@ export interface PopupSelectInjected { popup: PopupSelectController } +/** Full shell props: injected face + the locale seat. */ +export type PopupSelectViewProps = PopupSelectInjected & PropsLocale<'command'> + /** * Render the popupSelect shell overlay entry. - * @param props - injected face: the session's shell controller. + * @param props - injected face: the session's shell controller; `t` rides the standard locale seat. * @returns the select card while open; null while closed. */ -export function PopupSelectView({ popup }: PopupSelectInjected) { +export function PopupSelectView({ popup, t }: PopupSelectViewProps) { const state = useSyncExternalStore( fn => popup.state.subscribe(fn), () => popup.state.getSnapshot(), @@ -103,15 +107,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { ref={cardRef} className={css.card} style={{ maxHeight }} - aria-label={`/${String(state.command)} options`} + aria-label={t('overlay.aria', { command: String(state.command) })} onKeyDown={onKeyDown} > { popup.setSearch(ev.currentTarget.value) }} @@ -120,15 +124,15 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
{state.error} {state.status === 'failed' && ( - + )}
)} - {state.status === 'pending' &&
Loading options…
} - {state.submitting &&
Applying…
} - {state.status === 'ready' && rows.length === 0 &&
No options
} + {state.status === 'pending' &&
{t('status.loading')}
} + {state.submitting &&
{t('status.applying')}
} + {state.status === 'ready' && rows.length === 0 &&
{t('status.empty')}
} {state.status === 'ready' && ( -
+
{rows.map((option, index) => (
ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries') ctx.plugin(CommandService) // Conditional mount, same seam as ui-slash's MenuView registration: // 'conversation.input.overlay' is declared by the conversation composer @@ -51,6 +66,7 @@ export function apply(ctx: ClientContext): void { name: 'conversation.input.overlay', id: 'command-popup', order: 1, + locale: NS, inject: (sessionId): PopupSelectInjected => { const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`) diff --git a/packages/client/ui-command/src/client/locales.ts b/packages/client/ui-command/src/client/locales.ts new file mode 100644 index 0000000000..63c5862cf2 --- /dev/null +++ b/packages/client/ui-command/src/client/locales.ts @@ -0,0 +1,26 @@ +/** `command` namespace dictionaries (the popupSelect shell's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'search.placeholder': '搜索…', + 'search.aria': '筛选选项', + 'status.loading': '正在加载选项…', + 'status.applying': '正在应用…', + 'status.empty': '无选项', + 'overlay.aria': '/{command} 选项', + 'listbox.aria': '/{command} 匹配项', +} satisfies Record + +/** The command namespace key union. */ +export type CommandKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'search.placeholder': 'Search…', + 'search.aria': 'Filter options', + 'status.loading': 'Loading options…', + 'status.applying': 'Applying…', + 'status.empty': 'No options', + 'overlay.aria': '/{command} options', + 'listbox.aria': '/{command} matches', +} satisfies Record diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index 03c0df2d50..bb49cdf4d1 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -13,6 +13,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandServiceContract } from '../src/client/contract.ts' import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, CommandService, inject } from '../src/client/index.ts' const sid = (k: string): SessionId => k as SessionId @@ -41,6 +42,7 @@ async function bench() { }, }) ctx.provide('conversation', {}) + ctx.provide('locale', new LocaleService(ctx)) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const mint = (key: string) => { @@ -53,7 +55,7 @@ async function bench() { describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'connection']) + expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale']) }) it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 2cd9891478..d8fadaae7a 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -14,6 +14,12 @@ import type { SelectOption } from '../src/client/contract.ts' import type { PopupSpec, TokenSegment } from '../src/client/popup.ts' import { PopupSelectController } from '../src/client/popup.ts' import { PopupSelectView } from '../src/client/PopupSelectView.tsx' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locales.ts' + +// The framework-injected t seat, stubbed over the zh dictionaries (the default locale). +const t: Parameters[0]['t'] = makeTranslate(zh, commonZh) // jsdom has no scrollIntoView; the view calls it on the highlighted row. const scrollIntoView = vi.fn() @@ -47,12 +53,12 @@ async function mountOpen(overrides: Partial> = {}, consumeResu const consume = vi.fn((_segment: TokenSegment) => consumeResult) const focusComposer = vi.fn() const popup = new PopupSelectController({ consume, focusComposer }) - const view = render() + const view = render() await act(async () => { popup.open('theme', spec(overrides), 'ctx-A', SEGMENT) await Promise.resolve() }) - return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) } + return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) } } function rowLabels(): string[] { @@ -62,13 +68,13 @@ function rowLabels(): string[] { describe('PopupSelectView', () => { it('renders null while closed, opens with focus in the search input', async () => { const popup = new PopupSelectController({ consume: () => true, focusComposer: () => {} }) - const view = render() + const view = render() expect(view.container.childElementCount).toBe(0) await act(async () => { popup.open('theme', spec(), 'ctx-A', SEGMENT) await Promise.resolve() }) - const search = screen.getByRole('textbox', { name: 'Filter options' }) + const search = screen.getByRole('textbox', { name: '筛选选项' }) expect(document.activeElement).toBe(search) expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia']) }) @@ -82,7 +88,7 @@ describe('PopupSelectView', () => { expect(options).toHaveBeenCalledTimes(1) act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) }) expect(screen.queryByRole('option')).toBeNull() - expect(screen.queryByText('No options')).not.toBeNull() + expect(screen.queryByText('无选项')).not.toBeNull() }) it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => { @@ -110,13 +116,13 @@ describe('PopupSelectView', () => { it('caps the card height at the design maximum when the composer sits low enough', async () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect) await mountOpen() - expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px') + expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('320px') }) it('clamps the card height to the space above the composer minus the safe margin', async () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect) await mountOpen() - expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px') + expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px') }) it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => { @@ -148,7 +154,7 @@ describe('PopupSelectView', () => { const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) const { search, consume } = await mountOpen({ onSelect }) await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) }) - expect(screen.queryByText('Applying…')).not.toBeNull() + expect(screen.queryByText('正在应用…')).not.toBeNull() expect((search as HTMLInputElement).readOnly).toBe(true) await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) @@ -162,7 +168,7 @@ describe('PopupSelectView', () => { expect(consume).toHaveBeenCalledTimes(1) }) - it('a failed options load shows the error with a Retry button that reloads', async () => { + it('a failed options load shows the error with a retry button that reloads', async () => { let attempts = 0 await mountOpen({ options: () => { @@ -172,7 +178,7 @@ describe('PopupSelectView', () => { }) expect(screen.getByRole('alert').textContent).toContain('directory down') await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + fireEvent.click(screen.getByRole('button', { name: '重试' })) await Promise.resolve() }) expect(attempts).toBe(2) @@ -183,7 +189,7 @@ describe('PopupSelectView', () => { const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) }) await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) }) expect(screen.getByRole('alert').textContent).toContain('host rejected') - expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + expect(screen.queryByRole('button', { name: '重试' })).toBeNull() expect(consume).not.toHaveBeenCalled() expect(screen.getAllByRole('option').length).toBe(3) }) diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json index b95692eda1..f83486aa36 100644 --- a/packages/client/ui-command/tsconfig.json +++ b/packages/client/ui-command/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../connection" }, + { + "path": "../locale" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c124521092..cc9f5a575b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: bfb56dc52406e1377cd84c87866a644ade10293a -README.zh.md: e09bdcc4bebd8176a3061b221e07ae73a7a8941c +README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe +README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bfb56dc524..b4b1e56537 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -24,7 +24,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e09bdcc4be..74e0f3dc0e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -24,7 +24,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9c2008f092..da8c9b1910 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,6 +1,6 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -28,6 +28,14 @@ import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' +import { en, NS, zh, type ConversationKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */ + conversation: ConversationKey + } +} /** Services required by the conversation plugin. */ export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] @@ -68,32 +76,12 @@ export function apply(ctx: Context): void { const layout = ctx.layout const slots = ctx.slots - // Command hint locale: friendly placeholder text for claimed commands. The - // claimed /plan hint and the plan-mode textarea placeholder share one - // string: both describe the same next action. - const HINT_NS = 'command.hint' - const PLAN_HINT_ZH = '描述你的任务以生成计划' - const PLAN_HINT_EN = 'describe your task to generate plan' - ctx.effect(() => { - const disposers = [ - ctx.locale.register(HINT_NS, 'zh', { - plan: PLAN_HINT_ZH, - goal: '输入目标,智能体将持续执行', - 'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', - 'placeholder.plan': PLAN_HINT_ZH, - 'placeholder.default': '给智能体发消息', - }), - ctx.locale.register(HINT_NS, 'en', { - plan: PLAN_HINT_EN, - goal: 'describe the objective for a long-running task', - 'goal.active': 'goal active — edit / pause / resume / clear', - 'placeholder.plan': PLAN_HINT_EN, - 'placeholder.default': 'Message the agent', - }), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-conversation: command hint dictionaries') - const translateHint = ctx.locale.bind(HINT_NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') + + // Registration-time text (the view tab label) reads through the bound + // translate as a thunk, so it follows the active locale without + // re-registration; components read the standard `t` seat instead. + const t = ctx.locale.bind(NS) // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() @@ -103,7 +91,7 @@ export function apply(ctx: Context): void { for (const entry of slots.entries('conversation.view')) { /* v8 ignore next -- unreachable: list registration validates id at load. */ if (entry.options.id === undefined) continue - tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id }) + tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id }) } return tabs } @@ -132,6 +120,7 @@ export function apply(ctx: Context): void { // frame while strict session slots fill only their session-bound regions. slots.register({ name: 'conversation', + locale: NS, children: { 'conversation.session': { kind: 'single', scope: 'session' }, 'conversation.composer': { kind: 'chain', scope: 'session' }, @@ -163,6 +152,7 @@ export function apply(ctx: Context): void { // the resident parent keeps Hero and composer layout identity stable. slots.register({ name: 'conversation.session', + locale: NS, children: { 'conversation.view': { kind: 'list', scope: 'session' } }, store: chatStore, inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ @@ -185,6 +175,7 @@ export function apply(ctx: Context): void { // observableHook caching and hook order stay stable across transitions). slots.register({ name: 'conversation.composer.bar', + locale: NS, // The two named control seats in the bar's tool row (plan beside the // access control, model right); empty until their owning plugins // register (B ruling). @@ -198,7 +189,6 @@ export function apply(ctx: Context): void { keyboard: undefined, stop: undefined, command: undefined, - translateHint, hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, } } @@ -216,7 +206,6 @@ export function apply(ctx: Context): void { const result = await session.command(line) return result.ok && result.value.matched }, - translateHint, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, @@ -230,7 +219,7 @@ export function apply(ctx: Context): void { // pending — a question is a conversation the model is waiting on, while an // approval only blocks one tool call; answering the question first cannot // strand the approval (it re-elects the moment the question resolves). - slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel) + slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel) // The chat view: first entry of the ring this package just declared. // Declaring the keyed toolview hole here is claiming it: ChatView is the @@ -241,7 +230,8 @@ export function apply(ctx: Context): void { name: 'conversation.view', id: 'chat', order: 0, - label: 'Chat', + label: () => t('view.chat'), + locale: NS, children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, @@ -303,6 +293,7 @@ export function apply(ctx: Context): void { slots.register({ name: 'details', + locale: NS, store: chatStore, inject: (): DetailsInjected => ({ closeDetails: () => { layout.closeDetails() }, diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 34243fddd5..b328404518 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -8,11 +8,12 @@ // ends (`time` is omitted for mid-turn narration); Think / tool-head-only // nodes stay chrome-free. -import { memo } from 'react' +import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -20,7 +21,7 @@ import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean - /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */ + /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined /** Unix epoch ms for the IconActions clock; omitted while streaming or when * the parent withholds chrome (mid-turn content assistants). */ @@ -29,6 +30,8 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through the turn containing this finalized message. */ onFork?: ((seq: number) => void) | undefined + /** The owning view's locale seat, passed down as a plain prop. */ + t: ChatViewSlotProps['t'] } function firstLine(text: string): string { @@ -51,9 +54,10 @@ function hasContentText(blocks: readonly AssistantBlock[]): boolean { } /** Reasoning block as the Think variant summary row (figma 39:28304). */ -function ThinkRow({ text, running }: { text: string; running: boolean }) { +function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) { return ( } title="Think" @@ -66,8 +70,11 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, + blocks, streaming, interrupted, time, seq, onFork, t, }: AssistantMarkdownProps) { + // Stable per locale revision (t identity changes on switch): a fresh object + // per render would rebuild MarkdownText's component table every chunk. + const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -83,14 +90,23 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
{blocks.map((block, i) => { switch (block.kind) { - case 'text': return - case 'reasoning': return + case 'text': return ( + + ) + case 'reasoning': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null - default: return + default: return ( + t('json.truncated', { total })} + /> + ) } })} - {interrupted && 已停止} + {interrupted && {t('message.stopped')}}
{showActions && ( { onFork(seq) }} className={css.actions} + t={t} /> )}
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 088b605d55..f8536e5fe3 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -57,12 +57,13 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, t }: { renderSlot: RenderToolRow node: CodeSubCall openFile: OpenFile selected: boolean cwd: string | undefined + t: ChatViewSlotProps['t'] }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name @@ -73,7 +74,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, - fallback: , + fallback: , })}
) @@ -85,7 +86,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ const CallRow = memo(function CallRow({ - renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, + renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, t, }: { renderSlot: RenderToolRow callId: string @@ -100,6 +101,7 @@ const CallRow = memo(function CallRow({ selectedCallId?: string | undefined /** Session workspace root for path-relative summaries. */ cwd: string | undefined + t: ChatViewSlotProps['t'] }) { const owner = useMemo(() => ({ callId, toolName, block, openFile, cwd, @@ -108,7 +110,7 @@ const CallRow = memo(function CallRow({
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, - fallback: , + fallback: , })} {subCalls !== undefined && subCalls.length > 0 && (
@@ -120,6 +122,7 @@ const CallRow = memo(function CallRow({ openFile={openFile} selected={node.callId === selectedCallId} cwd={cwd} + t={t} /> ))}
@@ -129,7 +132,7 @@ const CallRow = memo(function CallRow({ }) /** Consecutive tool results as one step-run group (uniform 16px rhythm). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: { +const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, t }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] openFile: OpenFile @@ -139,6 +142,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec codeDispatches: ReadonlyMap /** Session workspace root for path-relative summaries. */ cwd: string | undefined + t: ChatViewSlotProps['t'] }) { return (
@@ -154,6 +158,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} cwd={cwd} + t={t} /> ))}
@@ -163,16 +168,17 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec /** One command lifecycle row: keyed dispatch on the command name with the * generic card as the render-site fallback (zero registration required). A * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node }: { +const CommandRow = memo(function CommandRow({ renderSlot, node, t }: { renderSlot: RenderToolRow node: CommandNode + t: ChatViewSlotProps['t'] }) { const owner = useMemo(() => ({ node }), [node]) return (
{renderSlot('conversation.chat.commandview', owner, { entryKey: node.name ?? '', - fallback: , + fallback: , })}
) @@ -214,23 +220,24 @@ function TurnDots() { /** The streaming partial, isolated so chunk batches re-render only this tail. * onGrow lets the scroll owner follow content the parent never re-renders for. */ -function StreamingTail({ useSession, onGrow }: { +function StreamingTail({ useSession, onGrow, t }: { useSession: UseConversation onGrow: () => void + t: ChatViewSlotProps['t'] }) { const partial = useSession(s => s.partial) useLayoutEffect(() => { onGrow() }) if (partial === null) return null - return + return } /** * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt, t }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -238,7 +245,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) const openState = useSession(s => s.openState) - const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) + const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) @@ -368,6 +375,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} cwd={cwd} + t={t} /> ) } @@ -382,32 +390,37 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + t={t} /> ) } if (node.kind === 'command') { - return + return } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return } return (
- {openState === 'loading' &&
载入历史…
} - {openState === 'error' &&
历史加载失败:{openErrorMessage}
} + {openState === 'loading' &&
{t('chat.loadingHistory')}
} + {openState === 'error' && openError !== null && ( +
+ {t('chat.loadError', { message: openError.message, code: openError.code })} +
+ )} {hasMore && (
)} {items.map(renderItem)} - + {runningCalls.length > 0 && (
{runningCalls.map(call => ( @@ -422,6 +435,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} cwd={cwd} + t={t} /> ))}
@@ -438,7 +452,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio - - {edit === true && ( - - diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 2eb8e313ae..bb6429470f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' @@ -18,6 +19,8 @@ export interface MessageItemProps { node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode /** Fork the session through the turn containing this message (user-bubble branch action). */ onFork?: (seq: number) => void + /** The owning view's locale seat, passed down as a plain prop. */ + t: ChatViewSlotProps['t'] } function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { @@ -63,7 +66,8 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) { + const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': { const { text, rest } = contentText(node.content) @@ -71,7 +75,7 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt
{projectUserText(text)} - {rest.map((block, i) => )} + {rest.map((block, i) => )}
{ onFork(node.seq) }} className={css.actions} + t={t} />
) @@ -89,21 +94,21 @@ export const MessageItem = memo(function MessageItem({ node, onFork }: MessageIt return (
- 插话 + {t('message.steering')} {projectUserText(text)} - {rest.map((block, i) => )} + {rest.map((block, i) => )}
) } case 'context': return ( - + ) default: return (
- +
) } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 365000ebb9..0be424a96b 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -10,12 +10,15 @@ import { useState, type MouseEvent, type ReactNode } from 'react' import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' import { DisclosureRow } from './DisclosureRow.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { + /** The render site's conversation locale seat (terminal/code body copy). */ + t: TranslateNS<'conversation'> variant: ToolRowVariant /** Wire tool name for tool-owned styling layered over the generic variant. */ toolName?: string | undefined @@ -56,6 +59,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { } export function ToolRow({ + t, variant, toolName, icon, @@ -125,9 +129,16 @@ export function ToolRow({
{terminalBody.description}
)} {terminalBody !== null - ? + ? ( + + ) : variant === 'code' - ? + ? :
{text}
}
diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts index b005b8404b..67b625e71d 100644 --- a/packages/client/ui-conversation/src/client/chat/message-chrome.ts +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -1,6 +1,11 @@ // Shared chrome helpers for user/assistant IconActions rows: clipboard write // and the compact date+clock label from a session-event epoch. +import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' + +/** The date-template share of the conversation dictionary the clock consumes. */ +export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'> + /** * Best-effort clipboard write; rejections stay swallowed (no success chrome). * @param text - Plain text to place on the clipboard. @@ -67,14 +72,16 @@ export function msUntilNextLocalMidnight(ms: number): number { } /** - * Compact local timestamp for message IconActions. - * Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`; - * other years → `YYYY年M月D日 HH:mm`. + * Compact local timestamp for message IconActions. Same calendar day → + * `HH:mm`; earlier this year → the `clock.md` date template + clock; other + * years → the `clock.ymd` template + clock. Pure: the date templates arrive + * through the caller's locale seat. * @param time - Unix epoch ms from the source session event. + * @param t - translate seat supplying the `clock.md` / `clock.ymd` templates. * @param now - Reference instant for the day/year cut (defaults to wall clock). * @returns Date-aware clock string (24-hour, zero-padded time). */ -export function formatMessageClock(time: number, now: number = Date.now()): string { +export function formatMessageClock(time: number, t: ClockTranslate, now: number = Date.now()): string { const d = new Date(time) const n = new Date(now) const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}` @@ -85,7 +92,7 @@ export function formatMessageClock(time: number, now: number = Date.now()): stri ) { return clock } - const md = `${d.getMonth() + 1}月${d.getDate()}日` - if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}` - return `${d.getFullYear()}年${md} ${clock}` + const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() } + const md = d.getFullYear() === n.getFullYear() ? t('clock.md', params) : t('clock.ymd', params) + return `${md} ${clock}` } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index dca8cbc567..bb9c540fbb 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,7 +1,7 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -282,8 +282,6 @@ export interface ComposerBarInjected { * Resolves admission: false = rejected/unmatched/transport failure. */ command: ((line: string) => Promise) | undefined - /** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */ - translateHint: (key: string) => string /** * Registrant hooks compartment: the renderer binds these to * useNotices/useLexicon (static absent sources without a session — hook @@ -306,11 +304,12 @@ export interface InputControlOwnerProps { locked: boolean } -/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */ +/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> & InjectFace + & PropsLocale<'conversation'> /** * Composer chain currency: what ConversationRoot dispatches at its @@ -325,7 +324,8 @@ export interface ComposerChainProps { /** * Full conversation-slot component props: runtime & child-render (view ring - * + composer chain/bar + input-region + hero picker slots) & store & injected shares. + * + composer chain/bar + input-region + hero picker slots) & store & injected + * shares & the locale seat. */ export type ConversationSlotProps = PropsRuntime<'conversation'> & PropsRenderSlots< @@ -336,13 +336,15 @@ export type ConversationSlotProps = | 'conversation.hero.workspace' > & ConversationInjected + & PropsLocale<'conversation'> -/** Full strict-session content props: per-session store, view ring, and callbacks. */ +/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> & PropsRenderSlots<'conversation.view'> & PropsStore & ConversationSessionInjected + & PropsLocale<'conversation'> /** The pending approval carrier the owner dispatches into the composer chain. */ export type ApprovalWait = PendingWait<'approval'> @@ -400,11 +402,13 @@ export class PendingApproval { /** * Full approval-composer props: the framework runtime share (chain currency + * session/global standard kit) plus the chain `matched` share — the entry's - * selector result, already narrowed to the approval carrier. No injected - * share: the carrier plus the domain face above carry the whole behavior - * surface; the paired command line derives from useSession in-component. + * selector result, already narrowed to the approval carrier — plus the + * standard locale seat. No injected share: the carrier plus the domain face + * above carry the whole behavior surface; the paired command line derives + * from useSession in-component. */ -export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } +export type ApprovalComposerProps = + PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'> /** * Injected share of the chat view entry: the two callbacks whose targets live @@ -423,10 +427,10 @@ export interface ChatViewInjected { forkAt: (seq: number) => void } -/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */ +/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ export type ChatViewSlotProps = PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'> - & PropsStore & ChatViewInjected + & PropsStore & ChatViewInjected & PropsLocale<'conversation'> /** * Injected share of the details slot: the panel is otherwise a pure reader of @@ -437,8 +441,8 @@ export interface DetailsInjected { closeDetails: () => void } -/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ -export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & DetailsInjected +/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */ +export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & DetailsInjected & PropsLocale<'conversation'> /** Owner share common to the hero / New-Session Workspace pickers. */ export interface EmptyWorkspaceOwnerProps { diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index e25d68cbbb..c2d3886910 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -8,9 +8,35 @@ * are derived once. * @module */ -import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts' +/** + * Build the TerminalBlock display copy from the conversation locale seat — + * the one place the primitive's label surface pairs with this package's + * dictionary, shared by every terminal render site (chat row, bash row, + * details panel). + * @param t - the render site's conversation locale seat. + * @returns the full label set for {@link TerminalBlockProps}'s `labels`. + */ +export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels { + return { + signal: signal => t('terminal.signal', { signal }), + exitCode: code => t('terminal.exitCode', { code }), + running: t('terminal.running'), + failed: t('terminal.failed'), + done: t('terminal.done'), + copy: t('copy'), + copied: t('copied'), + noOutput: t('terminal.noOutput'), + collapseAria: t('terminal.collapseAria'), + collapse: t('collapse'), + expandAria: hidden => t('terminal.expandAria', { n: hidden }), + expand: hidden => t('terminal.expandRest', { n: hidden }), + } +} + /** * Output lines the chat row's expanded terminal body shows before collapsing * the middle — half the primitive's own default, which the details panel diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 56d398def3..d04f2473e1 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -11,6 +11,7 @@ export type { CallId, ChatStoreState, SelectionTarget, ViewTab, } from './contract/views.ts' export type { ToolCallBlock } from './contract/tool-call-model.ts' +export type { ConversationKey } from './locales.ts' export type { ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts new file mode 100644 index 0000000000..737d6dc9f0 --- /dev/null +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -0,0 +1,170 @@ +/** `conversation` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'conversation' + +// The claimed /plan hint and the plan-mode textarea placeholder share one +// string: both describe the same next action. +const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划' +const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'view.chat': '对话', + 'hint.plan': PLAN_NEXT_ACTION_ZH, + 'hint.goal': '输入目标,智能体将持续执行', + 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', + 'placeholder.plan': PLAN_NEXT_ACTION_ZH, + 'placeholder.default': '给智能体发消息', + 'placeholder.unavailable': '会话不可用', + 'placeholder.hero': '描述你想要构建的内容', + 'placeholder.workspace': '选择一个工作区开始', + 'input.addAttachment': '添加附件', + 'input.stop': '停止生成', + 'input.send': '发送消息', + 'input.accessMode': '访问模式,当前:{name}', + 'hero.headline': '开始构建吧', + 'hero.chooseWorkspace': '选择工作区', + 'session.hierarchy': '会话层级', + 'details.title': '详情', + 'details.close': '关闭详情', + 'details.empty': '点击消息流中的工具行查看详情', + 'details.notInWindow': '该调用不在当前窗口内', + 'details.input': '输入', + 'details.output': '输出', + 'details.running': '运行中…', + 'todo.title': '任务清单', + 'todo.progress': '{done}/{total} 项任务 · {active} 项进行中', + 'todo.rowTitle': '更新任务清单', + 'todo.completed': '{done}/{total} 已完成', + 'chat.loadingHistory': '载入历史…', + 'chat.loadError': '历史加载失败:{message}({code})', + 'chat.loadOlder': '加载更早', + 'chat.toBottom': '回到底部', + 'message.extraBlock': '附加内容块', + 'message.steering': '插话', + 'message.contextInjection': '上下文注入', + 'message.unknownSurface': '未知 surface 事件:{type}', + 'message.unknownBlock': '未知内容块', + 'message.stopped': '已停止', + 'message.branch': '在新对话中分支', + 'command.running': '执行中…', + 'command.failed': '命令失败', + 'command.done': '已完成', + 'command.title': '命令', + 'approval.waiting': '等待审批', + 'approval.detail.aria': '审批详情', + 'approval.escalation': '工具 {toolName} 请求越权执行', + 'approval.reject': '拒绝', + 'approval.allowOnce': '允许一次', + 'ask.rowTitle': '提问', + 'ask.waiting': '等待回答', + 'ask.cancelled': '已取消', + 'ask.interrupted': '已中断', + 'ask.answered': '{answered}/{total} 已回答', + 'bash.running': '运行中', + 'bash.failed': '失败', + 'bash.stopped': '已停止', + 'queue.count': '{n} 条排队消息', + 'queue.edit': '编辑排队消息', + 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', + 'queue.save': '保存排队消息', + 'queue.cancelEdit': '取消编辑', + 'queue.remove': '删除排队消息', + 'queue.editFailed': '编辑失败:这条消息可能已经开始发送。', + 'queue.removeFailed': '删除失败:这条消息可能已经开始发送。', + 'terminal.signal': '信号 {signal}', + 'terminal.exitCode': '退出码 {code}', + 'terminal.running': '运行中', + 'terminal.failed': '失败', + 'terminal.done': '已完成', + 'terminal.noOutput': '无输出', + 'terminal.collapseAria': '收起输出', + 'terminal.expandAria': '展开其余 {n} 行输出', + 'terminal.expandRest': '… 其余 {n} 行', + 'json.truncated': '… 已截断,共 {total} 字符', + 'clock.md': '{m}月{d}日', + 'clock.ymd': '{y}年{m}月{d}日', +} satisfies Record + +/** The conversation namespace key union. */ +export type ConversationKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'view.chat': 'Chat', + 'hint.plan': PLAN_NEXT_ACTION_EN, + 'hint.goal': 'describe the objective for a long-running task', + 'hint.goal.active': 'goal active — edit / pause / resume / clear', + 'placeholder.plan': PLAN_NEXT_ACTION_EN, + 'placeholder.default': 'Message the agent', + 'placeholder.unavailable': 'Session unavailable', + 'placeholder.hero': 'Describe what you want to build', + 'placeholder.workspace': 'Choose a workspace to start', + 'input.addAttachment': 'Add attachment', + 'input.stop': 'Stop generating', + 'input.send': 'Send message', + 'input.accessMode': 'Access mode, current: {name}', + 'hero.headline': 'Let\'s start building', + 'hero.chooseWorkspace': 'Choose workspace', + 'session.hierarchy': 'Session hierarchy', + 'details.title': 'Details', + 'details.close': 'Close details', + 'details.empty': 'Click a tool row in the message flow to view its details', + 'details.notInWindow': 'This call is outside the current window', + 'details.input': 'Input', + 'details.output': 'Output', + 'details.running': 'Running…', + 'todo.title': 'To-dos', + 'todo.progress': '{done}/{total} tasks · {active} in progress', + 'todo.rowTitle': 'Update to-do list', + 'todo.completed': '{done}/{total} completed', + 'chat.loadingHistory': 'Loading history…', + 'chat.loadError': 'Failed to load history: {message} ({code})', + 'chat.loadOlder': 'Load earlier', + 'chat.toBottom': 'Back to bottom', + 'message.extraBlock': 'Extra content block', + 'message.steering': 'Interjection', + 'message.contextInjection': 'Context injection', + 'message.unknownSurface': 'Unknown surface event: {type}', + 'message.unknownBlock': 'Unknown content block', + 'message.stopped': 'Stopped', + 'message.branch': 'Branch into a new conversation', + 'command.running': 'Running…', + 'command.failed': 'Command failed', + 'command.done': 'Completed', + 'command.title': 'Command', + 'approval.waiting': 'Waiting for approval', + 'approval.detail.aria': 'Approval details', + 'approval.escalation': 'Tool {toolName} requests privileged execution', + 'approval.reject': 'Reject', + 'approval.allowOnce': 'Allow once', + 'ask.rowTitle': 'Ask question', + 'ask.waiting': 'waiting', + 'ask.cancelled': 'cancelled', + 'ask.interrupted': 'interrupted', + 'ask.answered': '{answered}/{total} answered', + 'bash.running': 'Running', + 'bash.failed': 'Failed', + 'bash.stopped': 'Stopped', + 'queue.count': '{n} queued messages', + 'queue.edit': 'Edit queued message', + 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', + 'queue.save': 'Save queued message', + 'queue.cancelEdit': 'Cancel editing', + 'queue.remove': 'Remove queued message', + 'queue.editFailed': 'Edit failed: this message may have already started sending.', + 'queue.removeFailed': 'Removal failed: this message may have already started sending.', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'exit code {code}', + 'terminal.running': 'Running', + 'terminal.failed': 'Failed', + 'terminal.done': 'Done', + 'terminal.noOutput': 'No output', + 'terminal.collapseAria': 'Collapse output', + 'terminal.expandAria': 'Expand the remaining {n} output lines', + 'terminal.expandRest': '… {n} more lines', + 'json.truncated': '… truncated, {total} characters total', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 67de5d0796..1bc6f75e85 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -5,13 +5,14 @@ // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' import { useEffect, useId, useState } from 'react' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, IconEditOutline16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' +import { NS } from '../locales.ts' import css from './QueueDock.module.css' /** Queue operations injected by the session-scoped registration. */ @@ -20,14 +21,14 @@ export interface QueueDockInjected { notify: (level: 'info' | 'error', text: string) => void } -/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ -export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected +/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */ +export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected & PropsLocale<'conversation'> /** * Queue strip: one item renders directly; multiple items default to a * collapsible count header; an empty queue renders nothing. */ -export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { +export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) { const queue = useSession(s => s.queue) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) @@ -67,7 +68,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { if (await applyAction( editing.id, { kind: 'edit', content: [{ type: 'text', text: editing.text }] }, - '编辑失败:这条消息可能已经开始发送。', + t('queue.editFailed'), )) setEditing(null) } @@ -83,7 +84,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { disabled={interactionActive} onClick={() => { setCollapsed(value => !value) }} > - {queue.length} 条排队消息 + {t('queue.count', { n: queue.length })} {expanded ? : } @@ -97,7 +98,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { { setEditing({ id: row.id, text: event.currentTarget.value }) }} onKeyDown={(event) => { @@ -120,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 3f9635bb48..c52ba0b6b3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -14,7 +14,7 @@ export type ConversationRootProps = ConversationSlotProps export function ConversationRoot({ sessionId, useSession, useSessions, useWorkspaces, useInput, - renderSlot, renderSlotChain, selectWorkspace, + renderSlot, renderSlotChain, selectWorkspace, t, }: ConversationRootProps) { const openState = useSession(s => s.openState) const composerPhase = useSession(s => s.composerPhase) @@ -94,6 +94,7 @@ export function ConversationRoot({ label={chipTitle} menuOpen={pickerOpen} onClick={() => { setPickerOpen(open => !open) }} + t={t} /> {renderSlot('conversation.hero.workspace', { open: pickerOpen, @@ -120,8 +121,8 @@ export function ConversationRoot({ const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', ...(inert - ? { disabled: true, placeholder: 'Choose a workspace to start' } - : hero ? { placeholder: 'Describe what you want to build' } : {}), + ? { disabled: true, placeholder: t('placeholder.workspace') } + : hero ? { placeholder: t('placeholder.hero') } : {}), overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), @@ -133,7 +134,7 @@ export function ConversationRoot({ const composerBar = (
{hero && } - {hero && } + {hero && } {hero && heroWorkspaceRow} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 33b24f5245..9c1ea2fd33 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, wrapActiveBody, + renderSlot, views, bindDraftMirror, open, wrapActiveBody, t, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -65,7 +65,7 @@ export function ConversationSession({ {!hideChrome && ( <>
-