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/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"
},