feat(web): author agent presets from a settings page

A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.

All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.

Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.

Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.

Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
Yichen Jiang
2026-08-04 12:23:40 +08:00
parent 52607cab69
commit 6dfc568ec2
56 changed files with 3478 additions and 110 deletions

View File

@@ -0,0 +1,215 @@
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.intro {
margin: 0;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
}
.notice {
margin: 0;
font-size: 12px;
color: var(--dsw-alias-state-warn-label);
}
.hint {
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rowCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--dsw-alias-bg-layer-3);
}
.rowHead {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.rowName {
font-size: 15px;
font-weight: 600;
}
.badge,
.defaultBadge {
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
line-height: 16px;
}
.badge {
border: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary);
}
.defaultBadge {
background: var(--dsw-alias-brand-primary);
color: var(--dsw-alias-label-primary-foreground);
}
.rowActions {
display: inline-flex;
gap: 8px;
margin-left: auto;
}
.secondaryButton {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 6px 14px;
background: var(--dsw-alias-bg-layer-3);
color: inherit;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.dangerButton {
border: none;
background: none;
color: var(--dsw-alias-state-error-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.secondaryButton:disabled,
.dangerButton:disabled,
.addButton:disabled {
opacity: 0.5;
cursor: default;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.input,
.code {
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
font: inherit;
font-size: 13px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
.code {
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
line-height: 1.5;
resize: vertical;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
tab-size: 2;
}
.input:focus,
.code:focus {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input::placeholder {
color: var(--dsw-alias-label-dimmed);
}
/* A shipped composition is drawn a rung up, and it is the one most likely to
overflow, so its scroll thumb rebinds to that rung. */
.code[readonly] {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-2);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.editorActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.addCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
padding: 14px 16px;
}
.addButton {
align-self: flex-start;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-size: 13px;
background: var(--dsw-alias-bg-layer-3);
color: inherit;
cursor: pointer;
}
.error {
margin: 0;
font-size: 12px;
color: var(--dsw-alias-state-error-primary);
}
.deleteDialog {
width: min(480px, 100%);
}
.deleteConfirm:not(:disabled) {
border-color: var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
}
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}

View File

@@ -0,0 +1,254 @@
/**
* Agent-presets settings section: the roster as rows, and one composition
* open in a YAML editor at a time.
*
* A shipped preset opens read-only — it is the known-good composition a local
* one is written against — so authoring starts by duplicating one. Deleting a
* preset leaves running sessions alone: a composition is mounted once at
* session creation and nothing re-reads the file.
*/
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts'
import type { AgentPresetSettingsKey } from './locales.ts'
import css from './AgentPresetSection.module.css'
/** Registration-side business face for the management section. */
export interface AgentPresetSectionInjected {
hooks: {
/** Page snapshot bound by the renderer as useAgentPresetSection. */
agentPresetSection: SnapshotStore<AgentPresetSectionState>
}
/** Read the roster; called once when the section first renders. */
load: () => Promise<void>
/** Open one preset's composition in the editor. */
open: (id: string) => Promise<void>
/** Open a copy of one preset — or of the default — as a new preset. */
createFrom: (from?: string) => Promise<void>
/** Close the editor, discarding the draft. */
close: () => void
/** Name the preset a new draft saves to. */
setId: (id: string) => void
/** Replace the draft's composition text. */
setContent: (content: string) => void
/** Save the open draft. */
save: () => Promise<void>
/** Ask for delete confirmation, or dismiss it with null. */
confirmDelete: (id: string | null) => void
/** Delete the preset awaiting confirmation. */
remove: () => Promise<void>
/** Make one preset the default for sessions created later. */
makeDefault: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetSectionInjected>
/** Editor sub-view props: the draft plus the actions that mutate it. */
interface EditorProps {
draft: PresetDraft
blocker: ReturnType<typeof draftBlocker>
t: (key: AgentPresetSettingsKey) => string
actions: Pick<AgentPresetSectionInjected, 'close' | 'save' | 'setContent' | 'setId'>
}
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
const message = draft.error ?? (blocker === undefined ? null : t(blocker))
return (
<div className={css.editor}>
{draft.creating
? (
<label className={css.field}>
<span className={css.fieldLabel}>{t('presetName')}</span>
<input
className={css.input}
value={draft.id}
autoFocus
spellCheck={false}
placeholder={t('presetNamePlaceholder')}
onChange={(event) => { actions.setId(event.target.value) }}
/>
<span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>
</label>
)
: null}
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
<label className={css.field}>
<span className={css.fieldLabel}>{t('composition')}</span>
<textarea
className={css.code}
value={draft.content}
readOnly={!draft.writable}
spellCheck={false}
rows={16}
onChange={(event) => { actions.setContent(event.target.value) }}
/>
</label>
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
<div className={css.editorActions}>
<Button variant="outline" disabled={draft.saving} onClick={() => { actions.close() }}>
{draft.writable ? t('cancel') : t('close')}
</Button>
{draft.writable
? (
<Button
disabled={draft.saving || blocker !== undefined}
onClick={() => { void actions.save() }}
>
{draft.saving ? t('saving') : t('save')}
</Button>
)
: null}
</div>
</div>
)
}
/**
* Render the Agent presets section content column.
* @param props - composed slot props.
* @returns the section, or null when the deployment composes no presets.
*/
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
const { useAgentPresetSection, t, load } = props
const state = useAgentPresetSection(snapshot => snapshot)
useEffect(() => {
void load()
}, [load])
// A deployment that composes no presets has nothing to manage: every
// session shares the host composition and the page would be an empty list.
if (state.status === 'unavailable') return null
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
const detail = state.error ?? ''
return (
<div className={css.section}>
<p className={css.error} role="alert">{`${t('error')} ${detail}`}</p>
<button type="button" className={css.secondaryButton} onClick={() => { void load() }}>
{t('retry')}
</button>
</div>
)
}
const { draft } = state
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
const editorActions = { close: props.close, save: props.save, setContent: props.setContent, setId: props.setId }
return (
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
<p className={css.intro}>{t('sectionIntro')}</p>
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
<ul className={css.rows}>
{state.rows.map(row => (
<li key={row.id} className={css.rowCard}>
<div className={css.rowHead}>
<span className={css.rowName}>{row.id}</span>
<span className={css.badge}>{row.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
{row.isDefault ? <span className={css.defaultBadge}>{t('defaultBadge')}</span> : null}
<span className={css.rowActions}>
{row.isDefault
? null
: (
<button
type="button"
className={css.secondaryButton}
onClick={() => { void props.makeDefault(row.id) }}
>
{t('setDefault')}
</button>
)}
<button
type="button"
className={css.secondaryButton}
onClick={() => { void props.open(row.id) }}
>
{row.trust === 'user' ? t('edit') : t('view')}
</button>
{state.authorable
? (
<button
type="button"
className={css.secondaryButton}
onClick={() => { void props.createFrom(row.id) }}
>
{t('duplicate')}
</button>
)
: null}
{row.trust === 'user'
? (
<button
type="button"
className={css.dangerButton}
onClick={() => { props.confirmDelete(row.id) }}
>
{t('delete')}
</button>
)
: null}
</span>
</div>
{draft !== null && !draft.creating && draft.source === row.id
? <Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
: null}
</li>
))}
</ul>
{draft !== null && draft.creating
? (
<div className={css.addCard}>
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
</div>
)
: (
<button
type="button"
className={css.addButton}
disabled={!state.authorable || state.rows.length === 0}
onClick={() => { void props.createFrom() }}
>
{`+ ${t('newPreset')}`}
</button>
)}
<Modal
open={state.pendingDelete !== null}
onClose={() => { props.confirmDelete(null) }}
title={t('deleteTitle')}
closeLabel={t('close')}
description={t('deleteDescription')}
className={css.deleteDialog as string}
footer={(
<>
<Button
variant="outline"
autoFocus
disabled={state.deleting}
onClick={() => { props.confirmDelete(null) }}
>
{t('cancel')}
</Button>
<Button
variant="outline"
className={css.deleteConfirm}
disabled={state.deleting}
onClick={() => { void props.remove() }}
>
{state.deleting ? t('deleting') : t('deleteConfirm')}
</Button>
</>
)}
/>
</div>
)
}

View File

@@ -1,31 +1,41 @@
/**
* Agent-preset surface plugin, browser half — one General-settings row that
* writes the default preset for sessions created later.
* Agent-preset surface plugin, browser half — three surfaces over one roster:
* a General-settings row for the default preset, a composer seat for the
* session about to start, and a settings section that authors the compositions
* themselves.
*
* 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.
* adopt an existing session under a different preset), so the General row is
* a new-session preference rather than a live switch, and the seat stops
* offering a choice once the conversation starts.
*/
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'
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetRow } from './AgentPresetRow.tsx'
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
import { AgentPresetSection } from './AgentPresetSection.tsx'
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
import { AgentPresetSeatController } from './seat-store.ts'
import { AgentPresetSectionController } from './section-store.ts'
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 { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
export type { AgentPresetSeatState } from './seat-store.ts'
export {
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
} from './section-store.ts'
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection']
@@ -35,7 +45,9 @@ export const inject = ['slots', 'locale', 'connection']
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api)
const { api } = ctx.get('connection') as ConnectionHandle
const controller = new AgentPresetSettingsController(api)
const section = new AgentPresetSectionController(api)
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
@@ -51,6 +63,9 @@ export function apply(ctx: ClientContext): void {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
void controller.load()
// The section reads the same roster and marks the same default, so a
// change made from either surface converges both.
if (section.store.getSnapshot().status !== 'idle') void section.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
@@ -90,6 +105,20 @@ export function apply(ctx: ClientContext): void {
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
})
const sectionInjected = (): AgentPresetSectionInjected => ({
hooks: { agentPresetSection: section.store },
load: () => section.load(),
open: (id: string) => section.open(id),
createFrom: (from?: string) => section.createFrom(from),
close: () => { section.close() },
setId: (id: string) => { section.setId(id) },
setContent: (content: string) => { section.setContent(content) },
save: () => section.save(),
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
remove: () => section.remove(),
makeDefault: (id: string) => section.makeDefault(id),
})
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'agent-preset',
@@ -97,4 +126,14 @@ export function apply(ctx: ClientContext): void {
locale: 'settings.agentPreset',
inject: injected,
}, AgentPresetRow))
// Ordered after Models: choosing a model is routine, and composing an
// agent is the deployment-shaping act behind it.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'agent-presets',
order: 20,
label: () => ctx.locale.bind('settings.agentPreset')('nav'),
locale: 'settings.agentPreset',
inject: sectionInjected,
}, AgentPresetSection))
}

