fix(client): route UI copy through locale

This commit is contained in:
Tianyi Cui
2026-08-23 17:31:37 +08:00
parent cc8ea70dc0
commit 3c10f5d2d3
154 changed files with 2489 additions and 896 deletions
@@ -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
@@ -1,4 +1,4 @@
# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary
# Agent Note: Full client copy rollout onto the typed locale seat
Status: implemented
@@ -6,7 +6,7 @@ English | [中文](2026-07-30-client-locale-full-rollout.zh.md)
## Problem
After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization.
After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch, and how the zero-Cordis ui-primitives atoms receive copy without depending on the runtime.
## Decision
@@ -14,16 +14,11 @@ After the typed locale standard seat landed (`locale:` on register → framework
**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record<string, string>` is the key source and `en satisfies Record<XxxKey, string>` locks bilingual balance.
**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md).
**The non-translation boundary (deliberate decisions, not debt):**
**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation.
- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim.
- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages.
- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately).
- **Boot copy stays hardcoded** (the framework-free boot page runs before the locale service exists).
**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure.
**Derivation layers keep display text out of identity.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank session titles and the Ungrouped label derive from the `blank` flag / absent `workspaceId`, while internal values stay empty or stable; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter.
**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)).
@@ -33,7 +28,7 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
- **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision.
- **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently.
- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text.
- **Translate external or wire error data**: rejected because provider and protocol diagnostics are evidence searched and compared verbatim. Product-authored surrounding failure chrome is translated; externally authored data is not.
- **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock.
- **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic.
@@ -41,5 +36,5 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
- A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue.
- Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically.
- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo.
- ui-primitives require localized label props, so adding a primitive render site also adds an explicit copy owner; omission fails typechecking instead of selecting a hidden language.
- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md).
@@ -1,4 +1,4 @@
# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界
# Agent Note: client 文案全量接入 typed locale 席位
Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## Problem
typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使未来的 agent(智能体)「补完」翻译
typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新,以及 zero-Cordis 的 ui-primitives 原子组件如何在不依赖运行时的情况下拿到文案。
## Decision
@@ -14,16 +14,11 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record<string, string>` 为 key 源、`en satisfies Record<XxxKey, string>` 锁双语平衡。
**zero-cordis 原子组件(ui-primitives文案 props 化**`HoverCard``copyLabel`/`copiedLabel``TerminalBlock`/`JsonTree``labels``CodeBlock``copyLabel`/`copiedLabel``MarkdownText``codeLabels``JsonBlock``truncatedLabel``ConnectionBanner``label``Modal``closeLabel`——默认值即原硬编码字符串,不传 props 的消费方渲染逐字节不变。已本地化插件从自己的 `t` 席位传字典驱动 label;传对象 props 的调用点按 `t` 身份 memo`MarkdownText` 的组件表按 `codeLabels` 身份缓存)
**zero-Cordis 原子组件(ui-primitives通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责
**不翻译边界(刻意决定,不是欠账):**
**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。
- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError 消息、wire 透出的 `error.message (code)` 原样呈现
- **设计字面量不进字典**:工具行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、整个 StatsLine——中英界面显示一致。
- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。
- **boot 文案保持硬编码**(不依赖框架的启动页运行早于 locale 服务可用)。
**派生层保持纯函数,本地化只在渲染层**ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。
**派生层不让展示文本承担身份。** ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}`,由渲染组合字典模板;blank 会话标题和未分组 label 从 `blank` 标志/`workspaceId` 缺席派生,内部值保持为空或稳定;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`workspace hover `date.ymd`),格式化函数接收 `t` 参数
**测试与 e2e 口径**`makeTranslate(...dicts)`dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage``en-US` 浏览器)打开,built-boot 快照 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`[由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。
@@ -33,7 +28,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
- **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 已经为每个包注册一次,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。
- **给 ui-primitives 造 locale 上下文/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费方(ui-trajectory)陪跑。props 化让每个消费方独立决定。
- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言
- **翻译外部或 wire 错误数据**:否决。提供方与协议诊断是需要原样搜索和比对的证据。产品编写的外围失败 chrome 会翻译,外部编写的数据不会
- **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。
- **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下「看得见搜不到」;占位行本无信息量,整体排除语义最稳。
@@ -41,5 +36,5 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
- 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。
- 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。
- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状
- ui-primitives 要求本地化 label prop,因此新增原子组件渲染点也必须新增明确的文案 owner;遗漏会在类型检查失败,而不是选择隐藏语言
- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。
@@ -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
@@ -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.
@@ -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 文案。
+1
View File
@@ -25,6 +25,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
4. **Registrations clean up.** Verify each new registry contribution passes the disposal tests required by [packages/AGENTS.md](../../../packages/AGENTS.md).
5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at the point where that package can observe it; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package invariant rules](../../../packages/AGENTS.md)).
6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect.
7. **Client UI copy is locale-owned.** Reject product text embedded in JSX, templates, helper returns, accessibility attributes, or primitive defaults. Require typed dictionary keys, the standard `t` seat or explicit localized props, `verify-client-ui-i18n`, and behavior evidence in each affected locale; preserve user/model/wire data and code tokens verbatim.
## Manual checks
+1 -1
View File
@@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an
- **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality.
- **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth.
- **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows.
- **Prompts and visible strings:** treat wording as behavior. Update the owning runnable snapshot for model-visible text and the repository-required behavior evidence for GUI text. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit.
- **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration.
Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only.
+3 -2
View File
@@ -122,11 +122,12 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
- **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change.
- **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)).
- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
- **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)).
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
@@ -1,6 +1,6 @@
- dialog "确认启用 Full access":
- heading "确认启用 Full access" [level=2]
- button "Close":
- button "关闭":
- img
- img
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
@@ -1,5 +1,5 @@
kind=matches
summary=显示 9 / 共 42 处匹配 · 3 个文件
summary=Showing 9 of 42 matches · 3 files
file=packages/client/ui-primitives/src/SearchBlock.tsx3
file=packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx4
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
@@ -8,7 +8,7 @@ line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() =
line=36: const search = searchCardModel(block)
line=56: search={search}
line=78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
expand=… 其余 4 行
expand=… 4 more lines
recovery=Found 9 of 42 matches
packages/client/ui-primitives/src/SearchBlock.tsx
+1 -1
View File
@@ -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)
+1
View File
@@ -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",
+5 -3
View File
@@ -106,9 +106,11 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
## Styling
## Styling and localization
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English.
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English.
Every product-visible string—including text, accessibility names, tooltips, placeholders, status/unit formatters, and primitive chrome—lives in a typed locale dictionary and reaches components through the standard `t` seat or an already-localized prop. Cordis-free primitives require complete label props and own no fallback copy. Keep user/model/wire data and code tokens verbatim; internal matching uses discriminants or stable ids, never localized text. `pnpm run verify-client-ui-i18n` enforces source ownership ([decision](../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
## Testing and coverage
@@ -145,6 +147,6 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com
1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
4. Tokens only in CSS; product copy follows the localization rule above; English comments.
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9
README.zh.md: 10fb3547376c8e960165a04fb4ea64ec8dd6f982
README.md: 4f54a9a2c5aa9d39ee3c93a4d8f2c6deb538b792
README.zh.md: d4c4833b2334c1b21f31e9b6f4d5116bbbb8591d
+1 -2
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). Product-authored Client UI text must enter through these typed dictionaries or an already-localized primitive prop; `verify-client-ui-i18n` enforces that source ownership ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
## Model Experience
@@ -14,5 +14,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly.
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
+1 -2
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件:LocaleRuntime——`zh``en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale`zh-CN``en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
locale 插件:LocaleRuntime——`zh``en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale`zh-CN``en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。产品编写的 Client UI 文本必须经这些 typed 字典或已本地化原子组件 prop 进入展示;`verify-client-ui-i18n` 会强制这项源码归属([决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。
## 模型体验
@@ -14,5 +14,4 @@ locale 插件:LocaleRuntime——`zh``en` 偏好以 `locale.preference` 存
## 已知限制与暂缓事项
- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
+14
View File
@@ -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<CommonKey, string>
+14
View File
@@ -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<string, string>
/** The common vocabulary key union (zh is the key-set source of truth). */
@@ -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) }}
@@ -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}
/>,
)
@@ -129,7 +129,7 @@ function TurnStatus({ startTime, t }: {
const showClock = elapsedMs >= 15_000
return (
<div className={css.turnStatus} role="status" aria-live="polite">
Deep diving...
{t('chat.deepDiving')}
{showClock && (
<span className={css.turnStatusClock} aria-hidden>
{formatRunDuration(elapsedMs, t)}
@@ -15,7 +15,7 @@ export function CompactionCommandCard({ node, compaction, t }: CompactionCommand
return (
<CompactionItem
node={compaction}
title="compact"
title={t('message.compaction.commandTitle')}
fallbackSummary={node.outcome?.text ?? null}
t={t}
/>
@@ -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({
<span className={css.compactionSummary}>{summary}</span>
</button>
{open && node.summary !== null
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
&& <div className={css.compactionBody}><MarkdownText text={node.summary} labels={labels} /></div>}
</div>
)
})
@@ -96,7 +96,7 @@ function ModelRetryItem({ node, active, t }: {
<div className={css.retryDetails}>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.delay')}</span>
{Math.round(node.delayMs)}ms
{t('duration.milliseconds', { milliseconds: Math.round(node.delayMs) })}
</div>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
@@ -46,7 +46,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
titleClassName={css.title}
chevronClassName={css.chevron}
icon={<IconThinkOutline14 size={14} />}
title="Think"
title={t('message.think')}
open={expanded}
expandable
expandOnRowClick
@@ -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(' | ')
@@ -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': 'Couldnt open file',
'fileOpen.unknown': 'Couldnt open this file',
'fileOpen.folderTitle': 'Couldnt 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',
@@ -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 })
}
@@ -121,7 +121,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
<span className={css.percent}>{reading}</span>
<span className={css.headline}>{headAfter}</span>
<span className={css.figures}>
{`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`}
{`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
</span>
</div>
<div className={css.bar}>
@@ -141,7 +141,7 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
<span className={`${css.swatch} ${row.color}`} aria-hidden />
{t(row.label)}
</dt>
<dd>{`~${formatTokens(breakdown[row.key])}`}</dd>
<dd>{`~${formatTokens(breakdown[row.key], t)}`}</dd>
</div>
))}
</dl>
@@ -57,8 +57,11 @@ function displayName(name: string): string {
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function optionLabel(option: PermissionSelectValue['options'][number]): string {
return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
function optionLabel(
option: PermissionSelectValue['options'][number],
t: ComposerBarProps['t'],
): string {
return option.value === FULL_ACCESS ? t('access.fullLabel') : displayName(option.name)
}
export interface PermissionSelectProps {
@@ -92,7 +95,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
.filter(o => o.value !== 'custom')
.map((option) => {
const icon = permissionGlyph(option.value)
return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } }
return { id: option.value, label: optionLabel(option, t), ...icon === undefined ? {} : { icon } }
})
const submit = (id: string): void => {
@@ -138,7 +141,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current, t) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
@@ -146,7 +149,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
{permissionGlyph(currentValue) !== undefined && (
<span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
)}
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current, t)}</span>
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
<IconChevronDownOutline14 />
</span>
@@ -159,6 +162,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
description={t('access.confirm.description')}
acknowledgeLabel={t('access.confirm.acknowledge')}
cancelLabel={t('access.confirm.cancel')}
closeLabel={t('close')}
confirmLabel={t('access.confirm.enable')}
acknowledged={acknowledged}
disabled={locked}
@@ -852,7 +852,7 @@ describe('MessageItem arms', () => {
expect(details?.open).toBe(false)
expect(details?.dataset.active).toBe('true')
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2 · 3s')
expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms')
expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500毫秒')
expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
act(() => { vi.advanceTimersByTime(1_100) })
@@ -153,15 +153,15 @@ describe('deriveStats', () => {
describe('formatters', () => {
it('formats token counts compactly', () => {
expect(formatTokens(517)).toBe('517')
expect(formatTokens(12_240)).toBe('12.2K')
expect(formatTokens(517_000)).toBe('517K')
expect(formatTokens(1_230_000)).toBe('1.2M')
expect(formatTokens(517, tEn)).toBe('517')
expect(formatTokens(12_240, tEn)).toBe('12.2K')
expect(formatTokens(517_000, tEn)).toBe('517K')
expect(formatTokens(1_230_000, tEn)).toBe('1.2M')
})
it('formats durations under and over a minute', () => {
expect(formatDuration(45_230)).toBe('45.2s')
expect(formatDuration(162_000)).toBe('2m42s')
expect(formatDuration(45_230, tEn)).toBe('45.2s')
expect(formatDuration(162_000, tEn)).toBe('2m42s')
})
})
@@ -272,7 +272,7 @@ describe('StatsLine', () => {
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
expect(view.container.textContent)
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
.toBe('1 轮 · 1 步| LLM 3.8| 首 token 平均 0.8 · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
})
it('renders without ResizeObserver support', () => {
@@ -869,7 +869,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
expect(view.getByRole('status').textContent).toBe('Deep diving...')
expect(view.getByRole('status').textContent).toBe('正在深入处理…')
})
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
@@ -929,7 +929,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
// Freshly mounted (as after a reload) yet already past the 15s gate.
const status = view.getByRole('status')
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/)
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
act(() => {
h.set({ queue: [{
@@ -941,7 +941,7 @@ describe('ChatView', () => {
text: 'also',
}] })
})
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/)
})
it('hands each ordered root call to the keyed business-node slot', () => {
@@ -32,7 +32,7 @@ describe('tails', () => {
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('Think')).toBeTruthy()
expect(view.getByText('思考')).toBeTruthy()
expect(view.getByText('thinking hard')).toBeTruthy()
expect(view.getByText(/未知内容块/)).toBeTruthy()
const stopped = render(
@@ -101,7 +101,7 @@ describe('ReasoningRow', () => {
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
fireEvent.click(view.getByText('思考'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
@@ -114,7 +114,7 @@ describe('ReasoningRow', () => {
renderMessageImages={renderMessageImages}
/>,
)
fireEvent.click(view.getByText('Think'))
fireEvent.click(view.getByText('思考'))
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
expect(view.queryByText('IN')).toBeNull()
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
@@ -107,6 +107,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
description={t('confirm.description')}
acknowledgeLabel={t('confirm.acknowledge')}
cancelLabel={t('confirm.cancel')}
closeLabel={t('close')}
confirmLabel={t('confirm.enable')}
acknowledged={acknowledged}
disabled={!state.writable || state.status === 'saving'}
@@ -58,14 +58,12 @@ export function PlanChip({ useProjection, locked, exitPlanMode, t }: PlanChipPro
disabled={locked || leaving}
onClick={off}
>
{/* Design literal, not copy: the chip wordmark stays 'Plan' in every locale. */}
Plan
{t('chip.label')}
<span className={css.close} aria-hidden>
<IconCloseFill14 size={12} />
</span>
</button>
{/* Failure copy stays English (error-surface policy: not localized). */}
{error !== null && <span className={css.error} role="status" title={error}>failed to exit plan mode</span>}
{error !== null && <span className={css.error} role="status" title={error}>{t('chip.exitFailed')}</span>}
</span>
)
}
@@ -2,10 +2,12 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'chip.label': 'Plan',
'chip.on.aria': 'plan mode 已开启,按下关闭',
'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off',
'chip.off.aria': 'plan mode 已关闭,按下开启',
'chip.off.title': 'plan mode 已关闭 — 点击开启(/plan',
'chip.exitFailed': '退出 plan mode 失败',
} satisfies Record<string, string>
/** The plan namespace key union. */
@@ -13,8 +15,10 @@ export type PlanKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'chip.label': 'Plan',
'chip.on.aria': 'Plan mode on, press to turn off',
'chip.on.title': 'Plan mode on — click to turn off (/plan off)',
'chip.off.aria': 'Plan mode off, press to turn on',
'chip.off.title': 'Plan mode off — click to turn on (/plan)',
'chip.exitFailed': 'Failed to exit plan mode',
} satisfies Record<PlanKey, string>
@@ -82,7 +82,7 @@ describe('PlanChip', () => {
.mockRejectedValueOnce('socket closed')
setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
expect((await screen.findByText('failed to exit plan mode')).getAttribute('title')).toBe('host said no')
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
expect(chip()).toBeTruthy()
fireEvent.click(chip())
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81
README.zh.md: 8de384f276e8be5b78d2659a1e14e4afe12c030e
README.md: c1c40e39710d46fae240f0b3281c36d660855010
README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36
+1 -1
View File
@@ -50,5 +50,5 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error.
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing gets those defaults. `WebBlock` does not yet follow this pattern: its source-list and fetch truncation notes and its empty-search note stay inline Chinese, pending the same label-prop treatment.
- **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`, so `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionBanner`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` receive complete localized labels through props. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.
+1 -1
View File
@@ -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。
@@ -7,9 +7,9 @@ import css from './ConnectionBanner.module.css'
* package is cordis-free, so copy arrives via props).
* @returns the banner, or null when connected.
*/
export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: {
export function ConnectionBanner({ reconnecting, label }: {
reconnecting: boolean
label?: string | undefined
label: string
}) {
if (!reconnecting) return null
return <div className={css.banner}>{label}</div>
@@ -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 (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
{copied ? labels.copied : labels.copy}
</button>
<div className={css.body}>
{head.map((row, index) => (
@@ -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)}
</button>
)}
{tail.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
</div>
<div className={css.footer}> +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
<div className={css.footer}> +{added} -{removed} · {labels.files(files)}</div>
</div>
)
}
@@ -14,21 +14,21 @@ import css from './HoverCard.module.css'
* @param props.disabled - suppress opening; turning true closes an open card.
* @param props.copyText - optional primary value copied by activation and
* included in the card's accessible name.
* @param props.copyLabel - accessible activation-label prefix (default "复制").
* @param props.copiedLabel - visible success label (default "复制成功").
* @param props.copyLabel - localized accessible activation-label prefix.
* @param props.copiedLabel - localized visible success label.
* @returns anchor wrapper with the conditional portaled card.
*/
export function HoverCard({
anchor, content, openDelayMs = 500, disabled = false,
copyText, copyLabel = '复制', copiedLabel = '复制成功',
copyText, copyLabel, copiedLabel,
}: {
anchor: ReactNode
content: ReactNode
openDelayMs?: number
disabled?: boolean
copyText?: string | undefined
copyLabel?: string | undefined
copiedLabel?: string | undefined
copyLabel: string
copiedLabel: string
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
+7 -25
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
@@ -17,9 +17,7 @@ const PREVIEW_DEPTH_LIMIT = 2
/**
* Display copy for the tree's copy affordance; the owner passes localized
* labels (this package is cordis-free, so copy arrives via props). Every
* field defaults to the current built-in value, so existing consumers render
* unchanged.
* labels (this package is cordis-free, so copy arrives via props).
*/
export interface JsonTreeLabels {
/** Menu item: copy the raw primitive value. */
@@ -44,19 +42,6 @@ export interface JsonTreeLabels {
copyButtonTitle: (action: string) => string
}
const DEFAULT_LABELS: JsonTreeLabels = {
copyValue: 'Copy value',
copyJson: 'Copy JSON',
copyPath: 'Copy property path',
copyPrettyJson: 'Copy pretty JSON',
copyCompactJson: 'Copy compact JSON',
copied: 'Copied',
copyFailed: 'Copy failed',
collapseNode: 'Collapse JSON node',
expandNode: 'Expand JSON node',
copyButtonTitle: action => `${action}; right-click for copy options`,
}
function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
return [
{ id: 'value', label: labels.copyValue },
@@ -388,15 +373,15 @@ export interface JsonTreeProps {
/** Parsed JSON object or array. */
data: object | unknown[]
/** Accessible label for the tree. */
label?: string
label: string
/** Optional positioning class owned by the caller. */
className?: string | undefined
/** Whether JSON rows expose copy actions. */
copyable?: boolean
/** Whether the top-level object or array is always expanded. */
expandTopLevel?: boolean
/** Localized display copy; omitted fields keep the built-in defaults. */
labels?: Partial<JsonTreeLabels> | undefined
/** Localized display copy supplied by the owning render site. */
labels: JsonTreeLabels
}
/**
@@ -406,16 +391,13 @@ export interface JsonTreeProps {
*/
export function JsonTree({
data,
label = 'JSON',
label,
className,
copyable = true,
expandTopLevel = true,
labels,
}: JsonTreeProps) {
const copyLabels = useMemo<JsonTreeLabels>(
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
[labels],
)
const copyLabels = labels
const rootEntries = entriesOf(data)
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
isExpandableValue(value) && entriesOf(value).length > 0
+2 -2
View File
@@ -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({
<div className={clsx(css.content, contentClassName)}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
<button type="button" className={css.close} aria-label={closeLabel ?? title} onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>
@@ -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 key={index} style={span.style}>{span.text}</span>)
}
@@ -51,6 +64,7 @@ function renderSpans(spans: readonly HighlightSpan[]) {
*/
export function ReadBlock({
label,
labels,
lines,
totalLines,
lang,
@@ -104,13 +118,13 @@ export function ReadBlock({
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{`显示 ${lines.length} / ${totalLines}`}</span>
<span className={css.count}>{labels.window(lines.length, totalLines)}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
{copied ? labels.copied : labels.copy}
</button>
)}
</div>
@@ -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)}
</button>
)}
{capped && rows(paired.slice(paired.length - tailLines))}
@@ -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={(
@@ -29,6 +29,8 @@ export interface SearchFileGroup {
/** Fields both search shapes carry (the render site positions; this component draws). */
interface SearchBlockCommon {
/** Localized chrome supplied by the owning render site. */
labels: SearchBlockLabels
/**
* Whether the tool capped the inline result: the shape carries only the
* retained results, not every result the search found. The banner summary
@@ -44,6 +46,19 @@ interface SearchBlockCommon {
className?: string | undefined
}
/** Localized chrome for {@link SearchBlock}. */
export interface SearchBlockLabels {
pathsSummary: (shown: number, total: number, truncated: boolean) => string
matchesSummary: (shown: number, total: number, files: number, truncated: boolean) => string
copy: string
copied: string
noResults: string
collapseAria: string
expandAria: (hidden: number) => string
collapse: string
expand: (hidden: number) => string
}
/** Props for the grouped-matches (`grep`) shape. */
export interface SearchMatchesBlockProps extends SearchBlockCommon {
kind: 'matches'
@@ -113,10 +128,9 @@ function shownCount(props: SearchBlockProps): number {
* @returns the summary text.
*/
function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string {
const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}`
return props.kind === 'paths'
? `${count} 个路径`
: `${count} 处匹配 · ${props.files.length} 个文件`
? props.labels.pathsSummary(shown, total, truncated)
: props.labels.matchesSummary(shown, total, props.files.length, truncated)
}
/**
@@ -232,12 +246,12 @@ export function SearchBlock(props: SearchBlockProps) {
<span className={css.summary}>{summaryText(props, shown, truncated, total)}</span>
{!empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
{copied ? props.labels.copied : props.labels.copy}
</button>
)}
</div>
{empty
? <div className={css.empty}></div>
? <div className={css.empty}>{props.labels.noResults}</div>
: (
<div className={css.body}>
{head.map(row => (
@@ -248,10 +262,10 @@ export function SearchBlock(props: SearchBlockProps) {
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`}
aria-label={expanded ? props.labels.collapseAria : props.labels.expandAria(hidden)}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
{expanded ? props.labels.collapse : props.labels.expand(hidden)}
</button>
)}
{tailHeader !== undefined && (
@@ -12,9 +12,7 @@ export const DEFAULT_TERMINAL_MAX_LINES = 16
/**
* Display copy for the terminal surface; the owner passes localized labels
* (this package is cordis-free, so copy arrives via props). Every field
* defaults to the current built-in value, so existing consumers render
* unchanged.
* (this package is cordis-free, so copy arrives via props).
*/
export interface TerminalBlockLabels {
/** Status pill text for a signal-terminated command. */
@@ -43,21 +41,6 @@ export interface TerminalBlockLabels {
expand: (hidden: number) => string
}
const DEFAULT_LABELS: TerminalBlockLabels = {
signal: signal => `信号 ${signal}`,
exitCode: exitCode => `退出码 ${exitCode}`,
running: '运行中',
failed: '失败',
done: '已完成',
copy: '复制',
copied: '复制成功',
noOutput: '无输出',
collapseAria: '收起输出',
collapse: '收起',
expandAria: hidden => `展开其余 ${hidden} 行输出`,
expand: hidden => `… 其余 ${hidden}`,
}
export interface TerminalBlockProps {
/** The command line, rendered verbatim after the prompt label. */
command: string
@@ -77,8 +60,8 @@ export interface TerminalBlockProps {
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Localized display copy; omitted fields keep the built-in defaults. */
labels?: Partial<TerminalBlockLabels> | undefined
/** Localized display copy supplied by the owning render site. */
labels: TerminalBlockLabels
}
/**
@@ -172,10 +155,7 @@ export function TerminalBlock({
className,
labels,
}: TerminalBlockProps) {
const copy = useMemo<TerminalBlockLabels>(
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
[labels],
)
const copy = labels
const text = output ?? ''
// A command's output ends with a newline; that terminator is not an extra
// blank line to draw or to count against the height cap. The check runs on the
+21 -8
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx'
import { MarkdownText } from './markdown/MarkdownText.tsx'
import { MarkdownText, type MarkdownLabels } from './markdown/MarkdownText.tsx'
import css from './WebBlock.module.css'
/**
@@ -21,6 +21,8 @@ export interface WebSourceView {
/** A `web_search` card: an optional answer over a capped citation list. */
export interface WebSearchBlockProps {
kind: 'search'
/** Localized chrome supplied by the owning render site. */
labels: WebBlockLabels
/** The provider-generated answer, rendered as markdown above the sources. */
answer?: string | undefined
/** The cited sources, in provider order. */
@@ -34,6 +36,8 @@ export interface WebSearchBlockProps {
/** A `web_fetch` card: the retrieval summary for one fetched URL. */
export interface WebFetchBlockProps {
kind: 'fetch'
/** Localized chrome supplied by the owning render site. */
labels: WebBlockLabels
/** The final URL after allowed redirects; becomes a safe external link when http(s). */
url: string
/** HTTP status code of the fetched response. */
@@ -47,6 +51,15 @@ export interface WebFetchBlockProps {
/** A completed web retrieval card, discriminated by `kind`. */
export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
/** Localized chrome for {@link WebBlock}. */
export interface WebBlockLabels {
noResults: string
sourcesTruncated: string
http: string
contentTruncated: string
markdown: MarkdownLabels
}
/**
* The URL to link to, or undefined when the URL must render as plain text. Only
* http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:`
@@ -132,7 +145,7 @@ function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: numbe
* @param props - see {@link WebSearchBlockProps}.
* @returns the search card element.
*/
function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBlockProps) {
function WebSearchBlock({ answer, sources, truncated, labels, className }: WebSearchBlockProps) {
// A provider may legitimately return no answer and no sources; the chat WebRow
// does not show the raw result content, so without this the user would see an
// empty card. Mirror the backend's `No results found.` render text.
@@ -140,16 +153,16 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc
return (
<div className={clsx(css.block, className)} data-web="search">
{answer !== undefined && answer !== '' && (
<div className={css.answer}><MarkdownText text={answer} /></div>
<div className={css.answer}><MarkdownText text={answer} labels={labels.markdown} /></div>
)}
{empty ? (
<div className={css.empty}></div>
<div className={css.empty}>{labels.noResults}</div>
) : (
<ol className={css.sources}>
{sources.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
</ol>
)}
{truncated && <div className={css.truncated}></div>}
{truncated && <div className={css.truncated}>{labels.sourcesTruncated}</div>}
</div>
)
}
@@ -159,13 +172,13 @@ function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBloc
* @param props - see {@link WebFetchBlockProps}.
* @returns the fetch card element.
*/
function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) {
function WebFetchBlock({ url, statusCode, truncated, labels, className }: WebFetchBlockProps) {
return (
<div className={clsx(css.block, css.fetch, className)} data-web="fetch">
<SafeLink url={url} label={url} className={css.fetchUrl} />
<div className={css.fetchMeta}>
<span className={css.status}>HTTP {statusCode}</span>
{truncated && <span className={css.truncated}></span>}
<span className={css.status}>{labels.http} {statusCode}</span>
{truncated && <span className={css.truncated}>{labels.contentTruncated}</span>}
</div>
</div>
)
+7 -4
View File
@@ -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'
@@ -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
@@ -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(() => {
@@ -19,16 +19,16 @@ import {
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
wrapBlockChildren,
} from './render.tsx'
import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import type { MarkdownFileMentions, MarkdownLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx'
export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './render.tsx'
/** One settled full render: parse with math, resolve references, append the footnote section. */
function renderSettled(
text: string,
codeLabels: MarkdownCodeLabels | undefined,
labels: MarkdownLabels,
fileMentions: MarkdownFileMentions | undefined,
): ReactNode[] {
const root = parseGfmWithMath(text)
@@ -36,7 +36,7 @@ function renderSettled(
collectReferenceTargets(root.children, targets)
const context: MarkdownRenderContext = {
streaming: false,
codeLabels,
labels,
fileMentions,
targets,
footnoteOrder: [],
@@ -67,8 +67,8 @@ class StreamingRenderer {
private lastText: string | null = null
private lastRendered: ReactNode[] = []
/** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {}
/** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */
constructor(private readonly labels: MarkdownLabels) {}
/**
* Render the current accumulated text. Idempotent per text value, so React
@@ -100,7 +100,7 @@ class StreamingRenderer {
if (newlyFrozen.length > 0) {
const frozenContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
labels: this.labels,
fileMentions: undefined,
targets: frameTargets,
footnoteOrder: this.frozenFootnoteOrder,
@@ -118,7 +118,7 @@ class StreamingRenderer {
}
const tailContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
labels: this.labels,
fileMentions: undefined,
targets: frameTargets,
footnoteOrder: [...this.frozenFootnoteOrder],
@@ -141,8 +141,8 @@ class StreamingRenderer {
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on
* the finalize swap) and parses incrementally across chunks; `codeLabels`
* forwards localized copy-button labels to fence CodeBlocks pass a
* the finalize swap) and parses incrementally across chunks; `labels`
* forwards localized fence and footnote chrome pass a
* reference-stable object (memoized per locale revision), because a new
* identity discards the streaming render cache mid-message. `fileMentions`
* links inline-code tokens its resolver recognizes as real files; this is
@@ -153,24 +153,24 @@ class StreamingRenderer {
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* images render directly.
*/
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: {
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
labels: MarkdownLabels
fileMentions?: MarkdownFileMentions | undefined
}) {
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
const streamLabelsRef = useRef<MarkdownLabels>(labels)
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
return renderSettled(text, codeLabels, fileMentions)
return renderSettled(text, labels, fileMentions)
}
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
streamRef.current = new StreamingRenderer(codeLabels)
streamLabelsRef.current = codeLabels
if (streamRef.current === null || streamLabelsRef.current !== labels) {
streamRef.current = new StreamingRenderer(labels)
streamLabelsRef.current = labels
}
return streamRef.current.render(text)
}, [text, streaming, codeLabels, fileMentions])
}, [text, streaming, labels, fileMentions])
return <div className={css.markdown}>{children}</div>
})
@@ -30,9 +30,15 @@ import css from './MarkdownText.module.css'
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
copiedLabel: string
}
/** Localized chrome for a Markdown document. */
export interface MarkdownLabels {
code: MarkdownCodeLabels
footnotes: string
}
function sanitizeUrl(url: string): string {
@@ -123,7 +129,7 @@ export interface MarkdownRenderContext {
/** Streaming arm: fences render plain and TeX stays literal. */
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly codeLabels: MarkdownCodeLabels | undefined
readonly labels: MarkdownLabels
/** Inside a blockquote's children: tables there always fill the quote's width. */
readonly inBlockquote?: boolean
/** Inline-code file mentions; absent wherever no opener vocabulary exists. */
@@ -329,8 +335,8 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
// that trim eat a REAL trailing blank line inside the fence instead.
code={`${node.value}\n`}
lang={context.streaming ? undefined : lang}
copyLabel={context.codeLabels?.copyLabel}
copiedLabel={context.codeLabels?.copiedLabel}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
/>
)
}
@@ -597,7 +603,7 @@ export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode
if (items.length === 0) return null
return (
<section key="footnotes" data-footnotes className="footnotes">
<h2 id="footnote-label" className="sr-only">Footnotes</h2>
<h2 id="footnote-label" className="sr-only">{context.labels.footnotes}</h2>
<ol>{items}</ol>
</section>
)
@@ -410,9 +410,9 @@ describe('Modal', () => {
describe('ConnectionBanner', () => {
it('renders only while reconnecting', () => {
const { container, rerender } = render(<ConnectionBanner reconnecting={false} />)
const { container, rerender } = render(<ConnectionBanner reconnecting={false} label="Reconnecting" />)
expect(container.firstChild).toBeNull()
rerender(<ConnectionBanner reconnecting />)
expect(container.textContent).toContain('重连')
rerender(<ConnectionBanner reconnecting label="Reconnecting" />)
expect(container.textContent).toContain('Reconnecting')
})
})
@@ -2,8 +2,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import type { ComponentProps } from 'react'
import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx'
import { highlightToHtml } from '../src/markdown/highlight.ts'
import { markdownLabels } from './labels.client.ts'
function CodeBlock(props: Omit<ComponentProps<typeof LocalizedCodeBlock>, 'copyLabel' | 'copiedLabel'>) {
return <LocalizedCodeBlock {...props} {...markdownLabels.code} />
}
afterEach(cleanup)
@@ -2,7 +2,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
import type { ComponentProps } from 'react'
import { DEFAULT_DIFF_MAX_LINES, DiffBlock as LocalizedDiffBlock, type DiffHunk } from '../src/index.ts'
import { diffBlockLabels } from './labels.client.ts'
function DiffBlock(props: Omit<ComponentProps<typeof LocalizedDiffBlock>, 'labels'>) {
return <LocalizedDiffBlock {...props} labels={diffBlockLabels} />
}
afterEach(cleanup)
@@ -25,7 +25,13 @@ function mount(props: {
copiedLabel?: string
} = {}) {
const view = render(
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
<HoverCard
anchor={<span>row</span>}
content={<div>card body</div>}
copyLabel={props.copyLabel ?? 'Copy'}
copiedLabel={props.copiedLabel ?? 'Copied'}
{...props}
/>,
)
const anchor = screen.getByText('row')
stubAnchorRect(anchor, { top: 40, right: 200 })
@@ -370,7 +376,15 @@ describe('HoverCard', () => {
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />)
view.rerender(
<HoverCard
anchor={<span>row</span>}
content={<div>card body</div>}
copyLabel="Copy"
copiedLabel="Copied"
disabled
/>,
)
expect(screen.queryByText('card body')).toBeNull()
})
@@ -2,7 +2,15 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { JsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComponentProps } from 'react'
import { JsonTree as LocalizedJsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
import { jsonTreeLabels } from './labels.client.ts'
function JsonTree(props: Omit<ComponentProps<typeof LocalizedJsonTree>, 'label' | 'labels'> & {
label?: string
}) {
return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
}
let writeText: ReturnType<typeof vi.fn>
@@ -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,
}
@@ -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)
@@ -6,7 +6,7 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Root, RootContent } from 'mdast'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { MarkdownText } from './markdown-test-components.tsx'
import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts'
import { parseGfm } from '../src/markdown/parse.ts'
@@ -95,9 +95,13 @@ describe('incremental streaming rendering', () => {
it('drops the streaming cache when the copy labels change identity', () => {
const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
const live = render(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy' }} />)
const live = render(
<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy', copiedLabel: 'Copied' }} />,
)
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
live.rerender(<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren' }} />)
live.rerender(
<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren', copiedLabel: 'Kopiert' }} />,
)
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
live.unmount()
})
@@ -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: [],
@@ -0,0 +1,37 @@
import type { ComponentProps } from 'react'
import {
JsonBlock as LocalizedJsonBlock,
MarkdownText as LocalizedMarkdownText,
type MarkdownCodeLabels,
type MarkdownLabels,
} from '../src/index.ts'
import { markdownLabels as defaultMarkdownLabels } from './labels.client.ts'
type MarkdownTextProps = Omit<ComponentProps<typeof LocalizedMarkdownText>, 'labels'> & {
labels?: MarkdownLabels
codeLabels?: MarkdownCodeLabels
}
export function MarkdownText({
labels,
codeLabels,
...props
}: MarkdownTextProps) {
const resolved = labels ?? (codeLabels === undefined
? defaultMarkdownLabels
: { ...defaultMarkdownLabels, code: codeLabels })
return <LocalizedMarkdownText {...props} labels={resolved} />
}
type JsonBlockProps = Omit<ComponentProps<typeof LocalizedJsonBlock>, 'truncatedLabel'> & {
truncatedLabel?: (total: number) => string
}
export function JsonBlock({ truncatedLabel, ...props }: JsonBlockProps) {
return (
<LocalizedJsonBlock
{...props}
truncatedLabel={truncatedLabel ?? (total => `… 已截断,共 ${total} 字符`)}
/>
)
}
@@ -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'
@@ -2,8 +2,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
import type { ComponentProps } from 'react'
import { DEFAULT_READ_MAX_LINES, ReadBlock as LocalizedReadBlock, type ReadBlockLine } from '../src/index.ts'
import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
import { readBlockLabels } from './labels.client.ts'
function ReadBlock(props: Omit<ComponentProps<typeof LocalizedReadBlock>, 'labels'>) {
return <LocalizedReadBlock {...props} labels={readBlockLabels} />
}
afterEach(cleanup)
@@ -2,8 +2,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts'
import type { SearchFileGroup } from '../src/index.ts'
import { DEFAULT_SEARCH_MAX_LINES, SearchBlock as LocalizedSearchBlock } from '../src/index.ts'
import type {
SearchFileGroup, SearchMatchesBlockProps, SearchPathsBlockProps,
} from '../src/index.ts'
import { searchBlockLabels } from './labels.client.ts'
type SearchBlockProps =
| Omit<SearchMatchesBlockProps, 'labels'>
| Omit<SearchPathsBlockProps, 'labels'>
function SearchMatchesBlock(props: Omit<SearchMatchesBlockProps, 'labels'>) {
return <LocalizedSearchBlock {...props} labels={searchBlockLabels} />
}
function SearchPathsBlock(props: Omit<SearchPathsBlockProps, 'labels'>) {
return <LocalizedSearchBlock {...props} labels={searchBlockLabels} />
}
function SearchBlock(props: SearchBlockProps) {
return props.kind === 'matches'
? <SearchMatchesBlock {...props} />
: <SearchPathsBlock {...props} />
}
afterEach(cleanup)
@@ -2,8 +2,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts'
import type { ComponentProps } from 'react'
import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock as LocalizedTerminalBlock } from '../src/index.ts'
import { writeClipboard } from '../src/clipboard.ts'
import { terminalBlockLabels } from './labels.client.ts'
function TerminalBlock(props: Omit<ComponentProps<typeof LocalizedTerminalBlock>, 'labels'>) {
return <LocalizedTerminalBlock {...props} labels={terminalBlockLabels} />
}
const ESC = '\u001b'
@@ -2,8 +2,29 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { WebBlock } from '../src/index.ts'
import type { WebSourceView } from '../src/index.ts'
import { WebBlock as LocalizedWebBlock } from '../src/index.ts'
import type {
WebFetchBlockProps, WebSearchBlockProps, WebSourceView,
} from '../src/index.ts'
import { webBlockLabels } from './labels.client.ts'
type WebBlockProps =
| Omit<WebSearchBlockProps, 'labels'>
| Omit<WebFetchBlockProps, 'labels'>
function WebSearchBlock(props: Omit<WebSearchBlockProps, 'labels'>) {
return <LocalizedWebBlock {...props} labels={webBlockLabels} />
}
function WebFetchBlock(props: Omit<WebFetchBlockProps, 'labels'>) {
return <LocalizedWebBlock {...props} labels={webBlockLabels} />
}
function WebBlock(props: WebBlockProps) {
return props.kind === 'search'
? <WebSearchBlock {...props} />
: <WebFetchBlock {...props} />
}
afterEach(cleanup)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 到达。
## 模型体验
@@ -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 }
@@ -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 <DocumentTitle {...title === undefined ? {} : { title }} />
const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild')
return <DocumentTitle productTitle={productTitle} {...title === undefined ? {} : { title }} />
}
return () => (
<>
@@ -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
@@ -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({}, () => <div data-testid="frame" />)
return { runtime, renderApp: buildRenderApp({ ctx: runtime.ctx }) }
}
@@ -13,13 +13,13 @@ describe('DocumentTitle', () => {
it('projects a durable title and restores the product title', () => {
vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness')
document.title = 'stale title'
const mounted = render(<DocumentTitle />)
const mounted = render(<DocumentTitle productTitle="DeepSeek Harness" />)
expect(document.title).toBe('DeepSeek Harness')
mounted.rerender(<DocumentTitle title="First title" />)
mounted.rerender(<DocumentTitle title="First title" productTitle="DeepSeek Harness" />)
expect(document.title).toBe('First title — DeepSeek Harness')
mounted.rerender(<DocumentTitle title="Revised title" />)
mounted.rerender(<DocumentTitle title="Revised title" productTitle="DeepSeek Harness" />)
expect(document.title).toBe('Revised title — DeepSeek Harness')
mounted.rerender(<DocumentTitle />)
mounted.rerender(<DocumentTitle productTitle="DeepSeek Harness" />)
expect(document.title).toBe('DeepSeek Harness')
mounted.unmount()
expect(document.title).toBe('DeepSeek Harness')
@@ -28,7 +28,7 @@ describe('DocumentTitle', () => {
it('uses the generic title when the build provides no title', () => {
vi.stubEnv('DSH_CLIENT_TITLE', '')
delete process.env.DSH_CLIENT_TITLE
const mounted = render(<DocumentTitle title="First title" />)
const mounted = render(<DocumentTitle title="First title" productTitle="DSH Local Build" />)
expect(document.title).toBe('First title — DSH Local Build')
mounted.unmount()
expect(document.title).toBe('DSH Local Build')
@@ -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
@@ -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 }
@@ -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
+1 -1
View File
@@ -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 `<ROUTE>_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.
@@ -6,7 +6,7 @@
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_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 页仍是诊断界面。
@@ -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() }}
/>
@@ -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}
/>
</div>
@@ -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')}
</button>
<button
type="button"
@@ -60,7 +60,7 @@ export function EditorFooter(props: EditorFooterProps): ReactNode {
disabled={props.submitDisabled}
onClick={props.onSubmit}
>
{props.busy ? t(props.submitBusyLabel) : t(props.submitLabel)}
{props.busy ? t(props.submitBusyLabelKey) : t(props.submitLabelKey)}
</button>
</div>
)
@@ -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 <p className={styles['error']}>{`${props.provider}: unresolvable settings path`}</p>
return <p className={styles['error']}>{props.provider}: {props.t('settingsPathUnresolvable')}</p>
}
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() }}
/>
@@ -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 官方模型,即可开始使用。',
@@ -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
@@ -385,9 +385,9 @@ describe('ModelsSection', () => {
credentialOnly
credentialRequired
autoFocusCredential
cancelLabel="onboardingLater"
submitLabel="onboardingSave"
submitBusyLabel="onboardingSaving"
cancelLabelKey="onboardingLater"
submitLabelKey="onboardingSave"
submitBusyLabelKey="onboardingSaving"
onClose={onClose}
/>)
@@ -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()
@@ -143,7 +143,7 @@ export function SidebarRoot({
{renderSlot('sidebar.brand.name', {}, {
fallback: (
<>
<span className={css.fallbackBrandName}>DSH Local Build</span>
<span className={css.fallbackBrandName}>{t('brand.localBuild')}</span>
{process.env.DSH_CLIENT_COMMIT_HASH
? <span className={css.buildRevision}>{process.env.DSH_CLIENT_COMMIT_HASH}</span>
: null}
@@ -266,7 +266,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
<span
class="fallbackBrandName"
>
DSH Local Build
DSH 本地构建
</span>
<span
class="buildRevision"
@@ -8,10 +8,12 @@ import type {
} from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
import { en } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
// English-dictionary translate stub: the shell renders the same copy the
// assertions below query by accessible name.
const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
const t: SidebarRootComponentProps['t'] = key =>
(en as Record<string, string>)[key] ?? (commonEn as Record<string, string>)[key] ?? key
afterEach(() => {
cleanup()
@@ -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)
@@ -141,7 +141,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) {
>
<span className={css.leading}>{leading}</span>
{status !== null ? <span className={css.visuallyHidden}>{status}</span> : null}
<span className={css.title}>Skill</span>
<span className={css.title}>{t('row.title')}</span>
<span className={css.separator} aria-hidden />
<span className={model.errorSummary === null ? css.summary : `${css.summary} ${css.errorSummary}`}>
{summary}
@@ -156,7 +156,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) {
{inspect !== undefined ? (
<button type="button" className={css.inspectButton} onClick={inspect}>
<IconInspectOutline12 />
Inspect
{t('row.inspect')}
</button>
) : null}
</div>
@@ -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<string, string>
@@ -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<SkillKey, string>

Some files were not shown because too many files have changed in this diff Show More