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 index c35962e7d3..66dff1b8ec 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab -2026-07-30-client-locale-full-rollout.zh.md: 427c9e5ef9c544a49e70b6ba8450511072f53a6e +2026-07-30-client-locale-full-rollout.md: aeb4deae28b0dfdb9ab75fd64fe3143958cd6910 +2026-07-30-client-locale-full-rollout.zh.md: a642b6062cb3dc7a2dfa22dd5d8cf7d9a02e3104 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 index 6701aefa45..aeb4deae28 100644 --- 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 @@ -1,4 +1,4 @@ -# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary +# Agent Note: Full client copy rollout onto the typed locale seat Status: implemented @@ -6,7 +6,7 @@ English | [中文](2026-07-30-client-locale-full-rollout.zh.md) ## Problem -After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization. +After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch, and how the zero-Cordis ui-primitives atoms receive copy without depending on the runtime. ## Decision @@ -14,16 +14,11 @@ After the typed locale standard seat landed (`locale:` on register → framework **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. -**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). +**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). -**The non-translation boundary (deliberate decisions, not debt):** +**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation. -- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim. -- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages. -- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately). -- **Boot copy stays hardcoded** (the framework-free boot page runs before the locale service exists). - -**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. +**Derivation layers keep display text out of identity.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank session titles and the Ungrouped label derive from the `blank` flag / absent `workspaceId`, while internal values stay empty or stable; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter. **Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). @@ -33,7 +28,7 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision. - **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently. -- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text. +- **Translate external or wire error data**: rejected because provider and protocol diagnostics are evidence searched and compared verbatim. Product-authored surrounding failure chrome is translated; externally authored data is not. - **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock. - **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic. @@ -41,5 +36,5 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. -- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. +- ui-primitives require localized label props, so adding a primitive render site also adds an explicit copy owner; omission fails typechecking instead of selecting a hidden language. - Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md). 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 index 427c9e5ef9..a642b6062c 100644 --- 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 @@ -1,4 +1,4 @@ -# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界 +# Agent Note: client 文案全量接入 typed locale 席位 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## Problem -typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使未来的 agent(智能体)「补完」翻译。 +typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新,以及 zero-Cordis 的 ui-primitives 原子组件如何在不依赖运行时的情况下拿到文案。 ## Decision @@ -14,16 +14,11 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费方渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 +**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。 -**不翻译边界(刻意决定,不是欠账):** +**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。 -- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError 消息、wire 透出的 `error.message (code)` 原样呈现。 -- **设计字面量不进字典**:工具行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、整个 StatsLine——中英界面显示一致。 -- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。 -- **boot 文案保持硬编码**(不依赖框架的启动页运行早于 locale 服务可用)。 - -**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 +**派生层不让展示文本承担身份。** ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}`,由渲染组合字典模板;blank 会话标题和未分组 label 从 `blank` 标志/`workspaceId` 缺席派生,内部值保持为空或稳定;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数接收 `t` 参数。 **测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot 快照 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。 @@ -33,7 +28,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 已经为每个包注册一次,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。 - **给 ui-primitives 造 locale 上下文/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费方(ui-trajectory)陪跑。props 化让每个消费方独立决定。 -- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。 +- **翻译外部或 wire 错误数据**:否决。提供方与协议诊断是需要原样搜索和比对的证据。产品编写的外围失败 chrome 会翻译,外部编写的数据不会。 - **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。 - **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下「看得见搜不到」;占位行本无信息量,整体排除语义最稳。 @@ -41,5 +36,5 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。 -- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。 +- ui-primitives 要求本地化 label prop,因此新增原子组件渲染点也必须新增明确的文案 owner;遗漏会在类型检查失败,而不是选择隐藏语言。 - e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml new file mode 100644 index 0000000000..0d78347cb9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md +2026-08-23-locale-owned-client-ui-copy.md: abcce4993c7155d2e280bd1185c305a6fb4bb562 +2026-08-23-locale-owned-client-ui-copy.zh.md: 3f24bf96277a0d6307e197d211b47dfb96c278cb diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md new file mode 100644 index 0000000000..abcce4993c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md @@ -0,0 +1,42 @@ +# Agent Note: Locale-owned client UI copy + +Status: implemented + +English | [中文](2026-08-23-locale-owned-client-ui-copy.zh.md) + +## Problem + +Typed locale namespaces and bilingual dictionary parity proved that registered dictionaries were complete, but they could not prove that presentation code used them. JSX text, accessibility attributes, formatter returns, and zero-Cordis primitive defaults could bypass `t` while every locale check remained green. The deferred and supposedly language-neutral exceptions recorded in the [initial full-rollout decision](2026-07-30-client-locale-full-rollout.md) accumulated into a mixed-language UI, especially in trajectory inspection and generic Tool cards. + +## Decision + +**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated. + +**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionBanner` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning. + +**Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state. + +**`verify-client-ui-i18n` enforces source ownership.** The TypeScript-AST check discovers every client TSX file, helper TS files under `ui-*`, and the web app source. It rejects natural-language JSX text, copy-bearing attributes and component props, literal JSX branches, label/copy data, named copy helpers, string-returning display formatters, and destructuring defaults. Locale dictionary owners and immutable language tokens are the narrow syntactic exclusions. Discovery refuses a narrowed corpus, unit fixtures pin admitted and excluded forms, and the check runs in the static CI and `hygiene` graphs. Dictionary-key parity remains a separate check: one gate proves copy enters the locale path, while the other proves both shipped languages implement that path. + +The product-authored error and design-literal exclusions, primitive defaults, and trajectory deferral in the [initial rollout](2026-07-30-client-locale-full-rollout.md) are superseded by this decision. Its label-thunk, typed-seat, browser-locale, date-formatting, and search-placeholder decisions remain active. + +## Verification + +The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface. + +## Alternatives considered + +**Rely on review and AGENTS.md alone.** Rejected because the existing rule and typed dictionaries coexisted with hundreds of bypasses; reviewers need a source-level failure at the introducing line. + +**Use a text regex or ban every string literal.** Rejected because TypeScript and JSX contain imports, CSS classes, discriminants, event names, SVG data, and user/wire values. Syntax-aware contexts provide useful signal without an ever-growing file allowlist, while the minimum discovery count prevents a falsely green narrowed scan. + +**Keep primitive fallback copy for convenient direct use.** Rejected because a fallback is itself an implicit locale choice. Required label props keep primitives framework-free and make each product render site name its copy owner. + +**Translate every string that reaches the DOM.** Rejected because authored data and protocol/code tokens are not product wording. Translating them corrupts evidence, identifiers, commands, paths, URLs, and provider diagnostics; only surrounding product chrome belongs to the locale system. + +## Consequences + +- Adding or changing client UI copy requires a typed dictionary key in both locales and behavior evidence for the affected render path. +- Pure primitives have larger explicit prop types, and tests provide deliberate label fixtures; this cost prevents hidden locale behavior. +- The AST check catches authored literal bypasses but cannot prove that an arbitrary dynamic string prop was translated. Types, dictionary parity, component tests, and review still own that semantic distinction. +- Boot markup that renders before the locale service and externally authored runtime data remain outside the dictionary path; product UI replaces boot copy after locale activation. diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md new file mode 100644 index 0000000000..3f24bf9627 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md @@ -0,0 +1,42 @@ +# Agent Note: locale 归属的 client UI 文案 + +Status: implemented + +[English](2026-08-23-locale-owned-client-ui-copy.md) | 中文 + +## Problem + +typed locale namespace 与双语字典对等性可以证明已注册字典完整,却无法证明展示代码使用了字典。JSX 文本、无障碍属性、格式化函数返回值和 zero-Cordis 原子组件默认值都可能绕过 `t`,而全部 locale 检查仍保持绿色。[最初的全量接入决策](2026-07-30-client-locale-full-rollout.zh.md)中缓做或假定为语言无关的例外逐渐形成混合语言 UI,trajectory 检查面和通用工具卡尤为明显。 + +## Decision + +**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。 + +**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionBanner` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 + +**本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。 + +**`verify-client-ui-i18n` 强制源码归属。** 基于 TypeScript AST 的检查会发现所有 client TSX 文件、`ui-*` 下的辅助 TS 文件和 web 应用源码;它拒绝自然语言 JSX 文本、承载文案的属性与组件 prop、JSX 字面量分支、label/copy 数据、具名文案辅助函数、返回字符串的展示格式化函数和解构默认值。locale 字典 owner 与不可变语言 token 是严格的语法级排除项。发现范围缩窄会直接失败,单元 fixture 固定纳入与排除形态,检查加入静态 CI 与 `hygiene` 图。字典 key 对等性仍由独立检查负责:一道门禁证明文案进入 locale 路径,另一道门禁证明两种发布语言都实现该路径。 + +[最初接入决策](2026-07-30-client-locale-full-rollout.zh.md)中的产品自产错误与设计字面量例外、原子组件默认文案和 trajectory 缓做均由本决定取代;其 label thunk、typed 席位、浏览器 locale、日期格式化和搜索占位行决定仍有效。 + +## Verification + +AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。 + +## Alternatives considered + +**只依赖评审与 AGENTS.md。** 否决。既有规则和 typed 字典与数百个绕过点同时存在;评审者需要在引入行收到源码级失败。 + +**使用文本正则,或禁止所有字符串字面量。** 否决。TypeScript 与 JSX 中包含 import、CSS class、判别值、事件名、SVG 数据和用户/wire 值。按语法上下文检查可在不扩张文件 allowlist 的情况下保持有效信号,而最小发现数量可防止扫描范围缩小后伪绿。 + +**为方便直接使用而保留原子组件回落文案。** 否决。回落值本身就是隐式 locale 选择。必填 label prop 让原子组件保持框架无关,并迫使每个产品渲染点明确文案 owner。 + +**翻译所有进入 DOM 的字符串。** 否决。外部编写的数据和协议/代码 token 并非产品措辞。翻译会破坏证据、标识符、命令、路径、URL 和提供方诊断;只有其周围的产品 chrome 属于 locale 系统。 + +## Consequences + +- 新增或修改 client UI 文案时,必须在两种 locale 中添加 typed 字典 key,并为受影响渲染路径提供行为证据。 +- 纯原子组件的显式 prop 类型变大,测试需提供有意选择的 label fixture;这项成本换来无隐藏 locale 行为。 +- AST 检查可以抓到产品编写的字面量绕过,却无法证明任意动态字符串 prop 已翻译。类型、字典对等性、组件测试和评审仍共同负责这一语义区分。 +- locale 服务之前渲染的 boot 标记和外部编写的运行时数据仍在字典路径之外;locale 激活后,产品 UI 会替换 boot 文案。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index e79f11cdc7..510e01f3d5 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -25,6 +25,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 4. **Registrations clean up.** Verify each new registry contribution passes the disposal tests required by [packages/AGENTS.md](../../../packages/AGENTS.md). 5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at the point where that package can observe it; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package invariant rules](../../../packages/AGENTS.md)). 6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect. +7. **Client UI copy is locale-owned.** Reject product text embedded in JSX, templates, helper returns, accessibility attributes, or primitive defaults. Require typed dictionary keys, the standard `t` seat or explicit localized props, `verify-client-ui-i18n`, and behavior evidence in each affected locale; preserve user/model/wire data and code tokens verbatim. ## Manual checks diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 9f9652415f..42f9bbab9e 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. - **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. -- **Prompts and visible strings:** treat wording as behavior. Update the owning runnable snapshot for model-visible text and the repository-required behavior evidence for GUI text. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. +- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. diff --git a/AGENTS.md b/AGENTS.md index 3eff4112d8..1928b25c31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,11 +122,12 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). -- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. +- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. - **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)). -- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). +- **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md index 1287e6e565..7852dffc5a 100644 --- a/apps/web/tests/snapshots/access-confirmation/ui.expected.md +++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md @@ -1,6 +1,6 @@ - dialog "确认启用 Full access?": - heading "确认启用 Full access?" [level=2] - - button "Close": + - button "关闭": - img - img - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 diff --git a/apps/web/tests/snapshots/search-card/grep-card.expected.txt b/apps/web/tests/snapshots/search-card/grep-card.expected.txt index 160251e62e..e67c6276d3 100644 --- a/apps/web/tests/snapshots/search-card/grep-card.expected.txt +++ b/apps/web/tests/snapshots/search-card/grep-card.expected.txt @@ -1,5 +1,5 @@ kind=matches -summary=显示 9 / 共 42 处匹配 · 3 个文件 +summary=Showing 9 of 42 matches · 3 files file=packages/client/ui-primitives/src/SearchBlock.tsx3 file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4 line=16: export const DEFAULT_SEARCH_MAX_LINES = 16 @@ -8,7 +8,7 @@ line=141: const [collapsed, setCollapsed] = useState>(() = line=36: const search = searchCardModel(block) line=56: search={search} line=78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow) -expand=… 其余 4 行 +expand=… 4 more lines recovery=Found 9 of 42 matches packages/client/ui-primitives/src/SearchBlock.tsx diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 9dba51c86c..1975b0b937 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -280,7 +280,7 @@ describe('web e2e: shipped default web search', () => { expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS) // The list is complete in the DOM, so the card carries no expand control. expect(await card.locator('button').count()).toBe(0) - expect(await card.getByText('来源列表已截断').isVisible()).toBe(true) + expect(await card.getByText('Source list truncated').isVisible()).toBe(true) const geometry = await sources.evaluate((element) => { const computed = getComputedStyle(element) diff --git a/package.json b/package.json index 54acae5cae..af7878df10 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", + "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 9baa35ebd0..0eb568afad 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -106,9 +106,11 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. -## Styling +## Styling and localization -[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English. +[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. + +Every product-visible string—including text, accessibility names, tooltips, placeholders, status/unit formatters, and primitive chrome—lives in a typed locale dictionary and reaches components through the standard `t` seat or an already-localized prop. Cordis-free primitives require complete label props and own no fallback copy. Keep user/model/wire data and code tokens verbatim; internal matching uses discriminants or stable ids, never localized text. `pnpm run verify-client-ui-i18n` enforces source ownership ([decision](../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). ## Testing and coverage @@ -145,6 +147,6 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com 1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. 3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. -4. Tokens only in CSS; Chinese product copy; English comments. +4. Tokens only in CSS; product copy follows the localization rule above; English comments. 5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 7f30563e17..096e4ab66a 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9 -README.zh.md: 10fb3547376c8e960165a04fb4ea64ec8dd6f982 +README.md: 4f54a9a2c5aa9d39ee3c93a4d8f2c6deb538b792 +README.zh.md: d4c4833b2334c1b21f31e9b6f4d5116bbbb8591d diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 3fb5cce334..4f54a9a2c5 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience @@ -14,5 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly. - **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 10fb354737..d4c4833b23 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 +locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 ## 模型体验 @@ -14,5 +14,4 @@ locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存 ## 已知限制与暂缓事项 -- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。 - **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。 diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index b12965c6f5..05ac20dfe7 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -7,6 +7,13 @@ export const en = { 'close': 'Close', 'copy': 'Copy', 'copied': 'Copied', + 'copy.failed': 'Copy failed', + 'copy.value': 'Copy value', + 'copy.json': 'Copy JSON', + 'copy.path': 'Copy property path', + 'copy.prettyJson': 'Copy pretty JSON', + 'copy.compactJson': 'Copy compact JSON', + 'copy.optionsHint': '{action}; right-click for copy options', 'retry': 'Retry', 'loading': 'Loading…', 'load.failed': 'Failed to load', @@ -23,7 +30,14 @@ export const en = { 'collapse': 'Collapse', 'expand': 'Expand', 'back': 'Back', + 'brand.localBuild': 'DSH Local Build', 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', + 'connection.reconnecting': 'Connection lost; reconnecting…', + 'json.collapseNode': 'Collapse JSON node', + 'json.expandNode': 'Expand JSON node', + 'json.label': 'JSON', + 'markdown.footnotes': 'Footnotes', + 'markdown.truncatedCharacters': '… truncated at {total} characters', } satisfies Record diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 5bb62c4344..24c7e7e3ec 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -5,6 +5,13 @@ export const zh = { 'close': '关闭', 'copy': '复制', 'copied': '复制成功', + 'copy.failed': '复制失败', + 'copy.value': '复制值', + 'copy.json': '复制 JSON', + 'copy.path': '复制属性路径', + 'copy.prettyJson': '复制格式化 JSON', + 'copy.compactJson': '复制紧凑 JSON', + 'copy.optionsHint': '{action};右键点击可选择复制方式', 'retry': '重试', 'loading': '加载中…', 'load.failed': '加载失败', @@ -21,9 +28,16 @@ export const zh = { 'collapse': '收起', 'expand': '展开', 'back': '返回', + 'brand.localBuild': 'DSH 本地构建', 'unknown': '未知', 'none': '无', 'truncated': '已截断', + 'connection.reconnecting': '连接已断开,正在重连…', + 'json.collapseNode': '收起 JSON 节点', + 'json.expandNode': '展开 JSON 节点', + 'json.label': 'JSON', + 'markdown.footnotes': '脚注', + 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', } satisfies Record /** The common vocabulary key union (zh is the key-set source of truth). */ diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 1b91a60d46..9c294ee1f9 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -164,6 +164,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { description={confirmation.description} acknowledgeLabel={confirmation.acknowledgeLabel} cancelLabel={confirmation.cancelLabel} + closeLabel={t('close')} confirmLabel={confirmation.confirmLabel} acknowledged={state.acknowledged} onAcknowledgedChange={(value) => { popup.acknowledge(value) }} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index d47c8e12d0..9cf97fa22b 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,6 +4,7 @@ import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -26,7 +27,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. - const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) + const labels = useMemo(() => markdownLabels(t), [t]) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -46,7 +47,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ key={i} text={block.text} streaming={streaming} - codeLabels={codeLabels} + labels={labels} fileMentions={mentions} />, ) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 8964a32482..ff13ad7d8f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -129,7 +129,7 @@ function TurnStatus({ startTime, t }: { const showClock = elapsedMs >= 15_000 return (
- Deep diving... + {t('chat.deepDiving')} {showClock && ( {formatRunDuration(elapsedMs, t)} diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx index a4fd43723b..bf4305dc5b 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -15,7 +15,7 @@ export function CompactionCommandCard({ node, compaction, t }: CompactionCommand return ( diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index bcab4a360a..182aed69c0 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -1,7 +1,7 @@ // A compaction marker does not replace shadowed transcript rows. It is // expandable only when the current window includes its cited summary. -import { memo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client' import { IconApiOutline14, @@ -10,6 +10,7 @@ import { MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { markdownLabels } from '../markdown-labels.ts' import css from './MessageItem.module.css' interface CompactionItemProps { @@ -34,6 +35,7 @@ export const CompactionItem = memo(function CompactionItem({ t, }: CompactionItemProps) { const [expanded, setExpanded] = useState(false) + const labels = useMemo(() => markdownLabels(t), [t]) const expandable = node.summary !== null const open = expandable && expanded const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null @@ -68,7 +70,7 @@ export const CompactionItem = memo(function CompactionItem({ {summary} {open && node.summary !== null - &&
} + &&
}
) }) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 0f2f2dcbe3..24bd054225 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -96,7 +96,7 @@ function ModelRetryItem({ node, active, t }: {
{t('message.retry.delay')} - {Math.round(node.delayMs)}ms + {t('duration.milliseconds', { milliseconds: Math.round(node.delayMs) })}
{t('message.retry.failure')} diff --git a/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx b/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx index f8a340d20c..1f5fba56b8 100644 --- a/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ReasoningRow.tsx @@ -46,7 +46,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool titleClassName={css.title} chevronClassName={css.chevron} icon={} - title="Think" + title={t('message.think')} open={expanded} expandable expandOnRowClick diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 2d9d14483b..29af5b76c9 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -81,12 +81,12 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats { * @param n - token count. * @returns display string. */ -export function formatTokens(n: number): string { +export function formatTokens(n: number, t: ComposerBarProps['t']): string { const scaled = (v: number): string => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10) if (n < 1_000) return String(n) - if (n < 1_000_000) return `${scaled(n / 1_000)}K` - return `${scaled(n / 1_000_000)}M` + if (n < 1_000_000) return t('number.thousand', { value: scaled(n / 1_000) }) + return t('number.million', { value: scaled(n / 1_000_000) }) } /** @@ -94,11 +94,14 @@ export function formatTokens(n: number): string { * @param ms - duration in milliseconds. * @returns display string. */ -export function formatDuration(ms: number): string { +export function formatDuration(ms: number, t: ComposerBarProps['t']): string { const s = ms / 1_000 - if (s < 60) return `${Math.round(s * 10) / 10}s` + if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 }) const whole = Math.round(s) - return `${Math.floor(whole / 60)}m${whole % 60}s` + return t('duration.compactMinutes', { + minutes: Math.floor(whole / 60), + seconds: whole % 60, + }) } /** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */ @@ -222,12 +225,12 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t if (stats.steps > 0) { groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps })) const durations: string[] = [] - if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) })) - if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) })) + if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) })) + if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) })) if (durations.length > 0) groups.push(durations.join(' · ')) const speeds: string[] = [] if (stats.ttftSteps > 0) { - speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) })) + speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) })) } if (stats.decodeMs > 0) { speeds.push(t('stats.tokensPerSecond', { @@ -247,8 +250,8 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t const cacheHit = cacheHitPercent(usage) if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit })) groups.push(t('stats.tokens', { - input: formatTokens(billedInputTokens(usage)), - output: formatTokens(usage.outputTokens), + input: formatTokens(billedInputTokens(usage), t), + output: formatTokens(usage.outputTokens, t), })) } const line = groups.join(' | ') diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f4e7a7c59a..025b7b6083 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -55,6 +55,11 @@ export const zh = { 'context.system': '系统提示词', 'context.tools': '工具', 'context.messages': '对话消息', + 'number.thousand': '{value}K', + 'number.million': '{value}M', + 'duration.compactSeconds': '{seconds}秒', + 'duration.compactMinutes': '{minutes}分{seconds}秒', + 'duration.milliseconds': '{milliseconds}毫秒', 'stats.counts': '{turns} 轮 · {steps} 步', 'stats.llm': 'LLM {duration}', 'stats.toolCall': '工具调用 {duration}', @@ -71,6 +76,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', + 'access.fullLabel': 'Full access', 'hero.headline': '探索未至之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', @@ -92,6 +98,7 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', + 'chat.deepDiving': '正在深入处理…', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', @@ -116,6 +123,8 @@ export const zh = { 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', + 'message.compaction.commandTitle': 'compact', + 'message.think': '思考', 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', @@ -157,6 +166,46 @@ export const zh = { 'row.running': '运行中', 'row.failed': '失败', 'row.stopped': '已停止', + 'row.input': '输入', + 'row.output': '输出', + 'row.inspect': '查看', + 'tool.title.search': '搜索', + 'tool.title.read': '读取', + 'tool.title.bash': 'Bash', + 'tool.title.write': '写入', + 'tool.title.edit': '编辑', + 'tool.title.code': '代码', + 'tool.title.generic': '工具调用', + 'tool.title.inspect': '查看', + 'tool.title.runCordis': '运行 Cordis 插件', + 'tool.title.stopCordis': '停止 Cordis 插件', + 'tool.title.removeCordis': '移除 Cordis 插件', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'Glob', + 'tool.title.webSearch': '网页搜索', + 'tool.title.webFetch': '网页获取', + 'diff.files.one': '{count} 个文件', + 'diff.files.other': '{count} 个文件', + 'diff.collapseAria': '收起差异', + 'diff.expandAria': '展开其余 {count} 行差异', + 'diff.expandRest': '… 其余 {count} 行', + 'read.window': '显示 {shown} / {total} 行', + 'read.collapseAria': '收起内容', + 'read.expandAria': '展开其余 {count} 行', + 'read.expandRest': '… 其余 {count} 行', + 'search.paths': '{shown} 个路径', + 'search.paths.truncated': '显示 {shown} / 共 {total} 个路径', + 'search.matches': '{shown} 处匹配 · {files} 个文件', + 'search.matches.truncated': '显示 {shown} / 共 {total} 处匹配 · {files} 个文件', + 'search.noResults': '无结果', + 'search.collapseAria': '收起结果', + 'search.expandAria': '展开其余 {count} 行结果', + 'search.expandRest': '… 其余 {count} 行', + 'web.noResults': '未找到结果', + 'web.sourcesTruncated': '来源列表已截断', + 'web.http': 'HTTP', + 'web.contentTruncated': '内容已截断', 'queue.count': '{n} 条排队消息', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', @@ -232,6 +281,11 @@ export const en = { 'context.system': 'System prompt', 'context.tools': 'Tools', 'context.messages': 'Messages', + 'number.thousand': '{value}K', + 'number.million': '{value}M', + 'duration.compactSeconds': '{seconds}s', + 'duration.compactMinutes': '{minutes}m{seconds}s', + 'duration.milliseconds': '{milliseconds}ms', 'stats.counts': '{turns} turns · {steps} steps', 'stats.llm': 'LLM {duration}', 'stats.toolCall': 'Tool call {duration}', @@ -248,6 +302,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', + 'access.fullLabel': 'Full access', 'hero.headline': 'Into the Unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', @@ -269,6 +324,7 @@ export const en = { 'chat.loadError': 'Failed to load history: {message} ({code})', 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', + 'chat.deepDiving': 'Deep diving...', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'fileOpen.folderTitle': 'Couldn’t open folder', @@ -293,6 +349,8 @@ export const en = { 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', + 'message.compaction.commandTitle': 'compact', + 'message.think': 'Think', 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', @@ -334,6 +392,46 @@ export const en = { 'row.running': 'Running', 'row.failed': 'Failed', 'row.stopped': 'Stopped', + 'row.input': 'IN', + 'row.output': 'OUT', + 'row.inspect': 'Inspect', + 'tool.title.search': 'Search', + 'tool.title.read': 'Read', + 'tool.title.bash': 'Bash', + 'tool.title.write': 'Write', + 'tool.title.edit': 'Edit', + 'tool.title.code': 'Code', + 'tool.title.generic': 'Tool call', + 'tool.title.inspect': 'Inspect', + 'tool.title.runCordis': 'Run Cordis Plugin', + 'tool.title.stopCordis': 'Stop Cordis Plugin', + 'tool.title.removeCordis': 'Remove Cordis Plugin', + 'tool.title.pwsh': 'Pwsh', + 'tool.title.grep': 'Grep', + 'tool.title.glob': 'Glob', + 'tool.title.webSearch': 'Search', + 'tool.title.webFetch': 'Fetch', + 'diff.files.one': '{count} file', + 'diff.files.other': '{count} files', + 'diff.collapseAria': 'Collapse diff', + 'diff.expandAria': 'Expand {count} more diff lines', + 'diff.expandRest': '… {count} more lines', + 'read.window': 'Showing {shown} of {total} lines', + 'read.collapseAria': 'Collapse content', + 'read.expandAria': 'Expand {count} more lines', + 'read.expandRest': '… {count} more lines', + 'search.paths': '{shown} paths', + 'search.paths.truncated': 'Showing {shown} of {total} paths', + 'search.matches': '{shown} matches · {files} files', + 'search.matches.truncated': 'Showing {shown} of {total} matches · {files} files', + 'search.noResults': 'No results', + 'search.collapseAria': 'Collapse results', + 'search.expandAria': 'Expand {count} more result lines', + 'search.expandRest': '… {count} more lines', + 'web.noResults': 'No results found', + 'web.sourcesTruncated': 'Source list truncated', + 'web.http': 'HTTP', + 'web.contentTruncated': 'Content truncated', 'queue.count': '{n} queued messages', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', diff --git a/packages/client/ui-conversation/src/client/markdown-labels.ts b/packages/client/ui-conversation/src/client/markdown-labels.ts new file mode 100644 index 0000000000..0f51d1fe6e --- /dev/null +++ b/packages/client/ui-conversation/src/client/markdown-labels.ts @@ -0,0 +1,26 @@ +/** Localized copy adapters for Cordis-free Markdown primitives. */ + +import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from './contract/slots.ts' + +/** + * Build the complete Markdown chrome copy for one locale revision. + * @param t - conversation locale seat. + * @returns labels for code fences and footnotes. + */ +export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { + return { + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + } +} + +/** + * Format the truncation footer for a JSON Markdown block. + * @param t - conversation locale seat. + * @param total - full serialized character count. + * @returns localized truncation footer. + */ +export function jsonTruncatedLabel(t: ChatViewSlotProps['t'], total: number): string { + return t('markdown.truncatedCharacters', { total }) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx index b6b62468d1..fbbc63b41b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx @@ -121,7 +121,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) { {reading} {headAfter} - {`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`} + {`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
@@ -141,7 +141,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) { {t(row.label)} -
{`~${formatTokens(breakdown[row.key])}`}
+
{`~${formatTokens(breakdown[row.key], t)}`}
))} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 1f72d993d6..249031f4c0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -57,8 +57,11 @@ function displayName(name: string): string { return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } -function optionLabel(option: PermissionSelectValue['options'][number]): string { - return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name) +function optionLabel( + option: PermissionSelectValue['options'][number], + t: ComposerBarProps['t'], +): string { + return option.value === FULL_ACCESS ? t('access.fullLabel') : displayName(option.name) } export interface PermissionSelectProps { @@ -92,7 +95,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect .filter(o => o.value !== 'custom') .map((option) => { const icon = permissionGlyph(option.value) - return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } } + return { id: option.value, label: optionLabel(option, t), ...icon === undefined ? {} : { icon } } }) const submit = (id: string): void => { @@ -138,7 +141,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect - {/* Failure copy stays English (error-surface policy: not localized). */} - {error !== null && failed to exit plan mode} + {error !== null && {t('chip.exitFailed')}} ) } diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 6410e6489b..b3048a7d5a 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -2,10 +2,12 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'chip.label': 'Plan', 'chip.on.aria': 'plan mode 已开启,按下关闭', 'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)', 'chip.off.aria': 'plan mode 已关闭,按下开启', 'chip.off.title': 'plan mode 已关闭 — 点击开启(/plan)', + 'chip.exitFailed': '退出 plan mode 失败', } satisfies Record /** The plan namespace key union. */ @@ -13,8 +15,10 @@ export type PlanKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'chip.label': 'Plan', 'chip.on.aria': 'Plan mode on, press to turn off', 'chip.on.title': 'Plan mode on — click to turn off (/plan off)', 'chip.off.aria': 'Plan mode off, press to turn on', 'chip.off.title': 'Plan mode off — click to turn on (/plan)', + 'chip.exitFailed': 'Failed to exit plan mode', } satisfies Record diff --git a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx index 04ec113ef3..70be459458 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx @@ -82,7 +82,7 @@ describe('PlanChip', () => { .mockRejectedValueOnce('socket closed') setup({ active: true, pending: false }, exitPlanMode) fireEvent.click(chip()) - expect((await screen.findByText('failed to exit plan mode')).getAttribute('title')).toBe('host said no') + expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') expect(chip()).toBeTruthy() fireEvent.click(chip()) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9bd21da46c..720136b270 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81 -README.zh.md: 8de384f276e8be5b78d2659a1e14e4afe12c030e +README.md: c1c40e39710d46fae240f0b3281c36d660855010 +README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7822a5d41e..c1c40e3971 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -50,5 +50,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error. -- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing gets those defaults. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment. +- **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`, so `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionBanner`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` receive complete localized labels through props. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 8de384f276..631936ad65 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -50,5 +50,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。 -- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费方得到的就是这些默认值。`WebBlock` 尚未跟进这一模式:它的来源列表截断提示与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 +- **面向用户的文案必须由渲染点传入**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionBanner`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 和 `WebBlock` 都通过 prop 接收完整的本地化 label。本包不持有任何语言回落值;遗漏会导致类型检查失败,各功能把自己的 typed `t` 席位映射到原子组件 label 接口([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/ConnectionBanner.tsx b/packages/client/ui-primitives/src/ConnectionBanner.tsx index 5418e37159..1cb38e99b2 100644 --- a/packages/client/ui-primitives/src/ConnectionBanner.tsx +++ b/packages/client/ui-primitives/src/ConnectionBanner.tsx @@ -7,9 +7,9 @@ import css from './ConnectionBanner.module.css' * package is cordis-free, so copy arrives via props). * @returns the banner, or null when connected. */ -export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: { +export function ConnectionBanner({ reconnecting, label }: { reconnecting: boolean - label?: string | undefined + label: string }) { if (!reconnecting) return null return
{label}
diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index b1284243e6..4d97c72375 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -22,12 +22,25 @@ export interface DiffHunk { export interface DiffBlockProps { /** One entry per applied hunk, in file order; empty renders nothing. */ diffs: DiffHunk[] + /** Localized chrome supplied by the owning render site. */ + labels: DiffBlockLabels /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */ maxLines?: number | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined } +/** Localized chrome for {@link DiffBlock}. */ +export interface DiffBlockLabels { + copy: string + copied: string + collapseAria: string + expandAria: (hidden: number) => string + collapse: string + expand: (hidden: number) => string + files: (count: number) => string +} + /** A single rendered body line and its role, so the height cap slices a flat list. */ interface DiffRow { kind: 'path' | 'del' | 'add' | 'gap' @@ -123,7 +136,7 @@ function copyText(rows: DiffRow[]): string { * @param props - see {@link DiffBlockProps}. * @returns the diff block element. */ -export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { +export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs]) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) @@ -153,7 +166,7 @@ export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className return (
{head.map((row, index) => ( @@ -164,17 +177,17 @@ export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className type="button" className={css.expand} aria-expanded={expanded} - aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`} + aria-label={expanded ? labels.collapseAria : labels.expandAria(hidden)} onClick={onToggle} > - {expanded ? '收起' : `… 其余 ${hidden} 行`} + {expanded ? labels.collapse : labels.expand(hidden)} )} {tail.map((row, index) => (
{row.text}
))}
-
└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}
+
└ +{added} -{removed} · {labels.files(files)}
) } diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 56dbe46fce..216df4c5d5 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -14,21 +14,21 @@ import css from './HoverCard.module.css' * @param props.disabled - suppress opening; turning true closes an open card. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. - * @param props.copyLabel - accessible activation-label prefix (default "复制"). - * @param props.copiedLabel - visible success label (default "复制成功"). + * @param props.copyLabel - localized accessible activation-label prefix. + * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, - copyText, copyLabel = '复制', copiedLabel = '复制成功', + copyText, copyLabel, copiedLabel, }: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean copyText?: string | undefined - copyLabel?: string | undefined - copiedLabel?: string | undefined + copyLabel: string + copiedLabel: string }) { const rootRef = useRef(null) const cardRef = useRef(null) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 13905acde9..b9afb33960 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, @@ -17,9 +17,7 @@ const PREVIEW_DEPTH_LIMIT = 2 /** * Display copy for the tree's copy affordance; the owner passes localized - * labels (this package is cordis-free, so copy arrives via props). Every - * field defaults to the current built-in value, so existing consumers render - * unchanged. + * labels (this package is cordis-free, so copy arrives via props). */ export interface JsonTreeLabels { /** Menu item: copy the raw primitive value. */ @@ -44,19 +42,6 @@ export interface JsonTreeLabels { copyButtonTitle: (action: string) => string } -const DEFAULT_LABELS: JsonTreeLabels = { - copyValue: 'Copy value', - copyJson: 'Copy JSON', - copyPath: 'Copy property path', - copyPrettyJson: 'Copy pretty JSON', - copyCompactJson: 'Copy compact JSON', - copied: 'Copied', - copyFailed: 'Copy failed', - collapseNode: 'Collapse JSON node', - expandNode: 'Expand JSON node', - copyButtonTitle: action => `${action}; right-click for copy options`, -} - function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] { return [ { id: 'value', label: labels.copyValue }, @@ -388,15 +373,15 @@ export interface JsonTreeProps { /** Parsed JSON object or array. */ data: object | unknown[] /** Accessible label for the tree. */ - label?: string + label: string /** Optional positioning class owned by the caller. */ className?: string | undefined /** Whether JSON rows expose copy actions. */ copyable?: boolean /** Whether the top-level object or array is always expanded. */ expandTopLevel?: boolean - /** Localized display copy; omitted fields keep the built-in defaults. */ - labels?: Partial | undefined + /** Localized display copy supplied by the owning render site. */ + labels: JsonTreeLabels } /** @@ -406,16 +391,13 @@ export interface JsonTreeProps { */ export function JsonTree({ data, - label = 'JSON', + label, className, copyable = true, expandTopLevel = true, labels, }: JsonTreeProps) { - const copyLabels = useMemo( - () => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }), - [labels], - ) + const copyLabels = labels const rootEntries = entriesOf(data) const firstExpandableIndex = rootEntries.findIndex(([, value]) => ( isExpandableValue(value) && entriesOf(value).length > 0 diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 4686efa63a..37f3694501 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -20,7 +20,7 @@ import css from './Modal.module.css' * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, }: { open: boolean onClose: () => void @@ -60,7 +60,7 @@ export function Modal({

{title}

-
diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index 9aba555e10..eca2582978 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -29,6 +29,8 @@ export interface ReadBlockProps { label?: string | undefined /** The returned window's lines, in file order, each keeping its file line number. */ lines: readonly ReadBlockLine[] + /** Localized chrome supplied by the owning render site. */ + labels: ReadBlockLabels /** Exact total line count in the file, for the "showing N of M" note when the read is a window. */ totalLines: number /** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */ @@ -39,6 +41,17 @@ export interface ReadBlockProps { className?: string | undefined } +/** Localized chrome for {@link ReadBlock}. */ +export interface ReadBlockLabels { + window: (shown: number, total: number) => string + copy: string + copied: string + collapseAria: string + expandAria: (hidden: number) => string + collapse: string + expand: (hidden: number) => string +} + function renderSpans(spans: readonly HighlightSpan[]) { return spans.map((span, index) => {span.text}) } @@ -51,6 +64,7 @@ function renderSpans(spans: readonly HighlightSpan[]) { */ export function ReadBlock({ label, + labels, lines, totalLines, lang, @@ -104,13 +118,13 @@ export function ReadBlock({
{label ?? ''}
{windowed && ( - {`显示 ${lines.length} / ${totalLines} 行`} + {labels.window(lines.length, totalLines)} )} {lang ?? ''} {/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */} {lines.length > 0 && ( )}
@@ -122,10 +136,10 @@ export function ReadBlock({ type="button" className={css.expand} aria-expanded={expanded} - aria-label={expanded ? '收起内容' : `展开其余 ${hidden} 行`} + aria-label={expanded ? labels.collapseAria : labels.expandAria(hidden)} onClick={onToggle} > - {expanded ? '收起' : `… 其余 ${hidden} 行`} + {expanded ? labels.collapse : labels.expand(hidden)} )} {capped && rows(paired.slice(paired.length - tailLines))} diff --git a/packages/client/ui-primitives/src/RiskConfirmation.tsx b/packages/client/ui-primitives/src/RiskConfirmation.tsx index d9f8ebea76..8990cb8e5b 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.tsx +++ b/packages/client/ui-primitives/src/RiskConfirmation.tsx @@ -13,6 +13,7 @@ export interface RiskConfirmationProps { description: string acknowledgeLabel: string cancelLabel: string + closeLabel: string confirmLabel: string acknowledged: boolean disabled?: boolean @@ -31,6 +32,7 @@ export function RiskConfirmation({ description, acknowledgeLabel, cancelLabel, + closeLabel, confirmLabel, acknowledged, disabled = false, @@ -43,6 +45,7 @@ export function RiskConfirmation({ open={open} onClose={onCancel} title={title} + closeLabel={closeLabel} className={css.confirmation ?? ''} contentClassName={css.confirmationContent ?? ''} footer={( diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 63989455e3..c2328d6a9d 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -29,6 +29,8 @@ export interface SearchFileGroup { /** Fields both search shapes carry (the render site positions; this component draws). */ interface SearchBlockCommon { + /** Localized chrome supplied by the owning render site. */ + labels: SearchBlockLabels /** * Whether the tool capped the inline result: the shape carries only the * retained results, not every result the search found. The banner summary @@ -44,6 +46,19 @@ interface SearchBlockCommon { className?: string | undefined } +/** Localized chrome for {@link SearchBlock}. */ +export interface SearchBlockLabels { + pathsSummary: (shown: number, total: number, truncated: boolean) => string + matchesSummary: (shown: number, total: number, files: number, truncated: boolean) => string + copy: string + copied: string + noResults: string + collapseAria: string + expandAria: (hidden: number) => string + collapse: string + expand: (hidden: number) => string +} + /** Props for the grouped-matches (`grep`) shape. */ export interface SearchMatchesBlockProps extends SearchBlockCommon { kind: 'matches' @@ -113,10 +128,9 @@ function shownCount(props: SearchBlockProps): number { * @returns the summary text. */ function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string { - const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}` return props.kind === 'paths' - ? `${count} 个路径` - : `${count} 处匹配 · ${props.files.length} 个文件` + ? props.labels.pathsSummary(shown, total, truncated) + : props.labels.matchesSummary(shown, total, props.files.length, truncated) } /** @@ -232,12 +246,12 @@ export function SearchBlock(props: SearchBlockProps) { {summaryText(props, shown, truncated, total)} {!empty && ( )}
{empty - ?
无结果
+ ?
{props.labels.noResults}
: (
{head.map(row => ( @@ -248,10 +262,10 @@ export function SearchBlock(props: SearchBlockProps) { type="button" className={css.expand} aria-expanded={expanded} - aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`} + aria-label={expanded ? props.labels.collapseAria : props.labels.expandAria(hidden)} onClick={onToggle} > - {expanded ? '收起' : `… 其余 ${hidden} 行`} + {expanded ? props.labels.collapse : props.labels.expand(hidden)} )} {tailHeader !== undefined && ( diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index f23c78c139..26e26fe2d2 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -12,9 +12,7 @@ export const DEFAULT_TERMINAL_MAX_LINES = 16 /** * Display copy for the terminal surface; the owner passes localized labels - * (this package is cordis-free, so copy arrives via props). Every field - * defaults to the current built-in value, so existing consumers render - * unchanged. + * (this package is cordis-free, so copy arrives via props). */ export interface TerminalBlockLabels { /** Status pill text for a signal-terminated command. */ @@ -43,21 +41,6 @@ export interface TerminalBlockLabels { expand: (hidden: number) => string } -const DEFAULT_LABELS: TerminalBlockLabels = { - signal: signal => `信号 ${signal}`, - exitCode: exitCode => `退出码 ${exitCode}`, - running: '运行中', - failed: '失败', - done: '已完成', - copy: '复制', - copied: '复制成功', - noOutput: '无输出', - collapseAria: '收起输出', - collapse: '收起', - expandAria: hidden => `展开其余 ${hidden} 行输出`, - expand: hidden => `… 其余 ${hidden} 行`, -} - export interface TerminalBlockProps { /** The command line, rendered verbatim after the prompt label. */ command: string @@ -77,8 +60,8 @@ export interface TerminalBlockProps { maxLines?: number | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined - /** Localized display copy; omitted fields keep the built-in defaults. */ - labels?: Partial | undefined + /** Localized display copy supplied by the owning render site. */ + labels: TerminalBlockLabels } /** @@ -172,10 +155,7 @@ export function TerminalBlock({ className, labels, }: TerminalBlockProps) { - const copy = useMemo( - () => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }), - [labels], - ) + const copy = labels const text = output ?? '' // A command's output ends with a newline; that terminator is not an extra // blank line to draw or to count against the height cap. The check runs on the diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx index 5341d86cfe..c070151a88 100644 --- a/packages/client/ui-primitives/src/WebBlock.tsx +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { MarkdownText } from './markdown/MarkdownText.tsx' +import { MarkdownText, type MarkdownLabels } from './markdown/MarkdownText.tsx' import css from './WebBlock.module.css' /** @@ -21,6 +21,8 @@ export interface WebSourceView { /** A `web_search` card: an optional answer over a capped citation list. */ export interface WebSearchBlockProps { kind: 'search' + /** Localized chrome supplied by the owning render site. */ + labels: WebBlockLabels /** The provider-generated answer, rendered as markdown above the sources. */ answer?: string | undefined /** The cited sources, in provider order. */ @@ -34,6 +36,8 @@ export interface WebSearchBlockProps { /** A `web_fetch` card: the retrieval summary for one fetched URL. */ export interface WebFetchBlockProps { kind: 'fetch' + /** Localized chrome supplied by the owning render site. */ + labels: WebBlockLabels /** The final URL after allowed redirects; becomes a safe external link when http(s). */ url: string /** HTTP status code of the fetched response. */ @@ -47,6 +51,15 @@ export interface WebFetchBlockProps { /** A completed web retrieval card, discriminated by `kind`. */ export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps +/** Localized chrome for {@link WebBlock}. */ +export interface WebBlockLabels { + noResults: string + sourcesTruncated: string + http: string + contentTruncated: string + markdown: MarkdownLabels +} + /** * The URL to link to, or undefined when the URL must render as plain text. Only * http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:` @@ -132,7 +145,7 @@ function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: numbe * @param props - see {@link WebSearchBlockProps}. * @returns the search card element. */ -function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBlockProps) { +function WebSearchBlock({ answer, sources, truncated, labels, className }: WebSearchBlockProps) { // A provider may legitimately return no answer and no sources; the chat WebRow // does not show the raw result content, so without this the user would see an // empty card. Mirror the backend's `No results found.` render text. @@ -140,16 +153,16 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc return (
{answer !== undefined && answer !== '' && ( -
+
)} {empty ? ( -
未找到结果
+
{labels.noResults}
) : (
    {sources.map((source, index) => )}
)} - {truncated &&
来源列表已截断
} + {truncated &&
{labels.sourcesTruncated}
}
) } @@ -159,13 +172,13 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc * @param props - see {@link WebFetchBlockProps}. * @returns the fetch card element. */ -function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) { +function WebFetchBlock({ url, statusCode, truncated, labels, className }: WebFetchBlockProps) { return (
- HTTP {statusCode} - {truncated && 内容已截断} + {labels.http} {statusCode} + {truncated && {labels.contentTruncated}}
) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 619ce0a758..80734f08e9 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -34,20 +34,23 @@ export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx' -export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx' +export type { ReadBlockProps, ReadBlockLine, ReadBlockLabels } from './ReadBlock.tsx' export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' -export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' +export type { DiffBlockProps, DiffHunk, DiffBlockLabels } from './DiffBlock.tsx' export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' export type { SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, + SearchBlockLabels, } from './SearchBlock.tsx' export { WebBlock } from './WebBlock.tsx' -export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' +export type { + WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView, WebBlockLabels, +} from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' -export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index d79f2b47f9..cd109874cb 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -12,12 +12,12 @@ export interface CodeBlockProps { /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */ - copyLabel?: string | undefined + copyLabel: string /** Copy-button label during the post-copy confirmation window. */ - copiedLabel?: string | undefined + copiedLabel: string } -export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) { +export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code // Re-render when a lazy grammar finishes loading, so a fence that showed plain // text while its language's grammar imported picks up highlighting. The diff --git a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx index b63d633131..67794291d1 100644 --- a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx @@ -5,17 +5,12 @@ import css from './JsonBlock.module.css' const MAX_CHARS = 20_000 -/** Default truncation footer; the owner passes a localized formatter. */ -function defaultTruncatedLabel(total: number): string { - return `… 已截断,共 ${total} 字符` -} - -export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: { +export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel }: { label: string payload: unknown defaultOpen?: boolean /** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */ - truncatedLabel?: ((total: number) => string) | undefined + truncatedLabel: (total: number) => string }) { const [open, setOpen] = useState(defaultOpen) const body = useMemo(() => { diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 4dff78d784..b4fc2d4678 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -19,16 +19,16 @@ import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, wrapBlockChildren, } from './render.tsx' -import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx' +import type { MarkdownFileMentions, MarkdownLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './render.tsx' /** One settled full render: parse with math, resolve references, append the footnote section. */ function renderSettled( text: string, - codeLabels: MarkdownCodeLabels | undefined, + labels: MarkdownLabels, fileMentions: MarkdownFileMentions | undefined, ): ReactNode[] { const root = parseGfmWithMath(text) @@ -36,7 +36,7 @@ function renderSettled( collectReferenceTargets(root.children, targets) const context: MarkdownRenderContext = { streaming: false, - codeLabels, + labels, fileMentions, targets, footnoteOrder: [], @@ -67,8 +67,8 @@ class StreamingRenderer { private lastText: string | null = null private lastRendered: ReactNode[] = [] - /** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */ - constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {} + /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */ + constructor(private readonly labels: MarkdownLabels) {} /** * Render the current accumulated text. Idempotent per text value, so React @@ -100,7 +100,7 @@ class StreamingRenderer { if (newlyFrozen.length > 0) { const frozenContext: MarkdownRenderContext = { streaming: true, - codeLabels: this.codeLabels, + labels: this.labels, fileMentions: undefined, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, @@ -118,7 +118,7 @@ class StreamingRenderer { } const tailContext: MarkdownRenderContext = { streaming: true, - codeLabels: this.codeLabels, + labels: this.labels, fileMentions: undefined, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], @@ -141,8 +141,8 @@ class StreamingRenderer { * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; * `streaming` renders fences and TeX plain (highlighting and KaTeX land on - * the finalize swap) and parses incrementally across chunks; `codeLabels` - * forwards localized copy-button labels to fence CodeBlocks — pass a + * the finalize swap) and parses incrementally across chunks; `labels` + * forwards localized fence and footnote chrome — pass a * reference-stable object (memoized per locale revision), because a new * identity discards the streaming render cache mid-message. `fileMentions` * links inline-code tokens its resolver recognizes as real files; this is @@ -153,24 +153,24 @@ class StreamingRenderer { * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: { +export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions }: { text: string streaming?: boolean - codeLabels?: MarkdownCodeLabels | undefined + labels: MarkdownLabels fileMentions?: MarkdownFileMentions | undefined }) { const streamRef = useRef(null) - const streamLabelsRef = useRef(codeLabels) + const streamLabelsRef = useRef(labels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, codeLabels, fileMentions) + return renderSettled(text, labels, fileMentions) } - if (streamRef.current === null || streamLabelsRef.current !== codeLabels) { - streamRef.current = new StreamingRenderer(codeLabels) - streamLabelsRef.current = codeLabels + if (streamRef.current === null || streamLabelsRef.current !== labels) { + streamRef.current = new StreamingRenderer(labels) + streamLabelsRef.current = labels } return streamRef.current.render(text) - }, [text, streaming, codeLabels, fileMentions]) + }, [text, streaming, labels, fileMentions]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index de713858cf..55a9dadc42 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -30,9 +30,15 @@ import css from './MarkdownText.module.css' /** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */ export interface MarkdownCodeLabels { /** Copy-button idle label. */ - copyLabel?: string | undefined + copyLabel: string /** Copy-button label during the post-copy confirmation window. */ - copiedLabel?: string | undefined + copiedLabel: string +} + +/** Localized chrome for a Markdown document. */ +export interface MarkdownLabels { + code: MarkdownCodeLabels + footnotes: string } function sanitizeUrl(url: string): string { @@ -123,7 +129,7 @@ export interface MarkdownRenderContext { /** Streaming arm: fences render plain and TeX stays literal. */ readonly streaming: boolean /** Localized fence copy-button labels. */ - readonly codeLabels: MarkdownCodeLabels | undefined + readonly labels: MarkdownLabels /** Inside a blockquote's children: tables there always fill the quote's width. */ readonly inBlockquote?: boolean /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ @@ -329,8 +335,8 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re // that trim eat a REAL trailing blank line inside the fence instead. code={`${node.value}\n`} lang={context.streaming ? undefined : lang} - copyLabel={context.codeLabels?.copyLabel} - copiedLabel={context.codeLabels?.copiedLabel} + copyLabel={context.labels.code.copyLabel} + copiedLabel={context.labels.code.copiedLabel} /> ) } @@ -597,7 +603,7 @@ export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode if (items.length === 0) return null return (
-

Footnotes

+

{context.labels.footnotes}

    {items}
) diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 568853d0cb..1f23fc33ca 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -410,9 +410,9 @@ describe('Modal', () => { describe('ConnectionBanner', () => { it('renders only while reconnecting', () => { - const { container, rerender } = render() + const { container, rerender } = render() expect(container.firstChild).toBeNull() - rerender() - expect(container.textContent).toContain('重连') + rerender() + expect(container.textContent).toContain('Reconnecting') }) }) diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx index f1599c9bc5..62edc7c4a6 100644 --- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { CodeBlock } from '../src/markdown/CodeBlock.tsx' +import type { ComponentProps } from 'react' +import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx' import { highlightToHtml } from '../src/markdown/highlight.ts' +import { markdownLabels } from './labels.client.ts' + +function CodeBlock(props: Omit, 'copyLabel' | 'copiedLabel'>) { + return +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/diff-block.client.spec.tsx b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx index 21d2ec5744..aad3fecf5e 100644 --- a/packages/client/ui-primitives/tests/diff-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/diff-block.client.spec.tsx @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts' +import type { ComponentProps } from 'react' +import { DEFAULT_DIFF_MAX_LINES, DiffBlock as LocalizedDiffBlock, type DiffHunk } from '../src/index.ts' +import { diffBlockLabels } from './labels.client.ts' + +function DiffBlock(props: Omit, 'labels'>) { + return +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 53d5d2bcf7..5bb6f5786e 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -25,7 +25,13 @@ function mount(props: { copiedLabel?: string } = {}) { const view = render( - row} content={
card body
} {...props} />, + row} + content={
card body
} + copyLabel={props.copyLabel ?? 'Copy'} + copiedLabel={props.copiedLabel ?? 'Copied'} + {...props} + />, ) const anchor = screen.getByText('row') stubAnchorRect(anchor, { top: 40, right: 200 }) @@ -370,7 +376,15 @@ describe('HoverCard', () => { fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() - view.rerender(row} content={
card body
} disabled />) + view.rerender( + row} + content={
card body
} + copyLabel="Copy" + copiedLabel="Copied" + disabled + />, + ) expect(screen.queryByText('card body')).toBeNull() }) diff --git a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx index b9dd962ad3..691ba88652 100644 --- a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx @@ -2,7 +2,15 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { JsonTree } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ComponentProps } from 'react' +import { JsonTree as LocalizedJsonTree } from '@deepseek-ai/dsh-client-ui-primitives' +import { jsonTreeLabels } from './labels.client.ts' + +function JsonTree(props: Omit, 'label' | 'labels'> & { + label?: string +}) { + return +} let writeText: ReturnType diff --git a/packages/client/ui-primitives/tests/labels.client.ts b/packages/client/ui-primitives/tests/labels.client.ts new file mode 100644 index 0000000000..bb27e9dd34 --- /dev/null +++ b/packages/client/ui-primitives/tests/labels.client.ts @@ -0,0 +1,64 @@ +import type { + DiffBlockLabels, + JsonTreeLabels, + MarkdownLabels, + ReadBlockLabels, + SearchBlockLabels, + TerminalBlockLabels, + WebBlockLabels, +} from '../src/index.ts' + +export const markdownLabels: MarkdownLabels = { + code: { copyLabel: '复制', copiedLabel: '复制成功' }, + footnotes: 'Footnotes', +} + +export const diffBlockLabels: DiffBlockLabels = { + copy: '复制', copied: '复制成功', collapseAria: '收起差异', + expandAria: hidden => `展开其余 ${hidden} 行差异`, + collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`, + files: count => `${count} ${count === 1 ? 'file' : 'files'}`, +} + +export const readBlockLabels: ReadBlockLabels = { + window: (shown, total) => `显示 ${shown} / ${total} 行`, + copy: '复制', copied: '复制成功', collapseAria: '收起内容', + expandAria: hidden => `展开其余 ${hidden} 行`, + collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`, +} + +export const searchBlockLabels: SearchBlockLabels = { + pathsSummary: (shown, total, truncated) => truncated + ? `显示 ${shown} / 共 ${total} 个路径` + : `${shown} 个路径`, + matchesSummary: (shown, total, files, truncated) => truncated + ? `显示 ${shown} / 共 ${total} 处匹配 · ${files} 个文件` + : `${shown} 处匹配 · ${files} 个文件`, + copy: '复制', copied: '复制成功', noResults: '无结果', + collapseAria: '收起结果', + expandAria: hidden => `展开其余 ${hidden} 行结果`, + collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`, +} + +export const terminalBlockLabels: TerminalBlockLabels = { + signal: signal => `信号 ${signal}`, + exitCode: code => `退出码 ${code}`, + running: '运行中', failed: '失败', done: '已完成', + copy: '复制', copied: '复制成功', noOutput: '无输出', + collapseAria: '收起输出', collapse: '收起', + expandAria: hidden => `展开其余 ${hidden} 行输出`, + expand: hidden => `… 其余 ${hidden} 行`, +} + +export const jsonTreeLabels: JsonTreeLabels = { + copyValue: 'Copy value', copyJson: 'Copy JSON', copyPath: 'Copy property path', + copyPrettyJson: 'Copy pretty JSON', copyCompactJson: 'Copy compact JSON', + copied: 'Copied', copyFailed: 'Copy failed', + collapseNode: 'Collapse JSON node', expandNode: 'Expand JSON node', + copyButtonTitle: action => `${action}; right-click for copy options`, +} + +export const webBlockLabels: WebBlockLabels = { + noResults: '未找到结果', sourcesTruncated: '来源列表已截断', + http: 'HTTP', contentTruncated: '内容已截断', markdown: markdownLabels, +} diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx index c14e2eacc2..ec368bd666 100644 --- a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx @@ -3,7 +3,7 @@ // user-visible Markdown changes rather than regenerating them for refactors. import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText } from './markdown-test-components.tsx' afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx index 4397aea124..f3624b96d4 100644 --- a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx @@ -6,7 +6,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { Root, RootContent } from 'mdast' -import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText } from './markdown-test-components.tsx' import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts' import { parseGfm } from '../src/markdown/parse.ts' @@ -95,9 +95,13 @@ describe('incremental streaming rendering', () => { it('drops the streaming cache when the copy labels change identity', () => { const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n') - const live = render() + const live = render( + , + ) expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy']) - live.rerender() + live.rerender( + , + ) expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren']) live.unmount() }) diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx index dbad9ef164..9eb4eb0265 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx @@ -8,7 +8,8 @@ import { StrictMode } from 'react' import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type * as Md from 'mdast' -import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MarkdownText } from './markdown-test-components.tsx' +import { markdownLabels } from './labels.client.ts' import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, } from '../src/markdown/render.tsx' @@ -19,7 +20,7 @@ afterEach(cleanup) function makeContext(): MarkdownRenderContext { return { streaming: false, - codeLabels: undefined, + labels: markdownLabels, fileMentions: undefined, targets: createReferenceTargets(), footnoteOrder: [], diff --git a/packages/client/ui-primitives/tests/markdown-test-components.tsx b/packages/client/ui-primitives/tests/markdown-test-components.tsx new file mode 100644 index 0000000000..77192d8e1c --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-test-components.tsx @@ -0,0 +1,37 @@ +import type { ComponentProps } from 'react' +import { + JsonBlock as LocalizedJsonBlock, + MarkdownText as LocalizedMarkdownText, + type MarkdownCodeLabels, + type MarkdownLabels, +} from '../src/index.ts' +import { markdownLabels as defaultMarkdownLabels } from './labels.client.ts' + +type MarkdownTextProps = Omit, 'labels'> & { + labels?: MarkdownLabels + codeLabels?: MarkdownCodeLabels +} + +export function MarkdownText({ + labels, + codeLabels, + ...props +}: MarkdownTextProps) { + const resolved = labels ?? (codeLabels === undefined + ? defaultMarkdownLabels + : { ...defaultMarkdownLabels, code: codeLabels }) + return +} + +type JsonBlockProps = Omit, 'truncatedLabel'> & { + truncatedLabel?: (total: number) => string +} + +export function JsonBlock({ truncatedLabel, ...props }: JsonBlockProps) { + return ( + `… 已截断,共 ${total} 字符`)} + /> + ) +} diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index 6b35712bd2..01e354ad81 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, MarkdownText } from './markdown-test-components.tsx' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' diff --git a/packages/client/ui-primitives/tests/read-block.client.spec.tsx b/packages/client/ui-primitives/tests/read-block.client.spec.tsx index 8982fb64aa..4ad939838d 100644 --- a/packages/client/ui-primitives/tests/read-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/read-block.client.spec.tsx @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts' +import type { ComponentProps } from 'react' +import { DEFAULT_READ_MAX_LINES, ReadBlock as LocalizedReadBlock, type ReadBlockLine } from '../src/index.ts' import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts' +import { readBlockLabels } from './labels.client.ts' + +function ReadBlock(props: Omit, 'labels'>) { + return +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/search-block.client.spec.tsx b/packages/client/ui-primitives/tests/search-block.client.spec.tsx index 1f925a45d3..462c334e20 100644 --- a/packages/client/ui-primitives/tests/search-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/search-block.client.spec.tsx @@ -2,8 +2,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts' -import type { SearchFileGroup } from '../src/index.ts' +import { DEFAULT_SEARCH_MAX_LINES, SearchBlock as LocalizedSearchBlock } from '../src/index.ts' +import type { + SearchFileGroup, SearchMatchesBlockProps, SearchPathsBlockProps, +} from '../src/index.ts' +import { searchBlockLabels } from './labels.client.ts' + +type SearchBlockProps = + | Omit + | Omit + +function SearchMatchesBlock(props: Omit) { + return +} + +function SearchPathsBlock(props: Omit) { + return +} + +function SearchBlock(props: SearchBlockProps) { + return props.kind === 'matches' + ? + : +} afterEach(cleanup) diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 15ae044881..45de33c8f0 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts' +import type { ComponentProps } from 'react' +import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock as LocalizedTerminalBlock } from '../src/index.ts' import { writeClipboard } from '../src/clipboard.ts' +import { terminalBlockLabels } from './labels.client.ts' + +function TerminalBlock(props: Omit, 'labels'>) { + return +} const ESC = '\u001b' diff --git a/packages/client/ui-primitives/tests/web-block.client.spec.tsx b/packages/client/ui-primitives/tests/web-block.client.spec.tsx index c595a7ef06..5248aeda5a 100644 --- a/packages/client/ui-primitives/tests/web-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/web-block.client.spec.tsx @@ -2,8 +2,29 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { WebBlock } from '../src/index.ts' -import type { WebSourceView } from '../src/index.ts' +import { WebBlock as LocalizedWebBlock } from '../src/index.ts' +import type { + WebFetchBlockProps, WebSearchBlockProps, WebSourceView, +} from '../src/index.ts' +import { webBlockLabels } from './labels.client.ts' + +type WebBlockProps = + | Omit + | Omit + +function WebSearchBlock(props: Omit) { + return +} + +function WebFetchBlock(props: Omit) { + return +} + +function WebBlock(props: WebBlockProps) { + return props.kind === 'search' + ? + : +} afterEach(cleanup) diff --git a/packages/client/ui-renderer/README.i18n.yaml b/packages/client/ui-renderer/README.i18n.yaml index a76bbe3a7f..4c0e05f393 100644 --- a/packages/client/ui-renderer/README.i18n.yaml +++ b/packages/client/ui-renderer/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-renderer/README.md -README.md: f63f1eff99d8d3357280f81648899bb68f5e2ef9 -README.zh.md: 1ff77298e7add13a0138604d5c8a0f86f6cd019f +README.md: 01ffc783eacc2588abca946d592ea72aa78fecd2 +README.zh.md: 3858330a08f23fd87a2f61620210e384ecc37ef4 diff --git a/packages/client/ui-renderer/README.md b/packages/client/ui-renderer/README.md index f63f1eff99..01ffc783ea 100644 --- a/packages/client/ui-renderer/README.md +++ b/packages/client/ui-renderer/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The browser Cordis plugin that owns the React rendering layer. [`dsh-client-web`](../web/README.md) renders a framework-free boot page and loads the complete client plugin roster; after every entry activates, it calls `ctx.uiRenderer.mount(container)`. This package provides that service, installs the slot renderer, hydrates the existing boot DOM, switches to the assembled application before the next paint, and returns the React root's unmount disposer. -The client entry also owns the React implementation of slot outlets, session providers, and observable-to-uSES binding. Business plugins pass bare observable sources through typed slot `hooks`; the renderer binds them at the outlet. The plugin activates after `slots`, `sessions`, and `layout`, projects the selected session title, and performs the sole context-level `renderSlot('root')` call. React, React DOM, Cordis, ui-slots, and ui-primitives retain one browser identity through the web shell's static module table; this package arrives as a dynamic client bundle. +The client entry also owns the React implementation of slot outlets, session providers, and observable-to-uSES binding. Business plugins pass bare observable sources through typed slot `hooks`; the renderer binds them at the outlet. The plugin activates after `slots`, `sessions`, and `locale`, projects the selected session title over the localized or build-configured product title, follows locale revisions, and performs the sole context-level `renderSlot('root')` call. React, React DOM, Cordis, ui-slots, and ui-primitives retain one browser identity through the web shell's static module table; this package arrives as a dynamic client bundle. ## Model Experience diff --git a/packages/client/ui-renderer/README.zh.md b/packages/client/ui-renderer/README.zh.md index 1ff77298e7..3858330a08 100644 --- a/packages/client/ui-renderer/README.zh.md +++ b/packages/client/ui-renderer/README.zh.md @@ -4,7 +4,7 @@ 负责 React 渲染层的浏览器 Cordis 插件。[`dsh-client-web`](../web/README.zh.md) 渲染不依赖框架的启动页并加载完整的客户端插件名册;所有 entry 激活后,它调用 `ctx.uiRenderer.mount(container)`。本包提供该服务、安装 slot 渲染器、hydrate 现有启动 DOM、在下一次绘制前切换到组装完成的应用,并返回 React 根的卸载 disposer。 -client entry 还持有 slot outlet、会话 provider 以及 observable 到 uSES 绑定的 React 实现。业务插件通过带类型的 slot `hooks` 传递裸 observable source;渲染器在 outlet 处完成绑定。插件在 `slots`、`sessions` 和 `layout` 就绪后激活,投影当前会话标题,并执行全程序唯一一次上下文级 `renderSlot('root')` 调用。React、React DOM、Cordis、ui-slots 和 ui-primitives 通过 web 外壳的静态模块表保持同一浏览器身份;本包则以动态客户端 bundle 到达。 +client entry 还持有 slot outlet、会话 provider 以及 observable 到 uSES 绑定的 React 实现。业务插件通过带类型的 slot `hooks` 传递裸 observable source;渲染器在 outlet 处完成绑定。插件在 `slots`、`sessions` 和 `locale` 就绪后激活,把当前会话标题投影到已本地化或由 build 配置的产品标题之上,跟随 locale revision,并执行全程序唯一一次上下文级 `renderSlot('root')` 调用。React、React DOM、Cordis、ui-slots 和 ui-primitives 通过 web 外壳的静态模块表保持同一浏览器身份;本包则以动态客户端 bundle 到达。 ## 模型体验 diff --git a/packages/client/ui-renderer/src/client/DocumentTitle.tsx b/packages/client/ui-renderer/src/client/DocumentTitle.tsx index a1111d99bd..4060a3b187 100644 --- a/packages/client/ui-renderer/src/client/DocumentTitle.tsx +++ b/packages/client/ui-renderer/src/client/DocumentTitle.tsx @@ -1,11 +1,11 @@ import { useEffect } from 'react' -const DEFAULT_CLIENT_TITLE = 'DSH Local Build' - /** Props for the browser title projection. */ export interface DocumentTitleProps { /** Durable title of the selected session, or undefined for the product title. */ title?: string + /** Build-configured or localized product title. */ + productTitle: string } /** @@ -14,8 +14,7 @@ export interface DocumentTitleProps { * @param props - Selected session title projection. * @returns No rendered content. */ -export function DocumentTitle({ title }: DocumentTitleProps): null { - const productTitle = process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE +export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null { useEffect(() => { document.title = title === undefined ? productTitle : `${title} — ${productTitle}` return () => { document.title = productTitle } diff --git a/packages/client/ui-renderer/src/client/app.tsx b/packages/client/ui-renderer/src/client/app.tsx index 446d184c5b..57546cb365 100644 --- a/packages/client/ui-renderer/src/client/app.tsx +++ b/packages/client/ui-renderer/src/client/app.tsx @@ -4,6 +4,7 @@ */ import type { ReactNode } from 'react' import type { Context } from '@deepseek-ai/cordis' +import type { LocaleFace } from '@deepseek-ai/dsh-client-ui-slots' import { bindSnapshotSelector } from './bind.ts' import { DocumentTitle } from './DocumentTitle.tsx' import type {} from '@deepseek-ai/dsh-client-runtime/client' @@ -23,13 +24,19 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { const { ctx } = deps const sessions = ctx.get('sessions') if (sessions === undefined) throw new Error('ui renderer: sessions service unavailable') + const locale = ctx.get('locale') as LocaleFace | undefined + if (locale === undefined) throw new Error('ui renderer: locale service unavailable') const useSessions = bindSnapshotSelector(sessions.list) + const useLocale = bindSnapshotSelector(locale) + const t = locale.bind('common') const SessionDocumentTitle = (): ReactNode => { + useLocale(snapshot => snapshot.revision) const title = useSessions((state) => { const id = state.current return id === undefined ? undefined : state.byId[id]?.title }) - return + const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild') + return } return () => ( <> diff --git a/packages/client/ui-renderer/src/client/index.ts b/packages/client/ui-renderer/src/client/index.ts index a7f45bd354..7979f2f0a0 100644 --- a/packages/client/ui-renderer/src/client/index.ts +++ b/packages/client/ui-renderer/src/client/index.ts @@ -38,7 +38,7 @@ declare module '@deepseek-ai/cordis' { } /** Services required before application assembly. */ -export const inject = ['slots', 'sessions'] +export const inject = ['slots', 'sessions', 'locale'] interface BootSnapshot { className: string diff --git a/packages/client/ui-renderer/tests/app.client.spec.tsx b/packages/client/ui-renderer/tests/app.client.spec.tsx index 0184198b77..6ec3c24754 100644 --- a/packages/client/ui-renderer/tests/app.client.spec.tsx +++ b/packages/client/ui-renderer/tests/app.client.spec.tsx @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { buildRenderApp } from '../src/client/app.tsx' +import { locale } from './locale.client.ts' let runtime: SlotTestRuntime | undefined @@ -18,6 +19,7 @@ afterEach(async () => { async function bench() { runtime = await SlotTestRuntime.create() + runtime.provide('locale', locale) await runtime.root.declare({}, () =>
) return { runtime, renderApp: buildRenderApp({ ctx: runtime.ctx }) } } diff --git a/packages/client/ui-renderer/tests/document-title.client.spec.tsx b/packages/client/ui-renderer/tests/document-title.client.spec.tsx index b0f8a3422c..2cec953cb1 100644 --- a/packages/client/ui-renderer/tests/document-title.client.spec.tsx +++ b/packages/client/ui-renderer/tests/document-title.client.spec.tsx @@ -13,13 +13,13 @@ describe('DocumentTitle', () => { it('projects a durable title and restores the product title', () => { vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness') document.title = 'stale title' - const mounted = render() + const mounted = render() expect(document.title).toBe('DeepSeek Harness') - mounted.rerender() + mounted.rerender() expect(document.title).toBe('First title — DeepSeek Harness') - mounted.rerender() + mounted.rerender() expect(document.title).toBe('Revised title — DeepSeek Harness') - mounted.rerender() + mounted.rerender() expect(document.title).toBe('DeepSeek Harness') mounted.unmount() expect(document.title).toBe('DeepSeek Harness') @@ -28,7 +28,7 @@ describe('DocumentTitle', () => { it('uses the generic title when the build provides no title', () => { vi.stubEnv('DSH_CLIENT_TITLE', '') delete process.env.DSH_CLIENT_TITLE - const mounted = render() + const mounted = render() expect(document.title).toBe('First title — DSH Local Build') mounted.unmount() expect(document.title).toBe('DSH Local Build') diff --git a/packages/client/ui-renderer/tests/locale.client.ts b/packages/client/ui-renderer/tests/locale.client.ts new file mode 100644 index 0000000000..66a149c637 --- /dev/null +++ b/packages/client/ui-renderer/tests/locale.client.ts @@ -0,0 +1,12 @@ +import type { LocaleFace } from '@deepseek-ai/dsh-client-ui-slots' + +/** Static locale face for renderer tests that do not exercise locale switching. */ +export const locale = { + bind: () => key => key === 'brand.localBuild' ? 'DSH Local Build' : key, + getSnapshot: () => ({ + active: 'en' as const, + locales: [{ id: 'zh' as const, label: '中文' }, { id: 'en' as const, label: 'English' }], + revision: 0, + }), + subscribe: () => () => {}, +} satisfies LocaleFace diff --git a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx index a966938468..14d24d6840 100644 --- a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx +++ b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx @@ -7,6 +7,7 @@ import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runti import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-renderer' import * as UiRenderer from '../src/client/index.ts' +import { locale } from './locale.client.ts' const mounted: (() => void)[] = [] @@ -25,6 +26,7 @@ async function bench() { const slots = ctx.get('slots') as SlotRegistry ctx.provide('sessions', new TestSessions(stabilize, ctx)) ctx.provide('workspaces', new TestWorkspaces(stabilize)) + ctx.provide('locale' as never, locale as never) const fiber = ctx.plugin({ inject: [...UiRenderer.inject], apply: UiRenderer.apply }) await fiber.await() return { ctx, slots, fiber } diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 5ccad5e4f7..0eb68f97f2 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-settings-models/README.md -README.md: 781d7a8134c8118ddf4a78f3ee153a8043bae0a6 -README.zh.md: 71c629dc6b0afd447e999d040328246677f75235 +README.md: cc430c91b9c4124fc70d0ec205b5870012dd5554 +README.zh.md: 62358adbc6f055697efe33e29e579f0aec64efc3 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 781d7a8134..cc430c91b9 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -6,7 +6,7 @@ Models settings and product-onboarding plugin. The same client Cordis plugin reg Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The notice step owns its exact copy and version in `src/onboarding-copy.ts`. On loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API; only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. +The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`. On loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API; only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. After that notice completes, the DeepSeek step projects first-run readiness from the same joined Models snapshot. ANY provider the user can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference and therefore authenticates natively. Only a user with none is asked for the official DeepSeek key. A mounted, active adapter with a missing writable reference renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. Configure later completes only this coordinator pass. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering; Models remains the diagnostic surface. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 71c629dc6b..62358adbc6 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -6,7 +6,7 @@ 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -声明步骤在 `src/onboarding-copy.ts` 中持有完整文案和版本。回环访问会通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`;只有明确点击「继续」才会记录当前版本。非回环浏览器无法使用这项仅限 Host 的 namespace,因此确认仅在当前进程有效,重载后声明会再次出现。 +声明步骤在 `src/client/locales.ts` 中持有完整文案,并在 `src/onboarding-copy.ts` 中持有确认版本。回环访问会通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`;只有明确点击「继续」才会记录当前版本。非回环浏览器无法使用这项仅限 Host 的 namespace,因此确认仅在当前进程有效,重载后声明会再次出现。 声明完成后,DeepSeek 步骤会从同一个 Models 联接快照得出首次运行就绪状态。只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名引用、因而走原生认证的路由。只有二者皆无的用户才会被要求填写 DeepSeek 官方密钥。适配器已挂载且活跃、引用可写但尚未配置时,既有 `ProviderEditor` 会以仅凭据模式渲染在共用引导弹窗中;`credentials.set` 仍是唯一的 secret 写入,且不会改变提供方设置。「稍后配置」只完成协调器当前这一轮。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤不渲染并直接完成;Models 页仍是诊断界面。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index f84118a2c6..836fc8efdc 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -226,7 +226,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { className={styles['input']} type="text" value={baseURL} - placeholder="https://gateway.example/v1" + placeholder={t('customBaseUrlPlaceholder')} aria-label={t('baseUrl')} disabled={profileDisabled} onChange={(event) => { setBaseURL(event.target.value) }} @@ -285,8 +285,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { t={t} busy={busy} submitDisabled={disabled || !ready} - submitLabel="create" - submitBusyLabel="creating" + submitLabelKey="create" + submitBusyLabelKey="creating" onCancel={() => { props.onClose(committed) }} onSubmit={() => { void create() }} /> diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 9e6cbb4c2a..024351087a 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -113,9 +113,9 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): credentialOnly credentialRequired autoFocusCredential - cancelLabel="onboardingLater" - submitLabel="onboardingSave" - submitBusyLabel="onboardingSaving" + cancelLabelKey="onboardingLater" + submitLabelKey="onboardingSave" + submitBusyLabelKey="onboardingSaving" onClose={finishCredential} />
diff --git a/packages/client/ui-settings-models/src/client/EditorFooter.tsx b/packages/client/ui-settings-models/src/client/EditorFooter.tsx index 6306609ca5..cca5ae5a7a 100644 --- a/packages/client/ui-settings-models/src/client/EditorFooter.tsx +++ b/packages/client/ui-settings-models/src/client/EditorFooter.tsx @@ -26,11 +26,11 @@ export interface EditorFooterProps { /** Whether the commit is refused, as judged by the owning card. */ submitDisabled: boolean /** Commit label while idle. */ - submitLabel: keyof typeof en + submitLabelKey: keyof typeof en /** Commit label while a commit is in flight. */ - submitBusyLabel: keyof typeof en + submitBusyLabelKey: keyof typeof en /** Dismiss label; defaults to the settings editor copy. */ - cancelLabel?: keyof typeof en + cancelLabelKey?: keyof typeof en /** Dismiss the card without committing. */ onCancel: () => void /** Run the card's commit. */ @@ -52,7 +52,7 @@ export function EditorFooter(props: EditorFooterProps): ReactNode { disabled={props.busy} onClick={props.onCancel} > - {t(props.cancelLabel ?? 'cancel')} + {t(props.cancelLabelKey ?? 'cancel')}
) diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 4cc6091b69..eb7a67cd03 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -76,11 +76,11 @@ export interface ProviderEditorProps { /** Give the credential field initial focus when this editor mounts. */ autoFocusCredential?: boolean /** Override the dismiss action copy. */ - cancelLabel?: keyof typeof en + cancelLabelKey?: keyof typeof en /** Override the idle commit action copy. */ - submitLabel?: keyof typeof en + submitLabelKey?: keyof typeof en /** Override the in-flight commit action copy. */ - submitBusyLabel?: keyof typeof en + submitBusyLabelKey?: keyof typeof en /** Close the editor; `changed` reports whether an Apply committed. */ onClose: (changed: boolean) => void } @@ -320,7 +320,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (node === undefined) { // A directory entry addressing a position its schema cannot resolve is a // host-side inconsistency; showing it beats a blank card. - return

{`${props.provider}: unresolvable settings path`}

+ return

{props.provider}: {props.t('settingsPathUnresolvable')}

} const keyLocked = keyState?.writable === false @@ -508,9 +508,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { || (props.credentialOnly !== true && modelFailure !== undefined) || shownKeyFailure !== undefined || (props.credentialRequired === true && keyValue.length === 0)} - submitLabel={props.submitLabel ?? 'apply'} - submitBusyLabel={props.submitBusyLabel ?? 'applying'} - {...props.cancelLabel === undefined ? {} : { cancelLabel: props.cancelLabel }} + submitLabelKey={props.submitLabelKey ?? 'apply'} + submitBusyLabelKey={props.submitBusyLabelKey ?? 'applying'} + {...props.cancelLabelKey === undefined ? {} : { cancelLabelKey: props.cancelLabelKey }} onCancel={() => { props.onClose(false) }} onSubmit={() => { void apply() }} /> diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 856ef64c7b..f1b0718ba5 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -1,7 +1,5 @@ /** Copy dictionaries for the Models settings section. */ -import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts' - /** English strings (the key-set source of truth for this pair). */ export const en = { nav: 'Models', @@ -87,11 +85,13 @@ export const en = { customApiUnset: 'Not selected', customNeedsBaseUrl: 'A custom provider needs a base URL.', customNeedsModels: 'A custom provider needs at least one model.', + customBaseUrlPlaceholder: 'https://gateway.example/v1', + settingsPathUnresolvable: 'unresolvable settings path', create: 'Create provider', creating: 'Creating\u2026', - welcomeTitle: WELCOME_NOTICE_COPY.en.title, - welcomeBody: WELCOME_NOTICE_COPY.en.body, - welcomeContinue: WELCOME_NOTICE_COPY.en.continueLabel, + welcomeTitle: 'Internal Testing Notice', + welcomeBody: "DeepSeek Harness 0.1 remains in testing for Harness developers. Many areas need further improvement, and we welcome feedback from the developer community. DeepSeek Harness's core plugins and foundational APIs will continue to evolve rapidly over the coming months.\n\nWe look forward to exploring the limits of intelligence with developers around the world, building on open-source, open, reusable, and composable infrastructure. We welcome Harness developers everywhere to join the DSH plugin ecosystem.", + welcomeContinue: 'Continue', welcomeError: 'The acknowledgement could not be saved. Please try again.', onboardingTitle: 'Add an API key to get started', onboardingDescription: 'Configure the official DeepSeek provider to start building.', @@ -189,11 +189,13 @@ export const zh: { [Key in keyof typeof en]: string } = { customApiUnset: '未选择', customNeedsBaseUrl: '自定义提供方需要填写 API 地址。', customNeedsModels: '自定义提供方至少需要一个模型。', + customBaseUrlPlaceholder: 'https://gateway.example/v1', + settingsPathUnresolvable: '无法解析设置路径', create: '创建提供方', creating: '创建中\u2026', - welcomeTitle: WELCOME_NOTICE_COPY.zh.title, - welcomeBody: WELCOME_NOTICE_COPY.zh.body, - welcomeContinue: WELCOME_NOTICE_COPY.zh.continueLabel, + welcomeTitle: '内测声明', + welcomeBody: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。', + welcomeContinue: '继续', welcomeError: '暂时无法保存确认状态,请重试。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-settings-models/src/onboarding-copy.ts b/packages/client/ui-settings-models/src/onboarding-copy.ts index d52371a9cf..8ee3277714 100644 --- a/packages/client/ui-settings-models/src/onboarding-copy.ts +++ b/packages/client/ui-settings-models/src/onboarding-copy.ts @@ -9,17 +9,3 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' * again. The acknowledgement is compared for exact equality. */ export const WELCOME_NOTICE_VERSION = '2026-08-13.1' - -/** The complete editable internal-testing notice in both supported GUI locales. */ -export const WELCOME_NOTICE_COPY = { - zh: { - title: '内测声明', - body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。', - continueLabel: '继续', - }, - en: { - title: 'Internal Testing Notice', - body: "DeepSeek Harness 0.1 remains in testing for Harness developers. Many areas need further improvement, and we welcome feedback from the developer community. DeepSeek Harness's core plugins and foundational APIs will continue to evolve rapidly over the coming months.\n\nWe look forward to exploring the limits of intelligence with developers around the world, building on open-source, open, reusable, and composable infrastructure. We welcome Harness developers everywhere to join the DSH plugin ecosystem.", - continueLabel: 'Continue', - }, -} as const diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 4948e12335..8a3b8fce0a 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -385,9 +385,9 @@ describe('ModelsSection', () => { credentialOnly credentialRequired autoFocusCredential - cancelLabel="onboardingLater" - submitLabel="onboardingSave" - submitBusyLabel="onboardingSaving" + cancelLabelKey="onboardingLater" + submitLabelKey="onboardingSave" + submitBusyLabelKey="onboardingSaving" onClose={onClose} />) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index bf2674976a..cad5d4d9fe 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -15,10 +15,15 @@ import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome- import type { WelcomeSection } from '../src/client/welcome-store.ts' import { en, zh } from '../src/client/locales.ts' import { - WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '../src/onboarding-copy.ts' +const WELCOME_NOTICE_COPY = { + en: { title: en.welcomeTitle, body: en.welcomeBody, continueLabel: en.welcomeContinue }, + zh: { title: zh.welcomeTitle, body: zh.welcomeBody, continueLabel: zh.welcomeContinue }, +} + afterEach(() => { cleanup() document.getElementById('root')?.remove() diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index a7ef74eaa6..d02518cb2d 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -143,7 +143,7 @@ export function SidebarRoot({ {renderSlot('sidebar.brand.name', {}, { fallback: ( <> - DSH Local Build + {t('brand.localBuild')} {process.env.DSH_CLIENT_COMMIT_HASH ? {process.env.DSH_CLIENT_COMMIT_HASH} : null} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index a8ef9f5d4e..ee59061984 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -266,7 +266,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo - DSH Local Build + DSH 本地构建 (en as Record)[key] ?? key +const t: SidebarRootComponentProps['t'] = key => + (en as Record)[key] ?? (commonEn as Record)[key] ?? key afterEach(() => { cleanup() diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx index dccea1bfc2..b5bceb9b3a 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx @@ -12,6 +12,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, waitFor } from '@testing-library/react' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' // The service reads its initial locale from the browser; these specs assert @@ -35,6 +37,7 @@ async function bench(options: { locale?: 'en' } = {}) { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { toggleSidebar: vi.fn() }) const locale = new LocaleRuntime(runtime.ctx) + locale.register('common', { zh: commonZh, en: commonEn }) if (options.locale === 'en') locale.setLocale('en') runtime.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index c6f9286154..bee776d93c 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -141,7 +141,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { > {leading} {status !== null ? {status} : null} - Skill + {t('row.title')} {summary} @@ -156,7 +156,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { {inspect !== undefined ? ( ) : null}
diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index 40ef78dea5..d7a9b94228 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -5,10 +5,12 @@ export const NS = 'skill' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'row.title': 'Skill', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'row.inspect': '查看', 'menu.userOnly': '仅用户', } satisfies Record @@ -17,9 +19,11 @@ export type SkillKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'row.title': 'Skill', 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'row.inspect': 'Inspect', 'menu.userOnly': 'user-only', } satisfies Record diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 2f8da20713..761107c1ff 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -125,17 +125,21 @@ describe('apply', () => { expect(presentation.dictionaries).toEqual([{ namespace: 'skill', dictionaries: { zh: { + 'row.title': 'Skill', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'row.inspect': '查看', 'menu.userOnly': '仅用户', }, en: { + 'row.title': 'Skill', 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'row.inspect': 'Inspect', 'menu.userOnly': 'user-only', }, }, diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index 8fbbaef7c0..e0556398ea 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -63,7 +63,7 @@ describe('SkillRow', () => { const card = screen.getByLabelText('说明') expect(card.textContent).toBe('说明Follow the issue workflow.\nKeep project fields in sync.') expect(view.container.textContent).not.toContain('{"name":"dsh-manage-issues"}') - fireEvent.click(screen.getByRole('button', { name: 'Inspect' })) + fireEvent.click(screen.getByRole('button', { name: '查看' })) expect(inspect).toHaveBeenCalledTimes(1) fireEvent.click(row) diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index e82d8adc44..90ffe6c4f1 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -63,13 +63,13 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { } /** Compact token count shared in shape with the conversation stats strip. */ -function formatTokens(value: number): string { +function formatTokens(value: number, t: TranslateNS): string { const scaled = (next: number): string => next >= 100 ? String(Math.round(next)) : String(Math.round(next * 10) / 10) if (value < 1_000) return String(value) - if (value < 1_000_000) return `${scaled(value / 1_000)}K` - return `${scaled(value / 1_000_000)}M` + if (value < 1_000_000) return t('tokens.thousand', { value: scaled(value / 1_000) }) + return t('tokens.million', { value: scaled(value / 1_000_000) }) } /** Sum the four disjoint durable provider-usage buckets. */ @@ -310,7 +310,7 @@ function CatalogRows({ ) const tokenMetric = totalTokens === undefined ? undefined - : `${formatTokens(totalTokens)} tok` + : t('tokens.total', { value: formatTokens(totalTokens, t) }) const durationMetric = durationMs === undefined ? undefined : { diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index b9c56ea001..312bad3af4 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -19,6 +19,9 @@ export const zh = { 'duration.yearsMonths': '约{years}年{months}个月', 'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒', 'duration.exactTitle': '总活跃耗时:{duration}', + 'tokens.thousand': '{value}K', + 'tokens.million': '{value}M', + 'tokens.total': '{value} tok', 'loading.label': '正在加载子代理…', 'loading.aria': '正在加载子代理', 'load.error': '无法加载子代理', @@ -57,6 +60,9 @@ export const en: Record = { 'duration.yearsMonths': '~{years}y {months}mo', 'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s', 'duration.exactTitle': 'Total active duration: {duration}', + 'tokens.thousand': '{value}K', + 'tokens.million': '{value}M', + 'tokens.total': '{value} tok', 'loading.label': 'Loading subagents…', 'loading.aria': 'Loading subagents', 'load.error': 'Unable to load subagents', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 0d511a1ceb..f31167fbde 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: b87236309c9bafe3e35d3d5977d56bd62a24de31 -README.zh.md: 3d6a708f0af979e173ece9543d46d4ec18756f62 +README.md: 79b1bf27d848f015f132e38a635dd98c05a5dd87 +README.zh.md: 89469445b346dcb58a192a51cacdf9e66af50647 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index b87236309c..79b1bf27d8 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -46,4 +46,4 @@ None. The package is client-only presentation. - The Host excludes `run_code` from Code Mode program bindings, so production events produce one dispatch level; the recursive Runtime/UI contract supports nesting. - First-party Tool views are colocated here and can move to their owning business packages independently through the keyed slot. -- Tool copy reuses the `ui-conversation` locale namespace. +- Tool titles, row chrome, and every Cordis-free primitive label reuse the `ui-conversation` locale namespace; presenter models retain locale keys or data rather than rendered wording. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 3d6a708f0a..89469445b3 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -46,4 +46,4 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` - Host 不把 `run_code` 暴露为 Code Mode 程序 binding,因此生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。 - 第一方工具视图集中在本包,可以通过 keyed slot 独立迁移到各自所属的业务包。 -- 工具文案复用 `ui-conversation` locale namespace。 +- 工具标题、行 chrome 与每个 Cordis-free 原子组件 label 都复用 `ui-conversation` locale namespace;presenter 模型保留 locale key 或数据,而不保留渲染后的措辞。 diff --git a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx index fd3e509f1c..ec908af84a 100644 --- a/packages/client/ui-tool/src/client/tool/ToolDetails.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolDetails.tsx @@ -5,6 +5,9 @@ import { diffCardModel } from './models/diff-card-model.ts' import { readCardModel } from './models/read-card-model.ts' import { searchCardModel } from './models/search-card-model.ts' import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts' +import { + diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, +} from './models/primitive-labels.ts' import { resultText } from './models/tool-call-model.ts' import { webCardModel } from './models/web-card-model.ts' import css from './ToolDetails.module.css' @@ -31,14 +34,14 @@ export function ToolDetails({ ) } const read = readCardModel(block, cwd, home) - if (read !== null) return + if (read !== null) return const diff = diffCardModel(block) - if (diff !== null) return + if (diff !== null) return const search = searchCardModel(block) if (search !== null) { return ( <> - + {search.recovery !== undefined ?
{search.recovery}
: null} ) @@ -48,7 +51,7 @@ export function ToolDetails({ const body = 'kind' in block ? resultText(block) : '' return ( <> - + {body !== '' ?
{body}
: null} ) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index c868666c68..30859bc911 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -3,13 +3,16 @@ import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts' import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts' +import { + diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, +} from '../models/primitive-labels.ts' import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' +import type { WebCardModelProps } from '../models/web-card-model.ts' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -39,7 +42,7 @@ export interface ToolRowProps { diff?: DiffCardModel | null | undefined read?: ReadCardModel | null | undefined search?: SearchCardModel | null | undefined - web?: WebBlockProps | null | undefined + web?: WebCardModelProps | null | undefined state: ToolRowState /** * Filesystem path from tool args; when set with onOpenFile, the summary @@ -181,13 +184,18 @@ export function ToolRow({ /> ) : diffBody !== null - ? + ? : readBody !== null - ? + ? : searchBody !== null ? ( <> - + {/* A capped search's recovery locator lives only in the result text; show it below the card so the dropped rows survive. */} {searchBody.recovery !== undefined && ( @@ -196,7 +204,7 @@ export function ToolRow({ ) : webBody !== null - ? + ? : ( <> {variant === 'code' && body !== null && ( @@ -208,7 +216,7 @@ export function ToolRow({
{cardBody !== null && (
- IN + {t('row.input')} {cardBody}
)} @@ -217,7 +225,7 @@ export function ToolRow({ )} {outputText !== null && (
- OUT + {t('row.output')} {outputText} @@ -234,7 +242,7 @@ export function ToolRow({ onClick={inspect} > - Inspect + {t('row.inspect')} )}
diff --git a/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts new file mode 100644 index 0000000000..4b48cf4f8e --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts @@ -0,0 +1,98 @@ +/** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */ + +import type { + DiffBlockLabels, + MarkdownLabels, + ReadBlockLabels, + SearchBlockLabels, + WebBlockLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' + +type T = TranslateNS<'conversation'> + +/** + * Build localized Markdown chrome labels. + * @param t - Conversation locale seat. + * @returns Markdown chrome labels. + */ +export function markdownLabels(t: T): MarkdownLabels { + return { + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + } +} + +/** + * Build localized diff-card chrome labels. + * @param t - Conversation locale seat. + * @returns Diff-card chrome labels. + */ +export function diffBlockLabels(t: T): DiffBlockLabels { + return { + copy: t('copy'), + copied: t('copied'), + collapseAria: t('diff.collapseAria'), + expandAria: count => t('diff.expandAria', { count }), + collapse: t('collapse'), + expand: count => t('diff.expandRest', { count }), + files: count => t(count === 1 ? 'diff.files.one' : 'diff.files.other', { count }), + } +} + +/** + * Build localized read-card chrome labels. + * @param t - Conversation locale seat. + * @returns Read-card chrome labels. + */ +export function readBlockLabels(t: T): ReadBlockLabels { + return { + window: (shown, total) => t('read.window', { shown, total }), + copy: t('copy'), + copied: t('copied'), + collapseAria: t('read.collapseAria'), + expandAria: count => t('read.expandAria', { count }), + collapse: t('collapse'), + expand: count => t('read.expandRest', { count }), + } +} + +/** + * Build localized search-card chrome labels. + * @param t - Conversation locale seat. + * @returns Search-card chrome labels. + */ +export function searchBlockLabels(t: T): SearchBlockLabels { + return { + pathsSummary: (shown, total, truncated) => t( + truncated ? 'search.paths.truncated' : 'search.paths', + { shown, total }, + ), + matchesSummary: (shown, total, files, truncated) => t( + truncated ? 'search.matches.truncated' : 'search.matches', + { shown, total, files }, + ), + copy: t('copy'), + copied: t('copied'), + noResults: t('search.noResults'), + collapseAria: t('search.collapseAria'), + expandAria: count => t('search.expandAria', { count }), + collapse: t('collapse'), + expand: count => t('search.expandRest', { count }), + } +} + +/** + * Build localized web-card chrome labels. + * @param t - Conversation locale seat. + * @returns Web-card chrome labels. + */ +export function webBlockLabels(t: T): WebBlockLabels { + return { + noResults: t('web.noResults'), + sourcesTruncated: t('web.sourcesTruncated'), + http: t('web.http'), + contentTruncated: t('web.contentTruncated'), + markdown: markdownLabels(t), + } +} diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts index 1c9393e6f4..4536833215 100644 --- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts @@ -32,7 +32,7 @@ import type { ToolCallBlock } from './tool-call-model.ts' type DistributiveOmit = T extends unknown ? Omit : never /** The {@link SearchBlockProps} union minus each render site's own fields. */ -type SearchBlockModelProps = DistributiveOmit +type SearchBlockModelProps = DistributiveOmit /** * Result rows the chat row's resident search body shows before collapsing the diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 02d3d56f30..8a8ab21785 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -11,6 +11,7 @@ // that produces the values). import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -20,11 +21,14 @@ export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'co /** Row state semantic; colors self-supplied via StateDot (design gives none). */ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' -/** Figma row titles per variant (design literals, not translatable copy). */ -export const VARIANT_TITLES: Record = { - search: 'Search', read: 'Read', bash: 'Bash', - write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call', -} +type ToolTitleKey = Extract, `tool.title.${string}`> + +/** Locale key per generic row variant. */ +export const VARIANT_TITLE_KEYS = { + search: 'tool.title.search', read: 'tool.title.read', bash: 'tool.title.bash', + write: 'tool.title.write', edit: 'tool.title.edit', code: 'tool.title.code', + others: 'tool.title.generic', +} as const satisfies Record /** * Known tool name -> variant. @@ -38,7 +42,7 @@ export const VARIANT_TITLES: Record = { const TOOL_VARIANTS: Record = { bash: 'bash', // The PowerShell twin is a shell tool: the bash row family (icon, colors) - // with its own title from TOOL_TITLES, not the generic `others` row. + // with its own title from TOOL_TITLE_KEYS, not the generic `others` row. pwsh: 'bash', read: 'read', web_fetch: 'read', @@ -60,13 +64,13 @@ const TOOL_VARIANTS: Record = { } /** Tool-owned titles that refine a generic row variant without replacing it. */ -const TOOL_TITLES: Record = { - cordis_package_inspect: 'Inspect', - cordis_runtime_inspect: 'Inspect', - cordis_run: 'Run Cordis Plugin', - cordis_stop: 'Stop Cordis Plugin', - cordis_undefine: 'Remove Cordis Plugin', - pwsh: 'Pwsh', +const TOOL_TITLE_KEYS: Record = { + cordis_package_inspect: 'tool.title.inspect', + cordis_runtime_inspect: 'tool.title.inspect', + cordis_run: 'tool.title.runCordis', + cordis_stop: 'tool.title.stopCordis', + cordis_undefine: 'tool.title.removeCordis', + pwsh: 'tool.title.pwsh', } /** @@ -81,7 +85,7 @@ export function classifyTool(toolName: string): ToolRowVariant { /** Everything ToolRow needs, derived once from the frozen slice. */ export interface ToolRowModel { variant: ToolRowVariant - title: string + titleKey: ToolTitleKey summary: string /** * Filesystem path from args (`path` / `file_path`) when the row is a file @@ -224,10 +228,10 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const base = argsRaw === '' ? block.callId : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home) - const toolTitle = TOOL_TITLES[toolName] + const toolTitleKey = TOOL_TITLE_KEYS[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. - const summary = variant === 'others' && toolName !== '' && toolTitle === undefined + const summary = variant === 'others' && toolName !== '' && toolTitleKey === undefined ? `${toolName} · ${base}` : base // The empty string is "no text" for both derived result fields: a settled @@ -237,7 +241,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const errorSummary = state === 'error' && output !== null ? firstLine(output) : null return { variant, - title: toolTitle ?? VARIANT_TITLES[variant], + titleKey: toolTitleKey ?? VARIANT_TITLE_KEYS[variant], summary, filePath: deriveFilePath(variant, argsRaw), body: deriveBody(variant, argsRaw), diff --git a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts index 270b387e62..8239861953 100644 --- a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts @@ -36,7 +36,17 @@ import type { ToolCallBlock } from './tool-call-model.ts' * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the web-card props, or null for the generic path. */ -export function webCardModel(block: ToolCallBlock): WebBlockProps | null { +type DistributiveOmit = T extends unknown ? Omit : never + +/** Web-card data owned by the presenter; render sites add localized labels and classes. */ +export type WebCardModelProps = DistributiveOmit + +/** + * Derive locale-independent web-card data from a frozen tool-call slice. + * @param block - Running or settled tool call from the conversation snapshot. + * @returns Web-card data, or null when the generic presenter owns the call. + */ +export function webCardModel(block: ToolCallBlock): WebCardModelProps | null { // Running calls have no result view; the web card is result-only. if (!('kind' in block)) return null const result = block.resultView diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index d3aa3ad7e1..32d7bb0dae 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -46,7 +46,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, variant={model.variant} toolName={toolName} icon={VARIANT_ICONS[model.variant]} - title={model.title} + title={t(model.titleKey)} summary={terminal?.description ?? search?.title ?? model.summary} // Single-file tools never expose an args body — the path link is the only // args interaction. A card is not an args body: a read/write/edit row is diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index f476e0b075..a408110581 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -89,7 +89,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: > {leading} {status !== null && {status}} - {model.title} + {t(model.titleKey)} {failureLine ?? terminal?.description ?? model.summary} @@ -110,7 +110,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
{model.body !== null && (
- IN + {t('row.input')} {model.body}
)} @@ -119,7 +119,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )} {model.output !== null && (
- OUT + {t('row.output')} {model.output} @@ -130,7 +130,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: {inspect !== undefined && ( )}
diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index ecd16b52c7..551bcd8e30 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -21,7 +21,7 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, variant={model.variant} toolName={toolName} icon={} - title={model.title} + title={t(model.titleKey)} summary={model.summary} body={null} output={model.output} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx index 375950c823..df3060c4e5 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx @@ -21,7 +21,7 @@ export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: Re variant={model.variant} toolName={toolName} icon={} - title={model.title} + title={t(model.titleKey)} summary={model.summary} body={null} output={model.output} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 80f59b886f..dfd33251de 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -9,10 +9,10 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'> -const SEARCH_TITLES: Record = { - grep: 'Grep', - glob: 'Glob', -} +const SEARCH_TITLE_KEYS = { + grep: 'tool.title.grep', + glob: 'tool.title.glob', +} as const /** Lets users expand grep or glob results and recover capped searches. */ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { @@ -24,7 +24,9 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { variant={model.variant} toolName={toolName} icon={} - title={SEARCH_TITLES[toolName] ?? model.title} + title={t(toolName === 'grep' + ? SEARCH_TITLE_KEYS.grep + : toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)} summary={search?.title ?? model.summary} body={null} // ToolRow ignores output when a structured card is present; otherwise it diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index 4be87b10d5..89cc20df83 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -9,10 +9,10 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' type WebRowProps = ToolCallViewProps & PropsLocale<'conversation'> -const WEB_TITLES: Record = { - web_search: 'Search', - web_fetch: 'Fetch', -} +const WEB_TITLE_KEYS = { + web_search: 'tool.title.webSearch', + web_fetch: 'tool.title.webFetch', +} as const /** Lets users expand a completed web search or fetch result. */ export function WebRow({ toolName, block, inspect, t }: WebRowProps) { @@ -25,7 +25,9 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) { variant={model.variant} toolName={toolName} icon={icon} - title={WEB_TITLES[toolName] ?? model.title} + title={t(toolName === 'web_search' + ? WEB_TITLE_KEYS.web_search + : toolName === 'web_fetch' ? WEB_TITLE_KEYS.web_fetch : model.titleKey)} summary={model.summary} body={null} output={model.output} diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index e4cf03c044..60aa0e9880 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -199,7 +199,7 @@ describe('FileMutationRow diff card', () => { }), 'write')} />) // The footer counts live inside the collapsed diff card. toggleRow(view) - expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy() + expect(view.getByText('└ +1 -0 · 1 个文件')).toBeTruthy() }) it('reflects the run state on its leading slot', () => { diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 400b8eecdd..4333cf93d4 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -193,7 +193,7 @@ describe('ReadRow keyed toolview', () => { it('collapses to the path summary; the whole row toggles the read card', () => { const view = render() - expect(view.getByText('Read')).toBeTruthy() + expect(view.getByText('读取')).toBeTruthy() // Collapsed: the path is the summary link alone, and the card is absent. expect(view.getAllByText('src/a.ts').length).toBe(1) expect(view.container.querySelector('[data-read]')).toBeNull() diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 4d6eeeacd8..b6fef1c6e7 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -430,8 +430,8 @@ describe('BashRow terminal card', () => { fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') - expect(view.getByText('IN')).toBeTruthy() - expect(view.getByText('OUT')).toBeTruthy() + expect(view.getByText('输入')).toBeTruthy() + expect(view.getByText('输出')).toBeTruthy() expect(view.getByText(/"command": "ls -la"/)).toBeTruthy() expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted') }) diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index d311453c3d..aca4c04283 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -51,9 +51,9 @@ describe('tool-call-model', () => { // Every define/run pair the model makes puts a row in the flow, so the // generic "Tool call · cordis_run · dyn-1" fallback is user-visible slop. const titleOf = (name: string) => toolRowModel(name, running({ name, argsRaw: '{"id":"dyn-1"}' })) - expect(titleOf('cordis_run').title).toBe('Run Cordis Plugin') - expect(titleOf('cordis_stop').title).toBe('Stop Cordis Plugin') - expect(titleOf('cordis_undefine').title).toBe('Remove Cordis Plugin') + expect(t(titleOf('cordis_run').titleKey)).toBe('运行 Cordis 插件') + expect(t(titleOf('cordis_stop').titleKey)).toBe('停止 Cordis 插件') + expect(t(titleOf('cordis_undefine').titleKey)).toBe('移除 Cordis 插件') // An owned title takes the tool name out of the summary slot, leaving the // package id as the only mutable text. expect(titleOf('cordis_run').summary).toBe('dyn-1') @@ -66,21 +66,21 @@ describe('tool-call-model', () => { // title here would be a second answer to what the card already renders. const model = toolRowModel('cordis_define', running({ name: 'cordis_define', argsRaw: '{"name":"clock"}' })) expect(model.variant).toBe('others') - expect(model.title).toBe('Tool call') + expect(t(model.titleKey)).toBe('工具调用') }) it('renders cordis mount verbs no shipped tool implements as generic calls', () => { // No shipped tool implements these cordis mount verbs, so a mapping would // be unreachable. expect(classifyTool('cordis_mount')).toBe('others') - expect(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).title).toBe('Tool call') - expect(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).title).toBe('Tool call') + expect(t(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).titleKey)).toBe('工具调用') + expect(t(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).titleKey)).toBe('工具调用') }) it('gives the pwsh shell row the bash family treatment with its own title', () => { const m = toolRowModel('pwsh', running()) expect(m.variant).toBe('bash') - expect(m.title).toBe('Pwsh') + expect(t(m.titleKey)).toBe('Pwsh') }) it('derives state across running/ok/error/interrupted', () => { @@ -92,7 +92,7 @@ describe('tool-call-model', () => { it('derives the bash summary from description over command', () => { const m = toolRowModel('bash', running()) - expect(m.title).toBe('Bash') + expect(t(m.titleKey)).toBe('Bash') expect(m.summary).toBe('List files') expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd') }) @@ -203,7 +203,7 @@ describe('tool-call-model', () => { argsRaw: '{"what":"api","name":"tools"}', }))).toMatchObject({ variant: 'read', - title: 'Inspect', + titleKey: 'tool.title.inspect', summary: 'api', }) expect(toolRowModel('cordis_run', running({ @@ -211,14 +211,14 @@ describe('tool-call-model', () => { argsRaw: '{"id":"dyn-2"}', }))).toMatchObject({ variant: 'others', - title: 'Run Cordis Plugin', + titleKey: 'tool.title.runCordis', summary: 'dyn-2', }) expect(toolRowModel('cordis_undefine', result({ call: { name: 'cordis_undefine', argsRaw: '{"id":"dyn-2"}' }, }))).toMatchObject({ variant: 'others', - title: 'Remove Cordis Plugin', + titleKey: 'tool.title.removeCordis', summary: 'dyn-2', }) }) @@ -364,9 +364,9 @@ describe('ToolRow', () => { const inspect = vi.fn() const view = render() // Collapsed: no pill. - expect(view.queryByText('Inspect')).toBeNull() + expect(view.queryByText('查看')).toBeNull() fireEvent.click(view.getByRole('button', { name: /Bash/ })) - const pill = view.getByText('Inspect') + const pill = view.getByText('查看') fireEvent.click(pill) expect(inspect).toHaveBeenCalledTimes(1) // The pill click must not collapse the row (body is a .row sibling). @@ -376,25 +376,25 @@ describe('ToolRow', () => { it('no inspect callback, no pill', () => { const view = render() fireEvent.click(view.getByRole('button')) - expect(view.queryByText('Inspect')).toBeNull() + expect(view.queryByText('查看')).toBeNull() }) it('the expanded card gutter-labels each section it carries (IN / OUT)', () => { const both = render() fireEvent.click(both.getByRole('button')) - expect(both.getByText('IN')).toBeTruthy() - expect(both.getByText('OUT')).toBeTruthy() + expect(both.getByText('输入')).toBeTruthy() + expect(both.getByText('输出')).toBeTruthy() expect(both.getByText('result text')).toBeTruthy() cleanup() const inputOnly = render() fireEvent.click(inputOnly.getByRole('button')) - expect(inputOnly.getByText('IN')).toBeTruthy() - expect(inputOnly.queryByText('OUT')).toBeNull() + expect(inputOnly.getByText('输入')).toBeTruthy() + expect(inputOnly.queryByText('输出')).toBeNull() cleanup() const outputOnly = render() fireEvent.click(outputOnly.getByRole('button')) - expect(outputOnly.queryByText('IN')).toBeNull() - expect(outputOnly.getByText('OUT')).toBeTruthy() + expect(outputOnly.queryByText('输入')).toBeNull() + expect(outputOnly.getByText('输出')).toBeTruthy() expect(outputOnly.getByText('only out')).toBeTruthy() }) }) @@ -415,7 +415,7 @@ describe('GenericToolCard', () => { const view = render( , ) - expect(view.getByText('Tool call')).toBeTruthy() + expect(view.getByText('工具调用')).toBeTruthy() expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() }) @@ -427,7 +427,7 @@ describe('GenericToolCard', () => { argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}', }))} />, ) - expect(view.getByText('Edit')).toBeTruthy() + expect(view.getByText('编辑')).toBeTruthy() expect(view.getByText('src/x.ts')).toBeTruthy() expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull() expect(view.container.querySelector('svg')).not.toBeNull() @@ -440,7 +440,7 @@ describe('GenericToolCard', () => { argsRaw: '{"file_path":"src/x.ts","content":"hello"}', }))} />, ) - expect(view.getByText('Write')).toBeTruthy() + expect(view.getByText('写入')).toBeTruthy() expect(view.getByText('src/x.ts')).toBeTruthy() expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull() expect(view.container.querySelector('svg')).not.toBeNull() @@ -450,7 +450,7 @@ describe('GenericToolCard', () => { const inspect = vi.fn() const view = render() fireEvent.click(view.getByRole('button', { name: /Bash/ })) - fireEvent.click(view.getByText('Inspect')) + fireEvent.click(view.getByText('查看')) expect(inspect).toHaveBeenCalledTimes(1) }) diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f9712c091e..1c87e66660 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -135,7 +135,7 @@ describe('chat row web body', () => { const globe = render().container.querySelector('svg')!.outerHTML const view = render() // Collapsed: the summary row alone, no card in the DOM. - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('网页搜索')).toBeTruthy() expect(view.container.querySelector('svg')?.outerHTML).toBe(globe) expect(view.queryByText('Titled')).toBeNull() expect(view.container.querySelector('[data-web]')).toBeNull() @@ -149,7 +149,7 @@ describe('chat row web body', () => { it('the WebRow expands to the fetch card, titled Fetch', () => { const view = render() - expect(view.getByText('Fetch')).toBeTruthy() + expect(view.getByText('网页获取')).toBeTruthy() expect(view.container.querySelector('[data-web]')).toBeNull() toggleRow(view) // The url shows as the card's link; scope to the card. @@ -160,7 +160,7 @@ describe('chat row web body', () => { it('a running web call is the summary row alone, with nothing to expand', () => { const view = render() - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('网页搜索')).toBeTruthy() expect(view.queryByText('Titled')).toBeNull() // No card material and no expandable body: clicking the row reveals nothing. expect(view.container.querySelector('[data-expandable]')).toBeNull() @@ -171,7 +171,7 @@ describe('chat row web body', () => { const view = render() - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('网页搜索')).toBeTruthy() expect(view.container.querySelector('[data-web]')).toBeNull() // The row reflects the error state so the summary line still reads as failed. expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index a2344e22ba..47b35ae17d 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: d3053076068a6c78997d570eb321cff27bc53eed -README.zh.md: 9d7718c0d7d6ef87ad0d84df80838029cf20dbc4 +README.md: 97badd562bbf132c9766c763ff604306d1a6c08d +README.zh.md: 5a17ee811047e8ffd15be849595d87adfe4ddf00 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index d305307606..97badd562b 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 9d7718c0d7..5a17ee8110 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index 51ec2a0ec3..a25b09833e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -5,6 +5,7 @@ import { type TrajectoryCellKind, type TrajectoryCellProps, } from './trajectory-record.ts' +import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' import css from './TrajectoryCell.module.css' export { formatElapsedSeconds } @@ -15,14 +16,14 @@ export type { } from './trajectory-record.ts' /** Display label per kind (matches the design tags). */ -const KIND_LABEL: Record = { - system: 'System', - user: 'User', - context: 'Context', - compacted: 'Compacted', - message: 'Message', - tool: 'Tool', - subtool: 'Sub', +const KIND_LABEL_KEY: Record = { + system: 'kind.system', + user: 'kind.user', + context: 'kind.context', + compacted: 'kind.compacted', + message: 'kind.message', + tool: 'kind.tool', + subtool: 'kind.sub', } const TAG_CLASS: Record = { @@ -41,6 +42,7 @@ const TAG_CLASS: Record = { * @returns the cell element. */ export function TrajectoryCell({ + t, index, kind, text, @@ -64,7 +66,7 @@ export function TrajectoryCell({ selected = false, className, ...rest -}: TrajectoryCellProps) { +}: TrajectoryCellProps & { t: TrajectoryTranslate }) { const rootClass = [ css.root, selected ? css.selected : undefined, @@ -75,7 +77,7 @@ export function TrajectoryCell({
#{index} - c !== undefined).join(' ')}>{KIND_LABEL[kind]} + c !== undefined).join(' ')}>{t(KIND_LABEL_KEY[kind])} {text} @@ -86,7 +88,7 @@ export function TrajectoryCell({ {think ?? ''} ) : null} - {formatElapsedSeconds(timeSeconds)} + {formatElapsedSeconds(timeSeconds, t)}
) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index dab72e6778..8777c83265 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -12,6 +12,7 @@ import { MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' import type { AssistantRequestConfig, ConversationPromptSnapshot, @@ -26,6 +27,8 @@ import { import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts' import type { TrajectoryTurnModel } from './layout.ts' import { trajectoryPreviewText } from './trajectory-preview.ts' +import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' +import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -35,14 +38,14 @@ const VIRTUALIZATION_THRESHOLD = 100 const VIRTUAL_OVERSCAN_ROWS = 12 const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600 -const KIND_LABEL: Record = { - system: 'SYSTEM', - user: 'USER', - context: 'CONTEXT', - compacted: 'COMPACTED', - message: 'ASSISTANT', - tool: 'TOOL', - subtool: 'SUBTOOL', +const KIND_LABEL_KEY: Record = { + system: 'kind.system', + user: 'kind.user', + context: 'kind.context', + compacted: 'kind.compacted', + message: 'kind.assistant', + tool: 'kind.tool', + subtool: 'kind.subtool', } function ToolWrenchIcon(): ReactNode { @@ -170,7 +173,7 @@ type RecordState = 'complete' | 'running' | 'error' interface DetailTabItem { id: DetailTab - label: string + labelKey: TrajectoryKey } interface ParentRecords { @@ -207,20 +210,42 @@ const TOOL_REQUEST_MAX_WIDTH = 480 const DEFAULT_TOOL_REQUEST_SHARE = 0.36 const DEFAULT_TOOL_REQUEST_OFFSET = 56 const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [ - { id: 'system-prompt', label: 'System Prompt' }, - { id: 'tools', label: 'Tools' }, + { id: 'system-prompt', labelKey: 'tab.systemPrompt' }, + { id: 'tools', labelKey: 'tab.tools' }, ] const SYSTEM_UPDATE_TABS: readonly DetailTabItem[] = [ - { id: 'diff', label: 'Diff' }, + { id: 'diff', labelKey: 'tab.diff' }, ...SYSTEM_PROMPT_TABS, ] const REQUEST_TABS: readonly DetailTabItem[] = [ - { id: 'overview', label: 'Summary' }, - { id: 'options', label: 'Options' }, - { id: 'usage', label: 'Usage' }, - { id: 'timing', label: 'Timing' }, + { id: 'overview', labelKey: 'tab.summary' }, + { id: 'options', labelKey: 'tab.options' }, + { id: 'usage', labelKey: 'tab.usage' }, + { id: 'timing', labelKey: 'tab.timing' }, ] +function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels { + return { + copyValue: t('copy.value'), + copyJson: t('copy.json'), + copyPath: t('copy.path'), + copyPrettyJson: t('copy.prettyJson'), + copyCompactJson: t('copy.compactJson'), + copied: t('copied'), + copyFailed: t('copy.failed'), + collapseNode: t('json.collapseNode'), + expandNode: t('json.expandNode'), + copyButtonTitle: action => t('copy.optionsHint', { action }), + } +} + +function markdownLabels(t: TrajectoryTranslate): MarkdownLabels { + return { + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + } +} + type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string } @@ -257,13 +282,15 @@ function defaultToolRequestWidth(splitWidth: number): number { ) } -function formatDurationMs(milliseconds: number): string { - if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms` - return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s` +function formatDurationMs(milliseconds: number, t: TrajectoryTranslate): string { + if (milliseconds < 1_000) return t('unit.milliseconds', { value: Math.round(milliseconds) }) + return t('unit.seconds', { + value: (milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1), + }) } -function formatStartedAt(timestamp: number | null): string { - if (timestamp === null || !Number.isFinite(timestamp)) return 'Not available' +function formatStartedAt(timestamp: number | null, t: TrajectoryTranslate): string { + if (timestamp === null || !Number.isFinite(timestamp)) return t('timing.notAvailable') const date = new Date(timestamp) const two = (value: number) => String(value).padStart(2, '0') const three = (value: number) => String(value).padStart(3, '0') @@ -281,70 +308,77 @@ function clickSelectsText(target: Node): boolean { && selection.getRangeAt(0).intersectsNode(target) } -function StartedAtValue({ timestamp }: { timestamp: number | null }) { +function StartedAtValue({ timestamp, t }: { timestamp: number | null; t: TrajectoryTranslate }) { const [showUnix, setShowUnix] = useState(false) - if (timestamp === null || !Number.isFinite(timestamp)) return
Not available
+ if (timestamp === null || !Number.isFinite(timestamp)) return
{t('timing.notAvailable')}
return (
) } -function totalTime(metrics: AssistantMetricDetail): string { - if (!metrics.timingRecorded) return 'Not recorded' - if (metrics.stepStartTime === null) return 'Step start unavailable' - if (metrics.completedTime === null) return 'Pending' - return formatDurationMs(Math.max(0, metrics.completedTime - metrics.stepStartTime)) +function totalTime(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.timingRecorded) return t('timing.notRecorded') + if (metrics.stepStartTime === null) return t('timing.stepStartUnavailable') + if (metrics.completedTime === null) return t('status.pending') + return formatDurationMs(Math.max(0, metrics.completedTime - metrics.stepStartTime), t) } -function ttft(metrics: AssistantMetricDetail): string { - if (!metrics.timingRecorded) return 'Not recorded' - if (metrics.stepStartTime === null) return 'Step start unavailable' - if (metrics.firstTokenTime === null) return 'First token unavailable' - return formatDurationMs(Math.max(0, metrics.firstTokenTime - metrics.stepStartTime)) +function ttft(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.timingRecorded) return t('timing.notRecorded') + if (metrics.stepStartTime === null) return t('timing.stepStartUnavailable') + if (metrics.firstTokenTime === null) return t('timing.firstTokenUnavailable') + return formatDurationMs(Math.max(0, metrics.firstTokenTime - metrics.stepStartTime), t) } -function generationTime(metrics: AssistantMetricDetail): string { - if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable' - if (metrics.completedTime === null) return 'Pending' - return formatDurationMs(Math.max(0, metrics.completedTime - metrics.firstTokenTime)) +function generationTime(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.timingRecorded || metrics.firstTokenTime === null) return t('timing.firstTokenUnavailable') + if (metrics.completedTime === null) return t('status.pending') + return formatDurationMs(Math.max(0, metrics.completedTime - metrics.firstTokenTime), t) } -function throughput(metrics: AssistantMetricDetail): string { - if (!metrics.usageProvided) return 'Usage unavailable' - if (metrics.outputTokens === null) return 'Output tokens unavailable' - if (!metrics.timingRecorded || metrics.firstTokenTime === null) return 'First token unavailable' - if (metrics.completedTime === null) return 'Pending' +function throughput(metrics: AssistantMetricDetail, t: TrajectoryTranslate): string { + if (!metrics.usageProvided) return t('timing.usageUnavailable') + if (metrics.outputTokens === null) return t('timing.outputTokensUnavailable') + if (!metrics.timingRecorded || metrics.firstTokenTime === null) return t('timing.firstTokenUnavailable') + if (metrics.completedTime === null) return t('status.pending') const generationSeconds = (metrics.completedTime - metrics.firstTokenTime) / 1_000 - if (generationSeconds <= 0) return 'Duration too short' - return `${(metrics.outputTokens / generationSeconds).toFixed(1)} tok/s` + if (generationSeconds <= 0) return t('timing.durationTooShort') + return t('unit.tokensPerSecond', { + value: (metrics.outputTokens / generationSeconds).toFixed(1), + }) } -function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) { +function AssistantTimingPanel({ + metrics, + t, +}: { metrics: AssistantMetricDetail; t: TrajectoryTranslate }) { return (
-
Started
-
Total duration
{totalTime(metrics)}
-
TTFT
{ttft(metrics)}
-
Generation
{generationTime(metrics)}
-
Throughput
{throughput(metrics)}
+
{t('timing.started')}
+
{t('timing.totalDuration')}
{totalTime(metrics, t)}
+
{t('timing.ttft')}
{ttft(metrics, t)}
+
{t('timing.generation')}
{generationTime(metrics, t)}
+
{t('timing.throughput')}
{throughput(metrics, t)}
) } /** Props for the trajectory ledger. */ export interface TrajectoryTableProps { + /** Trajectory locale seat. */ + t: TrajectoryTranslate /** Session-global request numbers for the request groups visible in this context. */ requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ @@ -485,57 +519,43 @@ function filterRecords( return filtered } -function requestStep(group: string): number | undefined { - if (!group.startsWith('Step ')) return undefined - const value = Number(group.slice('Step '.length)) - return Number.isInteger(value) && value > 0 ? value : undefined -} - function requestKey(turn: number | null, group: string): string { return `${turn}\u0000${group}` } -function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap { +function indexRequestBoundaries( + records: readonly TableRecord[], + requestGroups: ReadonlySet, +): ReadonlyMap { const boundaries = new Map() for (const record of records) { const key = requestKey(record.turn, record.group) + if (!requestGroups.has(key)) continue if (boundaries.has(key)) continue - if (requestStep(record.group) === undefined) { - if (record.groupStart) boundaries.set(key, record.cell.index) - continue - } if (record.cell.kind === 'user' || record.cell.kind === 'context') continue boundaries.set(key, record.cell.index) } return boundaries } -function sectionLabel(turn: number | null): string { - return turn === null ? 'Between turns' : `Turn ${turn}` +function sectionLabel(turn: number | null, t: TrajectoryTranslate): string { + return turn === null ? t('section.betweenTurns') : t('turn.label', { turn }) } function indexRequestNumbers( - records: readonly TableRecord[], sessionNumbers: readonly TrajectoryRequestNumber[] | undefined, - boundaries: ReadonlyMap, ): ReadonlyMap { const numbers = new Map() for (const request of sessionNumbers ?? []) { numbers.set(requestKey(request.turn, request.group), request.number) } - let next = Math.max(0, ...numbers.values()) + 1 - const boundaryRecords = records - .filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index - && requestStep(record.group) !== undefined) - .sort((left, right) => left.cell.index - right.cell.index) - for (const record of boundaryRecords) { - const key = requestKey(record.turn, record.group) - if (!numbers.has(key)) numbers.set(key, next++) - } return numbers } -function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { +function indexRequestBoundaryRuns( + records: readonly TableRecord[], + requestGroups: ReadonlySet, +): ReadonlyMap { const indexes = new Map() let runLength = 0 for (const record of records) { @@ -543,7 +563,11 @@ function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap< indexes.set(record.cell.index, runLength++) continue } - if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) { + if ( + runLength > 0 + && record.groupStart + && requestGroups.has(requestKey(record.turn, record.group)) + ) { indexes.set(record.cell.index, runLength) } runLength = 0 @@ -551,24 +575,32 @@ function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap< return indexes } -function summarizeTurn(records: readonly TableRecord[]): string { +function summarizeTurn( + records: readonly TableRecord[], + requestGroups: ReadonlySet, + t: TrajectoryTranslate, +): string { const steps = new Set( records - .map(record => record.group) - .filter(group => group.startsWith('Step ')), + .map(record => requestKey(record.turn, record.group)) + .filter(key => requestGroups.has(key)), ).size const toolCalls = records.filter(record => record.cell.kind === 'tool' || record.cell.kind === 'subtool', ).length return [ - `${steps} ${steps === 1 ? 'step' : 'steps'}`, - `${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`, + t(steps === 1 ? 'summary.steps.one' : 'summary.steps.other', { count: steps }), + t(toolCalls === 1 ? 'summary.toolCalls.one' : 'summary.toolCalls.other', { + count: toolCalls, + }), ].join(' · ') } function collapseTurnRecords( records: readonly TableRecord[], collapsedTurns: ReadonlySet, + requestGroups: ReadonlySet, + t: TrajectoryTranslate, ): TableRecord[] { const recordsByTurn = new Map() for (const record of records) { @@ -592,7 +624,7 @@ function collapseTurnRecords( groupStart: false, turnStart: false, turnEnd: true, - collapsedSummary: summarizeTurn(contentRecords.slice(1)), + collapsedSummary: summarizeTurn(contentRecords.slice(1), requestGroups, t), collapsedSummaryKind: 'turn', }, ] @@ -674,32 +706,32 @@ function stateOf(record: TableRecord): RecordState { return 'complete' } -function statusLabel(state: RecordState): string { - if (state === 'error') return 'Failed' - if (state === 'running') return 'Pending' - return 'Completed' +function statusLabel(state: RecordState, t: TrajectoryTranslate): string { + if (state === 'error') return t('status.failed') + if (state === 'running') return t('status.pending') + return t('status.completed') } -function TokenRows({ cell }: { cell: TrajectoryCellProps }) { +function TokenRows({ cell, t }: { cell: TrajectoryCellProps; t: TrajectoryTranslate }) { const content = cell.output !== undefined && cell.think !== undefined ? Math.max(0, cell.output - cell.think) : undefined return ( <>
-
Tokens
-
{cell.output === undefined ? '—' : `${cell.output} tok`}
+
{t('usage.tokens')}
+
{cell.output === undefined ? '—' : t('unit.tokens', { value: cell.output })}
{cell.think !== undefined && (
-
Reasoning
-
{cell.think} tok
+
{t('usage.reasoning')}
+
{t('unit.tokens', { value: cell.think })}
)} {content !== undefined && (
-
Content
-
{content} tok
+
{t('usage.content')}
+
{t('unit.tokens', { value: content })}
)} @@ -715,8 +747,8 @@ function inputTotal(usage: TrajectoryUsage): number | undefined { return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) } -function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { - if (usage === undefined) return

Usage not reported

+function UsageRows({ usage, t }: { usage: TrajectoryUsage | undefined; t: TrajectoryTranslate }) { + if (usage === undefined) return

{t('usage.notReported')}

const totalInput = inputTotal(usage) const otherOutput = usage.output !== undefined && usage.reasoning !== undefined ? usage.output - usage.reasoning @@ -724,39 +756,39 @@ function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { return (
{totalInput !== undefined && ( -
Input
{totalInput} tok
+
{t('usage.input')}
{t('unit.tokens', { value: totalInput })}
)} {usage.cacheRead !== undefined && (
-
Cached
-
{usage.cacheRead} tok
+
{t('usage.cached')}
+
{t('unit.tokens', { value: usage.cacheRead })}
)} {usage.cacheWrite !== undefined && (
-
Cache created
-
{usage.cacheWrite} tok
+
{t('usage.cacheCreated')}
+
{t('unit.tokens', { value: usage.cacheWrite })}
)} {usage.input !== undefined && (
-
Other
-
{usage.input} tok
+
{t('usage.other')}
+
{t('unit.tokens', { value: usage.input })}
)} {usage.output !== undefined && ( -
Output
{usage.output} tok
+
{t('usage.output')}
{t('unit.tokens', { value: usage.output })}
)} {usage.reasoning !== undefined && (
-
Reasoning
-
{usage.reasoning} tok
+
{t('usage.reasoning')}
+
{t('unit.tokens', { value: usage.reasoning })}
)} {otherOutput !== undefined && (
-
Content
-
{otherOutput} tok
+
{t('usage.content')}
+
{t('unit.tokens', { value: otherOutput })}
)}
@@ -766,19 +798,21 @@ function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { function RequestUsagePanel({ usage, cumulative, + t, }: { usage: TrajectoryUsage | undefined cumulative: TrajectoryUsage | undefined + t: TrajectoryTranslate }) { return (
-

This request

- +

{t('usage.thisRequest')}

+
-

Session cumulative

- +

{t('usage.sessionCumulative')}

+
) @@ -787,55 +821,59 @@ function RequestUsagePanel({ function RequestOptions({ options, preview = false, + t, }: { options: AssistantRequestConfig | undefined preview?: boolean + t: TrajectoryTranslate }) { if (options === undefined) { - return

Options not recorded

+ return

{t('options.notRecorded')}

} return ( ) } -function messageSourceLabel(source: unknown): string { +function messageSourceLabel(source: unknown, t: TrajectoryTranslate): string { if (typeof source !== 'object' || source === null || Array.isArray(source)) { - return 'Unknown' + return t('source.unknown') } const properties = source as Record const kind = properties.kind - if (kind === 'user') return 'User' + if (kind === 'user') return t('source.user') if (kind === 'plugin') { const plugin = properties.plugin return typeof plugin === 'string' && plugin !== '' - ? `Plugin · ${plugin}` - : 'Plugin' + ? t('source.pluginNamed', { plugin }) + : t('source.plugin') } if (kind === 'goal') { const round = properties.round return typeof round === 'number' && round > 0 - ? `Goal · Round ${round}` - : 'Goal' + ? t('source.goalRound', { round }) + : t('source.goal') } - if (typeof kind !== 'string' || kind === '') return 'Unknown' + if (typeof kind !== 'string' || kind === '') return t('source.unknown') return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}` } -function MessageSource({ record }: { record: TableRecord }) { +function MessageSource({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) { const source = record.cell.messageSource - if (source === undefined) return

Source not recorded

+ if (source === undefined) return

{t('source.notRecorded')}

const data = typeof source === 'object' && source !== null ? source : { value: source } return ( ) @@ -899,31 +937,31 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { } if (record.cell.kind === 'compacted') { return [ - { id: 'overview', label: 'Summary' }, - { id: 'raw', label: 'Raw Output' }, + { id: 'overview', labelKey: 'tab.summary' }, + { id: 'raw', labelKey: 'tab.rawOutput' }, ] } if (isMarkdownRecord(record)) { return [ - { id: 'overview', label: 'Summary' }, - { id: 'rendered', label: 'Preview' }, - { id: 'raw', label: 'Raw' }, + { id: 'overview', labelKey: 'tab.summary' }, + { id: 'rendered', labelKey: 'tab.preview' }, + { id: 'raw', labelKey: 'tab.raw' }, ...(record.cell.messageSource === undefined ? [] - : [{ id: 'source', label: 'Source' } as const]), + : [{ id: 'source', labelKey: 'tab.source' } as const]), ] } return [ - { id: 'overview', label: 'Summary' }, - ...(record.cell.inputDetail ? [{ id: 'input', label: 'Payload' } as const] : []), - ...(record.cell.outputDetail ? [{ id: 'output', label: 'Result' } as const] : []), - { id: 'schema', label: 'Schema' }, - { id: 'timing', label: 'Timing' }, + { id: 'overview', labelKey: 'tab.summary' }, + ...(record.cell.inputDetail ? [{ id: 'input', labelKey: 'tab.payload' } as const] : []), + ...(record.cell.outputDetail ? [{ id: 'output', labelKey: 'tab.result' } as const] : []), + { id: 'schema', labelKey: 'tab.schema' }, + { id: 'timing', labelKey: 'tab.timing' }, ] } -function recordDisplayText(cell: TrajectoryCellProps): string { - if (isToolCallOnly(cell)) return '' +function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string { + if (isToolCallOnly(cell, t)) return '' if (cell.previewMarkdown !== undefined) { const preview = trajectoryPreviewText(cell.previewMarkdown) if (cell.text === '') return preview @@ -957,11 +995,11 @@ function toolCallTextParts( } } -function isToolCallOnly(cell: TrajectoryCellProps): boolean { +function isToolCallOnly(cell: TrajectoryCellProps, t: TrajectoryTranslate): boolean { return cell.kind === 'message' && !cell.outputDetail && !cell.thinkingDetail - && cell.text === 'Tool call only' + && cell.text === t('layout.toolCallOnly') } interface RecordPresentationValue { @@ -975,25 +1013,27 @@ interface RecordPresentationValue { function RecordPresentation({ cell, children, + t, }: { cell: TrajectoryCellProps children: (value: RecordPresentationValue) => ReactNode + t: TrajectoryTranslate }) { const displayText = useMemo( - () => recordDisplayText(cell), + () => recordDisplayText(cell, t), [ cell.kind, cell.text, cell.previewMarkdown, - cell.inputDetail, cell.outputDetail, cell.thinkingDetail, + cell.inputDetail, cell.outputDetail, cell.thinkingDetail, t, ], ) const resultText = useMemo( () => recordResultText(cell), [cell.result, cell.resultPreviewMarkdown], ) - const toolCallOnly = isToolCallOnly(cell) + const toolCallOnly = isToolCallOnly(cell, t) const toolCallText = toolCallTextParts(cell.kind, displayText) const listDisplayText = toolCallOnly - ? '(tool call only)' + ? t('record.toolCallOnly') : toolCallText === undefined ? displayText : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') @@ -1010,9 +1050,12 @@ function RecordListText({ displayText, toolCallOnly, toolCallText, -}: Pick) { + t, +}: Pick & { + t: TrajectoryTranslate +}) { if (toolCallOnly) { - return (tool call only) + return {t('record.toolCallOnly')} } if (toolCallText === undefined) return displayText || '—' return ( @@ -1033,15 +1076,17 @@ function MarkdownFragment({ text, rendered, preview, + t, }: { text: string rendered: boolean preview: boolean + t: TrajectoryTranslate }) { if (rendered) { return (
- +
) } @@ -1055,9 +1100,11 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, + t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void + t: TrajectoryTranslate }) { return (
@@ -1068,14 +1115,14 @@ function SourceBlocks({ @@ -1083,12 +1130,12 @@ function SourceBlocks({ : (
- {`Block #${index + 1} ${block.type}`} + {t('block.label', { index: index + 1, type: block.type })}
)} {block.imageSrc !== undefined - ? + ? :
{block.content}
} ))} @@ -1099,9 +1146,11 @@ function SourceBlocks({ function PanelImage({ block, preview = false, + t, }: { block: TrajectorySourceBlock preview?: boolean + t: TrajectoryTranslate }) { if (block.imageSrc === undefined) return null return ( @@ -1110,7 +1159,7 @@ function PanelImage({ href={block.imageSrc} target="_blank" rel="noopener noreferrer" - title="Open image" + title={t('block.openImage')} > block.imageSrc !== undefined) ?? [] if (images.length === 0) return null return (
- {images.map((block, index) => )} + {images.map((block, index) => )}
) } @@ -1141,10 +1192,12 @@ function AssistantToolCalls({ blocks, preview, onOpenCall, + t, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean onOpenCall: (callId: string) => void + t: TrajectoryTranslate }) { const calls = blocks?.filter(block => block.type === 'tool-call') ?? [] if (calls.length === 0) return null @@ -1158,7 +1211,7 @@ function AssistantToolCalls({ {thinkingExpanded && ( @@ -1395,6 +1458,7 @@ function MarkdownRecordContent({ text={record.cell.thinkingDetail} rendered={rendered} preview={preview} + t={t} /> )}
@@ -1404,6 +1468,7 @@ function MarkdownRecordContent({ text={record.cell.outputDetail} rendered={rendered} preview={preview} + t={t} />
)} @@ -1411,10 +1476,12 @@ function MarkdownRecordContent({ blocks={record.cell.sourceBlocks} preview={preview} onOpenCall={onOpenCall} + t={t} />
) @@ -1424,37 +1491,38 @@ function MarkdownRecordContent({ const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true if (!source && !hasImages && !hasToolCalls) { - const emptyLabel = isToolCallOnly(record.cell) - ? 'Tool call only' - : record.cell.text || 'No content' + const emptyLabel = isToolCallOnly(record.cell, t) + ? t('record.toolCallOnly') + : record.cell.text || t('record.noContent') return

{emptyLabel}

} if (!rendered || (!hasImages && !hasToolCalls)) { - return + return } return (
- {source && } + {source && } {record.cell.kind === 'message' && ( )} - +
) } -function RecordTiming({ record }: { record: TableRecord }) { +function RecordTiming({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) { return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined - ? + ? : (
-
Started
-
Duration
{formatElapsedSeconds(record.cell.timeSeconds)}
-
Timing source
{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}
+
{t('timing.started')}
+
{t('timing.duration')}
{formatElapsedSeconds(record.cell.timeSeconds, t)}
+
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
) } @@ -1463,23 +1531,25 @@ function RequestTiming({ assistant, anchor, request, + t, }: { assistant: TableRecord | undefined anchor: TableRecord | undefined request: TrajectoryRequestNumber | undefined + t: TrajectoryTranslate }) { - if (assistant !== undefined) return + if (assistant !== undefined) return if (request?.startedAt !== undefined) { const duration = request.completedAt === null || request.completedAt === undefined ? null : Math.max(0, (request.completedAt - request.startedAt) / 1000) return (
-
Started
-
Duration
{formatElapsedSeconds(duration)}
+
{t('timing.started')}
+
{t('timing.duration')}
{formatElapsedSeconds(duration, t)}
-
Timing source
-
{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}
+
{t('timing.source')}
+
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
) @@ -1487,10 +1557,10 @@ function RequestTiming({ return (
-
Started
- +
{t('timing.started')}
+
-
Duration
{formatElapsedSeconds(null)}
+
{t('timing.duration')}
{formatElapsedSeconds(null, t)}
) } @@ -1499,15 +1569,17 @@ function RecordPayload({ record, direction, preview = false, + t, }: { record: TableRecord direction: 'input' | 'output' preview?: boolean + t: TrajectoryTranslate }) { const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail const missing = direction === 'input' - ? 'No payload captured' - : 'No result captured' + ? t('record.noPayload') + : t('record.noResult') if (!value) return

{missing}

const error = direction === 'output' && record.cell.isError === true const payloadClass = preview ? css.jsonPreview : css.jsonPayload @@ -1521,7 +1593,8 @@ function RecordPayload({ return ( ) @@ -1537,6 +1610,7 @@ function RecordPayload({ blocks={record.cell.outputBlocks} error={error} preview={preview} + t={t} /> ) } @@ -1554,7 +1628,7 @@ function RecordPayload({ error ? css.errorPayload : undefined, ].filter((className): className is string => className !== undefined).join(' ')} > - + ) } @@ -1562,7 +1636,8 @@ function RecordPayload({ return ( ) @@ -1572,7 +1647,7 @@ function RecordPayload({ css.payload, preview ? css.payloadPreview : undefined, error ? css.errorPayload : undefined, - value === 'No output' ? css.noOutputText : undefined, + value === t('record.noOutput') ? css.noOutputText : undefined, ].filter((value): value is string => value !== undefined).join(' ')} > {value} @@ -1583,12 +1658,14 @@ function RecordPayload({ function RecordSchema({ record, preview = false, + t, }: { record: TableRecord preview?: boolean + t: TrajectoryTranslate }) { if (!record.cell.schemaDetail) { - return

Schema unavailable

+ return

{t('record.schemaUnavailable')}

} const schema = parseToolSchema(record.cell.schemaDetail) if (schema !== undefined) { @@ -1599,10 +1676,11 @@ function RecordSchema({

{schema.description}

-

Parameters

+

{t('record.parameters')}

@@ -1691,6 +1769,7 @@ function OverviewSection({ * @returns The ledger and an optional local record inspector. */ export function TrajectoryTable({ + t, requestNumbers: sessionRequestNumbers, turns, streamingCells = [], @@ -1752,20 +1831,26 @@ export function TrajectoryTable({ useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) - const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords]) + const requestGroups = useMemo(() => new Set( + (sessionRequestNumbers ?? []).map(request => requestKey(request.turn, request.group)), + ), [sessionRequestNumbers]) + const requestBoundaries = useMemo( + () => indexRequestBoundaries(allRecords, requestGroups), + [allRecords, requestGroups], + ) const requestNumbers = useMemo( - () => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries), - [allRecords, requestBoundaries, sessionRequestNumbers], + () => indexRequestNumbers(sessionRequestNumbers), + [sessionRequestNumbers], ) const records = useMemo(() => { if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes) const turnRecords = collapsedTurns.size === 0 ? allRecords - : collapseTurnRecords(allRecords, collapsedTurns) + : collapseTurnRecords(allRecords, collapsedTurns, requestGroups, t) return collapsedAssistants.size === 0 ? turnRecords : collapseAssistantRecords(turnRecords, collapsedAssistants) - }, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes]) + }, [allRecords, collapsedAssistants, collapsedTurns, requestGroups, searchMatchIndexes, t]) const projectedVirtualRows = useMemo( () => groupTrajectoryVirtualRows(records), [records], @@ -1836,8 +1921,8 @@ export function TrajectoryTable({ record.cell.requestOnly === true && position === records.length - 1, })) const requestBoundaryRuns = useMemo( - () => indexRequestBoundaryRuns(records), - [records], + () => indexRequestBoundaryRuns(records, requestGroups), + [records, requestGroups], ) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail @@ -2213,7 +2298,7 @@ export function TrajectoryTable({
)} @@ -2239,8 +2324,8 @@ export function TrajectoryTable({ className={css.historyLoadButton} disabled={olderBusy || onLoadOlder === undefined} aria-label={olderBusy - ? 'Loading earlier history…' - : 'Load earlier history'} + ? t('history.loadingEarlierAria') + : t('history.loadEarlier')} onClick={() => { const pane = tablePaneRef.current if (pane !== null) requestOlder(pane, false) @@ -2250,10 +2335,10 @@ export function TrajectoryTable({ @@ -2493,12 +2590,13 @@ export function TrajectoryTable({ displayText={displayText} toolCallOnly={toolCallOnly} toolCallText={toolCallText} + t={t} /> {resultText !== undefined && ( - @@ -2532,17 +2630,17 @@ export function TrajectoryTable({ || (selected !== undefined && selectedState !== undefined)) && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index b1448b8536..148de3bc01 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -6,6 +6,7 @@ import { } from 'react' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryTranslate } from './locales.ts' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' import { deriveTrajectoryTimeline, @@ -81,15 +82,15 @@ function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail { } } -function timelineKindLabel(kind: TrajectoryCellKind): string { +function timelineKindLabel(kind: TrajectoryCellKind, t: TrajectoryTranslate): string { switch (kind) { - case 'system': return 'SYSTEM' - case 'user': return 'USER' - case 'context': return 'CONTEXT' - case 'compacted': return 'COMPACTED' - case 'message': return 'ASSISTANT' - case 'tool': return 'TOOL' - case 'subtool': return 'SUBTOOL' + case 'system': return t('kind.system') + case 'user': return t('kind.user') + case 'context': return t('kind.context') + case 'compacted': return t('kind.compacted') + case 'message': return t('kind.assistant') + case 'tool': return t('kind.tool') + case 'subtool': return t('kind.subtool') } } @@ -105,30 +106,33 @@ function formatRecordedTime(timestamp: number): string { function timelineTooltipLabel( kind: TrajectoryCellKind, detail: TimelineRecordDetail | undefined, + t: TrajectoryTranslate, ): string { - const heading = timelineKindLabel(kind) + const heading = timelineKindLabel(kind, t) if (detail === undefined) return heading const duration = detail.durationMs === undefined ? null - : `Total ${formatTimelineOffset(detail.durationMs)}` + : t('timeline.total', { duration: formatTimelineOffset(detail.durationMs, t) }) const range = detail.startedAt === undefined ? null : detail.durationMs === undefined - ? `Started ${formatRecordedTime(detail.startedAt)}` + ? t('timeline.started', { time: formatRecordedTime(detail.startedAt) }) : `${formatRecordedTime(detail.startedAt)} → ${formatRecordedTime( detail.startedAt + detail.durationMs, )}` const segments = detail.ttftMs === undefined || detail.decodingMs === undefined ? null - : `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset( - detail.decodingMs, - )}` + : t('timeline.ttftDecoding', { + ttft: formatTimelineOffset(detail.ttftMs, t), + decoding: formatTimelineOffset(detail.decodingMs, t), + }) const timing = [duration, segments].filter(value => value !== null).join(' · ') return [heading, range, timing].filter(value => value !== null && value !== '').join('\n') } /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { + t: TrajectoryTranslate turns: readonly TrajectoryTurnModel[] mode: TrajectoryTimelineMode range: TrajectoryTimeRange | null @@ -185,12 +189,12 @@ function rangeFraction( } } -function LaneLabels() { +function LaneLabels({ t }: { t: TrajectoryTranslate }) { return ( ) } @@ -199,14 +203,16 @@ function EarlierHistoryBoundary({ loading, onHover, onLoad, + t, }: { loading: boolean onHover: () => void onLoad: (() => void) | undefined + t: TrajectoryTranslate }) { return ( @@ -215,7 +221,7 @@ function EarlierHistoryBoundary({ className={css.earlierHistory} data-earlier-history data-loading={loading || undefined} - aria-label={loading ? 'Loading earlier history' : 'Load earlier history'} + aria-label={loading ? t('history.loadingEarlierAria') : t('history.loadEarlier')} aria-disabled={loading || onLoad === undefined} onClick={onLoad} onPointerEnter={(event) => { @@ -233,6 +239,7 @@ function EarlierHistoryBoundary({ /** Overview renderer with drag ranges, click-sized focus, and Escape reset. */ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ + t, turns, mode, range, @@ -380,16 +387,17 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ if (model === null) { return ( -
+
- +
- No timing data + {t('timeline.noTimingData')} {hasEarlierRecords && ( { setHover(null) }} onLoad={loadEarlier} + t={t} /> )}
@@ -575,14 +583,14 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } return ( -
+
- +
{ setHover(null) }} onLoad={loadEarlier} + t={t} /> )} {hover !== null && hover.recordIndex === null && draft === null && ( @@ -687,7 +696,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ return ( timelineTooltipLabel(span.kind, detail)} + label={() => timelineTooltipLabel(span.kind, detail, t)} side="bottom" delayMs={TIMELINE_TOOLTIP_DELAY_MS} > diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx index 6ebce17731..b073f74f05 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx' +import type { TrajectoryTranslate } from './locales.ts' import css from './TrajectoryTurn.module.css' export interface TrajectoryTurnProps { @@ -9,6 +10,8 @@ export interface TrajectoryTurnProps { turn: number /** Message / Step headers and TrajectoryCell rows. */ children?: ReactNode + /** Trajectory locale seat. */ + t: TrajectoryTranslate } /** @@ -16,10 +19,10 @@ export interface TrajectoryTurnProps { * @param props - turn index and body children. * @returns the turn section element. */ -export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) { +export function TrajectoryTurn({ turn, children, t }: TrajectoryTurnProps) { return (
- +
{children}
) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx index c37fbbd650..7ca8a2fa2e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx @@ -1,12 +1,17 @@ // TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels. import css from './TrajectoryTurnHeader.module.css' +import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' -const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const +const COLUMN_LABEL_KEYS: readonly TrajectoryKey[] = [ + 'column.input', 'column.output', 'column.think', 'column.time', +] export interface TrajectoryTurnHeaderProps { /** 1-based turn index shown as `Turn N`. */ turn: number + /** Trajectory locale seat. */ + t: TrajectoryTranslate } /** @@ -14,14 +19,14 @@ export interface TrajectoryTurnHeaderProps { * @param props.turn - turn index. * @returns the sticky header element. */ -export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) { +export function TrajectoryTurnHeader({ turn, t }: TrajectoryTurnHeaderProps) { return (
- Turn {turn} + {t('turn.label', { turn })}
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 36727078ef..ad87b1fbfe 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -201,7 +201,7 @@ export function TrajectoryView({ seq: entry.seq, turn, step, - group: `Step ${step}`, + group: t('group.step', { step }), number: index + 1, ...(request?.status === undefined ? {} : { status: request.status }), ...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }), @@ -226,7 +226,7 @@ export function TrajectoryView({ seq: request.startSeq, turn: request.turn, step: 0, - group: `Compaction ${request.startSeq}`, + group: t('group.compaction', { seq: request.startSeq }), number: index + 1, purpose: 'compaction', status: request.status, @@ -248,7 +248,7 @@ export function TrajectoryView({ return numbered }, [ - nodes, requests, + nodes, requests, t, ]) const partialTurn = partial?.turn ?? null const partialStep = partial?.step ?? null @@ -262,11 +262,11 @@ export function TrajectoryView({ runningCalls, requests, callSchemas, - }) + }, t) return { turns, lastIndex: lastCellIndex(turns) } }, [ nodes, eventLocations, partialTurn, partialStep, - runningCalls, requests, callSchemas, + runningCalls, requests, callSchemas, t, ]) const timelinePartialSignature = partialStructureSignature(partial) const timelinePartial = useMemo(() => partial === null @@ -278,15 +278,15 @@ export function TrajectoryView({ }, [partialStep, partialTurn, timelinePartialSignature]) const timelineTurns = useMemo( - () => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex), - [finalized, timelinePartial], + () => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex, t), + [finalized, timelinePartial, t], ) const timelineMode: TrajectoryTimelineMode = actualDuration ? actualTime ? 'actual' : 'duration' : actualTime ? 'time' : 'sequence' const partialSearchTurns = useMemo( - () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex), - [finalized.lastIndex, partial], + () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex, t), + [finalized.lastIndex, partial, t], ) const searchLayouts = useMemo( () => [finalized.turns, partialSearchTurns] as const, @@ -465,6 +465,7 @@ export function TrajectoryView({ t={t} />
{ const groups = bucket(turn).groups const last = groups.at(-1) - if (last?.title === 'Message') { + if (last?.title === t('group.message')) { last.laid.push(laid) return } - groups.push({ title: 'Message', laid: [laid] }) + groups.push({ title: t('group.message'), laid: [laid] }) } const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => { if (laid.length === 0) return const groups = bucket(turn).groups - const title = `Step ${step}` + const title = t('group.step', { step }) const existing = groups.find(group => group.title === title) if (existing !== undefined) { existing.laid.push(...laid) @@ -191,7 +197,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => { if (laid.length === 0) return const groups = bucket(turn).groups - const title = `Step ${step}` + const title = t('group.step', { step }) const existing = groups.find(group => group.title === title) if (existing === undefined) { groups.push({ title, laid: [...laid] }) @@ -286,7 +292,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T cell: { index: ++index, kind: 'system', - text: promptChangeLabel(change), + text: promptChangeLabel(change, t), sourceSeq: change.seq, ...(request.prompt === undefined ? {} : { promptDetail: request.prompt }), ...(change.previous === undefined @@ -309,11 +315,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T index: ++index, kind: 'compacted', text: request.status === 'running' - ? 'Compacting context…' + ? t('layout.compacting') : request.status === 'error' - ? request.error ?? 'Compaction failed' + ? request.error === COMPACTION_INTERRUPTED_ERROR + ? t('layout.compactionInterrupted') + : request.error ?? t('layout.compactionFailed') : request.summary === undefined - ? 'Context compacted' + ? t('layout.compacted') : '', ...(request.status === 'complete' && request.summary !== undefined ? previewContentProperty(request.summary) @@ -338,7 +346,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T attachUsage(cell, request.usage as UsageLike | undefined) const compaction: TurnBucket = { groups: [{ - title: `Compaction ${request.startSeq}`, + title: t('group.compaction', { seq: request.startSeq }), laid: [{ absTime: finiteTime(request.startedAt), cell, @@ -389,7 +397,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T } if (node.kind === 'assistant') { const laidList = withSubCalls( - expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById), + expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById, t), + t, ) if (node.step > 0) pushStep(node.turn, node.step, laidList) else for (const laid of laidList) pushMessage(node.turn, laid) @@ -421,7 +430,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'tool-result') { if (!emittedCallIds.has(node.callId)) { const toolName = node.call?.name - const resultPreview = summarizeResult(node) + const resultPreview = summarizeResult(node, t) const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), ...(toolName !== undefined ? { toolName } : {}), @@ -435,7 +444,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T ? summarizeCall(node.call.name, node.call.argsRaw) : resultAsText(resultPreview)), ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), - outputDetail: detailResult(node), + outputDetail: detailResult(node, t), outputBlocks: node.content.map(block => sourceBlock(block)), ...resultPreview, callId: node.callId, @@ -444,7 +453,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T startedAt: finiteTime(node.callTime), }, }] - for (const laid of expandSubCalls(node.subCalls, index)) { + for (const laid of expandSubCalls(node.subCalls, index, t)) { laidList.push(laid) index = laid.cell.index } @@ -466,8 +475,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T resultByCall, callStartById, callById, + t, { streaming: true }, - )) + ), t) if (partial.step > 0) pushStep(partial.turn, partial.step, laidList) else for (const laid of laidList) pushMessage(partial.turn, laid) const last = laidList[laidList.length - 1] @@ -492,7 +502,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T startedAt: finiteTime(call.time), }, }] - for (const laid of expandSubCalls(call.subCalls, index)) { + for (const laid of expandSubCalls(call.subCalls, index, t)) { laidList.push(laid) index = laid.cell.index } @@ -517,8 +527,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T } return [ - ...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry)), - ...standaloneCompactions.map(entry => toTurnModel(null, entry)), + ...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry, t)), + ...standaloneCompactions.map(entry => toTurnModel(null, entry, t)), ].sort((left, right) => firstCellIndex(left) - firstCellIndex(right)) } @@ -527,19 +537,21 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T * @param turns - Finalized layout derived with an empty-block partial anchor. * @param partial - Current in-flight assistant projection. * @param lastIndex - Highest cell index in the finalized layout. + * @param t - Trajectory locale translator. * @returns The original layout without a partial, otherwise a layout sharing every unaffected turn. */ export function appendTrajectoryPartialLayout( turns: readonly TrajectoryTurnModel[], partial: ConversationSnapshot['partial'], lastIndex: number, + t: TrajectoryTranslate, ): readonly TrajectoryTurnModel[] { if (partial === null) return turns const partialTurn = deriveTrajectoryLayout({ nodes: [], partial, runningCalls: [], - }).at(0) + }, t).at(0) if (partialTurn === undefined) return turns const streamed: TrajectoryTurnModel = { ...partialTurn, @@ -595,9 +607,10 @@ function attachToolSchema( function toTurnModel( turn: number | null, entry: TurnBucket, + t: TrajectoryTranslate, ): TrajectoryTurnModel { const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => { - const description = groupDescription(laid) + const description = groupDescription(laid, t) return { title, ...(description !== undefined ? { description } : {}), @@ -616,7 +629,10 @@ function firstCellIndex(turn: TrajectoryTurnModel): number { } /** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */ -function groupDescription(laid: readonly LaidCell[]): string | undefined { +function groupDescription( + laid: readonly LaidCell[], + t: TrajectoryTranslate, +): string | undefined { const parts: string[] = [] // Tool rows contribute start (absTime) and end (start + own duration) so a // single Tool cell still spans call→result for the group wall clock. @@ -629,11 +645,11 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { } } if (times.length >= 2) { - const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000) + const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000, t) if (span !== undefined) parts.push(span) } else if (times.length === 1) { const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds - const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined + const span = own !== null && own !== undefined ? formatGroupDuration(own, t) : undefined if (span !== undefined) parts.push(span) } const tools = new Map() @@ -647,9 +663,12 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { return parts.length === 0 ? undefined : parts.join(' ') } -function formatGroupDuration(seconds: number): string | undefined { +function formatGroupDuration( + seconds: number, + t: TrajectoryTranslate, +): string | undefined { if (!Number.isFinite(seconds)) return undefined - return formatElapsedSeconds(seconds) + return formatElapsedSeconds(seconds, t) } /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ @@ -670,6 +689,7 @@ function expandAssistant( results: Map, callStarts: ReadonlyMap, calls: ReadonlyMap, + t: TrajectoryTranslate, opts?: { streaming?: boolean }, ): LaidCell[] { if (opts?.streaming === true && node.blocks.length === 0) return [] @@ -697,7 +717,7 @@ function expandAssistant( sourceSeq: node.seq, text: messageText !== '' || thinkingText !== '' ? '' - : summarizeAssistantActivity(node.blocks), + : summarizeAssistantActivity(node.blocks, t), ...(messageText !== '' ? { previewMarkdown: messageText } : thinkingText !== '' @@ -729,7 +749,7 @@ function expandAssistant( : durationSeconds(result.time, result.callTime) const callAbs = finiteTime(callStarts.get(block.callId)) const call = calls.get(block.callId) - const resultPreview = result === undefined ? undefined : summarizeResult(result) + const resultPreview = result === undefined ? undefined : summarizeResult(result, t) out.push({ absTime: callAbs, toolName: block.name, @@ -742,7 +762,7 @@ function expandAssistant( callId: block.callId, ...(result !== undefined ? { - outputDetail: detailResult(result), + outputDetail: detailResult(result, t), outputBlocks: result.content.map(block => sourceBlock(block)), ...resultPreview, isError: result.isError, @@ -756,23 +776,26 @@ function expandAssistant( return out } -function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string { +function summarizeAssistantActivity( + blocks: readonly AssistantBlock[], + t: TrajectoryTranslate, +): string { const tools = new Map() for (const block of blocks) { if (block.kind !== 'tool-call') continue tools.set(block.name, (tools.get(block.name) ?? 0) + 1) } if (tools.size > 0) { - return 'Tool call only' + return t('layout.toolCallOnly') } return '' } -function promptChangeLabel(change: RequestPromptChange): string { - if (change.kind === 'initial') return 'Initial System Prompt' - if (change.kind === 'system') return 'System Prompt Updated' - if (change.kind === 'tools') return 'Tools Updated' - return 'System Prompt and Tools Updated' +function promptChangeLabel(change: RequestPromptChange, t: TrajectoryTranslate): string { + if (change.kind === 'initial') return t('layout.initialSystemPrompt') + if (change.kind === 'system') return t('layout.systemPromptUpdated') + if (change.kind === 'tools') return t('layout.toolsUpdated') + return t('layout.systemPromptAndToolsUpdated') } function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { @@ -975,13 +998,13 @@ function collectCallIds( /** Interleave each tool cell's nested child calls right after it, reindexing followers. */ -function withSubCalls(laidList: LaidCell[]): LaidCell[] { +function withSubCalls(laidList: LaidCell[], t: TrajectoryTranslate): LaidCell[] { if (!laidList.some(laid => laid.subCalls !== undefined && laid.subCalls.length > 0)) return laidList const out: LaidCell[] = [] let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0 for (const laid of laidList) { out.push({ ...laid, cell: { ...laid.cell, index: ++index } }) - for (const sub of expandSubCalls(laid.subCalls, index)) { + for (const sub of expandSubCalls(laid.subCalls, index, t)) { out.push(sub) index = sub.cell.index } @@ -993,13 +1016,14 @@ function withSubCalls(laidList: LaidCell[]): LaidCell[] { function expandSubCalls( subs: readonly ToolCallBlock[] | undefined, startIndex: number, + t: TrajectoryTranslate, ): LaidCell[] { if (subs === undefined || subs.length === 0) return [] const out: LaidCell[] = [] let index = startIndex for (const sub of subs) { const settled = 'kind' in sub - const resultPreview = settled ? summarizeResult(sub) : undefined + const resultPreview = settled ? summarizeResult(sub, t) : undefined const laid: LaidCell = { absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time), toolName: settled ? sub.call?.name ?? sub.callId : sub.name, @@ -1018,7 +1042,7 @@ function expandSubCalls( : { inputDetail: sub.argsRaw }), ...(settled ? { - outputDetail: detailResult(sub), + outputDetail: detailResult(sub, t), outputBlocks: sub.content.map(block => sourceBlock(block)), ...resultPreview, isError: sub.isError, @@ -1033,7 +1057,7 @@ function expandSubCalls( }, } out.push(laid) - for (const child of expandSubCalls(sub.subCalls, index)) { + for (const child of expandSubCalls(sub.subCalls, index, t)) { out.push(child) index = child.cell.index } @@ -1053,6 +1077,7 @@ function summarizeCall( function summarizeResult( node: ToolResultNode, + t: TrajectoryTranslate, ): Pick { if (node.isError) { return { result: node.error?.code ?? 'error' } @@ -1062,7 +1087,7 @@ function summarizeResult( return { result: '', resultPreviewMarkdown: block.text } } } - return { result: 'No output' } + return { result: t('record.noOutput') } } function resultAsText( @@ -1076,7 +1101,7 @@ function resultAsText( } } -function detailResult(node: ToolResultNode): string { +function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { if (node.isError) { return node.error === undefined ? 'error' @@ -1091,7 +1116,7 @@ function detailResult(node: ToolResultNode): string { node.content.length === 0 || node.content.every(block => block.type === 'text' && (typeof block.text !== 'string' || block.text === '')) - ) return 'No output' + ) return t('record.noOutput') return JSON.stringify(node.content, null, 2) } diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index f8adbde640..a0d91a897d 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -1,51 +1,200 @@ -/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */ +/** `trajectory` namespace dictionaries for the complete trajectory surface. */ /** Dictionary namespace owned by this plugin. */ export const NS = 'trajectory' -/** The trajectory dictionary key set (the source of truth for both locales). */ -export type TrajectoryKey = - | 'view.trajectory' - | 'toolbar.aria' - | 'toolbar.duration' - | 'toolbar.useActualDuration' - | 'toolbar.useEqualWidth' - | 'toolbar.actualTime' - | 'toolbar.turns' - | 'toolbar.expandTurns' - | 'toolbar.collapseTurns' - | 'toolbar.calls' - | 'toolbar.expandCalls' - | 'toolbar.collapseCalls' - | 'toolbar.search' - | 'toolbar.searchPlaceholder' +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'view.trajectory': '轨迹', + 'toolbar.aria': '轨迹工具栏', + 'toolbar.duration': '时长', + 'toolbar.useActualDuration': '使用实际时长', + 'toolbar.useEqualWidth': '使用等宽操作', + 'toolbar.actualTime': '实际时间', + 'toolbar.turns': '轮次', + 'toolbar.expandTurns': '展开所有轮次', + 'toolbar.collapseTurns': '收起所有轮次', + 'toolbar.calls': '调用', + 'toolbar.expandCalls': '展开所有调用', + 'toolbar.collapseCalls': '收起所有调用', + 'toolbar.search': '搜索轨迹', + 'toolbar.searchPlaceholder': '搜索', + 'kind.system': '系统', + 'kind.user': '用户', + 'kind.context': '上下文', + 'kind.compacted': '已压缩', + 'kind.message': '消息', + 'kind.assistant': '助手', + 'kind.tool': '工具', + 'kind.subtool': '子工具', + 'kind.sub': '子项', + 'column.input': '输入', + 'column.output': '输出', + 'column.think': '思考', + 'column.time': '时间', + 'column.model': '模型', + 'column.tools': '工具', + 'turn.label': '第 {turn} 轮', + 'section.betweenTurns': '轮次之间', + 'group.message': '消息', + 'group.step': '步骤 {step}', + 'group.compaction': '压缩 {seq}', + 'status.failed': '失败', + 'status.pending': '等待中', + 'status.completed': '已完成', + 'timing.notAvailable': '不可用', + 'timing.notRecorded': '未记录', + 'timing.stepStartUnavailable': '步骤开始时间不可用', + 'timing.firstTokenUnavailable': '首 token 时间不可用', + 'timing.usageUnavailable': '用量不可用', + 'timing.outputTokensUnavailable': '输出 token 数不可用', + 'timing.durationTooShort': '时长过短', + 'timing.showLocalTime': '显示本地时间', + 'timing.showUnixTimestamp': '显示 Unix 时间戳', + 'timing.started': '开始时间', + 'timing.totalDuration': '总时长', + 'timing.ttft': '首 token 延迟', + 'timing.generation': '生成', + 'timing.throughput': '吞吐量', + 'timing.duration': '时长', + 'timing.source': '计时来源', + 'timing.sessionTimestamps': '会话时间戳', + 'timing.sessionTimestampsRunning': '会话时间戳(运行中)', + 'timing.request': '请求计时', + 'unit.milliseconds': '{value} 毫秒', + 'unit.seconds': '{value} 秒', + 'unit.tokens': '{value} tok', + 'unit.tokensPerSecond': '{value} tok/s', + 'usage.tokens': 'Token', + 'usage.reasoning': '推理', + 'usage.content': '内容', + 'usage.notReported': '未报告用量', + 'usage.input': '输入', + 'usage.cached': '缓存读取', + 'usage.cacheCreated': '缓存写入', + 'usage.other': '其他', + 'usage.output': '输出', + 'usage.thisRequest': '本次请求', + 'usage.sessionCumulative': '会话累计', + 'options.notRecorded': '未记录选项', + 'options.json': '请求选项 JSON', + 'source.unknown': '未知', + 'source.user': '用户', + 'source.plugin': '插件', + 'source.pluginNamed': '插件 · {plugin}', + 'source.goal': '目标', + 'source.goalRound': '目标 · Round {round}', + 'source.notRecorded': '未记录来源', + 'source.messageJson': '消息来源 JSON', + 'tab.summary': '概述', + 'tab.rawOutput': '原始输出', + 'tab.preview': '预览', + 'tab.raw': '原始内容', + 'tab.source': '来源', + 'tab.payload': '参数', + 'tab.result': '结果', + 'tab.schema': 'Schema', + 'tab.timing': '计时', + 'tab.diff': '差异', + 'tab.systemPrompt': '系统提示词', + 'tab.tools': '工具', + 'tab.options': '选项', + 'tab.usage': '用量', + 'record.toolCallOnly': '(仅工具调用)', + 'record.noContent': '无内容', + 'record.noPayload': '未捕获参数', + 'record.noResult': '未捕获结果', + 'record.noOutput': '无输出', + 'record.schemaUnavailable': 'Schema 不可用', + 'record.parameters': '参数', + 'record.resultJson': '结果 JSON', + 'record.json': 'JSON', + 'record.parametersJson': '参数 JSON', + 'record.namedParametersJson': '{name} 参数 JSON', + 'record.payloadJson': '参数 JSON', + 'record.outputJson': '结果 JSON', + 'record.thinking': '思考', + 'record.systemPromptMissing': '本次请求没有系统提示词', + 'record.toolsMissing': '本次请求没有工具', + 'record.systemPrompt': '系统提示词', + 'record.tools': '工具', + 'block.openSummary': '打开第 {index} 个块的工具调用概述', + 'block.openSummaryTitle': '打开工具调用概述', + 'block.label': '块 #{index} {type}', + 'block.openImage': '打开图片', + 'history.loadingTrajectory': '正在加载轨迹…', + 'history.loadingEarlier': '正在加载更早的历史…', + 'history.loadingEarlierAria': '正在加载更早的历史…', + 'history.loadEarlier': '加载更早的历史', + 'history.clickToLoadEarlier': '点击加载更早的历史', + 'request.label': '请求 #{request}', + 'request.labelCompaction': '请求 #{request} · 压缩', + 'request.compaction': '压缩 · {section}', + 'request.compactionPurpose': '压缩', + 'request.retryProgress': '{retry}/{maximum}', + 'request.collapsedSummary': '已收起的{kind}概述,{summary}', + 'request.collapsedTurn': '轮次', + 'request.collapsedAssistant': '助手', + 'request.rowAria': '{request}{kind},{content}', + 'request.rowPrefix': '请求 {request},', + 'request.rowAriaCompaction': '请求 {request},压缩', + 'request.noContent': '无内容', + 'summary.toolCalls.one': '{count} 个工具调用', + 'summary.toolCalls.other': '{count} 个工具调用', + 'summary.steps.one': '{count} 个步骤', + 'summary.steps.other': '{count} 个步骤', + 'details.event': '事件详情', + 'details.resize': '调整事件详情宽度', + 'details.resizeTitle': '拖动调整大小;双击恢复默认值。', + 'details.close': '关闭详情', + 'details.status': '状态', + 'details.purpose': '用途', + 'details.provider': '提供方', + 'details.model': '模型', + 'details.toolCalls': '工具调用', + 'details.subtoolCalls': '子工具调用', + 'details.error': '错误', + 'details.retry': '重试', + 'details.scheduled': '已计划', + 'details.retryDelay': '重试延迟', + 'details.result': '结果', + 'details.compacted': '已压缩', + 'details.assistantMessage': '助手消息', + 'details.source': '来源', + 'details.hierarchy': '层级', + 'details.toolCall': '工具调用', + 'timeline.aria': '轨迹时间线', + 'timeline.overviewAria': '时间线概览;水平拖动可聚焦事件', + 'timeline.noTimingData': '无计时数据', + 'timeline.total': '总计 {duration}', + 'timeline.started': '开始于 {time}', + 'timeline.ttftDecoding': '首 token {ttft} · 解码 {decoding}', + 'layout.compacting': '正在压缩上下文…', + 'layout.compactionFailed': '上下文压缩失败', + 'layout.compacted': '上下文已压缩', + 'layout.toolCallOnly': '仅工具调用', + 'layout.initialSystemPrompt': '初始系统提示词', + 'layout.systemPromptUpdated': '系统提示词已更新', + 'layout.toolsUpdated': '工具已更新', + 'layout.systemPromptAndToolsUpdated': '系统提示词和工具已更新', + 'layout.compactionInterrupted': '上下文压缩在完成前被中断。', +} as const + +/** The trajectory dictionary key union. */ +export type TrajectoryKey = keyof typeof zh declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The trajectory view tab label and toolbar strings. */ - 'trajectory': TrajectoryKey + /** The complete trajectory ledger, timeline, inspector, and toolbar copy. */ + trajectory: TrajectoryKey } } -/** Simplified Chinese dictionary (the key-set source of truth). */ -export const zh: Record = { - 'view.trajectory': '轨迹', - 'toolbar.aria': '轨迹工具栏', - 'toolbar.duration': 'Duration', - 'toolbar.useActualDuration': 'Use actual duration', - 'toolbar.useEqualWidth': 'Use equal-width operations', - 'toolbar.actualTime': '实际时间', - 'toolbar.turns': 'Turns', - 'toolbar.expandTurns': 'Expand turns', - 'toolbar.collapseTurns': 'Collapse turns', - 'toolbar.calls': 'Calls', - 'toolbar.expandCalls': 'Expand calls', - 'toolbar.collapseCalls': 'Collapse calls', - 'toolbar.search': '搜索轨迹', - 'toolbar.searchPlaceholder': '搜索', -} +/** Namespace-bound translator threaded through trajectory presentation code. */ +export type TrajectoryTranslate = + import('@deepseek-ai/dsh-client-ui-slots').TranslateNS -/** English dictionary. */ +/** English dictionary, checked complete against the Chinese source of truth. */ export const en: Record = { 'view.trajectory': 'Trajectory', 'toolbar.aria': 'Trajectory toolbar', @@ -61,4 +210,163 @@ export const en: Record = { 'toolbar.collapseCalls': 'Collapse calls', 'toolbar.search': 'Search trajectory', 'toolbar.searchPlaceholder': 'Search', + 'kind.system': 'SYSTEM', + 'kind.user': 'USER', + 'kind.context': 'CONTEXT', + 'kind.compacted': 'COMPACTED', + 'kind.message': 'Message', + 'kind.assistant': 'ASSISTANT', + 'kind.tool': 'TOOL', + 'kind.subtool': 'SUBTOOL', + 'kind.sub': 'Sub', + 'column.input': 'Input', + 'column.output': 'Output', + 'column.think': 'Think', + 'column.time': 'Time', + 'column.model': 'Model', + 'column.tools': 'Tools', + 'turn.label': 'Turn {turn}', + 'section.betweenTurns': 'Between turns', + 'group.message': 'Message', + 'group.step': 'Step {step}', + 'group.compaction': 'Compaction {seq}', + 'status.failed': 'Failed', + 'status.pending': 'Pending', + 'status.completed': 'Completed', + 'timing.notAvailable': 'Not available', + 'timing.notRecorded': 'Not recorded', + 'timing.stepStartUnavailable': 'Step start unavailable', + 'timing.firstTokenUnavailable': 'First token unavailable', + 'timing.usageUnavailable': 'Usage unavailable', + 'timing.outputTokensUnavailable': 'Output tokens unavailable', + 'timing.durationTooShort': 'Duration too short', + 'timing.showLocalTime': 'Show local time', + 'timing.showUnixTimestamp': 'Show Unix timestamp', + 'timing.started': 'Started', + 'timing.totalDuration': 'Total duration', + 'timing.ttft': 'TTFT', + 'timing.generation': 'Generation', + 'timing.throughput': 'Throughput', + 'timing.duration': 'Duration', + 'timing.source': 'Timing source', + 'timing.sessionTimestamps': 'Session timestamps', + 'timing.sessionTimestampsRunning': 'Session timestamps (running)', + 'timing.request': 'Request Timing', + 'unit.milliseconds': '{value} ms', + 'unit.seconds': '{value} s', + 'unit.tokens': '{value} tok', + 'unit.tokensPerSecond': '{value} tok/s', + 'usage.tokens': 'Tokens', + 'usage.reasoning': 'Reasoning', + 'usage.content': 'Content', + 'usage.notReported': 'Usage not reported', + 'usage.input': 'Input', + 'usage.cached': 'Cached', + 'usage.cacheCreated': 'Cache created', + 'usage.other': 'Other', + 'usage.output': 'Output', + 'usage.thisRequest': 'This request', + 'usage.sessionCumulative': 'Session cumulative', + 'options.notRecorded': 'Options not recorded', + 'options.json': 'Request options JSON', + 'source.unknown': 'Unknown', + 'source.user': 'User', + 'source.plugin': 'Plugin', + 'source.pluginNamed': 'Plugin · {plugin}', + 'source.goal': 'Goal', + 'source.goalRound': 'Goal · Round {round}', + 'source.notRecorded': 'Source not recorded', + 'source.messageJson': 'Message source JSON', + 'tab.summary': 'Summary', + 'tab.rawOutput': 'Raw Output', + 'tab.preview': 'Preview', + 'tab.raw': 'Raw', + 'tab.source': 'Source', + 'tab.payload': 'Payload', + 'tab.result': 'Result', + 'tab.schema': 'Schema', + 'tab.timing': 'Timing', + 'tab.diff': 'Diff', + 'tab.systemPrompt': 'System Prompt', + 'tab.tools': 'Tools', + 'tab.options': 'Options', + 'tab.usage': 'Usage', + 'record.toolCallOnly': '(tool call only)', + 'record.noContent': 'No content', + 'record.noPayload': 'No payload captured', + 'record.noResult': 'No result captured', + 'record.noOutput': 'No output', + 'record.schemaUnavailable': 'Schema unavailable', + 'record.parameters': 'Parameters', + 'record.resultJson': 'Result JSON', + 'record.json': 'JSON', + 'record.parametersJson': 'parameters JSON', + 'record.namedParametersJson': '{name} parameters JSON', + 'record.payloadJson': 'Payload JSON', + 'record.outputJson': 'Result JSON', + 'record.thinking': 'Thinking', + 'record.systemPromptMissing': 'No system prompt in this request', + 'record.toolsMissing': 'No tools in this request', + 'record.systemPrompt': 'System Prompt', + 'record.tools': 'Tools', + 'block.openSummary': 'Open Block #{index} tool call summary', + 'block.openSummaryTitle': 'Open tool call summary', + 'block.label': 'Block #{index} {type}', + 'block.openImage': 'Open image', + 'history.loadingTrajectory': 'Loading trajectory…', + 'history.loadingEarlier': 'Loading earlier history…', + 'history.loadingEarlierAria': 'Loading earlier history…', + 'history.loadEarlier': 'Load earlier history', + 'history.clickToLoadEarlier': 'Click to load earlier history', + 'request.label': 'Request #{request}', + 'request.labelCompaction': 'Request #{request} · Compaction', + 'request.compaction': 'Compaction · {section}', + 'request.compactionPurpose': 'Compaction', + 'request.retryProgress': '{retry} of {maximum}', + 'request.collapsedSummary': 'Collapsed {kind} summary, {summary}', + 'request.collapsedTurn': 'turn', + 'request.collapsedAssistant': 'assistant', + 'request.rowAria': '{request}{kind}, {content}', + 'request.rowPrefix': 'Request {request}, ', + 'request.rowAriaCompaction': 'Request {request}, compaction', + 'request.noContent': 'no content', + 'summary.toolCalls.one': '{count} tool call', + 'summary.toolCalls.other': '{count} tool calls', + 'summary.steps.one': '{count} step', + 'summary.steps.other': '{count} steps', + 'details.event': 'Event details', + 'details.resize': 'Resize event details', + 'details.resizeTitle': 'Drag to resize. Double-click to reset.', + 'details.close': 'Close details', + 'details.status': 'Status', + 'details.purpose': 'Purpose', + 'details.provider': 'Provider', + 'details.model': 'Model', + 'details.toolCalls': 'Tool calls', + 'details.subtoolCalls': 'Subtool calls', + 'details.error': 'Error', + 'details.retry': 'Retry', + 'details.scheduled': 'Scheduled', + 'details.retryDelay': 'Retry delay', + 'details.result': 'Result', + 'details.compacted': 'Compacted', + 'details.assistantMessage': 'Assistant Message', + 'details.source': 'Source', + 'details.hierarchy': 'Hierarchy', + 'details.toolCall': 'Tool Call', + 'timeline.aria': 'Trajectory timeline', + 'timeline.overviewAria': 'Timeline overview; drag horizontally to focus events', + 'timeline.noTimingData': 'No timing data', + 'timeline.total': 'Total {duration}', + 'timeline.started': 'Started {time}', + 'timeline.ttftDecoding': 'TTFT {ttft} · Decoding {decoding}', + 'layout.compacting': 'Compacting context…', + 'layout.compactionFailed': 'Compaction failed', + 'layout.compacted': 'Context compacted', + 'layout.toolCallOnly': 'Tool call only', + 'layout.initialSystemPrompt': 'Initial System Prompt', + 'layout.systemPromptUpdated': 'System Prompt Updated', + 'layout.toolsUpdated': 'Tools Updated', + 'layout.systemPromptAndToolsUpdated': 'System Prompt and Tools Updated', + 'layout.compactionInterrupted': 'Compaction was interrupted before completion.', } diff --git a/packages/client/ui-trajectory/src/client/timeline.ts b/packages/client/ui-trajectory/src/client/timeline.ts index 6d3a0ef917..40cf1c7287 100644 --- a/packages/client/ui-trajectory/src/client/timeline.ts +++ b/packages/client/ui-trajectory/src/client/timeline.ts @@ -1,6 +1,7 @@ /** Operation-sequence and recorded-time projections for the trajectory overview. */ import type { TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryTranslate } from './locales.ts' import { formatDurationMillis } from './trajectory-record.ts' import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' @@ -37,10 +38,14 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange { /** * Format a timeline duration as an integer-millisecond label. * @param milliseconds - Non-negative duration in milliseconds. + * @param t - Trajectory locale translator. * @returns Millisecond label with thousands separators. */ -export function formatTimelineOffset(milliseconds: number): string { - return formatDurationMillis(milliseconds) +export function formatTimelineOffset( + milliseconds: number, + t: TrajectoryTranslate, +): string { + return formatDurationMillis(milliseconds, t) } function laneFor(kind: TrajectoryCellKind): number { diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index e4cd6e6e02..d22de55970 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryTranslate } from './locales.ts' /** Closed set of trajectory record kinds. */ export type TrajectoryCellKind = @@ -112,19 +113,29 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string { /** * Format a duration in milliseconds with thousands separators. * @param milliseconds - Duration in milliseconds, or `null` when absent. + * @param t - Trajectory locale translator. * @returns `—` when unknown, otherwise an integer-millisecond label. */ -export function formatDurationMillis(milliseconds: number | null): string { +export function formatDurationMillis( + milliseconds: number | null, + t: TrajectoryTranslate, +): string { if (milliseconds === null || !Number.isFinite(milliseconds)) return '—' const integer = String(Math.round(milliseconds)) - return `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} ms` + return t('unit.milliseconds', { + value: integer.replace(/\B(?=(\d{3})+(?!\d))/g, ','), + }) } /** * Format an elapsed duration given in seconds as a millisecond label. * @param seconds - Duration seconds, or `null` when absent. + * @param t - Trajectory locale translator. * @returns `—` when unknown, otherwise an integer-millisecond label. */ -export function formatElapsedSeconds(seconds: number | null): string { - return formatDurationMillis(seconds === null ? null : seconds * 1000) +export function formatElapsedSeconds( + seconds: number | null, + t: TrajectoryTranslate, +): string { + return formatDurationMillis(seconds === null ? null : seconds * 1000, t) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 8ca382bcc5..b40b47d8f2 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -4,6 +4,7 @@ import type { ConversationViewBuilder, ConversationViewDefinition, RequestView, ToolCallBlock, } from '@deepseek-ai/dsh-client-runtime/client' +import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' import type { TrajectoryConversationViewNode, TrajectoryRequestHeaderState, TrajectorySnapshot, @@ -106,7 +107,7 @@ function interruptCompactions( ...request, completedAt: boundary.time, status: 'error', - error: 'Compaction was interrupted before completion.', + error: COMPACTION_INTERRUPTED_ERROR, } } } diff --git a/packages/client/ui-trajectory/tests/cell.client.spec.tsx b/packages/client/ui-trajectory/tests/cell.client.spec.tsx index 2e7c0ddb45..76fce9e920 100644 --- a/packages/client/ui-trajectory/tests/cell.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/cell.client.spec.tsx @@ -5,12 +5,21 @@ */ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' +import type { ComponentProps } from 'react' import { - formatElapsedSeconds, - TrajectoryCell, + formatElapsedSeconds as formatElapsedSecondsWithLocale, + TrajectoryCell as LocalizedTrajectoryCell, type TrajectoryCellKind, } from '../src/client/TrajectoryCell.tsx' -import { formatDurationMillis } from '../src/client/trajectory-record.ts' +import { formatDurationMillis as formatDurationMillisWithLocale } from '../src/client/trajectory-record.ts' +import { t } from './locale.client.ts' + +const formatDurationMillis = (value: number | null) => formatDurationMillisWithLocale(value, t) +const formatElapsedSeconds = (value: number | null) => formatElapsedSecondsWithLocale(value, t) + +function TrajectoryCell(props: Omit, 't'>) { + return +} afterEach(cleanup) @@ -52,7 +61,7 @@ describe('TrajectoryCell', () => { />, ) expect(screen.getByText('#6')).toBeTruthy() - expect(screen.getByText('Tool')).toBeTruthy() + expect(screen.getByText('TOOL')).toBeTruthy() expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() expect(screen.getByText('5,000 ms')).toBeTruthy() }) @@ -88,8 +97,8 @@ describe('TrajectoryCell', () => { }) it.each([ - ['user', 'User'], - ['tool', 'Tool'], + ['user', 'USER'], + ['tool', 'TOOL'], ] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => { const { container } = render( , diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index ec8924505f..17fd1192f3 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -12,14 +12,26 @@ import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' import { - appendTrajectoryPartialLayout, deriveTrajectoryLayout, + appendTrajectoryPartialLayout as appendTrajectoryPartialLayoutWithLocale, + deriveTrajectoryLayout as deriveTrajectoryLayoutWithLocale, } from '../src/client/layout.ts' +import { t } from './locale.client.ts' + +const deriveTrajectoryLayout = ( + input: Parameters[0], +) => deriveTrajectoryLayoutWithLocale(input, t) + +const appendTrajectoryPartialLayout = ( + turns: Parameters[0], + partial: Parameters[1], + lastIndex: number, +) => appendTrajectoryPartialLayoutWithLocale(turns, partial, lastIndex, t) afterEach(cleanup) describe('TrajectoryTurnHeader', () => { it('renders Turn N and the four metric column labels', () => { - render() + render() expect(screen.getByText('Turn 1')).toBeTruthy() expect(screen.getByText('Input')).toBeTruthy() expect(screen.getByText('Output')).toBeTruthy() @@ -45,7 +57,7 @@ describe('TrajectoryGroupHeader', () => { describe('TrajectoryTurn', () => { it('wraps a sticky header and body children', () => { render( - + , ) diff --git a/packages/client/ui-trajectory/tests/locale.client.ts b/packages/client/ui-trajectory/tests/locale.client.ts new file mode 100644 index 0000000000..7039d319b8 --- /dev/null +++ b/packages/client/ui-trajectory/tests/locale.client.ts @@ -0,0 +1,21 @@ +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { en, zh, type TrajectoryTranslate } from '../src/client/locales.ts' + +function translator(dictionary: Record): TrajectoryTranslate { + return (key, params = {}) => { + const template = dictionary[key] ?? key + return template.replace(/\{(\w+)\}/g, (_match, name: string) => { + const value = params[name] + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : '' + }) + } +} + +/** English trajectory translator for component and pure-layout tests. */ +export const t = translator({ ...commonEn, ...en }) + +/** Chinese trajectory translator for real-view fixtures that open in Chinese. */ +export const tZh = translator({ ...commonZh, ...zh }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 467da8088a..5f4dd2b307 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -3,8 +3,43 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { ComponentProps } from 'react' +import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' +import { t } from './locale.client.ts' + +function TrajectoryTable(props: Omit, 't'>) { + const inferred: Array[number] & { firstIndex: number }> = [] + for (const turn of props.turns) { + for (const group of turn.groups) { + const step = /^Step (\d+)$/.exec(group.title)?.[1] + const compaction = /^Compaction (\d+)$/.exec(group.title)?.[1] + const firstIndex = group.cells[0]?.index ?? Number.MAX_SAFE_INTEGER + if (compaction !== undefined) { + inferred.push({ + turn: turn.turn, + step: 0, + group: group.title, + number: 0, + purpose: 'compaction', + firstIndex, + }) + } else if (step !== undefined && turn.turn !== null) { + inferred.push({ + turn: turn.turn, + step: Number(step), + group: group.title, + number: 0, + firstIndex, + }) + } + } + } + const requestNumbers = props.requestNumbers ?? inferred + .sort((left, right) => left.firstIndex - right.firstIndex) + .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 })) + return +} afterEach(() => { cleanup() diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 3df145b757..aa09426805 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -31,18 +31,24 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' -import { zh, type TrajectoryKey } from '../src/client/locales.ts' +import type { TrajectoryTranslate } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' -import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx' +import { TrajectoryTimeline as LocalizedTrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx' import { TrajectoryView, type TrajectoryViewInjected, } from '../src/client/TrajectoryView.tsx' import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' +import { t as tTrajectory, tZh } from './locale.client.ts' + +function TrajectoryTimeline( + props: Omit, 't'>, +) { + return +} const SID = 's1' as SessionId const sessionSnapshots = new WeakMap>() @@ -159,7 +165,7 @@ function emptyWorkspaces() { /** Standalone view props: the session-scope standard kit the outlet would bake. */ function standaloneProps( nodes: ConversationSnapshot['nodes'], -): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { +): ConvViewProps & { t: TrajectoryTranslate } { return { sessionId: SID, useSession: fakeSession(nodes).useSession, @@ -167,8 +173,8 @@ function standaloneProps( useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, // The locale seat the outlet would inject for the declared namespace. - t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, - } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } + t: tZh, + } as unknown as ConvViewProps & { t: TrajectoryTranslate } } /** Real-stack bench: root Context + real SlotRegistry ring + the plugin fiber. */ @@ -248,7 +254,7 @@ function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES loadOlder: trajectory.loadOlder, setActualDuration: trajectory.setActualDuration, useDuration: bindSnapshotSelector(trajectory.hooks.duration), - t: (key: TrajectoryKey) => zh[key], + t: tZh, } })() : injected @@ -369,12 +375,12 @@ describe('tab switching in ConversationRoot', () => { expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() - expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() + expect(screen.getByRole('region', { name: '轨迹时间线' })).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) + fireEvent.click(screen.getByRole('button', { name: '收起所有轮次' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) - expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '展开所有轮次' })) + expect(screen.getByRole('row', { name: /用户/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() expect(b.loadOlder).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) @@ -397,14 +403,14 @@ describe('tab switching in ConversationRoot', () => { mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' }) - expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy() - expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy() - expect(screen.getByText('Completed')).toBeTruthy() - expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy() + fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' }) + expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy() + expect(screen.getByText('第 1 轮 · 步骤 1')).toBeTruthy() + expect(screen.getByText('已完成')).toBeTruthy() + expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Close details' })) - expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '关闭详情' })) + expect(screen.queryByRole('complementary', { name: '事件详情' })).toBeNull() }) it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => { @@ -434,11 +440,11 @@ describe('tab switching in ConversationRoot', () => { const view = mount(b.slots, nodes) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(screen.getByText('Between turns')).toBeTruthy() + expect(screen.getByText('轮次之间')).toBeTruthy() expect(view.container.textContent).not.toContain('Turn null') - fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' })) - expect(screen.getByText('Compaction · Between turns')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '请求 #2 · 压缩' })) + expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy() expect(view.container.textContent).not.toContain('Turn null') }) @@ -486,31 +492,31 @@ describe('tab switching in ConversationRoot', () => { mount(b.slots, nodes) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' }) - const secondRequest = screen.getByRole('button', { name: 'Request #4 · Compaction' }) + const firstRequest = screen.getByRole('button', { name: '请求 #2 · 压缩' }) + const secondRequest = screen.getByRole('button', { name: '请求 #4 · 压缩' }) const firstSection = firstRequest.closest('tr')?.querySelector('span') const secondSection = secondRequest.closest('tr')?.querySelector('span') - expect(firstSection?.textContent).toBe('Between turns') - expect(secondSection?.textContent).toBe('Between turns') + expect(firstSection?.textContent).toBe('轮次之间') + expect(secondSection?.textContent).toBe('轮次之间') fireEvent.click(firstRequest) expect(firstSection?.className).toMatch(/turnLabelActive/) expect(secondSection?.className).not.toMatch(/turnLabelActive/) - expect(screen.getByText('Request #2')).toBeTruthy() - expect(screen.getByText('Compaction · Between turns')).toBeTruthy() + expect(screen.getByText('请求 #2')).toBeTruthy() + expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy() fireEvent.click(secondRequest) expect(firstSection?.className).not.toMatch(/turnLabelActive/) expect(secondSection?.className).toMatch(/turnLabelActive/) - expect(screen.getByText('Request #4')).toBeTruthy() - expect(screen.getByText('Compaction · Between turns')).toBeTruthy() + expect(screen.getByText('请求 #4')).toBeTruthy() + expect(screen.getByText('压缩 · 轮次之间')).toBeTruthy() }) it('dragging the overview focuses overlapping records without filtering the ledger', async () => { const b = await bench() mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), @@ -519,22 +525,22 @@ describe('tab switching in ConversationRoot', () => { fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 }) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBe('outside') const tablePane = screen.getByRole('table').parentElement expect(tablePane).not.toBeNull() fireEvent.click(tablePane as HTMLElement) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBeNull() fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 }) fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 }) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBe('outside') fireEvent.contextMenu(plot) - expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) + expect(screen.getByRole('row', { name: /用户/ }).getAttribute('data-timeline-focus')) .toBe('outside') }) @@ -542,7 +548,7 @@ describe('tab switching in ConversationRoot', () => { const b = await bench() const view = mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + const plot = screen.getByLabelText('时间线概览;水平拖动可聚焦事件') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), @@ -573,7 +579,7 @@ describe('tab switching in ConversationRoot', () => { ) expect(selectedRow?.getAttribute('aria-selected')).toBe('true') expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull() - expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy() + expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy() }) it('empty window keeps the toolbar and reports no timing data', async () => { @@ -581,12 +587,12 @@ describe('tab switching in ConversationRoot', () => { mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() - expect(screen.getByText('No timing data')).toBeTruthy() + expect(screen.getByText('无计时数据')).toBeTruthy() expect(screen.getByRole('button', { - name: 'Collapse turns', + name: '收起所有轮次', }).disabled).toBe(false) expect(screen.getByRole('button', { - name: 'Collapse calls', + name: '收起所有调用', }).disabled).toBe(false) expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull() @@ -694,7 +700,7 @@ describe('timeline projection', () => { .toContain('Click to load earlier history') fireEvent.click(boundary) expect(onLoadEarlier).toHaveBeenCalledOnce() - expect(screen.getByLabelText('Loading earlier history')).toBeTruthy() + expect(screen.getByLabelText('Loading earlier history…')).toBeTruthy() view.rerender( { setActualDuration={(value) => { firstDuration.set(value) }} />, ) - const duration = screen.getByRole('button', { name: 'Use actual duration' }) + const duration = screen.getByRole('button', { name: '使用实际时长' }) expect(duration.getAttribute('aria-pressed')).toBe('false') fireEvent.click(duration) @@ -1163,7 +1169,7 @@ describe('TrajectoryView state', () => { setActualDuration={(value) => { restoredDuration.set(value) }} />, ) - expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) + expect(screen.getByRole('button', { name: '使用实际时长' }).getAttribute('aria-pressed')) .toBe('true') }) diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx index bd683959d4..9e18e5aced 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts' import css from './PlanReviewPanel.module.css' @@ -25,6 +25,10 @@ function tooltip(description: string | undefined): { title?: string } { * @returns The plan-review takeover for this request. */ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) { + const markdownLabels = useMemo(() => ({ + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + }), [t]) // The panel waits for the host's resolved frame before leaving, so repeated // clicks must not resubmit. A failed send re-enables it and shows the error. const [busy, setBusy] = useState(false) @@ -50,7 +54,7 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) { {t('plan.header')}
- +
{error}
diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b2085cc151..dfb34c6819 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -125,6 +125,10 @@ export function QuestionComposer(props: QuestionComposerProps) { function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick) { const questions = pending.questions + const markdownLabels = useMemo(() => ({ + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + }), [t]) const [index, setIndex] = useState(0) const [drafts, setDrafts] = useState(() => questions.map(() => ({ selected: [], custom: '', skipped: false, @@ -289,7 +293,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick
{question.detail !== undefined && ( -
+
)}
{(question.options ?? []).map((option, optionIndex) => { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 8689da3cc9..69ddf2e313 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -335,7 +335,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.title} - {result.workspace} + {result.workspace || t('group.ungrouped')} {result.snippet !== undefined && ( {result.snippet} )} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 23649a24f7..c07984dc57 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -12,9 +12,6 @@ import { /** Group key for Sessions outside every Workspace. */ export const UNGROUPED_KEY = '' -/** Display label for the ungrouped bucket row. */ -export const UNGROUPED_LABEL = 'Ungrouped' - /** One top-level session row in a group or the flat list. */ export interface SessionNode { id: SessionId @@ -95,10 +92,10 @@ interface Group { * Directory display label: basename of the path (both separators accepted). * Ungrouped-bucket fallback for surfaces without a workspace title. * @param cwd - directory path, or undefined for the ungrouped bucket. - * @returns basename, the raw cwd when it has no basename, or the ungrouped label. + * @returns basename, the raw cwd when it has no basename, or an empty ungrouped marker. */ export function workspaceLabel(cwd: string | undefined): string { - if (cwd === undefined || cwd === '') return UNGROUPED_LABEL + if (cwd === undefined || cwd === '') return '' const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() return base !== undefined && base !== '' ? base : cwd } @@ -127,7 +124,7 @@ function sessionVisible(session: SessionSummary, current: SessionId | undefined, * and the renderer localizes its display label. */ function sessionTitle(session: SessionSummary): string { - return session.blank ? 'New Session' : session.displayTitle + return session.blank ? '' : session.displayTitle } /** Build one group without projecting session lineage into presentation. */ @@ -203,7 +200,7 @@ function groupByWorkspace( undefined, undefined, undefined, - UNGROUPED_LABEL, + '', ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder), ungroupedOrder === undefined ? 'recency' : 'account', )) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index f2e069de43..8269639774 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime, - UNGROUPED_KEY, UNGROUPED_LABEL, + UNGROUPED_KEY, } from '../src/client/tree.ts' import { createWorkspaceViewStore } from '../src/client/stores.ts' @@ -83,7 +83,7 @@ describe('deriveGroups', () => { const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! // The stored placeholder title stays canonical; the renderer swaps in // the localized New Session label via the blank flag. - expect(blankNode.title).toBe('New Session') + expect(blankNode.title).toBe('') expect(blankNode.blank).toBe(true) expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false) expect(groups[0]!.sessionCount).toBe(2) @@ -240,7 +240,7 @@ describe('deriveFlat', () => { } const rows = deriveFlat(sessions, noArchive) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) - expect(rows.map(row => row.title)).toEqual(['New Session', 'real']) + expect(rows.map(row => row.title)).toEqual(['', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) }) @@ -429,8 +429,8 @@ describe('createWorkspaceViewStore', () => { describe('workspaceLabel', () => { it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { - expect(workspaceLabel(undefined)).toBe(UNGROUPED_LABEL) - expect(workspaceLabel('')).toBe(UNGROUPED_LABEL) + expect(workspaceLabel(undefined)).toBe('') + expect(workspaceLabel('')).toBe('') expect(workspaceLabel('/projects/demo/')).toBe('demo') expect(workspaceLabel('C:\\projects\\demo\\')).toBe('demo') expect(workspaceLabel('/')).toBe('/') diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 8585d16982..8a50148e37 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — Repository scripts -Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. +Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. Source-ownership gates use syntax-aware discovery, guard against an empty or narrowed corpus, and test every admitted/excluded form that changes their detection boundary. diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 02dc89c868..ef9c3502d1 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -90,7 +90,7 @@ describe('gate graph validation', () => { expect(ids).toEqual([ 'rescope-vendor', 'knip', 'publint', 'constraints', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', - 'optional-dependency-imports', 'client-packages', 'cordis-config', + 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'cordis-config', 'runtime-closure', 'vendored-links', ]) expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ @@ -136,6 +136,15 @@ describe('gate graph validation', () => { }, ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( + 'keeps hard-coded Client UI copy enforcement in %s', + (mode) => { + const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id)) + + expect(ids).toContain('client-ui-i18n') + }, + ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( 'keeps application entrypoint enforcement in %s', (mode) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 872041c32b..029175953d 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -275,6 +275,7 @@ function ciSharedStaticGates(): Gate[] { label: 'optional dependency imports', }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), + pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] } @@ -640,6 +641,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { label: 'optional dependency imports', }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), + pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), ] } diff --git a/scripts/verify-client-ui-i18n.spec.ts b/scripts/verify-client-ui-i18n.spec.ts new file mode 100644 index 0000000000..f31f328e6d --- /dev/null +++ b/scripts/verify-client-ui-i18n.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { findUiI18nViolations } from './verify-client-ui-i18n.ts' + +function messages(source: string): string[] { + return findUiI18nViolations('packages/client/ui-example/src/client/View.tsx', source) + .map(violation => violation.text) +} + +describe('Client UI i18n source check', () => { + it('rejects direct JSX copy and copy-bearing attributes', () => { + expect(messages(` + const View = ({ ready }: { ready: boolean }) =>
+ Hard-coded text + +
+
+ `)).toEqual(['Overview', 'Hard-coded text', 'Search now', 'Wait', 'Still working']) + }) + + it('rejects copy kept in label data and copy helper returns', () => { + expect(messages(` + const TABS = [{ id: 'summary', label: 'Summary' }] + function statusLabel(status: string): string { + if (status === 'done') return 'Complete' + return 'Still running' + } + function duration(): string { return 'Not recorded' } + function mode(): string { return 'compact' } + function Dialog({ closeLabel = 'Close dialog' }: { closeLabel?: string }) { return closeLabel } + `)).toEqual(['Summary', 'Complete', 'Still running', 'Not recorded', 'Close dialog']) + }) + + it('accepts translated copy, dynamic values, structural attributes, and language tokens', () => { + expect(messages(` + const View = ({ t, value }: { t: (key: string) => string; value: string }) => ( +
+ {t('status.complete')} + null + {value === 'pending' && {value}} + {value} +
+ ) + `)).toEqual([]) + }) + + it('does not inspect locale dictionary owners', () => { + expect(findUiI18nViolations( + 'packages/client/ui-example/src/client/locales.ts', + 'export const en = { title: "Hard-coded by design" }', + )).toEqual([]) + }) +}) diff --git a/scripts/verify-client-ui-i18n.ts b/scripts/verify-client-ui-i18n.ts new file mode 100644 index 0000000000..cd3c349fa9 --- /dev/null +++ b/scripts/verify-client-ui-i18n.ts @@ -0,0 +1,329 @@ +/** + * Reject product UI copy embedded directly in Client source. + * + * Locale dictionaries are the only source files allowed to own translated + * text. Presentation code receives copy through its typed `t` seat or through + * an already-localized prop. This check covers JSX text and copy-bearing + * attributes, plus the common data/helper forms that feed them. + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' +import ts from 'typescript' + +const root = resolve(import.meta.dirname, '..') +const MINIMUM_CLIENT_UI_SOURCES = 400 + +const COPY_ATTRIBUTES = new Set([ + 'alt', + 'aria-description', + 'aria-label', + 'aria-valuetext', + 'cancelLabel', + 'closeLabel', + 'confirmLabel', + 'copyLabel', + 'description', + 'emptyLabel', + 'label', + 'placeholder', + 'title', + 'truncatedLabel', +]) +const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Placeholder|Summary|Text|Title|Tooltip)$/ + +const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|placeholder|title|tooltip)(?:s|_.*)?$/i +const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|placeholder|title|tooltip|tabs)$/i +const IMMUTABLE_LANGUAGE_TOKENS = new Set([ + 'Function', + 'K', + 'M', + 'Symbol', + 'false', + 'function()', + 'n', + 'null', + 'true', + 'undefined', +]) +const LOCALE_KEY = /^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$/ + +/** One hard-coded product-copy occurrence. */ +export interface UiI18nViolation { + /** One-based source column. */ + column: number + /** Repository-relative source path. */ + file: string + /** One-based source line. */ + line: number + /** Why this literal is treated as product copy. */ + reason: string + /** Compact literal text for the diagnostic. */ + text: string +} + +function localeOwner(file: string): boolean { + const normalized = file.replaceAll('\\', '/') + const base = normalized.slice(normalized.lastIndexOf('/') + 1) + return base === 'locale.ts' + || base === 'locales.ts' + || normalized.includes('/locales/') +} + +function containsProductText(text: string): boolean { + const normalized = text.replace(/\s+/g, ' ').trim() + return normalized !== '' + && !IMMUTABLE_LANGUAGE_TOKENS.has(normalized) + && !LOCALE_KEY.test(normalized) + && /\p{L}/u.test(normalized) +} + +function translationCall(node: ts.CallExpression): boolean { + const callee = node.expression + return ts.isIdentifier(callee) + ? callee.text === 't' + : ts.isPropertyAccessExpression(callee) && callee.name.text === 't' +} + +function propertyName(node: ts.PropertyName | ts.BindingName): string | undefined { + return ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined +} + +function copyAttribute(name: string): boolean { + return !name.endsWith('Key') + && (COPY_ATTRIBUTES.has(name) || COPY_ATTRIBUTE_SUFFIX.test(name)) +} + +function compactText(text: string): string { + const normalized = text.replace(/\s+/g, ' ').trim() + return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}...` +} + +function looksLikeNaturalText(text: string): boolean { + const normalized = text.replace(/\s+/g, ' ').trim() + return /\s|[\u3400-\u9fff]/u.test(normalized) || /^[A-Z]/.test(normalized) +} + +/** + * Find hard-coded product copy in one Client source file. + * @param file - repository-relative path used in diagnostics. + * @param sourceText - TypeScript or TSX source. + * @returns violations in source order. + */ +export function findUiI18nViolations(file: string, sourceText: string): UiI18nViolation[] { + if (localeOwner(file)) return [] + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ) + const violations = new Map() + + const report = ( + node: ts.Node, + text: string, + reason: string, + naturalOnly = false, + ): void => { + if ( + !containsProductText(text) + || (naturalOnly && !looksLikeNaturalText(text)) + || violations.has(node.getStart(source)) + ) return + const position = source.getLineAndCharacterOfPosition(node.getStart(source)) + violations.set(node.getStart(source), { + column: position.character + 1, + file, + line: position.line + 1, + reason, + text: compactText(text), + }) + } + + const collectExpression = ( + node: ts.Expression, + reason: string, + naturalOnly = false, + ): void => { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + report(node, node.text, reason, naturalOnly) + return + } + if (ts.isTemplateExpression(node)) { + report( + node, + [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join(''), + reason, + naturalOnly, + ) + return + } + if (ts.isCallExpression(node)) { + if (translationCall(node)) return + return + } + if ( + ts.isParenthesizedExpression(node) + || ts.isAsExpression(node) + || ts.isSatisfiesExpression(node) + || ts.isNonNullExpression(node) + ) { + collectExpression(node.expression, reason, naturalOnly) + return + } + if (ts.isConditionalExpression(node)) { + collectExpression(node.whenTrue, reason, naturalOnly) + collectExpression(node.whenFalse, reason, naturalOnly) + return + } + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + collectExpression(node.right, reason, naturalOnly) + } else if ( + node.operatorToken.kind === ts.SyntaxKind.PlusToken + || node.operatorToken.kind === ts.SyntaxKind.BarBarToken + || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) { + collectExpression(node.left, reason, naturalOnly) + collectExpression(node.right, reason, naturalOnly) + } + return + } + if (ts.isArrayLiteralExpression(node)) { + for (const element of node.elements) { + if (ts.isExpression(element)) collectExpression(element, reason, naturalOnly) + } + return + } + if (ts.isObjectLiteralExpression(node)) { + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + const name = propertyName(property.name) + const propertyOwnsCopy = name !== undefined + && (COPY_NAME.test(name) || COPY_SUFFIX.test(name)) + collectExpression(property.initializer, reason, naturalOnly || !propertyOwnsCopy) + } + } + } + } + + const enclosingFunctionName = (node: ts.Node): string | undefined => { + let current = node.parent + while (!ts.isSourceFile(current)) { + if (ts.isFunctionDeclaration(current) || ts.isMethodDeclaration(current)) { + return current.name === undefined ? undefined : propertyName(current.name) + } + if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) { + const parent = current.parent + return ts.isVariableDeclaration(parent) ? propertyName(parent.name) : undefined + } + current = current.parent + } + return undefined + } + + const hasExplicitStringReturn = (node: ts.Node): boolean => { + let current = node.parent + while (!ts.isSourceFile(current)) { + if ( + ts.isFunctionDeclaration(current) + || ts.isMethodDeclaration(current) + || ts.isArrowFunction(current) + || ts.isFunctionExpression(current) + ) return current.type?.kind === ts.SyntaxKind.StringKeyword + current = current.parent + } + return false + } + + const visit = (node: ts.Node): void => { + if (ts.isJsxText(node)) report(node, node.text, 'JSX text') + + if (ts.isJsxAttribute(node)) { + const name = node.name.getText(source) + if (copyAttribute(name) && node.initializer !== undefined) { + if (ts.isStringLiteral(node.initializer)) report(node.initializer, node.initializer.text, `${name} attribute`) + else if (ts.isJsxExpression(node.initializer) && node.initializer.expression !== undefined) { + collectExpression(node.initializer.expression, `${name} attribute`) + } + } + } + + if ( + ts.isJsxExpression(node) + && node.expression !== undefined + && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)) + ) collectExpression(node.expression, 'JSX child') + + if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) { + const name = propertyName(node.name) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.initializer, `${name} property`) + } + } + + if (ts.isVariableDeclaration(node) && node.initializer !== undefined) { + const name = propertyName(node.name) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.initializer, `${name} value`) + } + } + + if (ts.isBindingElement(node) && node.initializer !== undefined) { + const name = propertyName(node.name) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.initializer, `${name} default value`) + } + } + + if (ts.isReturnStatement(node) && node.expression !== undefined) { + const name = enclosingFunctionName(node) + if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { + collectExpression(node.expression, `${name} return value`) + } else if (file.endsWith('.tsx') && hasExplicitStringReturn(node)) { + collectExpression(node.expression, 'string return value', true) + } + } + + ts.forEachChild(node, visit) + } + visit(source) + return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column) +} + +function sourceFiles(): string[] { + return [...new Set([ + ...globSync('packages/client/*/src/**/*.tsx', { cwd: root }), + ...globSync('packages/client/ui-*/src/**/*.{ts,tsx}', { cwd: root }), + ...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }), + ])] + .map(file => file.split(sep).join('/')) + .filter(file => !file.endsWith('.d.ts')) + .sort() +} + +function main(): void { + const files = sourceFiles() + if (files.length < MINIMUM_CLIENT_UI_SOURCES) { + throw new Error( + `verify-client-ui-i18n: discovery narrowed to ${files.length} source file(s); expected at least ${MINIMUM_CLIENT_UI_SOURCES}.`, + ) + } + const violations = files.flatMap(file => + findUiI18nViolations(file, readFileSync(resolve(root, file), 'utf8'))) + if (violations.length > 0) { + console.error(`verify-client-ui-i18n: ${violations.length} hard-coded UI string(s):`) + for (const violation of violations) { + console.error( + ` ${violation.file}:${violation.line}:${violation.column} ${violation.reason}: ${JSON.stringify(violation.text)}`, + ) + } + process.exitCode = 1 + return + } + console.log(`verify-client-ui-i18n: ${files.length} Client UI source file(s) use locale-owned copy.`) +} + +if (import.meta.filename === resolve(process.argv[1] ?? '')) main()