feat(web): choose the default agent preset from General settings

One settings row picking which preset new sessions are composed from.

It is deliberately a new-session preference, not a live switch: a session's
preset is fixed at creation and the host refuses to adopt an existing session
under a different one, so the row says "applies to sessions you start from now
on" rather than implying it can retune a running agent.

Options and the current value come from one `agentPreset.list` call — the
roster already reports which id an unspecified session gets, so the row needs
no settings-schema introspection, unlike the permission row it is modelled on.
The write targets only the namespace's `default` field.

The menu marks `user` rows: a locally authored preset is exactly as privileged
as the plugins it names, and presenting it identically to a shipped one would
hide that.

An empty roster reads as `unavailable` and renders nothing, because composing
no presets is a valid deployment rather than a failure — distinct from a
roster call that failed, which surfaces its message.
This commit is contained in:
Yichen Jiang
2026-08-04 00:25:19 +08:00
parent e6fe32b3c3
commit 6758da87ae
24 changed files with 760 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
/* Agent-preset row: title/description plus the preset selector pill. */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled {
cursor: default;
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,104 @@
/**
* Agent-preset preference row: the preset new sessions are composed from.
* A running session keeps the composition it began with, so this row never
* disturbs work in progress.
*/
import { useEffect, useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentPresetSettingsState } from './settings-store.ts'
import type { AgentPresetSettingsKey } from './locales.ts'
import css from './AgentPresetRow.module.css'
/** Registration-side business face for the host-backed preference. */
export interface AgentPresetRowInjected {
hooks: {
/** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */
agentPreset: SnapshotStore<AgentPresetSettingsState>
}
/** Load the roster when the row first renders. */
load: () => Promise<void>
/** Persist one preset as the default for later sessions. */
select: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetRowProps =
PropsRuntime<'settings.general.item'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetRowInjected>
/**
* Render the new-session agent-preset selector.
* @param props - composed slot props.
* @returns the row, or null when the deployment composes no presets.
*/
export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) {
const state = useAgentPreset(snapshot => snapshot)
const [open, setOpen] = useState(false)
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (state.writable && state.status !== 'unavailable') return
setOpen(false)
}, [state.status, state.writable])
// A deployment that composes no presets has nothing to choose between, and
// every session shares the host composition — the row simply does not exist.
if (state.status === 'unavailable') return null
const busy = state.status === 'loading' || state.status === 'saving'
const label = state.currentValue === '' ? t('loading') : state.currentValue
const description: string = state.error ?? t('description')
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('title')}</div>
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
// A locally authored preset is exactly as privileged as the plugins it
// names, so the list says which rows are local rather than presenting
// every preset as shipped and vetted.
items={state.options.map(option => ({
id: option.id,
label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
}))}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)
void select(id)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
disabled={busy || !state.writable || state.options.length === 0}
onClick={() => { setOpen(value => !value) }}
>
{label}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Agent-preset row copy. */
'settings.agentPreset': AgentPresetSettingsKey
}
}

View File

@@ -0,0 +1,64 @@
/**
* Agent-preset surface plugin, browser half — one General-settings row that
* writes the default preset for sessions created later.
*
* A running session keeps the composition it began with (the host refuses to
* adopt an existing session under a different preset), so this row is a
* new-session preference rather than a live switch. Per-session choice at
* creation time belongs to the session-start surface, which reads the same
* roster.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetRow } from './AgentPresetRow.tsx'
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
import { en, zh } from './locales.ts'
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection']
/**
* Mount the General-settings row.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api)
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
const injected = (): AgentPresetRowInjected => ({
hooks: { agentPreset: controller.store },
load: () => controller.load(),
select: (id: string) => controller.select(id),
})
ctx.effect(() => {
// The roster is a live directory and the default is a settings field, so
// both an external settings edit and a reconnect can move this row.
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
void controller.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-agent-preset: settings refresh')
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'agent-preset',
order: -25,
locale: 'settings.agentPreset',
inject: injected,
}, AgentPresetRow))
}

View File

@@ -0,0 +1,23 @@
/** Locale bundles for the agent-preset General-settings row. */
/** Locale keys this row renders. */
export type AgentPresetSettingsKey =
| 'title' | 'description' | 'loading' | 'error' | 'userTrust'
/** English copy. */
export const en: Record<AgentPresetSettingsKey, string> = {
title: 'Agent preset',
description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.',
loading: 'Loading presets…',
error: 'Could not load agent presets.',
userTrust: 'Local',
}
/** Simplified Chinese copy. */
export const zh: Record<AgentPresetSettingsKey, string> = {
title: 'Agent preset',
description: '对此后新建的会话生效。运行中的会话保持它开始时的 preset。',
loading: '正在加载 preset…',
error: '无法加载 agent preset。',
userTrust: '本地',
}

