fix(web): persist general preferences in host settings

This commit is contained in:
Yichen Jiang
2026-08-07 16:43:59 +08:00
parent fcc3148cc9
commit 0833b29f25
78 changed files with 1153 additions and 615 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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。

View File

@@ -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.

View File

@@ -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` 分区为空。