View File

@@ -1,8 +1,13 @@
/** Locale bundles for the agent-preset General-settings row. */
/** Locale bundles for the agent-preset settings row, composer seat, and management section. */
/** Locale keys this row renders. */
/** Locale keys these surfaces render. */
export type AgentPresetSettingsKey =
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
| 'duplicate' | 'delete' | 'newPreset' | 'presetName' | 'presetNamePlaceholder' | 'copyOf'
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
| 'idRequired' | 'idInvalid' | 'idTaken'
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
/** English copy. */
export const en: Record<AgentPresetSettingsKey, string> = {
@@ -13,6 +18,36 @@ export const en: Record<AgentPresetSettingsKey, string> = {
userTrust: 'Local',
seatHint: 'Agent preset for this session — switchable until you send the first message',
lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
nav: 'Agent presets',
sectionIntro:
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
+ 'Built-in presets are read-only; duplicate one to make your own.',
builtIn: 'Built-in',
defaultBadge: 'Default',
setDefault: 'Set as default',
edit: 'Edit',
view: 'View',
duplicate: 'Duplicate',
delete: 'Delete',
newPreset: 'New preset',
presetName: 'Preset name',
presetNamePlaceholder: 'my-agent',
copyOf: 'Copied from',
composition: 'Composition (cordis.yml)',
readOnlyNotice: 'This preset ships with the deployment and cannot be edited. Duplicate it to make your own.',
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
close: 'Close',
retry: 'Retry',
idRequired: 'Name the preset.',
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
idTaken: 'A preset with this name already exists.',
deleteTitle: 'Delete this preset?',
deleteDescription:
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
deleteConfirm: 'Delete',
deleting: 'Deleting…',
}
/** Simplified Chinese copy. */
@@ -24,4 +59,31 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
userTrust: '本地',
seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
lockedHint: '会话开始后,其 agent preset 即固定',
nav: 'Agent preset',
sectionIntro: 'preset 即一个会话的 agent 所运行的插件组装 —— 它的工具、提示词与能力。内置 preset 只读;复制一份即可改成自己的。',
builtIn: '内置',
defaultBadge: '默认',
setDefault: '设为默认',
edit: '编辑',
view: '查看',
duplicate: '复制',
delete: '删除',
newPreset: '新建 preset',
presetName: 'preset 名称',
presetNamePlaceholder: 'my-agent',
copyOf: '复制自',
composition: '组装(cordis.yml)',
readOnlyNotice: '该 preset 随部署提供,不可编辑。复制一份即可改成自己的。',
save: '保存',
saving: '正在保存…',
cancel: '取消',
close: '关闭',
retry: '重试',
idRequired: '请填写 preset 名称。',
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
idTaken: '同名 preset 已存在。',
deleteTitle: '删除该 preset?',
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
deleteConfirm: '删除',
deleting: '正在删除…',
}

