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
@@ -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',
@@ -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'
@@ -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)
}
}
+34 -3
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,
)
})
}
@@ -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)
}