fix(web): persist theme preference in settings

This commit is contained in:
Yichen Jiang
2026-08-06 20:28:15 +08:00
parent 6a32047e77
commit dd473870dd
30 changed files with 692 additions and 121 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md
2026-08-06-host-backed-web-theme-preference.md: 129132586b0d0ccfdb5b32fdaa1f7178a7176db7
2026-08-06-host-backed-web-theme-preference.zh.md: 0c2dafff3fc3cec49a2261e31a61ecf99a10f126
@@ -0,0 +1,39 @@
# 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.
@@ -0,0 +1,39 @@
# 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` 分区为空。
+3 -1
View File
@@ -185,6 +185,8 @@ export interface LaunchOptions {
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
*/
remoteAuthority?: string
/** Reuse an existing harness home so a second Host can verify user settings across origins. */
harnessHome?: string
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -231,7 +233,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
let persistenceRoot: string
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
+31 -8
View File
@@ -1,6 +1,6 @@
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// the Language row (settings-scoped localization + persisted dsh.locale),
// the busy-state Enter preference, plus Permission as the persisted default
@@ -152,13 +152,13 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('flips the theme through the Appearance cubes and persists across reload', async () => {
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
await page.evaluate(() => ({
const readState = async (target: Page = page): Promise<{ attr: boolean; token: string; legacy: string | null }> =>
await target.evaluate(() => ({
attr: document.body.hasAttribute('data-ds-dark-theme'),
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
stored: localStorage.getItem('dsh.theme'),
legacy: localStorage.getItem('dsh.theme'),
}))
// Pin the OS scheme to light so the default `system` preference resolves
// light and the dark flip below is unambiguously the gesture's doing.
@@ -172,13 +172,15 @@ describe('web e2e: settings modal and General preferences', () => {
const darkCube = dialog.getByRole('button', { name: '深色' })
expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
await darkCube.click()
// The full cascade: pressed state, persisted preference, body attribute,
// The full cascade: pressed state, Host-backed preference, body attribute,
// alias token flip — all from one real user gesture.
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
const dark = await readState()
expect(dark.attr).toBe(true)
expect(dark.stored).toBe('dark')
expect(dark.legacy).toBeNull()
expect(dark.token).not.toBe(light.token)
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-theme:\n\s+preference: dark/)
await page.keyboard.press('Escape')
// Reload: the preference survives boot (restore + presenter initial apply).
@@ -189,7 +191,28 @@ describe('web e2e: settings modal and General preferences', () => {
await page.emulateMedia({ colorScheme: 'light' })
const reloaded = await readState()
expect(reloaded.attr).toBe(true)
expect(reloaded.stored).toBe('dark')
expect(reloaded.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.
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const secondTripwire = watchConsole(secondPage)
try {
expect(second.baseUrl).not.toBe(scaffold.baseUrl)
await secondPage.emulateMedia({ colorScheme: 'light' })
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const crossPort = await readState(secondPage)
expect(crossPort.attr).toBe(true)
expect(crossPort.legacy).toBeNull()
expect(secondTripwire.pageErrors).toEqual([])
expect(secondTripwire.warnings).toEqual([])
} finally {
await secondPage.close()
await second.close()
}
// `system` follows the emulated OS scheme (dark stays dark, light clears).
await page.getByRole('button', { name: '设置', exact: true }).click()
+2 -2
View File
@@ -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` |
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` |
| `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` |
| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` |
| `slash/input-begin-command` | - | `ui-conversation` |
| `slash/input-consume-token` | - | `ui-conversation` |
| `slash/input-insert-reference` | - | `ui-conversation` |
+2 -1
View File
@@ -399,6 +399,7 @@ flowchart TD
pkg_client_ui_slash --> pkg_client_ui_primitives
pkg_client_ui_slash --> pkg_client_ui_slots
pkg_client_ui_slash --> pkg_invariants
pkg_client_ui_theme --> pkg_client_connection
pkg_client_ui_theme --> pkg_client_locale
pkg_client_ui_theme --> pkg_client_runtime
pkg_client_ui_theme --> pkg_client_ui_primitives
@@ -1150,7 +1151,7 @@ flowchart TD
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
README.md: 88e21fe214ec806b101050949690283d811be36d
README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50
README.md: 32868bcac4313a3badfe92dbf41c84e793f09709
README.zh.md: a38765b8004826133875c38deeb66128d52ec986
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
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.
`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.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light``dark``system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }``body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8
主题插件:基于 --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)拥有
`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` 之后。
+6 -1
View File
@@ -25,6 +25,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
],
@@ -33,6 +34,7 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -42,6 +44,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:^",
@@ -64,6 +67,8 @@
"watch": "tsdown --watch"
},
"dependencies": {
"clsx": "^2.0.0"
"@deepseek-ai/dsh-settings": "workspace:^",
"clsx": "^2.0.0",
"schemastery": "^3.18.0"
}
}
@@ -10,7 +10,7 @@ import {
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemePreference } from './index.ts'
import type { ThemePreference } from '../theme-settings.ts'
import type { ThemeKey } from './locales.ts'
import type {} from './settings-contract.ts'
import type { createAppearanceRowStore } from './settings-store.ts'
+71 -46
View File
@@ -1,12 +1,14 @@
/**
* Browser theme registry over the `--dsw-*` token stylesheets. The service
* owns the theme preference (light/dark/system), resolves `system` through
* owns the live theme preference (light/dark/system), resolves `system` through
* `prefers-color-scheme`, and publishes immutable snapshots; it never touches
* the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin
* also registers the Appearance preference row into the settings General
* section — the theme feature owns its own settings surface.
* the DOM — ui-layout's presenter consumes the resolved snapshot. The Host
* settings controller loads and stores the preference in the user-settings
* document. The plugin also registers the Appearance preference row into the
* settings General section — the theme feature owns its own settings surface.
*/
import type { Context } from 'cordis'
import type { 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'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -14,11 +16,22 @@ 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,
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,
type ThemePreference,
} from '../theme-settings.ts'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.theme'
@@ -33,9 +46,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
export type ThemeTokens = Record<string, string>
/** Theme preference: a concrete theme id or follow-the-OS. */
export type ThemePreference = 'light' | 'dark' | 'system'
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */
export interface ThemeDefinition {
/** Theme id (the setTheme argument for concrete themes). */
@@ -76,12 +86,6 @@ declare module 'cordis' {
}
}
/** localStorage key holding the persisted theme preference. */
export const STORAGE_KEY = 'dsh.theme'
/** Default preference when nothing (or garbage) is persisted. */
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }),
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
@@ -103,14 +107,17 @@ export class ThemeService {
private revision = 0
private snapshot: ThemeSnapshot
private readonly media: MediaQueryList | undefined
private persist: (preference: ThemePreference) => void
/**
* @param ctx - owning context (change events are emitted on it; the
* media-query listener is released through ctx.effect on dispose).
* @param persist - durable write callback for built-in preferences.
*/
constructor(ctx: Context) {
constructor(ctx: Context, persist: (preference: ThemePreference) => void = () => {}) {
this.ctx = ctx
this.preference = restorePreference()
this.persist = persist
this.preference = DEFAULT_PREFERENCE
// Non-browser runs (node e2e booting the client tree) have no matchMedia.
this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)')
this.snapshot = this.buildSnapshot()
@@ -136,8 +143,17 @@ export class ThemeService {
}
/**
* Switch the theme preference — the only preference write entry. Persists
* the preference and emits `theme/change`.
* Bind the owning plugin's durable writer before the service is provided.
* @param persist - callback accepting built-in preference changes.
*/
bindPersistence(persist: (preference: ThemePreference) => void): void {
this.persist = persist
}
/**
* Switch the theme preference — the only user preference write entry.
* Built-in preferences are persisted and every accepted value emits
* `theme/change`.
* @param id - a registered theme id or `system`; unknown ids throw.
*/
setTheme(id: string): void {
@@ -146,7 +162,17 @@ export class ThemeService {
}
if (this.preference === id) return
this.preference = id as ThemePreference
persistPreference(this.preference)
if (isThemePreference(id)) this.persist(id)
this.publish()
}
/**
* Apply a preference read from Host settings without writing it back.
* @param preference - validated durable preference.
*/
syncPreference(preference: ThemePreference): void {
if (this.preference === preference) return
this.preference = preference
this.publish()
}
@@ -170,7 +196,7 @@ export class ThemeService {
this.themes = this.themes.filter(t => t.id !== definition.id)
if (this.preference === definition.id) {
this.preference = DEFAULT_PREFERENCE
persistPreference(this.preference)
this.persist(this.preference)
}
this.publish()
}
@@ -200,32 +226,8 @@ export class ThemeService {
}
}
/** Read the persisted preference; unknown or unreadable values fall back to the default. */
function restorePreference(): ThemePreference {
// Non-browser runs (node e2e booting the client tree) have no localStorage.
if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
} catch {
// Storage access can throw (privacy mode); the default below covers it.
}
return DEFAULT_PREFERENCE
}
/** Persist the preference; storage failures are non-fatal (preference resets next boot). */
function persistPreference(preference: ThemePreference): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, preference)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
/** Required services: slots + locale (the feature registers its own settings row with localized copy). */
export const inject = ['slots', 'locale']
/** Required services: settings transport plus slots/locale for the Appearance row. */
export const inject = ['slots', 'locale', 'connection']
/**
* Client plugin body: provide the theme service and register the
@@ -233,10 +235,33 @@ export const inject = ['slots', 'locale']
* slot (a feature owns its settings surface).
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
export async function apply(ctx: ClientContext): Promise<void> {
const connection = ctx.get('connection') as ConnectionHandle
const theme = new ThemeService(ctx)
const controller = new ThemeSettingsController(
connection.api,
theme,
connection.isLoopback ? 'host' : 'memory',
)
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()
@@ -4,7 +4,7 @@
* reads via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ThemePreference } from './index.ts'
import type { ThemePreference } from '../theme-settings.ts'
/** Store state mirrored from the theme snapshot. */
export interface AppearanceRowState {
@@ -0,0 +1,100 @@
/** 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<string, unknown>)[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<void> = 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<IApiClient, 'settings'>,
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<void> {
const generation = ++this.generation
if (this.persistence === 'memory') return
await this.writeTail
if (generation !== this.generation) return
let response: Awaited<ReturnType<Pick<IApiClient, 'settings'>['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<void> {
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
}
}
+34 -3
View File
@@ -1,4 +1,35 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host registration for the browser theme preference. */
/** Host plugin body — no host-side behavior for the theme plugin. */
export function apply(): void {}
import type { Context } from 'cordis'
import z from 'schemastery'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
type ThemePreference,
} from './theme-settings.ts'
export {
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
type ThemePreference,
} from './theme-settings.ts'
interface ThemeSettings {
preference: ThemePreference
}
const ThemeSettingsSchema: z<ThemeSettings> = z.object({
[THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE),
})
/**
* Register the durable theme section when a settings provider exists.
* @param ctx - Host context whose optional settings service owns the section.
*/
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(THEME_SETTINGS_NAMESPACE),
ThemeSettingsSchema,
)
})
}
+4 -4
View File
@@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the theme registry publishes immutable snapshots on
* its own `theme/change` event synchronously with the setter/registry
* mutation in the same service — snapshot/event agreement is asserted
* directly by this package's behavior specs.
* No runtime invariant: the settings seam validates and publishes the durable
* theme section, while the registry emits `theme/change` synchronously with
* its own mutations. Store/registry agreement is covered directly by this
* package's Host, controller, and service behavior specs.
*/
const install: InvariantInstaller = () => {}
@@ -0,0 +1,22 @@
/** Theme preferences stored in the Host user-settings document. */
/** Settings namespace owned by the theme plugin. */
export const THEME_SETTINGS_NAMESPACE = 'ui-theme'
/** Field carrying the selected built-in theme preference. */
export const THEME_PREFERENCE_FIELD = 'preference'
/** Theme preference persisted by the product Appearance row. */
export type ThemePreference = 'light' | 'dark' | 'system'
/** Default preference when the user-settings document has no override. */
export const DEFAULT_PREFERENCE: ThemePreference = 'system'
/**
* Narrow one wire or registry value to a persistable preference.
* @param value - value crossing the settings or registry boundary.
* @returns whether the value is a built-in preference.
*/
export function isThemePreference(value: unknown): value is ThemePreference {
return value === 'light' || value === 'dark' || value === 'system'
}
+61 -5
View File
@@ -2,11 +2,13 @@
* locale service, declaration-aware Appearance row registration, snapshot
* projection into the row store, and HMR collapse recovery. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
import {
apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE,
} from '@deepseek-ai/dsh-client-ui-theme/client'
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
@@ -17,12 +19,39 @@ usePinnedBrowserLanguages('zh-CN')
const SLOT = 'settings.general.item'
async function bench() {
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
let preference = 'system'
const namespace = () => ({
ns: THEME_SETTINGS_NAMESPACE,
schema: {},
value: { preference },
applies: 'live' as const,
secrets: [],
revision: 0,
})
const describe = vi.fn(() => Promise.resolve({
rpcId: 'theme-describe' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
},
}))
const mutate = vi.fn((request: { ops: { value: string }[] }) => {
preference = request.ops[0]!.value
return Promise.resolve({
rpcId: 'theme-mutate' as never,
result: { ok: true as const, value: namespace() },
})
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
return {
ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate,
setHostPreference: (next: string) => { preference = next },
}
}
/** Stand in for the settings shell: declare the General item slot from root. */
@@ -45,7 +74,7 @@ function faceOf(slots: SlotsService) {
describe('ui-theme apply', () => {
it('declares the slot and locale services', () => {
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => {
@@ -84,6 +113,33 @@ describe('ui-theme apply', () => {
face.setTheme('system')
expect(theme.getTheme().preference).toBe('system')
expect(instance.getSnapshot().preference).toBe('system')
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
})
it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => {
const b = await bench()
b.setHostPreference('dark')
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const theme = b.ctx.get('theme') as ThemeService
expect(theme.getTheme().preference).toBe('dark')
b.ctx.emit('settings/changed', 'unrelated')
expect(b.describe).toHaveBeenCalledOnce()
b.setHostPreference('light')
b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE)
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
b.setHostPreference('dark')
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
const remote = await bench(false)
declareItems(remote.slots)
await remote.ctx.plugin({ inject: [...inject], apply }).await()
const remoteTheme = remote.ctx.get('theme') as ThemeService
remoteTheme.setTheme('dark')
await Promise.resolve()
expect(remote.describe).not.toHaveBeenCalled()
expect(remote.mutate).not.toHaveBeenCalled()
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
@@ -0,0 +1,30 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
} from '@deepseek-ai/dsh-client-ui-theme'
class MemorySettings extends Settings {
readonly writable = true
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
return Promise.resolve()
}
}
describe('ui-theme host', () => {
it('registers, validates, and disposes the durable theme namespace with its fiber', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
const fiber = ctx.plugin({ apply })
await fiber.await()
const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE)
expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE })
await ctx.settings.update(ns, { preference: 'dark' })
expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' })
await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow()
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
})
@@ -15,18 +15,25 @@ describe('invariant companion', () => {
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
it('node-half waits for an optional settings provider', () => {
nodeApply(new Context())
expect(true).toBe(true)
})
it('client apply provides ctx.theme over the slots/locale edges', async () => {
// The feature registers its own Appearance settings row with localized
// copy, hence the slots + locale edges.
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
const ctx = new Context()
new SlotsService(ctx)
await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
ctx.provide('connection', {
api: { settings: { describe: () => Promise.resolve({
rpcId: 'theme-invariant' as never,
result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } },
}) } },
isLoopback: true,
} as never)
await ctx.plugin({ inject, apply: clientApply }).await()
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
})
@@ -0,0 +1,149 @@
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<T>(value: T): RpcResponse<T> {
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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
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<ReturnType<typeof ok<SettingsNamespaceView>>>()
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<ReturnType<typeof described>>()
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<ReturnType<typeof described>>()
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<never>()
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()
})
})
+31 -28
View File
@@ -1,21 +1,22 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
const make = (persist = vi.fn()): {
ctx: Context
theme: ThemeService
events: ThemeSnapshot[]
persist: typeof persist
} => {
const ctx = new Context()
const events: ThemeSnapshot[] = []
ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
return { ctx, theme: new ThemeService(ctx), events }
return { ctx, theme: new ThemeService(ctx, persist), events, persist }
}
describe('ThemeService', () => {
beforeEach(() => {
localStorage.clear()
})
it('defaults to the system preference resolved against prefers-color-scheme', () => {
const { theme } = make()
const snapshot = theme.getTheme()
@@ -26,12 +27,12 @@ describe('ThemeService', () => {
expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
})
it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
const { theme, events } = make()
it('setTheme switches, requests persistence, republishes, and keeps DOM untouched', () => {
const { theme, events, persist } = make()
theme.setTheme('dark')
expect(theme.getTheme().preference).toBe('dark')
expect(theme.getTheme().active.colorScheme).toBe('dark')
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
expect(persist).toHaveBeenCalledWith('dark')
expect(events).toHaveLength(1)
expect(events[0]).toBe(theme.getTheme())
// The service never touches presentation state.
@@ -39,13 +40,17 @@ describe('ThemeService', () => {
// Same-value set is a no-op (no extra event).
theme.setTheme('dark')
expect(events).toHaveLength(1)
expect(persist).toHaveBeenCalledOnce()
})
it('restores a persisted preference and falls back on garbage', () => {
localStorage.setItem(STORAGE_KEY, 'dark')
expect(make().theme.getTheme().preference).toBe('dark')
localStorage.setItem(STORAGE_KEY, 'sepia')
expect(make().theme.getTheme().preference).toBe('system')
it('syncs a Host preference without writing it back', () => {
const { theme, events, persist } = make()
theme.syncPreference('dark')
expect(theme.getTheme().preference).toBe('dark')
expect(events).toHaveLength(1)
expect(persist).not.toHaveBeenCalled()
theme.syncPreference('dark')
expect(events).toHaveLength(1)
})
it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
@@ -56,7 +61,7 @@ describe('ThemeService', () => {
})
it('registered themes join the snapshot; disposing the active one resets to default', () => {
const { theme, events } = make()
const { theme, events, persist } = make()
const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
theme.setTheme('sepia')
@@ -64,7 +69,10 @@ describe('ThemeService', () => {
dispose()
expect(theme.getTheme().preference).toBe('system')
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
// Custom ids are in-process extension themes; only the built-in product
// preferences cross the Host settings schema.
expect(persist).toHaveBeenCalledTimes(1)
expect(persist).toHaveBeenCalledWith('system')
// register + set + dispose = three publishes; disposer is idempotent.
expect(events.length).toBe(3)
dispose()
@@ -88,16 +96,11 @@ describe('ThemeService', () => {
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
})
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
vi.stubGlobal('localStorage', undefined)
try {
const { theme } = make()
expect(theme.getTheme().preference).toBe('system')
theme.setTheme('dark')
expect(theme.getTheme().preference).toBe('dark')
} finally {
vi.unstubAllGlobals()
}
it('uses a no-op persistence callback when constructed directly', () => {
const ctx = new Context()
const theme = new ThemeService(ctx)
theme.setTheme('dark')
expect(theme.getTheme().preference).toBe('dark')
})
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
+6
View File
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../connection"
},
{
"path": "../locale"
},
@@ -23,6 +26,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
README.md: c1e818fa8ff52b10e722d9fd450073a6aede85da
README.zh.md: dfac19fa04d6b934c86733cb2a075017740ed37a
+1 -1
View File
@@ -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 preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `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.
## Carrier layer (`/client` + root)
+1 -1
View File
@@ -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-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `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` 错误应答这些领域。
## 载体层(`/client` + 根路径)
+1 -1
View File
@@ -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'] as const
const WEB_SETTINGS_NAMESPACES = ['permission', 'ui-theme'] as const
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
@@ -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 and the product onboarding namespace are the
// non-model namespaces intentionally admitted by this surface.
// registering; permission, theme, and the product onboarding namespace
// are the non-model namespaces 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() }))
@@ -318,15 +318,23 @@ describe('settings domain', () => {
}), {
base: { defaultPreset: 'read-only' },
})
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}))
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission', 'ui-theme'])
const permission = expectOk(await api.settings.mutate(request({
ns: 'permission',
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
})))
expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
const theme = expectOk(await api.settings.mutate(request({
ns: 'ui-theme',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})))
expect(theme.value).toEqual({ preference: 'dark' })
for (const response of [
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
@@ -340,19 +348,29 @@ describe('settings domain', () => {
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
})
it('serves the product onboarding namespace without invalidating the model catalog', async () => {
it('serves product preference namespaces without invalidating the model catalog', async () => {
const ctx = await harness()
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}))
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['ui-onboarding'])
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
.toEqual(['ui-onboarding', 'ui-theme'])
const frames = await collectHost(api, ['host/settings-changed'], 2, async () => {
expectOk(await api.settings.mutate(request({
ns: 'ui-onboarding',
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
})))
expectOk(await api.settings.mutate(request({
ns: 'ui-theme',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})))
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'ui-onboarding' },
{ type: 'host/settings-changed', ns: 'ui-theme' },
])
})
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
+9
View File
@@ -2130,10 +2130,19 @@ importers:
packages/client/ui-theme:
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