View File

@@ -11,6 +11,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import {
createSnapshotStore, type SessionId, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import { messageOf } from './settings-store.ts'
import type { AgentPresetOption } from './settings-store.ts'
/** Composer-seat snapshot for one session. */
@@ -69,7 +70,7 @@ export class AgentPresetSeatController {
error: null,
})
} catch (error) {
this.set({ error: error instanceof Error ? error.message : String(error) })
this.set({ error: messageOf(error) })
}
}
@@ -90,11 +91,7 @@ export class AgentPresetSeatController {
}
this.set({ busy: false, current: response.result.value.agentPreset })
} catch (error) {
this.set({
busy: false,
current: before.current,
error: error instanceof Error ? error.message : String(error),
})
this.set({ busy: false, current: before.current, error: messageOf(error) })
}
}
}

View File

@@ -0,0 +1,292 @@
/**
* Agent-preset management controller: the roster as a list, and one
* composition open in the editor at a time.
*
* The host stays the single fact source. Every mutation writes through the
* wire and the page re-reads the roster afterwards, because a save can change
* more than the row it targeted — creating a preset adds one, and the shape
* check the host applies is what decides whether the text landed at all.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { messageOf, writeDefaultPreset } from './settings-store.ts'
/** Ids a preset directory may be named, mirroring the host's own rule. */
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
/** One preset row the page renders. */
export interface PresetRow {
/** Preset id, also its display name and directory name. */
id: string
/** Whether the preset ships with the deployment or was authored locally. */
trust: 'system' | 'user'
/** Whether a session that names no preset gets this one. */
isDefault: boolean
}
/** The composition currently open in the editor. */
export interface PresetDraft {
/** Preset the draft saves to; empty until a new one is named. */
id: string
/**
* The preset the text came from, shown while a new preset is unnamed so
* the editor can say what it is a copy of.
*/
source: string
/** Whether saving creates a preset rather than replacing one. */
creating: boolean
/** Composition text being edited. */
content: string
/** Whether this draft can be saved at all — a shipped preset opens read-only. */
writable: boolean
/** Whether a save is in flight. */
saving: boolean
/** The last save failure, cleared by the next edit. */
error: string | null
}
/** Page snapshot. */
export interface AgentPresetSectionState {
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
/** Whole-load failure text; a save failure stays on the draft. */
error: string | null
/** Whether the deployment configures a root new presets can be written to. */
authorable: boolean
/** Every preset the deployment currently supplies. */
rows: readonly PresetRow[]
/** The open editor, or null when the page is just the list. */
draft: PresetDraft | null
/** The preset awaiting delete confirmation. */
pendingDelete: string | null
/** Whether a delete is in flight. */
deleting: boolean
}
const INITIAL: AgentPresetSectionState = {
status: 'idle',
error: null,
authorable: false,
rows: [],
draft: null,
pendingDelete: null,
deleting: false,
}
/**
* Why this draft cannot be saved yet, as a locale key, or undefined when it
* can. Client-side only: the host re-checks both the id and the composition
* shape, and its answer is what the editor reports on failure.
* @param draft - the open draft.
* @param rows - the roster, for the collision check.
* @returns the blocking reason's locale key, or undefined when saveable.
*/
export function draftBlocker(
draft: PresetDraft,
rows: readonly PresetRow[],
): 'idRequired' | 'idInvalid' | 'idTaken' | undefined {
if (!draft.creating) return undefined
if (draft.id === '') return 'idRequired'
if (!PRESET_ID.test(draft.id)) return 'idInvalid'
// Replacing an existing preset is what Edit is for; a create that lands on
// a name already in use would overwrite something the user did not open.
if (rows.some(row => row.id === draft.id)) return 'idTaken'
return undefined
}
/** Reads the roster and drives the composition editor. */
export class AgentPresetSectionController {
/** Page snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
constructor(private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>) {}
private set(patch: Partial<AgentPresetSectionState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
private patchDraft(patch: Partial<PresetDraft>): void {
const { draft } = this.store.getSnapshot()
if (draft === null) return
this.set({ draft: { ...draft, ...patch } })
}
/**
* Load the roster. An empty roster means the deployment composes no
* presets, which is a valid deployment rather than a failure — the section
* 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, authorable } = response.result.value
if (presets.length === 0) {
this.set({ status: 'unavailable', rows: [], authorable, draft: null })
return
}
this.set({ status: 'ready', error: null, authorable, rows: presets.map(preset => ({ ...preset })) })
} catch (error) {
this.set({ status: 'error', error: messageOf(error) })
}
}
/**
* Open one preset's composition in the editor.
*
* A shipped preset opens read-only rather than not opening: it is the
* known-good composition a local one is written against, so being able to
* read it is the point.
* @param id - the preset to open.
* @returns once the composition loaded or the failure is on the page.
*/
async open(id: string): Promise<void> {
await this.openDraft(id, false)
}
/**
* Open a copy of one preset as a new, unnamed preset. With no argument the
* copy is taken from the default preset, which is the composition a new
* session gets and therefore the one worth starting from.
* @param from - the preset to copy, or undefined for the default.
* @returns once the composition loaded or the failure is on the page.
*/
async createFrom(from?: string): Promise<void> {
const source = from ?? this.store.getSnapshot().rows.find(row => row.isDefault)?.id
/* v8 ignore next -- the section only renders with a roster, and every roster marks a default */
if (source === undefined) return
await this.openDraft(source, true)
}
private async openDraft(source: string, creating: boolean): Promise<void> {
this.set({ error: null })
try {
const response = await this.api.agentPresets.read({ agentPreset: source })
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
const { content, writable } = response.result.value
this.set({
draft: {
id: creating ? '' : source,
source,
creating,
content,
// A copy is always writable: it lands in the local root regardless of
// where the text came from.
writable: creating || writable,
saving: false,
error: null,
},
})
} catch (error) {
this.set({ error: messageOf(error) })
}
}
/** Close the editor, discarding whatever was typed. */
close(): void {
this.set({ draft: null })
}
/**
* Name the preset a new draft saves to.
* @param id - the id typed into the editor.
*/
setId(id: string): void {
this.patchDraft({ id, error: null })
}
/**
* Replace the draft's composition text.
* @param content - the text in the editor.
*/
setContent(content: string): void {
this.patchDraft({ content, error: null })
}
/**
* Save the open draft, then re-read the roster.
*
* The host shape-checks the text, so a composition that could never load is
* refused here rather than at the next session that selects it.
* @returns once the write settled and the page reflects it.
*/
async save(): Promise<void> {
const draft = this.store.getSnapshot().draft
if (draft === null || draft.saving || !draft.writable) return
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
this.patchDraft({ saving: true, error: null })
try {
const response = await this.api.agentPresets.write({ agentPreset: draft.id, content: draft.content })
if (!response.result.ok) {
this.patchDraft({ saving: false, error: response.result.error.message })
return
}
this.set({ draft: null })
await this.load()
} catch (error) {
this.patchDraft({ saving: false, error: messageOf(error) })
}
}
/**
* Ask for confirmation before deleting one preset.
* @param id - the preset to delete, or null to dismiss the confirmation.
*/
confirmDelete(id: string | null): void {
if (this.store.getSnapshot().deleting) return
this.set({ pendingDelete: id })
}
/**
* Delete the preset awaiting confirmation, then re-read the roster.
*
* A session already composed from it keeps running: its composition was
* mounted at creation and nothing re-reads the file.
* @returns once the delete settled and the page reflects it.
*/
async remove(): Promise<void> {
const { pendingDelete, deleting, draft } = this.store.getSnapshot()
if (pendingDelete === null || deleting) return
this.set({ deleting: true, error: null })
try {
const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete })
if (!response.result.ok) {
this.set({ deleting: false, pendingDelete: null, error: response.result.error.message })
return
}
this.set({
deleting: false,
pendingDelete: null,
// The editor cannot stay open on a file that no longer exists.
draft: draft?.id === pendingDelete && !draft.creating ? null : draft,
})
await this.load()
} catch (error) {
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
}
}
/**
* Make one preset the default for sessions created later. Running sessions
* keep the composition they began with, so this never disturbs work.
* @param id - the preset to make default.
* @returns once the write settled and the roster was re-read.
*/
async makeDefault(id: string): Promise<void> {
const failure = await writeDefaultPreset(this.api, id)
if (failure !== undefined) {
this.set({ error: failure })
return
}
await this.load()
}
}