View File

@@ -0,0 +1,114 @@
/**
* Agent-preset default-settings controller.
*
* Options and the current default both come from one `agentPreset.list` call:
* the roster already reports which id a session with no explicit choice gets,
* so the row needs no schema introspection. Writes target the settings
* namespace's `default` field, which is what the host resolves at creation.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** The agent-preset settings namespace on the host wire. */
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
/** One selectable preset. */
export interface AgentPresetOption {
/** Preset id, written to Settings and shown as the label. */
id: string
/** Whether the preset ships with the deployment or was authored locally. */
trust: 'system' | 'user'
}
/** Agent-preset settings-row snapshot. */
export interface AgentPresetSettingsState {
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
error: string | null
writable: boolean
currentValue: string
options: readonly AgentPresetOption[]
}
const INITIAL: AgentPresetSettingsState = {
status: 'idle',
error: null,
writable: true,
currentValue: '',
options: [],
}
/** Reads the roster and persists the chosen default. */
export class AgentPresetSettingsController {
/** Row snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL)
constructor(private readonly api: IApiClient) {}
private set(patch: Partial<AgentPresetSettingsState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
/**
* Load the roster. An empty roster means the deployment composes no
* presets, which is a valid deployment rather than a failure — the row
* reports `unavailable` and renders nothing.
* @returns once the snapshot reflects the host.
*/
async load(): Promise<void> {
if (this.store.getSnapshot().status === 'loading') return
this.set({ status: 'loading', error: null })
try {
const response = await this.api.agentPresets.list({})
if (!response.result.ok) {
this.set({ status: 'error', error: response.result.error.message })
return
}
const presets = response.result.value.presets
if (presets.length === 0) {
this.set({ status: 'unavailable', options: [], currentValue: '' })
return
}
this.set({
status: 'ready',
error: null,
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
})
} catch (error) {
this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
}
}
/**
* Persist one preset as the default for sessions created later. Running
* sessions keep the composition they were created with, so this never
* disturbs work in progress.
* @param id - the preset to make default.
* @returns once the write settled and the roster was re-read.
*/
async select(id: string): Promise<void> {
const before = this.store.getSnapshot()
if (before.status === 'saving' || id === before.currentValue) return
this.set({ status: 'saving', error: null, currentValue: id })
try {
const response = await this.api.settings.update({
ns: AGENT_PRESET_SETTINGS_NS,
patch: { default: id },
})
if (!response.result.ok) {
this.set({ status: 'ready', currentValue: before.currentValue, error: response.result.error.message })
return
}
// Re-read rather than trust the patch: the host resolves the default
// through the same roster the row displays.
await this.load()
} catch (error) {
this.set({
status: 'ready',
currentValue: before.currentValue,
error: error instanceof Error ? error.message : String(error),
})
}
}
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -0,0 +1,9 @@
/**
* Agent-preset surface plugin, node half. The empty apply exists so the plugin
* appears in the host cordis.yml / Loader; the browser half ships the
* General-settings row through exports["./client"], discovered from the
* package.json dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`.
* @module @deepseek-ai/dsh-client-ui-agent-preset/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset'
/** Cordis companion plugin name. */
export const name = 'client-ui-agent-preset-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream
* or mutable runtime data; the roster and the settings write are host contracts covered there.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */