mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/windows-acl-hardening-followup
This commit is contained in:
@@ -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: 09baf5876029295f7a80b6a0fe6a6395d98f406c
|
||||
2026-07-30-client-locale-full-rollout.zh.md: a2f9b619be9928a713d5dbf527d7c2278e84bd1f
|
||||
2026-07-30-client-locale-full-rollout.md: 0faf4e0424e037b59b24d32f7fa987ac36497691
|
||||
2026-07-30-client-locale-full-rollout.zh.md: 5c26c2d5e7b75b89675b0b0d9ca3f147d2152bc8
|
||||
|
||||
@@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework
|
||||
|
||||
**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure.
|
||||
|
||||
**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)).
|
||||
**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)).
|
||||
|
||||
The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle).
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
|
||||
**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。
|
||||
|
||||
**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage` 打开(boot 前钉 `dsh.locale=en`),built-boot 快照同样钉 en——预期输出不受本地化迁移影响;settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为初始 locale 跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。
|
||||
**测试与 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.md))。
|
||||
|
||||
[settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中「apply 层订阅 `locale/change` 重注册刷新 label」的机制已被本决定取代(thunk + revision 生命周期)。
|
||||
|
||||
|
||||
@@ -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/bug-fix/2026-08-06-host-backed-web-preferences.md
|
||||
2026-08-06-host-backed-web-preferences.md: d56a8d2e330b214a1922997e3cc7165fd0fb31e4
|
||||
2026-08-06-host-backed-web-preferences.zh.md: 593646fe0845c20fb09cb7d115e6fa558226506e
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Persist Web user preferences through Host settings
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-host-backed-web-preferences.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web Appearance, Language, and busy-Enter preferences lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different partition and lost choices even though both processes used the same DSH home. These are user-level product preferences; session selection, drafts, disclosure state, and other transient browser state remain page-local.
|
||||
|
||||
The first theme implementation moved only Appearance to Host settings but awaited its initial RPC before providing `ThemeService`. A slow or unavailable settings request therefore suspended the assembled page. It also subscribed after the read, could miss an invalidation in that window, did not carry namespace revisions on writes, and allowed queued writes from a disposed plugin to reach the Host.
|
||||
|
||||
## Decision
|
||||
|
||||
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary.
|
||||
|
||||
The client runtime provides one `bindSettingsScope` lifecycle per namespace — the browser mirror of the Host-side settings owner seam. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through dsh-client-schema-form, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local.
|
||||
|
||||
User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence.
|
||||
|
||||
Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host relay would recreate the settings service around a browser-specific format.
|
||||
|
||||
**Mirror Host settings into `localStorage`.** A second authority requires boot and invalidation conflict rules while retaining the partition that caused the defect. The Host document is the sole durable source.
|
||||
|
||||
**Await the initial read to avoid a provisional render.** Configuration availability is not a prerequisite for drawing the page. A background read may cause one live convergence, but it keeps failure isolated and preserves the existing browser/system/default fallbacks.
|
||||
|
||||
**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas keep product policy out of the shared runtime.
|
||||
|
||||
**A per-field preference controller with paired sync/persist callbacks.** The first shared lifecycle synchronized one scalar field through a domain `sync` callback while the service wrote back through an injected `persist` callback. The mutual callbacks forced two-phase construction — a defaulted no-op writer later replaced via `bindPersistence` — every additional field of a namespace would have carried its own controller and whole-document read, and each domain re-declared a hand-written guard the registered wire schema already expresses. The namespace scope publishes a snapshot the service subscribes to and accepts writes directly, so the callback pair and the second construction phase do not exist.
|
||||
|
||||
**Move every `localStorage` entry into settings.** Current session, drafts, panel disclosure, trajectory display state, and similar entries are browser-instance state rather than user configuration. Promoting them would synchronize transient navigation state across tabs and ports without a product contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
Appearance, Language, and busy-Enter choices follow the DSH user home across reloads, ports, and loopback origins. Direct edits to `settings.yaml` converge through the existing invalidation stream, while legacy `dsh.theme`, `dsh.locale`, and `dsh.conversation.busyEnter` entries are neither read nor written.
|
||||
|
||||
Boot may briefly show the domain default before the background read settles. A transient read failure keeps that default or the last good in-process value; reconnect retries. A write rejection can visibly restore the durable preference after the immediate local change.
|
||||
|
||||
Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, schema-validated section acceptance, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The namespace-granular scope also carries multi-field sections, so later configuration surfaces can ride the same lifecycle instead of hand-rolling describe/mutate synchronization. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: 通过 Host settings 持久化 Web 用户偏好
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-06-host-backed-web-preferences.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区并丢失选择,即使两个进程使用同一个 DSH home。这些是用户级产品偏好;会话选择、草稿、折叠展开状态和其他瞬态浏览器状态仍保留在页面内。
|
||||
|
||||
第一版主题实现只把 Appearance 移入 Host settings,但会在提供 `ThemeService` 之前等待初始 RPC。缓慢或不可用的 settings 请求因而会挂起组装后的页面。该实现还在读取后才建立订阅,可能错过此窗口内的失效通知;它写入时不携带 namespace revision,并且允许已释放插件所排队的写入到达 Host。
|
||||
|
||||
## 决策
|
||||
|
||||
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。
|
||||
|
||||
客户端运行时为每个 namespace 提供一份 `bindSettingsScope` 生命周期——即 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。
|
||||
|
||||
用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。
|
||||
|
||||
远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 中继会围绕浏览器特有格式重新实现一套 settings 服务。
|
||||
|
||||
**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会要求另外定义启动与失效时的冲突规则,同时依然保留造成该缺陷的分区。Host settings 文档是唯一的持久化真源。
|
||||
|
||||
**等待初始读取,以避免暂定渲染。** 绘制页面不以配置可用为前置条件。后台读取可能引发一次实时收敛,但它会隔离失败,并保留既有的浏览器/系统/默认回落路径。
|
||||
|
||||
**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema,可以避免把产品政策放入共享运行时。
|
||||
|
||||
**带成对 sync/persist 回调的逐字段偏好控制器。** 第一版共享生命周期经领域提供的 `sync` 回调同步单个标量字段,服务则经注入的 `persist` 回调写回。这对相互依赖的回调迫使构造分两阶段完成——写入器先默认为无操作,稍后经 `bindPersistence` 替换——namespace 每新增一个字段,本都得再携带一个自己的控制器和一次全文档读取,且每个领域都重新声明了一个已注册 wire schema 本已表达的手写校验器。namespace scope 发布一份供服务订阅的快照并直接接受写入,因此这对回调与第二个构造阶段都不存在。
|
||||
|
||||
**把每个 `localStorage` 条目都移入 settings。** 当前会话、草稿、面板展开状态、trajectory 显示状态和类似条目属于浏览器实例状态,而非用户配置。将它们提升为设置,会在没有产品契约的情况下,跨标签页和端口同步短暂导航状态。
|
||||
|
||||
## 后果
|
||||
|
||||
Appearance、Language 和繁忙态 Enter 选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin。直接编辑 `settings.yaml` 所产生的变更会通过现有失效流收敛,而旧的 `dsh.theme`、`dsh.locale` 和 `dsh.conversation.busyEnter` 条目既不会被读取,也不会被写入。
|
||||
|
||||
启动时可能会在后台读取结算前短暂显示领域默认值。短暂的读取失败会保留该默认值或上一个正确的进程内值;重连时会重试。写入被拒时,界面可能会在本地值立即变化后明显恢复为持久化偏好。
|
||||
|
||||
聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、经 schema 校验的分节接受、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。以 namespace 为粒度的 scope 也承载多字段分节,因此后续的配置表面可以沿用同一份生命周期,而不必手搭 describe/mutate 同步。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。
|
||||
@@ -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/feature/2026-07-30-web-queue-steer-action.md
|
||||
2026-07-30-web-queue-steer-action.md: b04095b81f499982c8680a2d3627d8e98a70d8ac
|
||||
2026-07-30-web-queue-steer-action.zh.md: 1b46ec90a27fbc3c8f3660a7cbdccb78a21a2cfa
|
||||
2026-07-30-web-queue-steer-action.md: 2718c5b3cc95f1ab02db80230ba158d9b5c3b4e6
|
||||
2026-07-30-web-queue-steer-action.zh.md: 377117e2aa9c20b1c39d1fb7f450dbe729b00580
|
||||
|
||||
@@ -20,7 +20,7 @@ Activating the action requests strict current-turn steering for that exact `Inbo
|
||||
|
||||
The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface.
|
||||
|
||||
The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The browser persists the preference, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure.
|
||||
The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The Host settings document persists the preference across Web origins sharing one DSH home, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure.
|
||||
|
||||
### Agent and lifecycle boundary
|
||||
|
||||
@@ -38,7 +38,7 @@ The Host's existing `queuedMirror` remains the sole transient inbox authority. I
|
||||
|
||||
When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending the durable `user/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row.
|
||||
|
||||
The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence.
|
||||
The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser submission policy owns the live busy-Enter preference while the Host settings service owns durability; the policy resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions, and the Settings row and InputBar share it without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence.
|
||||
|
||||
### Verification
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Web composer 原本会在 agent(智能体)运行期间把所有 Enter 提交
|
||||
|
||||
running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。
|
||||
|
||||
Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。浏览器会持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。
|
||||
Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。Host settings 文档会在共享同一 DSH home 的 Web origin 之间持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。
|
||||
|
||||
### Agent 与生命周期边界
|
||||
|
||||
@@ -38,7 +38,7 @@ Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/q
|
||||
|
||||
AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久会话事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。
|
||||
|
||||
现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的约定:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该约定。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。
|
||||
现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的约定:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该约定。浏览器提交策略拥有实时繁忙态 Enter 偏好,而 Host settings 服务拥有持久性;该策略只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势,Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。
|
||||
|
||||
### 验证
|
||||
|
||||
|
||||
@@ -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/feature/2026-07-31-browser-derived-initial-locale.md
|
||||
2026-07-31-browser-derived-initial-locale.md: 0c49a6bbfec0ab33a5aa3ce53dde0cac747f3816
|
||||
2026-07-31-browser-derived-initial-locale.zh.md: ca8eeceae58e25836bb42f7c1cec0e61a232d921
|
||||
2026-07-31-browser-derived-initial-locale.md: 3fed32ad46f01ef3f88f3182a1cb21f40031ca1b
|
||||
2026-07-31-browser-derived-initial-locale.zh.md: 6cd11797ebc516f78a36f6c8825c4d074a7dd445
|
||||
|
||||
@@ -10,15 +10,15 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r
|
||||
|
||||
## Decision
|
||||
|
||||
**The initial locale resolves through three ordered sources: the persisted preference, then the browser, then `FALLBACK_LOCALE`.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and is the only place the order is expressed; `restorePreference()` now returns `LocaleId | undefined` (an absent, unparseable, or unreachable store reads as *no preference*) so the next source can speak.
|
||||
**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active.
|
||||
|
||||
**Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express.
|
||||
|
||||
**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
|
||||
|
||||
**An explicit choice is permanent.** `setLocale` persistence is untouched, and the persisted value is consulted first, so a user who picked a language keeps it even when travelling between browser profiles or system languages. Nothing writes the detected locale back to storage: detection is re-derived every boot and stays invisible to the "has the user chosen?" question.
|
||||
**An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question.
|
||||
|
||||
**The browser e2e lane now pins the browser language, not just storage.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` keeps pinning `dsh.locale=en`, which still wins over any browser language. `settings-chrome.e2e.ts` gained a scenario opening a second `en-US` page with empty storage and asserting the settings surface comes up English — the assembled-app proof of this feature.
|
||||
**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -33,4 +33,4 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r
|
||||
- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
|
||||
- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen".
|
||||
- Tests that construct a `LocaleService` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all).
|
||||
- Detection cost is one array walk per service construction, and no storage write, so boot behavior and the persisted-state surface are unchanged.
|
||||
- Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation.
|
||||
|
||||
@@ -10,15 +10,15 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
**初始 locale 依次经三个来源解析:已持久化的偏好、浏览器、`FALLBACK_LOCALE`。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,是这一顺序的唯一表达处;`restorePreference()` 现在返回 `LocaleId | undefined`(存储项缺失、无法解析或不可访问,一律读作*没有偏好*),后一个来源才有开口的机会。
|
||||
**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。
|
||||
|
||||
**浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。
|
||||
|
||||
**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。
|
||||
|
||||
**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置文件或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。
|
||||
**显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。
|
||||
|
||||
**浏览器 e2e 车道现在钉住浏览器语言,而不只是存储项。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 仍然钉 `dsh.locale=en`,它依旧压过任何浏览器语言。`settings-chrome.e2e.ts` 新增一个场景:另开一个存储项为空的 `en-US` 页面,断言设置界面以英文呈现——这是本功能在组装后应用中的证据。
|
||||
**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -33,4 +33,4 @@ Status: implemented
|
||||
- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
|
||||
- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。
|
||||
- 在 jsdom 下构造 `LocaleService` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。
|
||||
- 探测的代价是每次服务构造遍历一次数组,且不写存储,因此启动行为与持久化状态面均无变化。
|
||||
- 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。
|
||||
|
||||
@@ -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/testing/2026-07-24-web-gui-browser-e2e-lane.md
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: 84912895514f9dba4ff61bc8842444a81c5fbefa
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: 3c94b42d1b85190dfe6b3b8ebb14efb4624fd326
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: e572929ae6762da6adc2e77e1dba19361beaf670
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: 99f86f40ba006c4024f367b73ce52f8679b8d2fd
|
||||
|
||||
@@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side
|
||||
|
||||
No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
|
||||
|
||||
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; the scenarios asserting Chinese copy leave storage unset and open a `zh-CN` browser instead, because the client derives its initial locale from `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)), and `settings-chrome.e2e.ts` additionally covers both switch directions and the English-browser default.
|
||||
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
|
||||
|
||||
### Expected outputs
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。
|
||||
|
||||
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则不预设该存储项,改为开启 `zh-CN` 浏览器,因为客户端的初始 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md)),而 `settings-chrome.e2e.ts` 还额外覆盖双向切换与英文浏览器默认态。
|
||||
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
|
||||
|
||||
### 预期输出
|
||||
|
||||
|
||||
@@ -70,7 +70,12 @@ let unmount: (() => void) | undefined
|
||||
export function installAssembledBootEnv(): void {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('dsh.locale', 'en')
|
||||
// The locale service derives its provisional locale from the browser and
|
||||
// takes an explicit choice only from Host settings, which this lane's
|
||||
// fixture transport does not serve; pinning the navigator is what selects
|
||||
// English here.
|
||||
Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
|
||||
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
@@ -88,6 +93,11 @@ export function installAssembledBootEnv(): void {
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
// Deleting the own properties uncovers jsdom's own accessors again
|
||||
// (Navigator declares both readonly, hence the erased receiver).
|
||||
const ownNavigator = navigator as unknown as Record<string, unknown>
|
||||
delete ownNavigator.languages
|
||||
delete ownNavigator.language
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ installAssembledBootEnv()
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const group = (await within(tree).findAllByText('fixture'))
|
||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
||||
@@ -33,7 +33,6 @@ async function openFixtureSession(): Promise<void> {
|
||||
}
|
||||
|
||||
it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => {
|
||||
localStorage.setItem('dsh.locale', 'zh')
|
||||
mountAssembledApp()
|
||||
await openFixtureSession()
|
||||
|
||||
@@ -73,24 +72,23 @@ it('renders the history image pair through the authorized attachment route and o
|
||||
fireEvent.doubleClick(frame)
|
||||
const lightbox = await screen.findByRole('dialog')
|
||||
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
|
||||
fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ }))
|
||||
fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts pasted images into the composer rail in order and removes them', async () => {
|
||||
localStorage.setItem('dsh.locale', 'zh')
|
||||
mountAssembledApp()
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 })
|
||||
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="在“fixture”中新建会话"]')
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
|
||||
if (start === null) throw new Error('fixture Workspace new-session action missing')
|
||||
fireEvent.click(start)
|
||||
|
||||
// Image-only send arming is pinned at package level (input-bar.spec.tsx);
|
||||
// this assembled lane pins the intake chain over the built graph.
|
||||
const textarea = await screen.findByPlaceholderText('描述你想要构建的内容', {}, { timeout: 10_000 })
|
||||
const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
|
||||
const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
@@ -102,7 +100,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
|
||||
// The rail is an accessible group holding the draft thumbnail (queried via
|
||||
// DOM: jsdom's a11y-visibility computation hides the composer subtree).
|
||||
const rail = await waitFor(() => {
|
||||
const el = document.querySelector('[role="group"][aria-label="待发送图片"]')
|
||||
const el = document.querySelector('[role="group"][aria-label="Pending images"]')
|
||||
if (el === null) throw new Error('attachment rail missing')
|
||||
return el
|
||||
}, { timeout: 5_000 })
|
||||
@@ -129,10 +127,10 @@ it('accepts pasted images into the composer rail in order and removes them', asy
|
||||
.toEqual(['pasted.png', 'second.png'])
|
||||
})
|
||||
|
||||
const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')]
|
||||
const remove = [...rail.querySelectorAll('button[aria-label^="Remove image"]')]
|
||||
if (remove.length !== 2) throw new Error('remove buttons missing')
|
||||
for (const button of remove) fireEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull()
|
||||
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,6 +251,8 @@ export interface LaunchOptions {
|
||||
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
|
||||
*/
|
||||
remoteAuthority?: string
|
||||
/** Reuse an existing harness home so a second Host can verify user settings across origins. */
|
||||
harnessHome?: string
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -297,16 +299,19 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
|
||||
// paths at load, and an in-process boot must NEVER touch the developer's
|
||||
// real ~/.dsh document or credential file.
|
||||
const harnessHome = join(workspaceCwd, '.dsh-home')
|
||||
const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
|
||||
// Skill discovery is model-visible input, and its roots now resolve inside a
|
||||
// PRESET — a subtree this lane's include patches cannot reach, because the
|
||||
// roster mounts it directly per session rather than as a row of the booted
|
||||
// tree. The row's documented fallback is the environment, so pin that: the
|
||||
// whole scaffold lifetime, not just the boot, since presets mount when a
|
||||
// session is created. Without this a developer's real ~/.dsh/skills silently
|
||||
// enters replay requests and goldens while CI sees none.
|
||||
// enters replay requests and goldens while CI sees none. `DSH_HOME` follows
|
||||
// the resolved harness home so a scaffold sharing another's home — the
|
||||
// cross-port persistence scenario — pins the same roots the settings and
|
||||
// credentials rows were configured with.
|
||||
const skillRootEnvironment = {
|
||||
DSH_HOME: join(workspaceCwd, '.dsh-home'),
|
||||
DSH_HOME: harnessHome,
|
||||
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
|
||||
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
|
||||
// section switching, both close paths), the Appearance preference row (the
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token +
|
||||
// browser theme-color metadata)
|
||||
// the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// the busy-state Enter preference, plus Permission as the persisted default
|
||||
// for subsequently created sessions.
|
||||
// the Language row and busy-state Enter preference (both Host-backed), plus
|
||||
// Permission as the persisted default for subsequently created sessions.
|
||||
// Zero model calls: everything is pure client + persistence state on a blank
|
||||
// frame, so there is no fixture and a stray stream would fail loud on the
|
||||
// open llm seam.
|
||||
@@ -153,23 +152,24 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('flips the theme through the Appearance cubes and persists across reload', async () => {
|
||||
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
||||
interface ThemeState {
|
||||
attr: boolean
|
||||
background: string
|
||||
stored: string | null
|
||||
/** Pre-migration localStorage key; the Host-backed world never writes it. */
|
||||
legacy: string | null
|
||||
themeColor: string | null
|
||||
themeColorCount: number
|
||||
token: string
|
||||
}
|
||||
const readState = async (): Promise<ThemeState> => await page.evaluate(() => {
|
||||
const readState = async (target: Page = page): Promise<ThemeState> => await target.evaluate(() => {
|
||||
const metas = document.head.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
|
||||
const computed = getComputedStyle(document.body)
|
||||
return {
|
||||
attr: document.body.hasAttribute('data-ds-dark-theme'),
|
||||
background: computed.backgroundColor,
|
||||
stored: localStorage.getItem('dsh.theme'),
|
||||
legacy: localStorage.getItem('dsh.theme'),
|
||||
themeColor: metas[0]?.content ?? null,
|
||||
themeColorCount: metas.length,
|
||||
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
|
||||
@@ -193,27 +193,51 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const darkCube = dialog.getByRole('button', { name: '深色' })
|
||||
expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
|
||||
await darkCube.click()
|
||||
// The full cascade: pressed state, persisted preference, body attribute,
|
||||
// The full cascade: pressed state, Host-backed preference, body attribute,
|
||||
// alias token flip — all from one real user gesture.
|
||||
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
||||
const dark = await readState()
|
||||
expect(dark.attr).toBe(true)
|
||||
expect(dark.stored).toBe('dark')
|
||||
expect(dark.legacy).toBeNull()
|
||||
expect(dark.token).not.toBe(light.token)
|
||||
expectThemeColorSynchronized(dark)
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/ui-theme:\n\s+preference: dark/)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Reload: the preference survives boot (restore + presenter initial apply).
|
||||
// Reload: the preference survives the background Host read + presenter update.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
|
||||
const reloaded = await readState()
|
||||
expect(reloaded.attr).toBe(true)
|
||||
expect(reloaded.stored).toBe('dark')
|
||||
expect(reloaded.legacy).toBeNull()
|
||||
expectThemeColorSynchronized(reloaded)
|
||||
|
||||
// A second live Host binds another ephemeral port but shares the same
|
||||
// user-settings home. Its fresh origin has no theme localStorage and still
|
||||
// converges to dark before the settings dialog opens.
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.emulateMedia({ colorScheme: 'light' })
|
||||
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true)
|
||||
const secondState = await readState(secondPage)
|
||||
expect(secondState.legacy).toBeNull()
|
||||
expectThemeColorSynchronized(secondState)
|
||||
expect(secondTripwire.pageErrors).toEqual([])
|
||||
expect(secondTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await secondPage.close()
|
||||
await second.close()
|
||||
}
|
||||
|
||||
// `system` follows the emulated OS scheme (dark stays dark, light clears).
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
|
||||
@@ -233,7 +257,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('persists the busy-state Enter behavior across reload and restores Queue', async () => {
|
||||
it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -241,7 +265,9 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await dialog.getByRole('button', { name: '排队发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '插话发送' }).click()
|
||||
await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/ui-conversation:\n\s+busyEnter: steer/)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
@@ -251,15 +277,36 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await secondPage.getByRole('button', { name: '设置', exact: true }).click()
|
||||
await secondPage.getByRole('dialog', { name: '设置' })
|
||||
.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
expect(secondTripwire.pageErrors).toEqual([])
|
||||
expect(secondTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await secondPage.close()
|
||||
await second.close()
|
||||
}
|
||||
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '排队发送' }).click()
|
||||
await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/ui-conversation:\n\s+busyEnter: queue/)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('switches the settings surface language and persists dsh.locale', async () => {
|
||||
it('persists the settings language across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const zhDialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -276,7 +323,9 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await enDialog.waitFor({ timeout: 10_000 })
|
||||
expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
|
||||
await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/locale:\n\s+preference: en/)
|
||||
// Reload keeps English; then restore zh so shared page state (and the
|
||||
// other specs' 设置-anchored selectors + goldens) see the default again.
|
||||
const warningStart = tripwire.warnings.length
|
||||
@@ -285,24 +334,47 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
const enTrigger = page.getByRole('button', { name: 'Settings' })
|
||||
await enTrigger.waitFor({ timeout: 10_000 })
|
||||
|
||||
// A Chinese browser on another port still receives the explicit English
|
||||
// preference from the shared Host settings document.
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
|
||||
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
|
||||
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await secondPage.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
await secondPage.getByRole('dialog', { name: 'Settings' })
|
||||
.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
expect(secondTripwire.pageErrors).toEqual([])
|
||||
expect(secondTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await secondPage.close()
|
||||
await second.close()
|
||||
}
|
||||
|
||||
await enTrigger.click()
|
||||
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
|
||||
await page.getByRole('menuitem', { name: '中文' }).click()
|
||||
await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/locale:\n\s+preference: zh/)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('opens an English browser in English without any stored preference', async () => {
|
||||
// A second page under a different browser language: nothing is persisted
|
||||
// for it, so the settings surface must follow the browser rather than the
|
||||
// product fallback the shared zh page shows.
|
||||
// A fresh Host home has no locale preference, so its surface follows the
|
||||
// browser rather than the product fallback.
|
||||
const fresh = await launchWebScaffold({})
|
||||
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
|
||||
const enTripwire = watchConsole(enPage)
|
||||
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
|
||||
try {
|
||||
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await enPage.goto(fresh.baseUrl, { waitUntil: 'load' })
|
||||
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
@@ -315,6 +387,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(enTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await enPage.close()
|
||||
await fresh.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
|
||||
@@ -18,18 +18,17 @@ export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
export const ZH_BROWSER_LOCALE = 'zh-CN'
|
||||
|
||||
/**
|
||||
* Open the standard browser-test page with English selected before client
|
||||
* boot. This keeps role locators and goldens deterministic across localized
|
||||
* component migrations; the scenarios asserting the Chinese surface bypass
|
||||
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
|
||||
* Open the standard browser-test page advertising English before client boot.
|
||||
* This keeps role locators and goldens deterministic while leaving the Host
|
||||
* settings document free to override the provisional browser-derived locale;
|
||||
* scenarios asserting the Chinese surface advertise
|
||||
* {@link ZH_BROWSER_LOCALE} instead.
|
||||
* @param browser - Playwright browser owning the page.
|
||||
* @param height - Viewport height; width is fixed to the lane baseline.
|
||||
* @returns the initialized page.
|
||||
*/
|
||||
export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height } })
|
||||
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
|
||||
return page
|
||||
return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' })
|
||||
}
|
||||
|
||||
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360
|
||||
event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f
|
||||
event-producer-consumer.md: d2e5cd66c2406cf84320fc1c0537d580214dd93d
|
||||
event-producer-consumer.zh.md: 12ac194d68ed6121cc0b2c5e9bf2c7f77944f57b
|
||||
|
||||
@@ -62,7 +62,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| Event string | Dispatchers | Listeners |
|
||||
| --- | --- | --- |
|
||||
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
|
||||
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `credentials/changed` | `runtime` (`emit`) | `ui-models` |
|
||||
| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
|
||||
| `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` |
|
||||
@@ -71,7 +71,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `locale/change` | `locale` (`emit`) | `locale` |
|
||||
| `models/changed` | `runtime` (`emit`) | `ui-models` |
|
||||
| `session/preset-changed` | `runtime` (`emit`) | `ui-command` |
|
||||
| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `slash/input-begin-command` | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | - | `ui-conversation` |
|
||||
| `slash/input-insert-reference` | - | `ui-conversation` |
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
| Event string | Dispatchers | Listeners |
|
||||
| --- | --- | --- |
|
||||
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
|
||||
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `credentials/changed` | `runtime` (`emit`) | `ui-models` |
|
||||
| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
|
||||
| `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` |
|
||||
@@ -73,7 +73,7 @@
|
||||
| `locale/change` | `locale` (`emit`) | `locale` |
|
||||
| `models/changed` | `runtime` (`emit`) | `ui-models` |
|
||||
| `session/preset-changed` | `runtime` (`emit`) | `ui-command` |
|
||||
| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `slash/input-begin-command` | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | - | `ui-conversation` |
|
||||
| `slash/input-insert-reference` | - | `ui-conversation` |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: 6763ca6e84a5cc2e5e776a56e5cfc20b710017aa
|
||||
module-graph.zh.md: 339345b39a8225c34ecf0ba73efff34d4b940046
|
||||
module-graph.md: a81c8fa85eb85d5ec31bfff286fceff511880715
|
||||
module-graph.zh.md: b1ec05f45d1dac545e020f6438f2e2b4d197ebdf
|
||||
|
||||
@@ -382,6 +382,7 @@ flowchart TD
|
||||
pkg_attachment_local --> pkg_attachment
|
||||
pkg_attachment_local --> pkg_invariants
|
||||
pkg_attachment_local --> pkg_paths
|
||||
pkg_client_locale --> pkg_client_connection
|
||||
pkg_client_locale --> pkg_client_runtime
|
||||
pkg_client_locale --> pkg_client_ui_primitives
|
||||
pkg_client_locale --> pkg_client_ui_slots
|
||||
@@ -455,6 +456,7 @@ flowchart TD
|
||||
pkg_client_ui_slash --> pkg_client_ui_primitives
|
||||
pkg_client_ui_slash --> pkg_client_ui_slots
|
||||
pkg_client_ui_slash --> pkg_invariants
|
||||
pkg_client_ui_theme --> pkg_client_connection
|
||||
pkg_client_ui_theme --> pkg_client_locale
|
||||
pkg_client_ui_theme --> pkg_client_runtime
|
||||
pkg_client_ui_theme --> pkg_client_ui_primitives
|
||||
@@ -1050,6 +1052,7 @@ flowchart TD
|
||||
pkg_client_ui_conversation --> pkg_agent
|
||||
pkg_client_ui_conversation --> pkg_attachment
|
||||
pkg_client_ui_conversation --> pkg_brand
|
||||
pkg_client_ui_conversation --> pkg_client_connection
|
||||
pkg_client_ui_conversation --> pkg_client_locale
|
||||
pkg_client_ui_conversation --> pkg_client_runtime
|
||||
pkg_client_ui_conversation --> pkg_client_ui_primitives
|
||||
@@ -1289,7 +1292,7 @@ flowchart TD
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1305,7 +1308,7 @@ flowchart TD
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
|
||||
@@ -1424,7 +1427,7 @@ flowchart TD
|
||||
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
|
||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -384,6 +384,7 @@ flowchart TD
|
||||
pkg_attachment_local --> pkg_attachment
|
||||
pkg_attachment_local --> pkg_invariants
|
||||
pkg_attachment_local --> pkg_paths
|
||||
pkg_client_locale --> pkg_client_connection
|
||||
pkg_client_locale --> pkg_client_runtime
|
||||
pkg_client_locale --> pkg_client_ui_primitives
|
||||
pkg_client_locale --> pkg_client_ui_slots
|
||||
@@ -457,6 +458,7 @@ flowchart TD
|
||||
pkg_client_ui_slash --> pkg_client_ui_primitives
|
||||
pkg_client_ui_slash --> pkg_client_ui_slots
|
||||
pkg_client_ui_slash --> pkg_invariants
|
||||
pkg_client_ui_theme --> pkg_client_connection
|
||||
pkg_client_ui_theme --> pkg_client_locale
|
||||
pkg_client_ui_theme --> pkg_client_runtime
|
||||
pkg_client_ui_theme --> pkg_client_ui_primitives
|
||||
@@ -1052,6 +1054,7 @@ flowchart TD
|
||||
pkg_client_ui_conversation --> pkg_agent
|
||||
pkg_client_ui_conversation --> pkg_attachment
|
||||
pkg_client_ui_conversation --> pkg_brand
|
||||
pkg_client_ui_conversation --> pkg_client_connection
|
||||
pkg_client_ui_conversation --> pkg_client_locale
|
||||
pkg_client_ui_conversation --> pkg_client_runtime
|
||||
pkg_client_ui_conversation --> pkg_client_ui_primitives
|
||||
@@ -1291,7 +1294,7 @@ flowchart TD
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
|
||||
| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1307,7 +1310,7 @@ flowchart TD
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
|
||||
@@ -1426,7 +1429,7 @@ flowchart TD
|
||||
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
|
||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -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: f1efefde4557e1c29c0556f8b670f1534430ab79
|
||||
README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291
|
||||
README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604
|
||||
README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
|
||||
Locale plugin: LocaleService — 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 `zh` 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. 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 → zh → 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
|
||||
locale 插件:LocaleService——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
|
||||
"description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
@@ -47,6 +49,10 @@
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
|
||||
@@ -13,7 +13,12 @@ import type { Context } from 'cordis'
|
||||
import {
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
|
||||
} from '../locale-settings.ts'
|
||||
import { en, zh, type CommonKey } from '../locales/index.ts'
|
||||
import {
|
||||
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
|
||||
@@ -26,6 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
|
||||
|
||||
// The translate currency lives in ui-slots (the render machinery synthesizes
|
||||
// the seat); re-exported here so dictionary owners import one package.
|
||||
@@ -44,9 +50,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
|
||||
/** Locale identifier: the two shipped locales. */
|
||||
export type LocaleId = 'zh' | 'en'
|
||||
|
||||
/** One selectable locale: id plus its self-described display name. */
|
||||
export interface LocaleDefinition {
|
||||
/** Locale id (persisted; the setLocale argument). */
|
||||
@@ -91,9 +94,6 @@ export const COMMON_NS = 'common'
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.locale'
|
||||
|
||||
/** localStorage key holding the persisted locale id. */
|
||||
export const STORAGE_KEY = 'dsh.locale'
|
||||
|
||||
/** The two shipped locales. */
|
||||
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
|
||||
{ id: 'zh', label: '中文' },
|
||||
@@ -116,13 +116,25 @@ export class LocaleService {
|
||||
private snapshot: LocaleSnapshot
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly ctx: Context
|
||||
private readonly host: SettingsScope<LocaleSettings> | undefined
|
||||
/** Browser-derived locale standing wherever no explicit Host selection does. */
|
||||
private readonly provisional: LocaleId
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it).
|
||||
* @param ctx - owning context (change events are emitted on it; the scope
|
||||
* listener is released through ctx.effect on dispose).
|
||||
* @param host - durable preference scope owned by the providing plugin;
|
||||
* absent compositions (standalone dictionary registries) stay process-local.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, host?: SettingsScope<LocaleSettings>) {
|
||||
this.ctx = ctx
|
||||
this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 })
|
||||
this.host = host
|
||||
this.provisional = resolveInitialLocale()
|
||||
this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 })
|
||||
if (host !== undefined) {
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
|
||||
this.adopt(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,16 +167,28 @@ export class LocaleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale — the only preference write entry. Persists the
|
||||
* id and emits `locale/change`.
|
||||
* Switch the active locale — the only user preference write entry.
|
||||
* @param id - a registered locale id; unknown ids throw.
|
||||
*/
|
||||
setLocale(id: string): void {
|
||||
const match = this.snapshot.locales.find(l => l.id === id)
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
if (this.snapshot.active === match.id) return
|
||||
persistPreference(match.id)
|
||||
this.publish(match.id, true)
|
||||
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the scope's accepted durable selection without writing it back; an
|
||||
* absent selection returns to the browser-derived locale.
|
||||
* @param host - the constructor-narrowed scope driving this adoption.
|
||||
*/
|
||||
private adopt(host: SettingsScope<LocaleSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined) return
|
||||
const target = section.preference ?? this.provisional
|
||||
if (this.snapshot.active === target) return
|
||||
this.publish(target, true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,27 +312,11 @@ export class LocaleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale a fresh service opens with: an explicit preference the user
|
||||
* already chose wins over the browser's own language, which in turn wins over
|
||||
* {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language
|
||||
* this app does not ship).
|
||||
* The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit
|
||||
* Host preference may replace this provisional value after plugin activation.
|
||||
*/
|
||||
function resolveInitialLocale(): LocaleId {
|
||||
return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
/** Read the persisted locale id; unknown or unreadable values read as no preference. */
|
||||
function restorePreference(): LocaleId | undefined {
|
||||
// Non-browser runs (node e2e booting the client tree) have no localStorage.
|
||||
if (typeof localStorage === 'undefined') return undefined
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'zh' || stored === 'en') return stored
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode); an unreadable store simply
|
||||
// records no preference, and the browser language decides instead.
|
||||
}
|
||||
return undefined
|
||||
return detectBrowserLocale() ?? FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,8 +333,7 @@ function detectBrowserLocale(): LocaleId | undefined {
|
||||
/* oxlint-disable-next-line typescript/no-unnecessary-condition --
|
||||
* The DOM lib types `languages` as always present; embedders and older
|
||||
* WebViews ship a Navigator without it, and spreading undefined would
|
||||
* throw at boot. Same environment-boundary distrust as the localStorage
|
||||
* guards below. */
|
||||
* throw at boot. */
|
||||
for (const tag of [...(navigator.languages ?? []), navigator.language]) {
|
||||
const primary = tag.toLowerCase().split('-')[0]
|
||||
const match = LOCALES.find(locale => locale.id === primary)
|
||||
@@ -335,19 +342,8 @@ function detectBrowserLocale(): LocaleId | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
|
||||
function persistPreference(id: LocaleId): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode / quota); the preference simply
|
||||
// does not survive the session.
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the slot registry (the feature registers its own settings row). */
|
||||
export const inject = ['slots']
|
||||
/** Required services: slot registration plus the settings transport. */
|
||||
export const inject = ['slots', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the locale service with base dictionaries and
|
||||
@@ -356,7 +352,8 @@ export const inject = ['slots']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
|
||||
const locale = new LocaleService(ctx, host)
|
||||
locale.register(COMMON_NS, { zh, en })
|
||||
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
||||
ctx.provide('locale', locale)
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
/** Host registration for the browser locale preference. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the locale plugin. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from 'cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts'
|
||||
|
||||
export {
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
|
||||
type LocaleId, type LocaleSettings,
|
||||
} from './locale-settings.ts'
|
||||
|
||||
/**
|
||||
* Register the durable locale section when a settings provider exists.
|
||||
* @param ctx - Host context whose optional settings service owns the section.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(LOCALE_SETTINGS_NAMESPACE),
|
||||
LocaleSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
26
packages/client/locale/src/locale-settings.ts
Normal file
26
packages/client/locale/src/locale-settings.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Locale preference stored in the Host user-settings document. */
|
||||
|
||||
import z from 'schemastery'
|
||||
|
||||
/** Settings namespace owned by the locale plugin. */
|
||||
export const LOCALE_SETTINGS_NAMESPACE = 'locale'
|
||||
|
||||
/** Field carrying an explicit locale selection; absence delegates to the browser. */
|
||||
export const LOCALE_PREFERENCE_FIELD = 'preference'
|
||||
|
||||
/** Locale identifiers shipped by the browser client. */
|
||||
export const LOCALE_IDS = ['zh', 'en'] as const
|
||||
|
||||
/** Shipped locale identifier. */
|
||||
export type LocaleId = typeof LOCALE_IDS[number]
|
||||
|
||||
/** Durable locale section shared by the Host schema and the browser scope. */
|
||||
export interface LocaleSettings {
|
||||
/** Explicit locale selection; absence delegates to the browser. */
|
||||
preference?: LocaleId
|
||||
}
|
||||
|
||||
/** Durable locale schema; also the wire envelope the browser scope validates against. */
|
||||
export const LocaleSettingsSchema: z<LocaleSettings> = z.object({
|
||||
[LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false),
|
||||
})
|
||||
@@ -4,8 +4,11 @@
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import {
|
||||
apply, inject, SETTINGS_NS,
|
||||
} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts'
|
||||
import { LanguageRow } from '../src/client/LanguageRow.tsx'
|
||||
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
@@ -14,7 +17,36 @@ const SLOT = 'settings.general.item'
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
let preference: string | undefined
|
||||
let revision = 0
|
||||
const namespace = () => ({
|
||||
ns: LOCALE_SETTINGS_NAMESPACE,
|
||||
schema: LocaleSettingsSchema.toJSON(),
|
||||
value: preference === undefined ? {} : { preference },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision,
|
||||
})
|
||||
const describe = vi.fn(async () => ({
|
||||
rpcId: 'locale-describe' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
|
||||
},
|
||||
}))
|
||||
const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
|
||||
preference = request.ops[0]!.value
|
||||
revision += 1
|
||||
return {
|
||||
rpcId: 'locale-mutate' as never,
|
||||
result: { ok: true as const, value: namespace() },
|
||||
}
|
||||
})
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
|
||||
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the General item slot from root. */
|
||||
@@ -47,7 +79,7 @@ describe('locale apply', () => {
|
||||
})
|
||||
|
||||
it('declares the slot service', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
})
|
||||
|
||||
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
|
||||
@@ -91,6 +123,23 @@ describe('locale apply', () => {
|
||||
expect(locale.getLocale().active).toBe('zh')
|
||||
expect(instance.getSnapshot().active).toBe('zh')
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
|
||||
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('en')
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const locale = b.ctx.get('locale') as LocaleService
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
b.setHostPreference(undefined)
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
|
||||
b.setHostPreference('en')
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
expect(b.describe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
30
packages/client/locale/tests/host.spec.ts
Normal file
30
packages/client/locale/tests/host.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
LOCALE_SETTINGS_NAMESPACE, apply,
|
||||
} from '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('locale host', () => {
|
||||
it('registers an optional explicit locale preference with the Host settings lifecycle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE)
|
||||
expect(ctx.settings.get(ns)).toEqual({})
|
||||
await ctx.settings.update(ns, { preference: 'en' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ preference: 'en' })
|
||||
await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
})
|
||||
@@ -14,16 +14,16 @@ describe('invariant companion', () => {
|
||||
await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
it('node-half apply tolerates a Host without settings', () => {
|
||||
nodeApply(new Context())
|
||||
})
|
||||
|
||||
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
|
||||
// The feature registers its own Language settings row, hence the slots edge.
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const locale = ctx.get('locale')
|
||||
expect(locale).toBeInstanceOf(LocaleService)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
|
||||
const make = (host?: StubSettingsScope<LocaleSettings>): {
|
||||
ctx: Context
|
||||
svc: LocaleService
|
||||
events: LocaleSnapshot[]
|
||||
} => {
|
||||
const ctx = new Context()
|
||||
const events: LocaleSnapshot[] = []
|
||||
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
|
||||
return { ctx, svc: new LocaleService(ctx), events }
|
||||
return { ctx, svc: new LocaleService(ctx, host?.scope), events }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +29,6 @@ const stubLanguages = (...tags: string[]): void => {
|
||||
|
||||
describe('LocaleService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// A Chinese browser is the baseline these specs assert their zh state on.
|
||||
stubLanguages('zh-CN')
|
||||
})
|
||||
@@ -132,16 +136,25 @@ describe('LocaleService', () => {
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const { svc, events } = make()
|
||||
it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(svc.getLocale())
|
||||
expect(events[0]!.revision).toBe(1)
|
||||
svc.setLocale('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('setLocale without a host scope stays process-local', () => {
|
||||
const { svc, events } = make()
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws on unknown locale ids', () => {
|
||||
@@ -149,14 +162,37 @@ describe('LocaleService', () => {
|
||||
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
|
||||
})
|
||||
|
||||
it('restores a persisted locale over the browser language, and garbage reads as no preference', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'en')
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
localStorage.setItem(STORAGE_KEY, 'fr')
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
it('adopts a Host preference over the browser language without writing it back', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { preference: 'en' }, revision: 2 })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => {
|
||||
it('an absent Host preference returns to the browser-derived locale', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
host.publish({ value: {}, revision: 2 })
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('adopts a section already standing at construction and releases its subscription on dispose', async () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
const { ctx, svc } = make(host)
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => {
|
||||
stubLanguages('en-GB', 'zh-CN')
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
stubLanguages('zh-Hant-TW')
|
||||
@@ -176,8 +212,7 @@ describe('LocaleService', () => {
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
// Node exposes its own global navigator; without a window it must not
|
||||
// reach the resolution at all.
|
||||
@@ -188,12 +223,11 @@ describe('LocaleService', () => {
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
})
|
||||
|
||||
it('keeps the browser language out of the way once a preference exists', () => {
|
||||
it('lets an explicit in-process preference replace the browser-derived value', () => {
|
||||
stubLanguages('en-US')
|
||||
const { svc } = make()
|
||||
svc.setLocale('zh')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('zh')
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('exposes the two shipped locales with self-described labels', () => {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -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/runtime/README.md
|
||||
README.md: 753d1de796ba8ff20217d423555710429e9b7a75
|
||||
README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5
|
||||
README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412
|
||||
README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
|
||||
## Slot declaration injection
|
||||
|
||||
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
|
||||
## Slot 声明注入
|
||||
|
||||
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
@@ -61,7 +62,8 @@
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -43,6 +43,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
|
||||
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
|
||||
export { resolveWorkspacePath } from './workspaces/path.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
|
||||
261
packages/client/runtime/src/client/settings-scope.ts
Normal file
261
packages/client/runtime/src/client/settings-scope.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/** Host-backed settings-namespace synchronization for browser plugins. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConnectionHandle, IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
|
||||
|
||||
/** Client-side sync state of one settings namespace. */
|
||||
export interface SettingsScopeSnapshot<T> {
|
||||
/**
|
||||
* `loading` until the first accepted section, `ready` while one stands, and
|
||||
* `unavailable` when the namespace is not exposed to this client or the
|
||||
* connection keeps preferences process-local (memory mode).
|
||||
*/
|
||||
status: 'loading' | 'ready' | 'unavailable'
|
||||
/** Last accepted schema-resolved section; undefined before the first acceptance. */
|
||||
value: T | undefined
|
||||
/** Namespace revision fencing the next write; undefined before the first Host view. */
|
||||
revision: number | undefined
|
||||
/** Whether the Host document accepts writes; memory mode never does. */
|
||||
writable: boolean
|
||||
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
|
||||
mode: 'host' | 'memory'
|
||||
}
|
||||
|
||||
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
|
||||
export interface SettingsScopeSpec<T> {
|
||||
/** Settings namespace registered by the owning Host plugin. */
|
||||
namespace: string
|
||||
/**
|
||||
* Narrow one wire section; undefined keeps the last accepted value. The
|
||||
* default validates the section against the namespace's own serialized wire
|
||||
* schema, so domains add a decoder only to narrow beyond that schema.
|
||||
*/
|
||||
decode?: (section: unknown) => T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive owner handle over one namespace's durable section — the browser
|
||||
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
|
||||
* and observe the snapshot and route explicit user choices through `set`.
|
||||
*/
|
||||
export interface SettingsScope<T> {
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T>
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
* publish; a rejected or failed latest write reloads Host state instead.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
}
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
/**
|
||||
* Serializes one namespace's Host reads and writes behind a snapshot store.
|
||||
* Reads never block plugin activation; writes carry the latest known
|
||||
* namespace revision and teardown waits for the operation already crossing
|
||||
* the wire.
|
||||
*/
|
||||
export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
private readGeneration = 0
|
||||
private writeGeneration = 0
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param spec - namespace identity and optional narrowing decoder.
|
||||
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: SettingsFace,
|
||||
private readonly spec: SettingsScopeSpec<T>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
|
||||
status: persistence === 'host' ? 'loading' : 'unavailable',
|
||||
value: undefined,
|
||||
revision: undefined,
|
||||
writable: false,
|
||||
mode: persistence,
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T> {
|
||||
return this.store.getSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.store.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Host refresh; a newer read or user write suppresses stale publication.
|
||||
* @returns settlement after the queued read completes or is skipped.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
const generation = ++this.readGeneration
|
||||
return this.enqueue(() => this.read(generation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one field write; see {@link SettingsScope.set} for the ordering,
|
||||
* revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void> {
|
||||
this.readGeneration += 1
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
const revision = this.getSnapshot().revision
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
|
||||
try {
|
||||
response = await this.api.settings.mutate({
|
||||
ns: this.spec.namespace,
|
||||
ops: [{ op: 'set', path: [field], value }],
|
||||
...(revision === undefined ? {} : { expectedRevision: revision }),
|
||||
})
|
||||
} catch (_settingsWriteFailure) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
this.accept(response.result.value, generation === this.writeGeneration)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queued operations and wait for the current wire call to settle.
|
||||
* @returns settlement after the controller reaches quiescence.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.readGeneration += 1
|
||||
this.writeGeneration += 1
|
||||
await this.tail
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
|
||||
const task = this.tail.then(async () => {
|
||||
if (this.disposed) return
|
||||
await operation()
|
||||
})
|
||||
// The returned task carries its own settlement to the caller; the queue
|
||||
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
|
||||
this.tail = task.catch(() => {})
|
||||
return task
|
||||
}
|
||||
|
||||
private async read(generation: number): Promise<void> {
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || this.disposed) return
|
||||
const { namespaces, writable } = response.result.value
|
||||
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
const publish = generation === this.readGeneration
|
||||
if (view === undefined) {
|
||||
if (publish) {
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'unavailable'
|
||||
draft.writable = writable
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
this.accept(view, publish, writable)
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
|
||||
const decoded = publish ? this.decode(view) : undefined
|
||||
this.store.update((draft) => {
|
||||
draft.revision = view.revision
|
||||
if (writable !== undefined) draft.writable = writable
|
||||
if (decoded === undefined) return
|
||||
draft.status = 'ready'
|
||||
draft.value = decoded
|
||||
})
|
||||
}
|
||||
|
||||
private decode(view: SettingsNamespaceView): T | undefined {
|
||||
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
|
||||
// Sections are plain objects by construction; schemastery alone would
|
||||
// resolve null or an array through object defaults instead of refusing.
|
||||
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
|
||||
let failure: string | undefined
|
||||
try {
|
||||
failure = validateDraft(rehydrateSchema(view.schema), view.value)
|
||||
} catch (_malformedSchemaEnvelope) {
|
||||
// A schema envelope this client cannot rehydrate vouches for no section;
|
||||
// the value is treated exactly like a schema-invalid one.
|
||||
return undefined
|
||||
}
|
||||
return failure === undefined ? view.value as T : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope to settings and connection invalidations on the
|
||||
* caller's plugin lifecycle. Listeners exist before the initial background
|
||||
* read starts, so activation never blocks on the settings transport.
|
||||
* @param ctx - owning browser plugin context.
|
||||
* @param spec - domain-owned namespace contract.
|
||||
* @returns the bound scope consumed by the domain's services and rows.
|
||||
*/
|
||||
export function bindSettingsScope<T>(
|
||||
ctx: Context,
|
||||
spec: SettingsScopeSpec<T>,
|
||||
): SettingsScope<T> {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new SettingsScopeController<T>(
|
||||
connection.api,
|
||||
spec,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `runtime: ${spec.namespace} settings scope`)
|
||||
return controller
|
||||
}
|
||||
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
bindSettingsScope, SettingsScopeController, type SettingsScope,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
const ENVELOPE = z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}).toJSON()
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function rejected<T>(): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `scope-${rpc++}` as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function view(value: unknown, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'ui-test',
|
||||
schema: ENVELOPE,
|
||||
value,
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function described(value: unknown, revision = 0) {
|
||||
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** Record each distinct published section, starting from the current one. */
|
||||
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
|
||||
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
|
||||
scope.subscribe(() => {
|
||||
const value = scope.getSnapshot().value
|
||||
if (value !== seen[seen.length - 1]) seen.push(value)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('SettingsScopeController', () => {
|
||||
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
})
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
|
||||
.mockResolvedValueOnce(described(null, 5))
|
||||
.mockResolvedValueOnce(described('scalar', 6))
|
||||
.mockResolvedValueOnce(described(['queue'], 7))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const good = trackValues(scope)
|
||||
for (let i = 0; i < 7; i++) await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 7,
|
||||
})
|
||||
expect(good).toEqual([undefined, { preference: 'dark' }])
|
||||
})
|
||||
|
||||
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
|
||||
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const statuses: string[] = []
|
||||
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
|
||||
const stale = scope.load()
|
||||
const fresh = scope.load()
|
||||
await Promise.all([stale, fresh])
|
||||
expect(statuses).not.toContain('unavailable')
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
|
||||
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot().status).toBe('ready')
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('applies a custom decode override in place of the wire schema', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.load()
|
||||
const dark = scope.set('preference', 'dark')
|
||||
const light = scope.set('preference', 'light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
first.resolve(ok(view({ preference: 'dark' }, 5)))
|
||||
await Promise.all([dark, light])
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
|
||||
expect(mutate).toHaveBeenNthCalledWith(1, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
expectedRevision: 4,
|
||||
})
|
||||
expect(mutate).toHaveBeenNthCalledWith(2, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
|
||||
expectedRevision: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.set('preference', 'system')
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await Promise.all([
|
||||
scope.set('preference', 'dark'),
|
||||
scope.set('preference', 'system'),
|
||||
scope.set('preference', 'light'),
|
||||
])
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a subscriber throws', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
let thrown = false
|
||||
scope.subscribe(() => {
|
||||
if (thrown) return
|
||||
thrown = true
|
||||
throw new Error('subscriber failed')
|
||||
})
|
||||
await expect(scope.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(scope.load()).resolves.toBeUndefined()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const describeCall = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
const dark = scope.set('preference', 'dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
const light = scope.set('preference', 'light')
|
||||
let stopped = false
|
||||
const stop = scope.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await scope.set('preference', 'system')
|
||||
await scope.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('keeps a remote browser in memory mode without Host calls', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
'memory',
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
|
||||
})
|
||||
await scope.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindSettingsScope', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.emit('settings/changed', 'unrelated')
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
})
|
||||
await fiber.dispose()
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory mode without starting a settings read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
|
||||
await fiber.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
|
||||
@@ -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/test-runtime/README.md
|
||||
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
|
||||
README.zh.md: 1df28f7b25c35333e91476e10480c22a728cdab3
|
||||
README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0
|
||||
README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
|
||||
|
||||
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
|
||||
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
|
||||
|
||||
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。功能套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
|
||||
|
||||
替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
|
||||
替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`;`stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
|
||||
|
||||
局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import type { Stabilizer } from './fixtures.ts'
|
||||
|
||||
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
|
||||
export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { stubSettingsScope } from './settings-scope.ts'
|
||||
export type { StubSettingsScope } from './settings-scope.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
|
||||
48
packages/client/test-runtime/src/settings-scope.ts
Normal file
48
packages/client/test-runtime/src/settings-scope.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Test double for the client settings-scope seam. */
|
||||
import { vi } from 'vitest'
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Handle over one stubbed scope: the scope, its write spy, and publication controls. */
|
||||
export interface StubSettingsScope<T> {
|
||||
/** The scope face handed to the service under test. */
|
||||
scope: SettingsScope<T>
|
||||
/** Spy behind `scope.set`; resolves immediately. */
|
||||
set: ReturnType<typeof vi.fn>
|
||||
/** @returns how many listeners are currently subscribed (disposal assertions). */
|
||||
listenerCount(): number
|
||||
/**
|
||||
* Replace part of the snapshot and notify subscribers, as a Host
|
||||
* acceptance would.
|
||||
* @param next - snapshot fields to replace.
|
||||
*/
|
||||
publish(next: Partial<SettingsScopeSnapshot<T>>): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory settings scope for service specs: starts in the host
|
||||
* loading state, records writes, and lets the test publish Host acceptances.
|
||||
* @returns the stub handle.
|
||||
*/
|
||||
export function stubSettingsScope<T>(): StubSettingsScope<T> {
|
||||
let snapshot: SettingsScopeSnapshot<T> = {
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
}
|
||||
const listeners = new Set<() => void>()
|
||||
const set = vi.fn(() => Promise.resolve())
|
||||
return {
|
||||
scope: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
set,
|
||||
},
|
||||
set,
|
||||
listenerCount: () => listeners.size,
|
||||
publish: (next) => {
|
||||
snapshot = { ...snapshot, ...next }
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 23dbb1a5492afefe9064d86429f21b926f594a53
|
||||
README.zh.md: 1d27beed9ca426096692b5710cc8d77396499449
|
||||
README.md: 7c4855a75abb982ff55b903808d6a65c42cbc91c
|
||||
README.zh.md: c3a5d7beb2e90289f4340fc251fd3527370e3e23
|
||||
|
||||
@@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one
|
||||
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
|
||||
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
|
||||
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-conversation",
|
||||
"description": "Conversation domain: skeleton, ordered chat flow, composer, and details host",
|
||||
"description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
@@ -36,12 +37,15 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-attachment": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
@@ -60,6 +64,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { resolveWorkspacePath, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
@@ -33,6 +35,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import { registerConversationNodes } from './conversation-nodes/register.ts'
|
||||
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
@@ -43,7 +46,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = [
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale',
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
|
||||
'conversationEvents', 'conversationViews',
|
||||
]
|
||||
|
||||
@@ -124,7 +127,9 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy()
|
||||
const submissionPolicy = new ComposerSubmissionPolicy(
|
||||
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
|
||||
)
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Composer submission vocabulary shared by the input and settings domains. */
|
||||
|
||||
/** Delivery mode requested for one ordinary composer message. */
|
||||
export type InputSubmitMode = 'queue' | 'steer'
|
||||
import type { BusyEnterBehavior } from '../../submission-settings.ts'
|
||||
|
||||
/** Configurable meaning of plain Enter while the addressed agent is busy. */
|
||||
export type BusyEnterBehavior = InputSubmitMode
|
||||
export type { BusyEnterBehavior } from '../../submission-settings.ts'
|
||||
|
||||
/** Delivery mode requested for one ordinary composer message. */
|
||||
export type InputSubmitMode = BusyEnterBehavior
|
||||
|
||||
/** Keyboard gesture whose delivery mode the submission policy resolves. */
|
||||
export type ComposerSubmitGesture = 'enter' | 'accelerated'
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
/**
|
||||
* Browser-local Composer submission policy. It owns the persisted busy-Enter
|
||||
* Composer submission policy. It owns the live busy-Enter
|
||||
* preference and resolves keyboard gestures into queue/steer delivery modes;
|
||||
* Host and Agent keep the actual delivery-window authority.
|
||||
*/
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSnapshotStore, type SettingsScope, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
|
||||
} from '../contract/composer-submission.ts'
|
||||
import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
|
||||
import type { ConversationSettings } from '../../submission-settings.ts'
|
||||
|
||||
/** localStorage key holding the busy-Enter preference. */
|
||||
export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter'
|
||||
|
||||
/** Default preserves Enter-as-Queue for running conversations. */
|
||||
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
|
||||
export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts'
|
||||
|
||||
/**
|
||||
* Persisted policy used by both the composer inject face and its Settings row.
|
||||
* Busy-Enter policy used by both the composer inject face and its Settings row.
|
||||
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
|
||||
* submission into the next waking Queue item.
|
||||
*/
|
||||
export class ComposerSubmissionPolicy {
|
||||
/** Reactive preference source for the Settings row. */
|
||||
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
|
||||
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR)
|
||||
private readonly host: SettingsScope<ConversationSettings> | undefined
|
||||
|
||||
/**
|
||||
* @param host - durable preference scope owned by the providing plugin;
|
||||
* absent compositions stay process-local. The adoption subscription shares
|
||||
* the scope's plugin lifetime — a disposed scope never publishes again, so
|
||||
* the policy needs no release hook.
|
||||
*/
|
||||
constructor(host?: SettingsScope<ConversationSettings>) {
|
||||
this.host = host
|
||||
if (host !== undefined) {
|
||||
host.subscribe(() => { this.adopt(host) })
|
||||
this.adopt(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one keyboard gesture without changing state.
|
||||
@@ -42,36 +57,23 @@ export class ComposerSubmissionPolicy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change and persist the plain-Enter behavior used during busy state.
|
||||
* Change the plain-Enter behavior used during busy state; the live value
|
||||
* publishes before the durable write starts.
|
||||
* @param behavior - Queue or Steer.
|
||||
*/
|
||||
setBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (this.busyEnter.getSnapshot() === behavior) return
|
||||
this.busyEnter.set(behavior)
|
||||
persistBusyEnter(behavior)
|
||||
void this.host?.set(BUSY_ENTER_FIELD, behavior)
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a valid preference; unavailable or corrupt storage uses Queue. */
|
||||
function restoreBusyEnter(): BusyEnterBehavior {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
let stored: string | null
|
||||
try {
|
||||
stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY)
|
||||
} catch {
|
||||
// Storage access can fail in privacy modes; the default remains usable.
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
if (stored === 'queue' || stored === 'steer') return stored
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
|
||||
/** Persist a preference when browser storage is available. */
|
||||
function persistBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior)
|
||||
} catch {
|
||||
// A storage failure makes the preference session-only; input stays usable.
|
||||
/**
|
||||
* Adopt the scope's accepted durable behavior without writing it back.
|
||||
* @param host - the constructor-narrowed scope driving this adoption.
|
||||
*/
|
||||
private adopt(host: SettingsScope<ConversationSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return
|
||||
this.busyEnter.set(section.busyEnter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Host loader entry for the browser-only conversation plugin. */
|
||||
/** Host registration for browser conversation preferences. */
|
||||
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from 'cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts'
|
||||
|
||||
export {
|
||||
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
|
||||
DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings,
|
||||
} from './submission-settings.ts'
|
||||
|
||||
/**
|
||||
* Register the durable conversation section when a settings provider exists.
|
||||
* @param ctx - Host context whose optional settings service owns the section.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE),
|
||||
ConversationSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
29
packages/client/ui-conversation/src/submission-settings.ts
Normal file
29
packages/client/ui-conversation/src/submission-settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** Busy-Enter preference stored in the Host user-settings document. */
|
||||
|
||||
import z from 'schemastery'
|
||||
|
||||
/** Settings namespace owned by the conversation plugin. */
|
||||
export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation'
|
||||
|
||||
/** Field carrying the delivery mode for plain Enter while an agent is busy. */
|
||||
export const BUSY_ENTER_FIELD = 'busyEnter'
|
||||
|
||||
/** Busy-Enter behaviors accepted at settings and input boundaries. */
|
||||
export const BUSY_ENTER_BEHAVIORS = ['queue', 'steer'] as const
|
||||
|
||||
/** Configurable meaning of plain Enter while the addressed agent is busy. */
|
||||
export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number]
|
||||
|
||||
/** Default preserves Enter-as-Queue for running conversations. */
|
||||
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
|
||||
|
||||
/** Durable conversation section shared by the Host schema and the browser scope. */
|
||||
export interface ConversationSettings {
|
||||
/** Delivery mode for plain Enter while the addressed agent is busy. */
|
||||
busyEnter: BusyEnterBehavior
|
||||
}
|
||||
|
||||
/** Durable conversation schema; also the wire envelope the browser scope validates against. */
|
||||
export const ConversationSettingsSchema: z<ConversationSettings> = z.object({
|
||||
[BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR),
|
||||
})
|
||||
@@ -47,6 +47,7 @@ function sessionFakeFor() {
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
const sessionFake = sessionFakeFor()
|
||||
await runtime.sessions.add({
|
||||
id: ROOT,
|
||||
|
||||
@@ -50,6 +50,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
|
||||
async function bench(opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -74,6 +75,7 @@ async function bench(opts?: { blank?: boolean }) {
|
||||
describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -90,6 +92,7 @@ describe('resident composer', () => {
|
||||
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
@@ -153,6 +156,7 @@ describe('resident composer', () => {
|
||||
describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -21,6 +21,7 @@ const CHILD = 'child-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
|
||||
await runtime.sessions.add(
|
||||
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: the node-half empty apply
|
||||
// and AssistantMarkdown reasoning/unknown block arms.
|
||||
// Branch tails the acceptance specs do not reach: the node-half apply
|
||||
// without a settings service and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
@@ -16,8 +17,8 @@ const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
it('node-half apply tolerates a Host without settings', () => {
|
||||
expect(() => { nodeApply(new Context()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
|
||||
30
packages/client/ui-conversation/tests/host.spec.ts
Normal file
30
packages/client/ui-conversation/tests/host.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-conversation host', () => {
|
||||
it('registers, validates, and disposes the durable busy-Enter preference', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const ns = settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE)
|
||||
expect(ctx.settings.get(ns)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR })
|
||||
await ctx.settings.update(ns, { busyEnter: 'steer' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ busyEnter: 'steer' })
|
||||
await expect(ctx.settings.update(ns, { busyEnter: 'invalid' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import {
|
||||
BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
|
||||
ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR,
|
||||
} from '../src/client/input/submission-policy.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
import type { ConversationSettings } from '../src/submission-settings.ts'
|
||||
|
||||
describe('ComposerSubmissionPolicy', () => {
|
||||
it('defaults to Queue and only applies the preference while running', () => {
|
||||
@@ -28,40 +25,42 @@ describe('ComposerSubmissionPolicy', () => {
|
||||
expect(policy.resolve(true, 'accelerated', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'enter', true)).toBe('queue')
|
||||
expect(policy.resolve(false, 'accelerated', true)).toBe('queue')
|
||||
expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer')
|
||||
})
|
||||
|
||||
it('restores a valid preference and leaves an identical write untouched', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer')
|
||||
const write = vi.spyOn(Storage.prototype, 'setItem')
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
it('writes an explicit change through the scope after publishing it locally', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
const observed: string[] = []
|
||||
let liveBehavior = (): string => 'unconstructed'
|
||||
const scope: typeof host.scope = {
|
||||
...host.scope,
|
||||
set: (field, value) => {
|
||||
observed.push(`${field}=${String(value)}:${liveBehavior()}`)
|
||||
return host.scope.set(field, value)
|
||||
},
|
||||
}
|
||||
const policy = new ComposerSubmissionPolicy(scope)
|
||||
liveBehavior = () => policy.busyEnter.getSnapshot()
|
||||
policy.setBusyEnter('steer')
|
||||
expect(observed).toEqual(['busyEnter=steer:steer'])
|
||||
expect(host.set).toHaveBeenCalledWith('busyEnter', 'steer')
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adopts a Host preference without writing it back and leaves an identical write untouched', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
const policy = new ComposerSubmissionPolicy(host.scope)
|
||||
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
policy.setBusyEnter('steer')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
write.mockRestore()
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { busyEnter: 'steer' }, revision: 2 })
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
|
||||
it('uses Queue for invalid, unavailable, or unreadable storage', () => {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid')
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => { throw new Error('blocked') },
|
||||
setItem: vi.fn(),
|
||||
})
|
||||
expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue')
|
||||
})
|
||||
|
||||
it('keeps the in-memory preference when persistence throws', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('quota') },
|
||||
})
|
||||
const policy = new ComposerSubmissionPolicy()
|
||||
policy.setBusyEnter('steer')
|
||||
it('adopts a section already standing at construction', () => {
|
||||
const host = stubSettingsScope<ConversationSettings>()
|
||||
host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true })
|
||||
const policy = new ComposerSubmissionPolicy(host.scope)
|
||||
expect(policy.busyEnter.getSnapshot()).toBe('steer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
@@ -65,6 +68,9 @@
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
@@ -340,6 +340,7 @@ describe('plugin registration', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -22,9 +22,10 @@ beforeEach(() => {
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
// Theme now injects ['slots', 'locale'] (it registers its Appearance
|
||||
// settings row); seat a real locale service so the theme fiber activates.
|
||||
// Theme registers its Appearance settings row and requires the connection
|
||||
// seam for persistence; model this bench as a remote, memory-only browser.
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
|
||||
await slotsFiber.await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import {
|
||||
SubagentCatalogAction, type SubagentCatalogInjected,
|
||||
} from '../src/client/SubagentCatalogAction.tsx'
|
||||
@@ -86,8 +86,9 @@ async function fullBench(sessions: SessionSummary[]) {
|
||||
const face = sessionsWith(sessions)
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', face)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { source: captured!, face, ctx }
|
||||
}
|
||||
@@ -121,8 +122,9 @@ describe('apply', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlashService).await()
|
||||
ctx.provide('sessions', sessionsWith(FAMILY))
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
|
||||
@@ -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-theme/README.md
|
||||
README.md: 2d576cc16b0b8fef453ad54cacd9cb195dc75e7a
|
||||
README.zh.md: b1f17c65749a94f1f48b5f7af9c8b18a5d85eb76
|
||||
README.md: 7b81728b02c0dadcbd134a4bca20c49721a3dd52
|
||||
README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens).
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
@@ -33,6 +34,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
@@ -64,6 +66,8 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ThemePreference } from './index.ts'
|
||||
import type { ThemePreference } from '../theme-settings.ts'
|
||||
import type { ThemeKey } from './locales.ts'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type { createAppearanceRowStore } from './settings-store.ts'
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
/**
|
||||
* Browser theme registry over the `--dsw-*` token stylesheets. The service
|
||||
* owns the theme preference (light/dark/system), resolves `system` through
|
||||
* owns the live theme preference (light/dark/system), resolves `system` through
|
||||
* `prefers-color-scheme`, and publishes immutable snapshots; it never touches
|
||||
* the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin
|
||||
* also registers the Appearance preference row into the settings General
|
||||
* section — the theme feature owns its own settings surface.
|
||||
* the DOM — ui-layout's presenter consumes the resolved snapshot. The Host
|
||||
* settings scope loads and stores the preference in the user-settings
|
||||
* document. The plugin also registers the Appearance preference row into the
|
||||
* settings General section — the theme feature owns its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
import { AppearanceRow } from './AppearanceRow.tsx'
|
||||
import { createAppearanceRowStore } from './settings-store.ts'
|
||||
import { en, zh, type ThemeKey } from './locales.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from '../theme-settings.ts'
|
||||
|
||||
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
export type { AppearanceRowState } from './settings-store.ts'
|
||||
export type { ThemeKey } from './locales.ts'
|
||||
export type { ThemePreference, ThemeSettings } from '../theme-settings.ts'
|
||||
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.theme'
|
||||
@@ -33,9 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
|
||||
export type ThemeTokens = Record<string, string>
|
||||
|
||||
/** Theme preference: a concrete theme id or follow-the-OS. */
|
||||
export type ThemePreference = 'light' | 'dark' | 'system'
|
||||
|
||||
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */
|
||||
export interface ThemeDefinition {
|
||||
/** Theme id (the setTheme argument for concrete themes). */
|
||||
@@ -76,12 +81,6 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** localStorage key holding the persisted theme preference. */
|
||||
export const STORAGE_KEY = 'dsh.theme'
|
||||
|
||||
/** Default preference when nothing (or garbage) is persisted. */
|
||||
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
|
||||
const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
||||
Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }),
|
||||
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
|
||||
@@ -98,6 +97,7 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
||||
*/
|
||||
export class ThemeService {
|
||||
private readonly ctx: Context
|
||||
private readonly host: SettingsScope<ThemeSettings>
|
||||
private themes: ThemeDefinition[] = [...BUILTIN_THEMES]
|
||||
private preference: ThemePreference
|
||||
private revision = 0
|
||||
@@ -106,11 +106,13 @@ export class ThemeService {
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it; the
|
||||
* media-query listener is released through ctx.effect on dispose).
|
||||
* media-query and scope listeners are released through ctx.effect on dispose).
|
||||
* @param host - durable preference scope owned by the same plugin.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, host: SettingsScope<ThemeSettings>) {
|
||||
this.ctx = ctx
|
||||
this.preference = restorePreference()
|
||||
this.host = host
|
||||
this.preference = DEFAULT_PREFERENCE
|
||||
// Non-browser runs (node e2e booting the client tree) have no matchMedia.
|
||||
this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)')
|
||||
this.snapshot = this.buildSnapshot()
|
||||
@@ -125,6 +127,8 @@ export class ThemeService {
|
||||
return () => { media.removeEventListener('change', onChange) }
|
||||
}, 'ui-theme: prefers-color-scheme listener')
|
||||
}
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption')
|
||||
this.adopt()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,8 +140,9 @@ export class ThemeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the theme preference — the only preference write entry. Persists
|
||||
* the preference and emits `theme/change`.
|
||||
* Switch the theme preference — the only user preference write entry.
|
||||
* Built-in preferences are written through the settings scope and every
|
||||
* accepted value emits `theme/change`.
|
||||
* @param id - a registered theme id or `system`; unknown ids throw.
|
||||
*/
|
||||
setTheme(id: string): void {
|
||||
@@ -146,7 +151,15 @@ export class ThemeService {
|
||||
}
|
||||
if (this.preference === id) return
|
||||
this.preference = id as ThemePreference
|
||||
persistPreference(this.preference)
|
||||
if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id)
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Adopt the scope's accepted durable preference without writing it back. */
|
||||
private adopt(): void {
|
||||
const section = this.host.getSnapshot().value
|
||||
if (section === undefined || this.preference === section.preference) return
|
||||
this.preference = section.preference
|
||||
this.publish()
|
||||
}
|
||||
|
||||
@@ -170,7 +183,6 @@ export class ThemeService {
|
||||
this.themes = this.themes.filter(t => t.id !== definition.id)
|
||||
if (this.preference === definition.id) {
|
||||
this.preference = DEFAULT_PREFERENCE
|
||||
persistPreference(this.preference)
|
||||
}
|
||||
this.publish()
|
||||
}
|
||||
@@ -200,32 +212,8 @@ export class ThemeService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the persisted preference; unknown or unreadable values fall back to the default. */
|
||||
function restorePreference(): ThemePreference {
|
||||
// Non-browser runs (node e2e booting the client tree) have no localStorage.
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode); the default below covers it.
|
||||
}
|
||||
return DEFAULT_PREFERENCE
|
||||
}
|
||||
|
||||
/** Persist the preference; storage failures are non-fatal (preference resets next boot). */
|
||||
function persistPreference(preference: ThemePreference): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, preference)
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode / quota); the preference simply
|
||||
// does not survive the session.
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: slots + locale (the feature registers its own settings row with localized copy). */
|
||||
export const inject = ['slots', 'locale']
|
||||
/** Required services: settings transport plus slots/locale for the Appearance row. */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the theme service and register the
|
||||
@@ -234,7 +222,8 @@ export const inject = ['slots', 'locale']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const theme = new ThemeService(ctx)
|
||||
const host = bindSettingsScope<ThemeSettings>(ctx, { namespace: THEME_SETTINGS_NAMESPACE })
|
||||
const theme = new ThemeService(ctx, host)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* reads via props.useStore.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ThemePreference } from './index.ts'
|
||||
import type { ThemePreference } from '../theme-settings.ts'
|
||||
|
||||
/** Store state mirrored from the theme snapshot. */
|
||||
export interface AppearanceRowState {
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
/** Host registration for the browser theme preference. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the theme plugin. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from 'cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
/**
|
||||
* Register the durable theme section when a settings provider exists.
|
||||
* @param ctx - Host context whose optional settings service owns the section.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(THEME_SETTINGS_NAMESPACE),
|
||||
ThemeSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the theme registry publishes immutable snapshots on
|
||||
* its own `theme/change` event synchronously with the setter/registry
|
||||
* mutation in the same service — snapshot/event agreement is asserted
|
||||
* directly by this package's behavior specs.
|
||||
* No runtime invariant: the settings scope validates and publishes the durable
|
||||
* theme section, while the registry emits `theme/change` synchronously with
|
||||
* its own mutations. Store/registry agreement is covered directly by this
|
||||
* package's Host, scope, and service behavior specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
38
packages/client/ui-theme/src/theme-settings.ts
Normal file
38
packages/client/ui-theme/src/theme-settings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Theme preferences stored in the Host user-settings document. */
|
||||
|
||||
import z from 'schemastery'
|
||||
|
||||
/** Built-in preferences accepted at the registry and settings boundaries. */
|
||||
export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const
|
||||
|
||||
/** Settings namespace owned by the theme plugin. */
|
||||
export const THEME_SETTINGS_NAMESPACE = 'ui-theme'
|
||||
|
||||
/** Field carrying the selected built-in theme preference. */
|
||||
export const THEME_PREFERENCE_FIELD = 'preference'
|
||||
|
||||
/** Theme preference persisted by the product Appearance row. */
|
||||
export type ThemePreference = typeof THEME_PREFERENCES[number]
|
||||
|
||||
/** Default preference when the user-settings document has no override. */
|
||||
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
|
||||
|
||||
/** Durable theme section shared by the Host schema and the browser scope. */
|
||||
export interface ThemeSettings {
|
||||
/** Selected built-in preference. */
|
||||
preference: ThemePreference
|
||||
}
|
||||
|
||||
/** Durable theme schema; also the wire envelope the browser scope validates against. */
|
||||
export const ThemeSettingsSchema: z<ThemeSettings> = z.object({
|
||||
[THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE),
|
||||
})
|
||||
|
||||
/**
|
||||
* Narrow one wire or registry value to a persistable preference.
|
||||
* @param value - value crossing the settings or registry boundary.
|
||||
* @returns whether the value is a built-in preference.
|
||||
*/
|
||||
export function isThemePreference(value: unknown): value is ThemePreference {
|
||||
return THEME_PREFERENCES.some(preference => preference === value)
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
* locale service, declaration-aware Appearance row registration, snapshot
|
||||
* projection into the row store, and HMR collapse recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
|
||||
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
|
||||
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
@@ -17,12 +18,45 @@ usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const SLOT = 'settings.general.item'
|
||||
|
||||
async function bench() {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function bench(isLoopback = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
|
||||
let preference = 'system'
|
||||
const namespace = () => ({
|
||||
ns: THEME_SETTINGS_NAMESPACE,
|
||||
schema: ThemeSettingsSchema.toJSON(),
|
||||
value: { preference },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
})
|
||||
const describe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'theme-describe' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
|
||||
},
|
||||
}))
|
||||
const mutate = vi.fn((request: { ops: { value: string }[] }) => {
|
||||
preference = request.ops[0]!.value
|
||||
return Promise.resolve({
|
||||
rpcId: 'theme-mutate' as never,
|
||||
result: { ok: true as const, value: namespace() },
|
||||
})
|
||||
})
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate,
|
||||
setHostPreference: (next: string) => { preference = next },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the General item slot from root. */
|
||||
@@ -45,7 +79,7 @@ function faceOf(slots: SlotsService) {
|
||||
|
||||
describe('ui-theme apply', () => {
|
||||
it('declares the slot and locale services', () => {
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => {
|
||||
@@ -84,6 +118,57 @@ describe('ui-theme apply', () => {
|
||||
face.setTheme('system')
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
expect(instance.getSnapshot().preference).toBe('system')
|
||||
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('dark')
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
b.ctx.emit('settings/changed', 'unrelated')
|
||||
expect(b.describe).toHaveBeenCalledOnce()
|
||||
b.setHostPreference('light')
|
||||
b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
|
||||
b.setHostPreference('dark')
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
|
||||
const remote = await bench(false)
|
||||
declareItems(remote.slots)
|
||||
await remote.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const remoteTheme = remote.ctx.get('theme') as ThemeService
|
||||
remoteTheme.setTheme('dark')
|
||||
await Promise.resolve()
|
||||
expect(remote.describe).not.toHaveBeenCalled()
|
||||
expect(remote.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates before a slow initial settings read and converges when it settles', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('dark')
|
||||
const describe = b.describe.getMockImplementation()!
|
||||
const pending = deferred<Awaited<ReturnType<typeof describe>>>()
|
||||
b.describe.mockImplementationOnce(() => pending.promise)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
pending.resolve(await describe())
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores an invalid preference crossing the settings wire', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('sepia')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
30
packages/client/ui-theme/tests/host.spec.ts
Normal file
30
packages/client/ui-theme/tests/host.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
|
||||
} from '@deepseek-ai/dsh-client-ui-theme'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-theme host', () => {
|
||||
it('registers, validates, and disposes the durable theme namespace with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE)
|
||||
expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE })
|
||||
await ctx.settings.update(ns, { preference: 'dark' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' })
|
||||
await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme'
|
||||
import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
|
||||
import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
@@ -15,18 +15,25 @@ describe('invariant companion', () => {
|
||||
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
it('node-half waits for an optional settings provider', () => {
|
||||
nodeApply(new Context())
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('client apply provides ctx.theme over the slots/locale edges', async () => {
|
||||
// The feature registers its own Appearance settings row with localized
|
||||
// copy, hence the slots + locale edges.
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: () => Promise.resolve({
|
||||
rpcId: 'theme-invariant' as never,
|
||||
result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } },
|
||||
}) } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: localeApply }).await()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
|
||||
})
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
|
||||
const make = (host = stubSettingsScope<ThemeSettings>()): {
|
||||
ctx: Context
|
||||
theme: ThemeService
|
||||
events: ThemeSnapshot[]
|
||||
host: StubSettingsScope<ThemeSettings>
|
||||
} => {
|
||||
const ctx = new Context()
|
||||
const events: ThemeSnapshot[] = []
|
||||
ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
|
||||
return { ctx, theme: new ThemeService(ctx), events }
|
||||
return { ctx, theme: new ThemeService(ctx, host.scope), events, host }
|
||||
}
|
||||
|
||||
describe('ThemeService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults to the system preference resolved against prefers-color-scheme', () => {
|
||||
const { theme } = make()
|
||||
const snapshot = theme.getTheme()
|
||||
@@ -26,12 +28,12 @@ describe('ThemeService', () => {
|
||||
expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
|
||||
})
|
||||
|
||||
it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
|
||||
const { theme, events } = make()
|
||||
it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => {
|
||||
const { theme, events, host } = make()
|
||||
theme.setTheme('dark')
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
expect(theme.getTheme().active.colorScheme).toBe('dark')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(theme.getTheme())
|
||||
// The service never touches presentation state.
|
||||
@@ -39,13 +41,24 @@ describe('ThemeService', () => {
|
||||
// Same-value set is a no-op (no extra event).
|
||||
theme.setTheme('dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores a persisted preference and falls back on garbage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'dark')
|
||||
expect(make().theme.getTheme().preference).toBe('dark')
|
||||
localStorage.setItem(STORAGE_KEY, 'sepia')
|
||||
expect(make().theme.getTheme().preference).toBe('system')
|
||||
it('adopts a published Host section without writing it back', () => {
|
||||
const { theme, events, host } = make()
|
||||
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { preference: 'dark' }, revision: 2 })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adopts a section already standing at construction', () => {
|
||||
const host = stubSettingsScope<ThemeSettings>()
|
||||
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
|
||||
const { theme } = make(host)
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
})
|
||||
|
||||
it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
|
||||
@@ -56,7 +69,7 @@ describe('ThemeService', () => {
|
||||
})
|
||||
|
||||
it('registered themes join the snapshot; disposing the active one resets to default', () => {
|
||||
const { theme, events } = make()
|
||||
const { theme, events, host } = make()
|
||||
const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
|
||||
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
|
||||
theme.setTheme('sepia')
|
||||
@@ -64,7 +77,9 @@ describe('ThemeService', () => {
|
||||
dispose()
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
|
||||
// Custom ids are in-process extension themes; only the built-in product
|
||||
// preferences cross the Host settings schema.
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
// register + set + dispose = three publishes; disposer is idempotent.
|
||||
expect(events.length).toBe(3)
|
||||
dispose()
|
||||
@@ -88,16 +103,11 @@ describe('ThemeService', () => {
|
||||
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
try {
|
||||
const { theme } = make()
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
theme.setTheme('dark')
|
||||
expect(theme.getTheme().preference).toBe('dark')
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
it('context dispose releases the scope subscription', async () => {
|
||||
const { ctx, host } = make()
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ const LAYOUT_CHILDREN = {
|
||||
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -155,6 +155,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
slots.installLocale(locale)
|
||||
|
||||
@@ -64,6 +64,7 @@ const LAYOUT_CHILDREN = {
|
||||
*/
|
||||
async function bench(nodes: ToolResultNode[]) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
runtime.provide('layout', layout)
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
@@ -196,6 +197,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
describe('registrant declaration injection', () => {
|
||||
it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 64f6ae7bcd92735f821c8f8d2b3b93203dbac17e
|
||||
README.zh.md: 680fcee730674a21b5c2407247ce46b1a01cf6f3
|
||||
README.md: 03726c6671ec711704870d23722d83c72d9c4d35
|
||||
README.zh.md: f001866af2015671ed6429b392e3f880000e6c38
|
||||
|
||||
@@ -52,7 +52,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Non-model settings namespaces intentionally served to the Web client. */
|
||||
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
|
||||
const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const
|
||||
|
||||
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
|
||||
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
|
||||
@@ -309,8 +309,8 @@ describe('settings domain', () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane remains opt-in, so a
|
||||
// future internal plugin cannot become remotely configurable just by
|
||||
// registering; permission and the product onboarding namespace are the
|
||||
// non-model namespaces intentionally admitted by this surface.
|
||||
// registering; locale, permission, conversation, theme, and the product
|
||||
// onboarding namespace are intentionally admitted by this surface.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
@@ -319,15 +319,41 @@ describe('settings domain', () => {
|
||||
}), {
|
||||
base: { defaultPreset: 'read-only' },
|
||||
})
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}))
|
||||
ctx.settings.register(settingsNamespace('locale'), z.object({
|
||||
preference: z.union(['zh', 'en']).required(false),
|
||||
}))
|
||||
ctx.settings.register(settingsNamespace('ui-conversation'), z.object({
|
||||
busyEnter: z.union(['queue', 'steer']).default('queue'),
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual([
|
||||
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
|
||||
])
|
||||
const permission = expectOk(await api.settings.mutate(request({
|
||||
ns: 'permission',
|
||||
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
|
||||
})))
|
||||
expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
|
||||
const theme = expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-theme',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
})))
|
||||
expect(theme.value).toEqual({ preference: 'dark' })
|
||||
const locale = expectOk(await api.settings.mutate(request({
|
||||
ns: 'locale',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'en' }],
|
||||
})))
|
||||
expect(locale.value).toEqual({ preference: 'en' })
|
||||
const conversation = expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-conversation',
|
||||
ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }],
|
||||
})))
|
||||
expect(conversation.value).toEqual({ busyEnter: 'steer' })
|
||||
|
||||
for (const response of [
|
||||
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
|
||||
@@ -341,19 +367,29 @@ describe('settings domain', () => {
|
||||
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
|
||||
})
|
||||
|
||||
it('serves the product onboarding namespace without invalidating the model catalog', async () => {
|
||||
it('serves product preference namespaces without invalidating the model catalog', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
||||
.toEqual(['ui-onboarding'])
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
||||
.toEqual(['ui-onboarding', 'ui-theme'])
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 2, async () => {
|
||||
expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-onboarding',
|
||||
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
|
||||
})))
|
||||
expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-theme',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
})))
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'ui-onboarding' },
|
||||
{ type: 'host/settings-changed', ns: 'ui-theme' },
|
||||
])
|
||||
})
|
||||
|
||||
it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => {
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -1651,6 +1651,16 @@ importers:
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/client/locale:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: ^0.0.1
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
@@ -1700,6 +1710,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-schema-form':
|
||||
specifier: workspace:^
|
||||
version: link:../schema-form
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
@@ -1758,6 +1771,9 @@ importers:
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: link:../../../vendor/schemastery
|
||||
|
||||
packages/client/schema-form:
|
||||
dependencies:
|
||||
@@ -1902,9 +1918,15 @@ importers:
|
||||
|
||||
packages/client/ui-conversation:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
@@ -1915,6 +1937,9 @@ importers:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
@@ -2637,9 +2662,18 @@ importers:
|
||||
|
||||
packages/client/ui-theme:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: ^0.0.1
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
|
||||
@@ -162,8 +162,13 @@ export default defineConfig({
|
||||
'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx',
|
||||
'packages/client/ui-workspace/src/client/WorkspacePicker.tsx',
|
||||
'packages/client/web-react/src/*',
|
||||
'packages/client/runtime/src/*',
|
||||
'packages/client/ui-conversation/src/*',
|
||||
// This isolated settings-scope lifecycle has complete unit coverage;
|
||||
// keep it out of the broader client-runtime GUI debt exemption.
|
||||
'packages/client/runtime/src/**/!(settings-scope).ts',
|
||||
// Keep the browser conversation tree under its existing GUI debt
|
||||
// exemption while gating the newly stateful Host half and vocabulary.
|
||||
'packages/client/ui-conversation/src/client/*',
|
||||
'packages/client/ui-conversation/src/invariant.ts',
|
||||
'packages/client/ui-primitives/src/DisclosureRow.tsx',
|
||||
'packages/client/ui-tool/src/*',
|
||||
'packages/client/ui-slots/src/*',
|
||||
|
||||
Reference in New Issue
Block a user