View File

@@ -13,6 +13,42 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
/** The agent-preset settings namespace on the host wire. */
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
/**
* Human text for a rejected wire call. A transport failure rejects with an
* Error; a host or a runtime can reject with anything, and the surface still
* has to say something.
* @param error - the rejection value.
* @returns the message to show.
*/
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Persist one preset as the default for sessions created later.
*
* The default is a settings field rather than a preset property, so both the
* General row and the management section write it here — one home for which
* namespace and field the host resolves at session creation.
* @param api - the settings wire face.
* @param id - the preset to make default.
* @returns the failure message, or undefined once the write landed.
*/
export async function writeDefaultPreset(
api: Pick<IApiClient, 'settings'>,
id: string,
): Promise<string | undefined> {
let response
try {
response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } })
} catch (error) {
// The transport rejected rather than answering; the caller must be able to
// say so instead of the row silently snapping back.
return messageOf(error)
}
return response.result.ok ? undefined : response.result.error.message
}
/** One selectable preset. */
export interface AgentPresetOption {
/** Preset id, written to Settings and shown as the label. */
@@ -65,7 +101,8 @@ export class AgentPresetSettingsController {
return
}
const presets = response.result.value.presets
if (presets.length === 0) {
const [first] = presets
if (first === undefined) {
this.set({ status: 'unavailable', options: [], currentValue: '' })
return
}
@@ -73,10 +110,12 @@ export class AgentPresetSettingsController {
status: 'ready',
error: null,
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
// A roster can mark nothing default: settings can name a preset that
// was since deleted, and the picker still has to show something.
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
})
} catch (error) {
this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
this.set({ status: 'error', error: messageOf(error) })
}
}
@@ -91,24 +130,13 @@ export class AgentPresetSettingsController {
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),
})
const failure = await writeDefaultPreset(this.api, id)
if (failure !== undefined) {
this.set({ status: 'ready', currentValue: before.currentValue, error: failure })
return
}
// Re-read rather than trust the patch: the host resolves the default
// through the same roster the row displays.
await this.load()
}
}