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

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