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

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

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
Locale plugin: LocaleService — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
locale 插件:LocaleService——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
## 模型体验

View File

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

View File

@@ -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<string, string>
/** Locale identifier: the two shipped locales. */
export type LocaleId = 'zh' | 'en'
/** One selectable locale: id plus its self-described display name. */
export interface LocaleDefinition {
/** Locale id (persisted; the setLocale argument). */
@@ -91,9 +94,6 @@ export const COMMON_NS = 'common'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.locale'
/** localStorage key holding the persisted locale id. */
export const STORAGE_KEY = 'dsh.locale'
/** The two shipped locales. */
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
{ id: 'zh', label: '中文' },
@@ -116,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.

View File

@@ -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<LocaleSettings> = 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,
)
})
}

View File

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

View File

@@ -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 () => {

View File

@@ -0,0 +1,30 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
LOCALE_SETTINGS_NAMESPACE, apply,
} from '@deepseek-ai/dsh-client-locale'
class MemorySettings extends Settings {
readonly writable = true
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
return Promise.resolve()
}
}
describe('locale host', () => {
it('registers an optional explicit locale preference with the Host settings lifecycle', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
const fiber = ctx.plugin({ apply })
await fiber.await()
const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE)
expect(ctx.settings.get(ns)).toEqual({})
await ctx.settings.update(ns, { preference: 'en' })
expect(ctx.settings.get(ns)).toEqual({ preference: 'en' })
await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow()
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
})

View File

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

View File

@@ -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', () => {

View File

@@ -20,6 +20,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}

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/runtime/README.md
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
README.md: c05089badb29ad0e22ed1f66d7804eccbb11c1d4
README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2

View File

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

View File

@@ -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 注册仍会抛出异常。

View File

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

View File

@@ -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<T> {
/** 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<IApiClient, 'settings'>
/**
* 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<T> {
private tail: Promise<void> = 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<T>,
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<void> {
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<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
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<void> {
this.disposed = true
this.readGeneration += 1
this.writeGeneration += 1
await this.tail
}
private enqueue(operation: () => Promise<void>): Promise<void> {
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
const task = this.tail.then(async () => {
if (this.disposed) return
await operation()
})
// The returned task carries its own settlement to the caller; the queue
// tail is kept fulfilled so one failed target callback cannot strand later operations.
this.tail = task.catch(() => {})
return task
}
private async read(generation: number): Promise<void> {
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
return
}
if (!response.result.ok || this.disposed) return
const 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<string, unknown>)[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<T>(
ctx: Context,
spec: SettingsPreferenceSpec<T>,
): SettingsPreferenceController<T> {
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
}

View File

@@ -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<T>(value: T): RpcResponse<T> {
return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } }
}
function rejected<T>(): RpcResponse<T> {
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<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 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<RpcResponse<SettingsNamespaceView>>()
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<RpcResponse<SettingsNamespaceView>>()
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<ReturnType<typeof described>>()
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()
})
})

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-conversation/README.md
README.md: 8d6c26f67916f043251c58a3283542bd58a08666
README.zh.md: 8dd43cca59f8dfda18ce036b5d8c6f948306c947
README.md: 2789265d867e8e1e23f97e01b2ea7d12960a188c
README.zh.md: 9707c8b64f872fae52bb0c5900f5db403ed75e59

View File

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

View File

@@ -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 提供其余状态和回调。

View File

@@ -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:^",

View File

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

View File

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

View File

@@ -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<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = 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)
}
}

View File

@@ -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<ConversationSettings> = 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,
)
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

@@ -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<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
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)
})
})

View File

@@ -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()
})
})

View File

@@ -11,6 +11,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../ui-slots"
},
@@ -47,6 +50,9 @@
{
"path": "../locale"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
},

View File

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

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: 32868bcac4313a3badfe92dbf41c84e793f09709
README.zh.md: a38765b8004826133875c38deeb66128d52ec986
README.md: b79eac0d7777ac7af9b6a8960dc4d9797b41513d
README.zh.md: c57ccbdb8fdfb735b3a5d0d66f3538dd01966ada

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

View File

@@ -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` 之后。

View File

@@ -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:^",

View File

@@ -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<void> {
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()

View File

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

View File

@@ -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<ThemeSettings> = z.object({
[THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE),
[THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE),
})
/**

View File

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

View File

@@ -19,6 +19,12 @@ usePinnedBrowserLanguages('zh-CN')
const SLOT = 'settings.general.item'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => { resolve = done })
return { promise, resolve }
}
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
@@ -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<Awaited<ReturnType<typeof describe>>>()
b.describe.mockImplementationOnce(() => pending.promise)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const theme = b.ctx.get('theme') as ThemeService
expect(theme.getTheme().preference).toBe('system')
pending.resolve(await describe())
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
await fiber.dispose()
})
it('ignores an invalid preference crossing the settings wire', async () => {
const b = await bench()
b.setHostPreference('sepia')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const theme = b.ctx.get('theme') as ThemeService
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
expect(theme.getTheme().preference).toBe('system')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
const b = await bench()
const host = declareItems(b.slots)

View File

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

View File

@@ -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<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()
})
})

View File

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

View File

@@ -8,9 +8,6 @@
"src"
],
"references": [
{
"path": "../connection"
},
{
"path": "../locale"
},