diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index 2efe235e28..f8e6600a47 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c -2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675 +2026-07-30-client-locale-full-rollout.md: 0faf4e0424e037b59b24d32f7fa987ac36497691 +2026-07-30-client-locale-full-rollout.zh.md: 895a2b4e87d2734bad27724f56b3205d81ac755e diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 09baf58760..0faf4e0424 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -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). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 806916aea1..895a2b4e87 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -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 snapshot 同样钉 en——golden 对语言迁移免疫;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 snapshot 同样固定 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 生命周期)。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 7e804aad59..13dd2d5672 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md -2026-08-06-host-backed-web-theme-preference.md: 129132586b0d0ccfdb5b32fdaa1f7178a7176db7 -2026-08-06-host-backed-web-theme-preference.zh.md: 0c2dafff3fc3cec49a2261e31a61ecf99a10f126 +# 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: ee1c0aea360eb1a4b34eadc86c5c3091abc6663a +2026-08-06-host-backed-web-preferences.zh.md: 376e670f9af39f43783a1447498ca2d4c65a49cd diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md new file mode 100644 index 0000000000..ee1c0aea36 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -0,0 +1,41 @@ +# 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 `bindSettingsPreference` lifecycle for scalar preferences. 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. Domain services publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then accept a validated Host value without writing it back. + +User changes update the live service synchronously and queue a `settings.mutate` path operation. The controller 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 and decoders keep product policy out of the shared runtime. + +**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, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. 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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md new file mode 100644 index 0000000000..376e670f9a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -0,0 +1,41 @@ +# 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 并列;仅注册它们,绝不会跨越该配置边界。 + +客户端运行时为标量偏好提供一份 `bindSettingsPreference` 生命周期。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。领域服务会立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后接纳已校验的 Host 值,但不将其写回。 + +用户变更会同步更新实时服务,并将一项 `settings.mutate` 路径操作排入队列。控制器会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,控制器会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 + +远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 + +## 曾考虑的替代方案 + +**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 中继会围绕浏览器特有格式重新实现一套 settings 服务。 + +**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会要求另外定义启动与失效时的冲突规则,同时依然保留造成该缺陷的分区。Host settings 文档是唯一的持久化真源。 + +**等待初始读取,以避免暂定渲染。** 绘制页面不以配置可用为前置条件。后台读取可能引发一次实时收敛,但它会隔离失败,并保留既有的浏览器/系统/默认回落路径。 + +**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema 和解码器,可以避免把产品政策放入共享运行时。 + +**把每个 `localStorage` 条目都移入 settings。** 当前会话、草稿、面板展开状态、trajectory 显示状态和类似条目属于浏览器实例状态,而非用户配置。将它们提升为设置,会在没有产品契约的情况下,跨标签页和端口同步短暂导航状态。 + +## 后果 + +Appearance、Language 和繁忙态 Enter 选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin。直接编辑 `settings.yaml` 所产生的变更会通过现有失效流收敛,而旧的 `dsh.theme`、`dsh.locale` 和 `dsh.conversation.busyEnter` 条目既不会被读取,也不会被写入。 + +启动时可能会在后台读取结算前短暂显示领域默认值。短暂的读取失败会保留该默认值或上一个正确的进程内值;重连时会重试。写入被拒时,界面可能会在本地值立即变化后明显恢复为持久化偏好。 + +聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md deleted file mode 100644 index 129132586b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Persist the Web theme through Host settings - -Status: implemented - -English | [中文](2026-08-06-host-backed-web-theme-preference.zh.md) - -## Problem - -The Web theme preference lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different storage partition and returned to the default system theme even though both processes used the same DSH home. - -The theme is a user-level product preference rather than page-local state. DSH already has a user-settings service with a file-backed provider, a loopback-only configuration wire, and invalidation frames for external edits and other tabs. - -## Decision - -The `@deepseek-ai/dsh-client-ui-theme` Host half registers `ui-theme.preference` with the built-in `light`, `dark`, and `system` values and a `system` default. The local settings provider stores an override in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. - -The loopback client loads that namespace before it provides `ThemeService`, so the initial presenter snapshot reflects the durable preference without relying on an origin cache. `ThemeService.setTheme` still changes the live snapshot synchronously; its persistence callback sends a `settings.mutate` path operation. The controller serializes rapid selections in gesture order, ignores stale settlements, reloads after a rejected latest write, and refetches on `settings/changed` or `connection/reset`. - -The API proxy explicitly exposes `ui-theme` beside `permission` and `ui-onboarding`. Registration alone remains insufficient to cross the configuration boundary. Remote browsers cannot call the privileged settings API and retain only a process-local selection. - -Only the built-in product preferences cross the Host schema. Third-party registered theme ids remain an in-process extension because the Host cannot validate a browser plugin's dynamic registry during startup. - -## Alternatives considered - -**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host-side relay would recreate a settings service around a browser-specific format. - -**Use a cookie without an explicit port.** Cookies would couple preference durability to the served hostname, still split localhost aliases, and introduce HTTP state outside the user-settings ownership model. - -**Mirror Host settings into `localStorage`.** A second authority creates boot and invalidation conflict rules while retaining the origin partition that caused the defect. The Host document is the sole durable source. - -**Expose every registered settings namespace.** Automatic exposure would let an unrelated plugin become remotely configurable by registering with the general settings seam. The API proxy keeps an explicit allowlist. - -## Consequences - -Theme selections follow the DSH user home across reloads, ports, and loopback origins, and direct edits to `settings.yaml` converge through the existing invalidation stream. The settings document contains a readable section such as `ui-theme: { preference: dark }`; no theme value is written to `localStorage`. - -Startup performs one loopback settings read before publishing the theme service. A transient read failure keeps the system default or last good in-process value and reconnect can retry. A write rejection can visibly restore the durable preference after the immediate theme change. - -Unit coverage pins schema registration, ordered writes, stale-response containment, failure recovery, invalidation refresh, and remote memory mode. The real Web settings scenario writes dark through the UI, verifies the YAML document, reloads, and boots a second Host on another port against the same DSH home with an empty theme `localStorage` partition. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md deleted file mode 100644 index 0c2dafff3f..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 通过 Host settings 持久化 Web 主题 - -Status: implemented - -[English](2026-08-06-host-backed-web-theme-preference.md) | 中文 - -## 问题 - -Web 主题偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区,并回到默认的系统主题,即使两个进程使用同一个 DSH home。 - -主题是用户级产品偏好,而非页面局部状态。DSH 已有用户 settings 服务及其基于文件的提供方,也已有仅限回环请求的配置协议,并为外部编辑和其他标签页提供失效帧。 - -## 决策 - -`@deepseek-ai/dsh-client-ui-theme` 的 Host half 注册 `ui-theme.preference`,可取内置值 `light`、`dark` 与 `system`,默认值为 `system`。本地 settings 提供方将覆盖值存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。 - -来自回环地址的客户端会在提供 `ThemeService` 之前加载该 namespace,因此初始呈现器快照会反映持久化偏好,无需依赖按 origin 划分的缓存。`ThemeService.setTheme` 仍会同步更新实时快照;它的持久化回调会发送一项 `settings.mutate` 路径操作。控制器按操作顺序串行处理连续快速选择,忽略陈旧操作的结算结果,在最新写入被拒后重新加载持久化值,并在发生 `settings/changed` 或 `connection/reset` 时重新拉取。 - -API 代理会显式暴露 `ui-theme`,与 `permission` 和 `ui-onboarding` 并列。仅注册该设置,仍不足以跨越配置边界。远程浏览器无法调用特权 settings API,其主题选择仅保留在进程内。 - -只有产品内置偏好才会跨越 Host schema。第三方注册的主题 id 仍是进程内扩展,因为 Host 无法在启动期间校验浏览器插件的动态注册表。 - -## 曾考虑的替代方案 - -**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 侧中继会围绕浏览器特有格式重新实现一套 settings 服务。 - -**使用不显式包含端口的 cookie。** Cookie 会将偏好的持久性与提供服务的 hostname 耦合,localhost 的不同 alias 仍会各自分区,还会在用户 settings 的所有权模型之外引入 HTTP 状态。 - -**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会导致启动与失效时需要另外定义冲突规则,同时依然保留造成该缺陷的 origin 分区。Host 侧 settings 文档是唯一的持久化真源。 - -**暴露所有已注册的 settings namespace。** 自动暴露会让与本功能无关的插件仅凭向通用 settings seam 注册,就成为可远程配置的插件。API 代理保留一份显式 allowlist。 - -## 后果 - -主题选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin;直接编辑 `settings.yaml` 所产生的变更也会通过现有失效流收敛。settings 文档包含形如 `ui-theme: { preference: dark }` 的可读分节;不会向 `localStorage` 写入主题值。 - -启动时会在发布主题服务之前执行一次回环 settings 读取。短暂的读取失败会保留系统默认值或上一个正确的进程内值,并可在重连时重试。写入被拒时,界面可能会在主题立即变化后明显恢复为持久化偏好。 - -单元测试覆盖 schema 注册、有序写入、陈旧响应隔离、故障恢复、失效刷新与远程端仅内存模式。真实 Web settings 场景通过 UI 写入 dark,校验 YAML 文档,重新加载,再使用同一个 DSH home 在另一个端口上启动第二个 Host,此时主题 `localStorage` 分区为空。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 624da25dc2..104f49af8a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/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: b04902b8a8a0d727b01aa6ba5562e12cc5d36c92 +2026-07-30-web-queue-steer-action.md: 2718c5b3cc95f1ab02db80230ba158d9b5c3b4e6 +2026-07-30-web-queue-steer-action.zh.md: 2abc4747ca85d7059dd8bdd86f4d7c5314f41f24 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index b04095b81f..2718c5b3cc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index b04902b8a8..2abc4747ca 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -20,7 +20,7 @@ Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 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 行,让持久 session 事件先进入线性 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 单次入队项收敛。 ### 验证 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index d1fb6cb6c7..05e3f4b9e2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/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: c013d24dcd3bb49d176eaddd42ff41dde320ff1f +2026-07-31-browser-derived-initial-locale.md: 3fed32ad46f01ef3f88f3182a1cb21f40031ca1b +2026-07-31-browser-derived-initial-locale.zh.md: d47243fb6c9dc2269e1401454b92a528a0f4476a diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 0c49a6bbfe..3fed32ad46 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index c013d24dcd..d47243fb6c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -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 偏好可能引发一次实时收敛。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 638e91a016..9373b3fe79 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f +2026-07-24-web-gui-browser-e2e-lane.md: 7bbe584fe75973aa5da22054e1b220538328d153 +2026-07-24-web-gui-browser-e2e-lane.zh.md: f966dd494b64b17f7692a7aa55161ebc98dc393e diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index f8519a9622..7bbe584fe7 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -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 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 294f3e840e..f966dd494b 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -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 的不同端口之间的偏好持久化。 ### 预期输出 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0e168ba9fe..d4244e8495 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -18,11 +18,46 @@ import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { + id: '@deepseek-ai/dsh-client-ui-theme', + dir: 'ui-theme', + url: '/plugins/ui-theme.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-locale', + ], + immediately: true, + }, + { + id: '@deepseek-ai/dsh-client-locale', + dir: 'locale', + url: '/plugins/locale.js', + rev: 'fx', + inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime'], + immediately: true, + }, + { + id: '@deepseek-ai/dsh-client-ui-layout', + dir: 'ui-layout', + url: '/plugins/ui-layout.js', + rev: 'fx', + inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-theme'], + }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-conversation', + dir: 'ui-conversation', + url: '/plugins/ui-conversation.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-locale', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-layout', + ], + }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', @@ -66,7 +101,8 @@ let unmount: (() => void) | undefined export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() - localStorage.setItem('dsh.locale', 'en') + 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) => @@ -84,6 +120,9 @@ export function installAssembledBootEnv(): void { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') + const ownNavigator = navigator as unknown as Record + delete ownNavigator.languages + delete ownNavigator.language vi.unstubAllGlobals() }) } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 919f3b242c..774ceb332f 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -2,9 +2,8 @@ // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) -// 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. @@ -183,19 +182,18 @@ describe('web e2e: settings modal and General preferences', () => { .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' }) - const reloaded = await readState() - expect(reloaded.attr).toBe(true) - expect(reloaded.legacy).toBeNull() + await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + expect((await readState()).legacy).toBeNull() // A second live Host binds another ephemeral port but shares the same - // user-settings home. Its fresh origin has no theme localStorage and must - // still render dark before the settings dialog opens. + // 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) @@ -204,9 +202,8 @@ describe('web e2e: settings modal and General preferences', () => { await secondPage.emulateMedia({ colorScheme: 'light' }) await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const crossPort = await readState(secondPage) - expect(crossPort.attr).toBe(true) - expect(crossPort.legacy).toBeNull() + await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true) + expect((await readState(secondPage)).legacy).toBeNull() expect(secondTripwire.pageErrors).toEqual([]) expect(secondTripwire.warnings).toEqual([]) } finally { @@ -230,7 +227,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: '设置' }) @@ -238,7 +235,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 @@ -248,15 +247,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: '设置' }) @@ -273,7 +293,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 @@ -282,24 +304,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() @@ -312,6 +357,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(enTripwire.warnings).toEqual([]) } finally { await enPage.close() + await fresh.close() } }, 90_000) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 1b7b67aab3..40b9be39ca 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -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 { - 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. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 59b20a6762..e0c5ec838a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,14 +62,14 @@ 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`, `ui-theme` | +| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/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/ui/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-title/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/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | -| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | +| `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` | diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index d1ef53207f..3918beb028 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: f1efefde4557e1c29c0556f8b670f1534430ab79 -README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291 +README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604 +README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index f1efefde45..5bea46cd4e 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -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`; 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`; 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 diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index a8b5704d28..2333bc7c2b 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -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 → 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 → 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)拥有。 ## 模型体验 diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 75742a80d0..cfadff76b8 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -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", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 5d195ee275..ac694b0bce 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -13,7 +13,10 @@ 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 { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { + isLocaleId, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from '../locale-settings.ts' import { en, zh, type CommonKey } from '../locales/index.ts' import { en as settingsEn, zh as settingsZh, type SettingsLocaleKey, @@ -26,6 +29,9 @@ 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 { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} 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 -/** 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,15 +116,26 @@ export class LocaleService { private snapshot: LocaleSnapshot private listeners = new Set<() => void>() private readonly ctx: Context + private persist: (id: LocaleId) => void /** * @param ctx - owning context (change events are emitted on it). + * @param persist - durable write callback for explicit locale selections. */ - constructor(ctx: Context) { + constructor(ctx: Context, persist: (id: LocaleId) => void = () => {}) { this.ctx = ctx + this.persist = persist this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 }) } + /** + * Bind the owning plugin's durable writer before the service is provided. + * @param persist - callback accepting explicit locale changes. + */ + bindPersistence(persist: (id: LocaleId) => void): void { + this.persist = persist + } + /** * Read the current immutable locale snapshot. * @returns the current snapshot (stable reference until the next change). @@ -155,16 +166,24 @@ 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) + this.persist(match.id) + } + + /** + * Apply an explicit Host preference without writing it back. + * @param id - validated shipped locale. + */ + syncPreference(id: LocaleId): void { + if (this.snapshot.active === id) return + this.publish(id, true) } /** @@ -288,27 +307,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 +328,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 +337,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 @@ -357,8 +348,16 @@ export const inject = ['slots'] */ export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) + const browserLocale = locale.getLocale().active locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) + const controller = bindSettingsPreference(ctx, { + namespace: LOCALE_SETTINGS_NAMESPACE, + field: LOCALE_PREFERENCE_FIELD, + decode: value => isLocaleId(value) ? value : browserLocale, + sync: (id) => { locale.syncPreference(id) }, + }) + locale.bindPersistence((id) => { void controller.persist(id) }) ctx.provide('locale', locale) // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it // so the render machinery can synthesize the `t` standard seat. diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index c220373932..09afbef04e 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,4 +1,33 @@ -/** 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 z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from './locale-settings.ts' + +export { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from './locale-settings.ts' + +interface LocaleSettings { + preference?: LocaleId +} + +const LocaleSettingsSchema: z = z.object({ + [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), +}) + +/** + * 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, + ) + }) +} diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts new file mode 100644 index 0000000000..dd1ad39339 --- /dev/null +++ b/packages/client/locale/src/locale-settings.ts @@ -0,0 +1,22 @@ +/** Locale preference stored in the Host user-settings document. */ + +/** 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] + +/** + * Narrow one settings-wire value to a shipped locale. + * @param value - value crossing the settings boundary. + * @returns whether the value names a shipped locale. + */ +export function isLocaleId(value: unknown): value is LocaleId { + return LOCALE_IDS.some(locale => locale === value) +} diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index a3007f8c78..2bd424a974 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -4,7 +4,9 @@ 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, LOCALE_SETTINGS_NAMESPACE, SETTINGS_NS, +} from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -14,7 +16,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: {}, + 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 +78,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 +122,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 () => { diff --git a/packages/client/locale/tests/host.spec.ts b/packages/client/locale/tests/host.spec.ts new file mode 100644 index 0000000000..8fa339e660 --- /dev/null +++ b/packages/client/locale/tests/host.spec.ts @@ -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> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + 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) + }) +}) diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index fa62ca79f6..2b362cb115 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -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) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 442701dbb3..9215bd51e6 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -2,7 +2,7 @@ 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 { LocaleService } from '@deepseek-ai/dsh-client-locale/client' const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { const ctx = new Context() @@ -24,7 +24,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 +131,19 @@ describe('LocaleService', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { + it('setLocale requests persistence, republishes an immutable snapshot, and no-ops on same value', () => { const { svc, events } = make() + const persist = vi.fn() + svc.bindPersistence(persist) svc.setLocale('en') expect(svc.getLocale().active).toBe('en') - expect(localStorage.getItem(STORAGE_KEY)).toBe('en') + expect(persist).toHaveBeenCalledWith('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(persist).toHaveBeenCalledOnce() }) it('throws on unknown locale ids', () => { @@ -149,14 +151,19 @@ 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('syncs a Host preference over the browser language without writing it back', () => { + const { svc, events } = make() + const persist = vi.fn() + svc.bindPersistence(persist) + svc.syncPreference('en') + expect(svc.getLocale().active).toBe('en') + expect(events).toHaveLength(1) + expect(persist).not.toHaveBeenCalled() + svc.syncPreference('en') + expect(events).toHaveLength(1) }) - it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => { + 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 +183,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 +194,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', () => { diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8585ba74ca..313c11f5bf 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 23c867e4c0..5698297cb4 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 -README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d +README.md: c05089badb29ad0e22ed1f66d7804eccbb11c1d4 +README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8ac29a4258..c05089badb 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -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`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. 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. Contract: api-contracts v3 §4. 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. +`bindSettingsPreference` is the browser lifecycle for one domain-owned scalar setting. It subscribes before starting a nonblocking initial read, serializes writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. Loopback pages use the Host settings API; remote pages stay in memory. Domain packages own the namespace schema, value guard, 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. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 0e065e43ec..ccbb96266c 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -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`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +`bindSettingsPreference` 是单项由领域持有的标量设置所用的浏览器生命周期。它在开始非阻塞初始读取前建立订阅,使用已知最新 namespace revision 串行写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。回环页面使用 Host settings API,远程页面则只保留内存状态。namespace schema、取值校验器、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 + ## Slot 声明注入 `ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 06f88a9131..2854a16659 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -21,6 +21,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 { bindSettingsPreference, SettingsPreferenceController } from './settings-preference.ts' +export type { SettingsPreferenceSpec } from './settings-preference.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' export type { diff --git a/packages/client/runtime/src/client/settings-preference.ts b/packages/client/runtime/src/client/settings-preference.ts new file mode 100644 index 0000000000..a459999cc7 --- /dev/null +++ b/packages/client/runtime/src/client/settings-preference.ts @@ -0,0 +1,160 @@ +/** Host-backed scalar preference synchronization for browser plugins. */ + +import type { Context } from 'cordis' +import type { + ConnectionHandle, IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' + +/** Domain-owned description of one scalar field in a settings namespace. */ +export interface SettingsPreferenceSpec { + /** Settings namespace registered by the owning Host plugin. */ + namespace: string + /** Scalar field inside that namespace. */ + field: string + /** Validate a wire value; undefined leaves the current in-process value active. */ + decode(value: unknown): T | undefined + /** Apply a validated Host value without writing it back. */ + sync(value: T): void +} + +type SettingsFace = Pick + +/** + * Serializes one scalar preference's Host reads and writes. Reads never block + * plugin activation; writes carry the latest known namespace revision and + * teardown waits for the operation already crossing the wire. + */ +export class SettingsPreferenceController { + private tail: Promise = Promise.resolve() + private readGeneration = 0 + private writeGeneration = 0 + private revision: number | undefined + private disposed = false + + /** + * @param api - settings wire face. + * @param spec - namespace, field validator, and live target. + * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly spec: SettingsPreferenceSpec, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} + + /** + * 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 { + const generation = ++this.readGeneration + return this.enqueue(() => this.read(generation)) + } + + /** + * Queue one user preference write. Rapid selections preserve mutation order, + * while only the latest settlement may resynchronize the live target. + * @param value - validated domain preference selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + persist(value: T): Promise { + this.readGeneration += 1 + const generation = ++this.writeGeneration + return this.enqueue(async () => { + let response: Awaited> + try { + response = await this.api.settings.mutate({ + ns: this.spec.namespace, + ops: [{ op: 'set', path: [this.spec.field], value }], + ...(this.revision === undefined ? {} : { expectedRevision: this.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 { + this.disposed = true + this.readGeneration += 1 + this.writeGeneration += 1 + await this.tail + } + + private enqueue(operation: () => Promise): Promise { + 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 target callback cannot strand later operations. + this.tail = task.catch(() => {}) + return task + } + + private async read(generation: number): Promise { + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + return + } + if (!response.result.ok || this.disposed) return + const view = response.result.value.namespaces.find(candidate => candidate.ns === this.spec.namespace) + if (view === undefined) return + this.accept(view, generation === this.readGeneration) + } + + private accept(view: SettingsNamespaceView, publish: boolean): void { + this.revision = view.revision + if (!publish || typeof view.value !== 'object' || view.value === null) return + const value = this.spec.decode((view.value as Record)[this.spec.field]) + if (value !== undefined) this.spec.sync(value) + } +} + +/** + * Bind one controller to settings and connection invalidations on the caller's + * plugin lifecycle. Listeners exist before the initial background read starts. + * @param ctx - owning browser plugin context. + * @param spec - domain-owned scalar preference contract. + * @returns the bound controller used by the domain's user-write callback. + */ +export function bindSettingsPreference( + ctx: Context, + spec: SettingsPreferenceSpec, +): SettingsPreferenceController { + const connection = ctx.get('connection') as ConnectionHandle + const controller = new SettingsPreferenceController( + 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}.${spec.field} preference`) + return controller +} diff --git a/packages/client/runtime/tests/settings-preference.spec.ts b/packages/client/runtime/tests/settings-preference.spec.ts new file mode 100644 index 0000000000..a93df780bb --- /dev/null +++ b/packages/client/runtime/tests/settings-preference.spec.ts @@ -0,0 +1,237 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + bindSettingsPreference, SettingsPreferenceController, +} from '../src/client/settings-preference.ts' + +type Preference = 'light' | 'dark' | 'system' + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(): RpcResponse { + return { + rpcId: `preference-${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: {}, + value, + applies: 'live', + secrets: [], + revision, + } +} + +function described(value: unknown, revision = 0) { + return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +function spec(values: Preference[]) { + return { + namespace: 'ui-test', + field: 'preference', + decode: (value: unknown): Preference | undefined => + value === 'light' || value === 'dark' || value === 'system' ? value : undefined, + sync: (value: Preference) => { values.push(value) }, + } +} + +describe('SettingsPreferenceController', () => { + it('loads only a valid owned field and contains unavailable transports', async () => { + const values: Preference[] = [] + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'sepia' })) + .mockResolvedValueOnce(described(null)) + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const controller = new SettingsPreferenceController({ settings: { describe } } as never, spec(values)) + for (let i = 0; i < 6; i++) await controller.load() + expect(values).toEqual(['dark']) + }) + + it('serializes rapid writes, carries revisions, and publishes only the latest settlement', async () => { + const first = deferred>() + const values: Preference[] = [] + const describe = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await controller.load() + const dark = controller.persist('dark') + const light = controller.persist('light') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + first.resolve(ok(view({ preference: 'dark' }, 5))) + await Promise.all([dark, light]) + expect(values).toEqual(['system', 'light']) + 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 values: Preference[] = [] + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + .mockResolvedValueOnce(described({ preference: 'light' }, 3)) + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await controller.persist('dark') + await controller.persist('system') + expect(values).toEqual(['system', 'light']) + }) + + it('does not recover superseded rejected or thrown writes', async () => { + const values: Preference[] = [] + const describe = vi.fn() + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await Promise.all([ + controller.persist('dark'), + controller.persist('system'), + controller.persist('light'), + ]) + expect(describe).not.toHaveBeenCalled() + expect(values).toEqual(['light']) + }) + + it('keeps the queue usable when a target callback throws', async () => { + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' })) + .mockResolvedValueOnce(described({ preference: 'sepia' })) + const controller = new SettingsPreferenceController( + { settings: { describe } } as never, + { ...spec([]), sync: () => { throw new Error('target failed') } }, + ) + await expect(controller.load()).rejects.toThrow('target failed') + await expect(controller.load()).resolves.toBeUndefined() + }) + + it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { + const first = deferred>() + const mutate = vi.fn().mockReturnValue(first.promise) + const values: Preference[] = [] + const controller = new SettingsPreferenceController( + { settings: { mutate } } as never, + spec(values), + ) + const dark = controller.persist('dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const light = controller.persist('light') + let stopped = false + const stop = controller.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 controller.persist('system') + await controller.load() + expect(mutate).toHaveBeenCalledOnce() + expect(values).toEqual([]) + }) + + it('keeps remote-browser preferences in memory without Host calls', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec([]), + 'memory', + ) + await controller.load() + await controller.persist('dark') + await controller.dispose() + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) +}) + +describe('bindSettingsPreference', () => { + it('subscribes before the initial read and converges to the latest queued invalidation', async () => { + const initial = deferred>() + const describe = 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 } }, + isLoopback: true, + } as never) + const values: Preference[] = [] + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (scope: Context) => { bindSettingsPreference(scope, spec(values)) }, + }) + await fiber.await() + await vi.waitFor(() => { expect(describe).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(describe).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(values).toEqual(['system']) }) + await fiber.dispose() + ctx.emit('settings/changed', 'ui-test') + await Promise.resolve() + expect(describe).toHaveBeenCalledTimes(3) + }) + + it('binds a remote browser in memory without starting a settings read', async () => { + const describe = vi.fn() + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe } }, + isLoopback: false, + } as never) + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (scope: Context) => { bindSettingsPreference(scope, spec([])) }, + }) + await fiber.await() + await fiber.dispose() + expect(describe).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 78169601c8..c1f1278e61 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 8d6c26f67916f043251c58a3283542bd58a08666 -README.zh.md: 8dd43cca59f8dfda18ce036b5d8c6f948306c947 +README.md: 2789265d867e8e1e23f97e01b2ea7d12960a188c +README.zh.md: 9707c8b64f872fae52bb0c5900f5db403ed75e59 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8d6c26f679..2789265d86 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara 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 stays absent because the message has not entered a durable turn. 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 branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, 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. 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. 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8dd43cca59..9707c8b64f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 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 仍然换行。已寻址 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 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index ff4e74da5e..6503f3cbd6 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", - "description": "Conversation domain: skeleton (header/tabs/composer), chat view, ctx.toolviews registry, minimal details panel", + "description": "Conversation domain: shell, chat and tool views, input policy with Host-backed busy-Enter preference, and details panel", "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,9 +37,12 @@ }, "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-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", @@ -50,6 +54,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..8f61c30e01 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,7 @@ /** 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 type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, 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' @@ -36,6 +36,9 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' +import { + BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, isBusyEnterBehavior, +} from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -45,7 +48,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -97,6 +100,13 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() const submissionPolicy = new ComposerSubmissionPolicy() + const preference = bindSettingsPreference(ctx, { + namespace: CONVERSATION_SETTINGS_NAMESPACE, + field: BUSY_ENTER_FIELD, + decode: value => isBusyEnterBehavior(value) ? value : undefined, + sync: (behavior) => { submissionPolicy.syncPreference(behavior) }, + }) + submissionPolicy.bindPersistence((behavior) => { void preference.persist(behavior) }) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts index c5bcdc7826..23d9df94c1 100644 --- a/packages/client/ui-conversation/src/client/contract/composer-submission.ts +++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts @@ -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' diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 6ef87e42c8..968406972c 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -1,5 +1,5 @@ /** - * 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. */ @@ -7,12 +7,9 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' +import { DEFAULT_BUSY_ENTER_BEHAVIOR } 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. @@ -21,7 +18,21 @@ export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' */ export class ComposerSubmissionPolicy { /** Reactive preference source for the Settings row. */ - readonly busyEnter: SnapshotStore = createSnapshotStore(restoreBusyEnter()) + readonly busyEnter: SnapshotStore = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR) + private persist: (behavior: BusyEnterBehavior) => void + + /** @param persist - durable write callback for explicit behavior changes. */ + constructor(persist: (behavior: BusyEnterBehavior) => void = () => {}) { + this.persist = persist + } + + /** + * Bind the owning plugin's durable writer before the policy is exposed. + * @param persist - callback accepting explicit behavior changes. + */ + bindPersistence(persist: (behavior: BusyEnterBehavior) => void): void { + this.persist = persist + } /** * Resolve one keyboard gesture without changing state. @@ -42,36 +53,21 @@ export class ComposerSubmissionPolicy { } /** - * Change and persist the plain-Enter behavior used during busy state. + * Change the plain-Enter behavior used during busy state. * @param behavior - Queue or Steer. */ setBusyEnter(behavior: BusyEnterBehavior): void { if (this.busyEnter.getSnapshot() === behavior) return this.busyEnter.set(behavior) - persistBusyEnter(behavior) + this.persist(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. + /** + * Apply a Host preference without writing it back. + * @param behavior - validated behavior from settings. + */ + syncPreference(behavior: BusyEnterBehavior): void { + if (this.busyEnter.getSnapshot() === behavior) return + this.busyEnter.set(behavior) } } diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 142d3853e3..2377c8a73f 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,4 +1,35 @@ -/** 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 z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, +} from './submission-settings.ts' + +export { + BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, +} from './submission-settings.ts' + +interface ConversationSettings { + busyEnter: BusyEnterBehavior +} + +const ConversationSettingsSchema: z = z.object({ + [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), +}) + +/** + * 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, + ) + }) +} diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts new file mode 100644 index 0000000000..a1ba6e082c --- /dev/null +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -0,0 +1,25 @@ +/** Busy-Enter preference stored in the Host user-settings document. */ + +/** 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' + +/** + * Narrow one settings-wire value to a busy-Enter behavior. + * @param value - value crossing the settings boundary. + * @returns whether the value names a supported behavior. + */ +export function isBusyEnterBehavior(value: unknown): value is BusyEnterBehavior { + return BUSY_ENTER_BEHAVIORS.some(behavior => behavior === value) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 49682fea52..6868422eb2 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -47,6 +47,7 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 16163065eb..d6c8a8d106 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -96,6 +96,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -183,6 +184,7 @@ describe('terminal card assembly', () => { 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 }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -201,6 +203,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 }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -270,6 +273,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 }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index df8fff6719..1a906235c8 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -24,6 +24,7 @@ const CHILD = 'child-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) 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 }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 8c4af6a921..88dc16a838 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -137,6 +137,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) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index e2319157ea..12e4011964 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -62,6 +62,7 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layout) const locale = new LocaleService(runtime.ctx) @@ -193,6 +194,7 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index c92e43db6c..6f9f91da73 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot, -// bash sample state dots, the node-half empty apply, and AssistantMarkdown +// bash sample state dots, the node-half optional settings registration, and AssistantMarkdown // reasoning/unknown block arms. import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -25,8 +26,8 @@ const t: GenericToolCardProps['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('ToolRow stopped state renders the warning dot in the leading slot', () => { diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts new file mode 100644 index 0000000000..bb16273d64 --- /dev/null +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -0,0 +1,37 @@ +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' +import { isBusyEnterBehavior } from '../src/submission-settings.ts' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-conversation host', () => { + it('narrows settings-wire values to the supported behavior pair', () => { + expect(isBusyEnterBehavior('queue')).toBe(true) + expect(isBusyEnterBehavior('steer')).toBe(true) + expect(isBusyEnterBehavior('later')).toBe(false) + }) + + 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) + }) +}) diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts index 5b892982ab..6519e9f9d3 100644 --- a/packages/client/ui-conversation/tests/submission-policy.spec.ts +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -1,14 +1,9 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' 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() -}) - describe('ComposerSubmissionPolicy', () => { it('defaults to Queue and only applies the preference while running', () => { const policy = new ComposerSubmissionPolicy() @@ -21,6 +16,8 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', false)).toBe('queue') const changed = vi.fn() + const persist = vi.fn() + policy.bindPersistence(persist) policy.busyEnter.subscribe(changed) policy.setBusyEnter('steer') expect(changed).toHaveBeenCalledTimes(1) @@ -28,40 +25,25 @@ 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') + expect(persist).toHaveBeenCalledWith('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('syncs a Host preference without writing it back and leaves an identical write untouched', () => { + const persist = vi.fn() + const policy = new ComposerSubmissionPolicy(persist) + policy.syncPreference('steer') expect(policy.busyEnter.getSnapshot()).toBe('steer') policy.setBusyEnter('steer') - expect(write).not.toHaveBeenCalled() - write.mockRestore() + expect(persist).not.toHaveBeenCalled() }) - 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') }, - }) + it('publishes the in-memory preference before calling the durable writer', () => { const policy = new ComposerSubmissionPolicy() + const persist = vi.fn(() => { + expect(policy.busyEnter.getSnapshot()).toBe('steer') + }) + policy.bindPersistence(persist) policy.setBusyEnter('steer') - expect(policy.busyEnter.getSnapshot()).toBe('steer') + expect(persist).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 04b265bdd5..f2b78f7dfe 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection" + }, { "path": "../ui-slots" }, @@ -47,6 +50,9 @@ { "path": "../locale" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 09221e1e94..30d956fc39 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,7 +18,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' @@ -84,8 +84,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 } } @@ -119,8 +120,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 diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 04fd1e81c2..84438c92a4 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: 32868bcac4313a3badfe92dbf41c84e793f09709 -README.zh.md: a38765b8004826133875c38deeb66128d52ec986 +README.md: b79eac0d7777ac7af9b6a8960dc4d9797b41513d +README.zh.md: c57ccbdb8fdfb735b3a5d0d66f3538dd01966ada diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 32868bcac4..b79eac0d77 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -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 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 loads `ui-theme.preference` before providing the service 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, 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. Contract: api-contracts v3 §8; the [Host-backed preference decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md) owns the persistence boundary. +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. Contract: api-contracts v3 §8; 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. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index a38765b800..c57ccbdb8f 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --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 内联变量)。来自回环地址的浏览器会在提供该服务前加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md)拥有。 +主题插件:基于 --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;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。契约:api-contracts v3 §8;该持久化边界由[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` 之后。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7635da17b8..a4f9a78d37 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -44,7 +44,6 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 497f4a22f1..05c8a741af 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -8,28 +8,24 @@ * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' -import { ThemeSettingsController } from './theme-settings.ts' import { en, zh, type ThemeKey } from './locales.ts' import { - DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' -export type { ThemePreferenceTarget } from './theme-settings.ts' -export { ThemeSettingsController } from './theme-settings.ts' export type { ThemeKey } from './locales.ts' export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from '../theme-settings.ts' @@ -196,7 +192,6 @@ export class ThemeService { this.themes = this.themes.filter(t => t.id !== definition.id) if (this.preference === definition.id) { this.preference = DEFAULT_PREFERENCE - this.persist(this.preference) } this.publish() } @@ -235,33 +230,17 @@ export const inject = ['slots', 'locale', 'connection'] * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export async function apply(ctx: ClientContext): Promise { - const connection = ctx.get('connection') as ConnectionHandle +export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) - const controller = new ThemeSettingsController( - connection.api, - theme, - connection.isLoopback ? 'host' : 'memory', - ) + const controller = bindSettingsPreference(ctx, { + namespace: THEME_SETTINGS_NAMESPACE, + field: THEME_PREFERENCE_FIELD, + decode: value => isThemePreference(value) ? value : undefined, + sync: (preference) => { theme.syncPreference(preference) }, + }) theme.bindPersistence((preference) => { void controller.persist(preference) }) - await controller.load() ctx.provide('theme', theme) - ctx.effect(() => { - const refresh = (ns?: string): void => { - if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return - void controller.load() - } - const disposers = [ - ctx.on('settings/changed', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - return () => { - controller.dispose() - for (const dispose of disposers) dispose() - } - }, 'ui-theme: settings invalidations') - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() diff --git a/packages/client/ui-theme/src/client/theme-settings.ts b/packages/client/ui-theme/src/client/theme-settings.ts deleted file mode 100644 index 66b332313b..0000000000 --- a/packages/client/ui-theme/src/client/theme-settings.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Host-backed persistence controller for the browser theme preference. */ - -import type { - IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-client-connection/client' -import { - THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference, - type ThemePreference, -} from '../theme-settings.ts' - -/** Preference target implemented by {@link ThemeService}. */ -export interface ThemePreferenceTarget { - /** - * Apply a Host value without writing it back. - * @param preference - validated durable preference. - */ - syncPreference(preference: ThemePreference): void -} - -function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined { - if (typeof view.value !== 'object' || view.value === null) return undefined - const preference = (view.value as Record)[THEME_PREFERENCE_FIELD] - return isThemePreference(preference) ? preference : undefined -} - -/** Coordinates startup reads, ordered writes, and pushed invalidations. */ -export class ThemeSettingsController { - private generation = 0 - private writeTail: Promise = Promise.resolve() - - /** - * @param api - settings wire face. - * @param target - live theme service receiving durable values. - * @param persistence - remote browsers stay process-local because the settings API is loopback-only. - */ - constructor( - private readonly api: Pick, - private readonly target: ThemePreferenceTarget, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} - - /** - * Load the durable preference after earlier writes settle; the latest operation wins. - * @returns nothing; an unavailable or invalid descriptor leaves the last good value active. - */ - async load(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') return - await this.writeTail - if (generation !== this.generation) return - let response: Awaited['settings']['describe']>> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - // A transport failure leaves the last good in-process theme active. A - // connection/reset or settings/changed notification retries the read. - return - } - if (!response.result.ok || generation !== this.generation) return - const view = response.result.value.namespaces.find( - candidate => candidate.ns === THEME_SETTINGS_NAMESPACE, - ) - if (view === undefined) return - const preference = preferenceOf(view) - if (preference !== undefined) this.target.syncPreference(preference) - } - - /** - * Persist one user selection. Writes are serialized so rapid picks land in - * gesture order; a rejected latest write reloads the durable value. - * @param preference - selected built-in preference. - * @returns nothing after the write or recovery read settles. - */ - async persist(preference: ThemePreference): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') return - const write = this.writeTail.then(async () => { - const response = await this.api.settings.mutate({ - ns: THEME_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }], - }) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation === this.generation) { - const accepted = preferenceOf(response.result.value) - if (accepted !== undefined) this.target.syncPreference(accepted) - } - }) - this.writeTail = write.catch(() => {}) - try { - await write - } catch { - if (generation === this.generation) await this.load() - } - } - - /** Prevent in-flight reads and writes from publishing after plugin disposal. */ - dispose(): void { - this.generation += 1 - } -} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 5f746d6d83..32d3689950 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -4,12 +4,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from './theme-settings.ts' export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from './theme-settings.ts' @@ -18,7 +18,7 @@ interface ThemeSettings { } const ThemeSettingsSchema: z = z.object({ - [THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE), + [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), }) /** diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index e93b3c56e0..ca06ec28a7 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -1,5 +1,8 @@ /** Theme preferences stored in the Host user-settings document. */ +/** 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' @@ -7,7 +10,7 @@ export const THEME_SETTINGS_NAMESPACE = 'ui-theme' export const THEME_PREFERENCE_FIELD = 'preference' /** Theme preference persisted by the product Appearance row. */ -export type ThemePreference = 'light' | 'dark' | 'system' +export type ThemePreference = typeof THEME_PREFERENCES[number] /** Default preference when the user-settings document has no override. */ export const DEFAULT_PREFERENCE: ThemePreference = 'system' @@ -18,5 +21,5 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system' * @returns whether the value is a built-in preference. */ export function isThemePreference(value: unknown): value is ThemePreference { - return value === 'light' || value === 'dark' || value === 'system' + return THEME_PREFERENCES.some(preference => preference === value) } diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 350ea0525a..d134340560 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -19,6 +19,12 @@ usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() @@ -122,7 +128,7 @@ describe('ui-theme apply', () => { declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeService - expect(theme.getTheme().preference).toBe('dark') + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) b.ctx.emit('settings/changed', 'unrelated') expect(b.describe).toHaveBeenCalledOnce() b.setHostPreference('light') @@ -142,6 +148,30 @@ describe('ui-theme apply', () => { 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>>() + 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 () => { const b = await bench() const host = declareItems(b.slots) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 42a2651099..c5eedc9dd7 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -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' @@ -26,7 +26,6 @@ describe('invariant companion', () => { 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, @@ -34,6 +33,7 @@ describe('invariant companion', () => { }) } }, 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) }) diff --git a/packages/client/ui-theme/tests/theme-settings.spec.ts b/packages/client/ui-theme/tests/theme-settings.spec.ts deleted file mode 100644 index b2b921a4c2..0000000000 --- a/packages/client/ui-theme/tests/theme-settings.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { - THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, ThemeSettingsController, - type ThemePreference, -} from '@deepseek-ai/dsh-client-ui-theme/client' - -let rpc = 0 - -function ok(value: T): RpcResponse { - return { rpcId: `theme-${rpc++}` as never, result: { ok: true, value } } -} - -function view(preference: unknown = 'system'): SettingsNamespaceView { - return { - ns: THEME_SETTINGS_NAMESPACE, - schema: {}, - value: { [THEME_PREFERENCE_FIELD]: preference }, - applies: 'live', - secrets: [], - revision: 0, - } -} - -function described(preference: unknown = 'system') { - return ok({ writable: true, hasDocument: true, namespaces: [view(preference)] }) -} - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } -} - -function target() { - const values: ThemePreference[] = [] - return { values, syncPreference: (preference: ThemePreference) => { values.push(preference) } } -} - -describe('ThemeSettingsController', () => { - it('loads a valid Host value and ignores unavailable or malformed namespaces', async () => { - const receiver = target() - const describe = vi.fn() - .mockResolvedValueOnce(described('dark')) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) - .mockResolvedValueOnce(described('sepia')) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [{ ...view(), value: null }] })) - .mockResolvedValueOnce({ - rpcId: 'failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'offline', details: {} } }, - }) - .mockRejectedValueOnce(new Error('transport offline')) - const controller = new ThemeSettingsController({ settings: { describe } } as never, receiver) - for (let i = 0; i < 6; i++) await controller.load() - expect(receiver.values).toEqual(['dark']) - }) - - it('persists ordered rapid selections and publishes only the latest settlement', async () => { - const first = deferred>>() - const calls: string[] = [] - const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { - const preference = request.ops[0]!.value - calls.push(preference) - if (preference === 'dark') return first.promise - return ok(view(preference)) - }) - const receiver = target() - const controller = new ThemeSettingsController({ settings: { mutate } } as never, receiver) - const dark = controller.persist('dark') - const light = controller.persist('light') - await Promise.resolve() - expect(calls).toEqual(['dark']) - first.resolve(ok(view('dark'))) - await Promise.all([dark, light]) - expect(calls).toEqual(['dark', 'light']) - expect(receiver.values).toEqual(['light']) - expect(mutate).toHaveBeenNthCalledWith(1, { - ns: THEME_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: 'dark' }], - }) - }) - - it('reloads after a rejected latest write and contains stale reads and disposal', async () => { - const stale = deferred>() - const describe = vi.fn() - .mockImplementationOnce(() => stale.promise) - .mockResolvedValueOnce(described('system')) - const mutate = vi.fn().mockResolvedValue({ - rpcId: 'rejected' as never, - result: { ok: false as const, error: { code: 'settings-rejected' as const, message: 'disk full', details: {} } }, - }) - const receiver = target() - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - const oldLoad = controller.load() - await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) - await controller.persist('dark') - stale.resolve(described('light')) - await oldLoad - expect(receiver.values).toEqual(['system']) - - const disposedRead = deferred>() - describe.mockImplementationOnce(() => disposedRead.promise) - const pending = controller.load() - controller.dispose() - disposedRead.resolve(described('dark')) - await pending - expect(receiver.values).toEqual(['system']) - }) - - it('keeps remote-browser persistence in memory without calling Host settings', async () => { - const describe = vi.fn() - const mutate = vi.fn() - const receiver = target() - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver, 'memory') - await controller.load() - await controller.persist('dark') - expect(describe).not.toHaveBeenCalled() - expect(mutate).not.toHaveBeenCalled() - expect(receiver.values).toEqual([]) - }) - - it('reloads after a thrown write and ignores a malformed success response', async () => { - const receiver = target() - const describe = vi.fn().mockResolvedValue(described('light')) - const mutate = vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(ok(view('sepia'))) - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - await controller.persist('dark') - await controller.persist('system') - expect(receiver.values).toEqual(['light']) - }) - - it('lets an explicit refresh supersede a stale rejected write', async () => { - const rejected = deferred() - const receiver = target() - const describe = vi.fn().mockResolvedValue(described('system')) - const mutate = vi.fn().mockReturnValue(rejected.promise) - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - const write = controller.persist('dark') - await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - const refresh = controller.load() - rejected.reject(new Error('stale rejection')) - await Promise.all([write, refresh]) - expect(receiver.values).toEqual(['system']) - expect(describe).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 68f0f3c7f8..f6d8a7ff62 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -71,8 +71,7 @@ describe('ThemeService', () => { expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) // Custom ids are in-process extension themes; only the built-in product // preferences cross the Host settings schema. - expect(persist).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledWith('system') + expect(persist).not.toHaveBeenCalled() // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 6b15b210d6..f3ca3240c2 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,9 +8,6 @@ "src" ], "references": [ - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5035572ba4..f9ba0ce0a1 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: c1e818fa8ff52b10e722d9fd450073a6aede85da -README.zh.md: dfac19fa04d6b934c86733cb2a075017740ed37a +README.md: 2e7e50c2251a0cf0daa5821d210a34635acd57ea +README.zh.md: 2d2d732bbc1982f750991fc90de51b78c7ed1019 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c1e818fa8f..2e7e50c225 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the 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 browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `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 catalog invalidation frame: clients refetch `command.list` instead of diffing. -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 `permission` 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 `permission`, `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. +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) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index dfac19fa04..2d2d732bbc 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与 `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 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission`、`ui-theme` 或 `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` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 80d4923dac..2b211f2b6f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -74,7 +74,7 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' const DEFAULT_MAX_MESSAGES = 50 /** Non-model settings namespaces intentionally served to the Web client. */ -const WEB_SETTINGS_NAMESPACES = ['permission', 'ui-theme'] 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 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index cc16519f65..a803d2048f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -308,8 +308,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, theme, 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() })) @@ -321,10 +321,18 @@ describe('settings domain', () => { 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', 'ui-theme']) + 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' }], @@ -335,6 +343,16 @@ describe('settings domain', () => { 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' } })), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 562e645956..0809ab5150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1286,6 +1286,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:^ @@ -1480,10 +1490,19 @@ 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-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2142,6 +2161,9 @@ 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 @@ -2152,9 +2174,6 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..84e273f0da 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -160,8 +160,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 scalar-settings lifecycle has complete unit coverage; + // keep it out of the broader client-runtime GUI debt exemption. + 'packages/client/runtime/src/**/!(settings-preference).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-slots/src/*', 'packages/client/ui-layout/src/*', 'packages/client/web/src/*',