feat(agent-presets): give a preset a name and a description

A picker showed directory names, so the settings page could only ever list
`standard` / `core-web` / `cordis` and hope the reader knew what they meant.
A preset may now publish display text in an optional `preset.yml` beside
its composition, and the section renders cards — name, description, and the
one in use — instead of rows.

The file carries display text ONLY. `id` is the directory name and `trust`
comes from the root a preset was discovered under, so neither is writable
there: otherwise a locally authored preset could name itself into the
shipped set. It is a separate file because a composition is a top-level list
of plugin rows — YAML cannot carry sibling keys beside it, and a fake
metadata row would hand the Loader something to load.

Every read failure degrades to no metadata; absent, malformed, wrongly
typed, and blank all mean the same thing and the picker falls back to the
id. Presentation is not capability: a preset whose name is broken still
mounts.

The editor gained name and description fields above the YAML, and clearing
both removes the file rather than storing a blank name.
This commit is contained in:
Yichen Jiang
2026-08-05 16:30:14 +08:00
parent 5b93f48f78
commit 7281615d44
27 changed files with 593 additions and 68 deletions

View File

@@ -29,35 +29,77 @@
color: var(--dsw-alias-label-tertiary);
}
.rows {
/* Cards, not rows: a preset is a thing you pick, and the description is the
part that tells them apart — a row would bury it beside the actions. */
.cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
gap: 12px;
}
.rowCard {
.card {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 12px 14px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
background: var(--dsw-alias-bg-layer-3);
}
.rowHead {
/* The default preset is the one in use; it reads as selected rather than
merely badged. */
.cardActive {
background: var(--dsw-alias-bg-layer-2);
border-color: var(--dsw-alias-label-tertiary);
}
.cardHead {
display: flex;
align-items: baseline;
gap: 8px;
}
.cardName {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.inUse {
margin-left: auto;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.cardDesc {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
flex: 1;
}
.cardMeta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.rowName {
font-size: 15px;
font-weight: 600;
.cardId {
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--dsw-alias-label-dimmed);
}
.cardFoot {
display: flex;
padding-top: 4px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.badge,
@@ -80,30 +122,38 @@
.rowActions {
display: inline-flex;
gap: 8px;
margin-left: auto;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.secondaryButton {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 6px 14px;
background: var(--dsw-alias-bg-layer-3);
color: inherit;
border: none;
border-radius: 7px;
padding: 5px 8px;
background: none;
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 13px;
font-size: 12.5px;
cursor: pointer;
}
.dangerButton {
border: none;
border-radius: 7px;
padding: 5px 8px;
background: none;
color: var(--dsw-alias-state-error-primary);
font: inherit;
font-size: 13px;
font-size: 12.5px;
cursor: pointer;
}
.secondaryButton:hover:not(:disabled),
.dangerButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
.secondaryButton:disabled,
.dangerButton:disabled,
.addButton:disabled {

View File

@@ -35,6 +35,10 @@ export interface AgentPresetSectionInjected {
setId: (id: string) => void
/** Replace the draft's composition text. */
setContent: (content: string) => void
/** Rename the draft. */
setName: (name: string) => void
/** Replace the draft's description. */
setDescription: (description: string) => void
/** Save the open draft. */
save: () => Promise<void>
/** Ask for delete confirmation, or dismiss it with null. */
@@ -56,7 +60,8 @@ interface EditorProps {
draft: PresetDraft
blocker: ReturnType<typeof draftBlocker>
t: (key: AgentPresetSettingsKey) => string
actions: Pick<AgentPresetSectionInjected, 'close' | 'save' | 'setContent' | 'setId'>
actions: Pick<AgentPresetSectionInjected,
'close' | 'save' | 'setContent' | 'setDescription' | 'setId' | 'setName'>
}
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
@@ -66,19 +71,44 @@ function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
{draft.creating
? (
<label className={css.field}>
<span className={css.fieldLabel}>{t('presetName')}</span>
<span className={css.fieldLabel}>{t('presetId')}</span>
<input
className={css.input}
value={draft.id}
autoFocus
spellCheck={false}
placeholder={t('presetNamePlaceholder')}
placeholder={t('presetIdPlaceholder')}
onChange={(event) => { actions.setId(event.target.value) }}
/>
<span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>
</label>
)
: null}
{draft.writable
? (
<>
<label className={css.field}>
<span className={css.fieldLabel}>{t('displayName')}</span>
<input
className={css.input}
value={draft.name}
spellCheck={false}
placeholder={draft.id === '' ? t('displayNamePlaceholder') : draft.id}
onChange={(event) => { actions.setName(event.target.value) }}
/>
</label>
<label className={css.field}>
<span className={css.fieldLabel}>{t('displayDescription')}</span>
<input
className={css.input}
value={draft.description}
placeholder={t('displayDescriptionPlaceholder')}
onChange={(event) => { actions.setDescription(event.target.value) }}
/>
</label>
</>
)
: null}
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
<label className={css.field}>
<span className={css.fieldLabel}>{t('composition')}</span>
@@ -142,20 +172,33 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
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 }
const editorActions = {
close: props.close,
save: props.save,
setContent: props.setContent,
setDescription: props.setDescription,
setId: props.setId,
setName: props.setName,
}
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}>
<ul className={css.cards}>
{state.rows.map(row => (
<li key={row.id} className={css.rowCard}>
<div className={css.rowHead}>
<span className={css.rowName}>{row.id}</span>
<li key={row.id} className={row.isDefault ? `${css.card} ${css.cardActive}` : css.card}>
<div className={css.cardHead}>
<span className={css.cardName}>{row.name ?? row.id}</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</div>
<p className={css.cardDesc}>{row.description ?? t('noDescription')}</p>
<div className={css.cardMeta}>
<span className={css.badge}>{row.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
{row.isDefault ? <span className={css.defaultBadge}>{t('defaultBadge')}</span> : null}
<code className={css.cardId}>{row.id}</code>
</div>
<div className={css.cardFoot}>
<span className={css.rowActions}>
{row.isDefault
? null

View File

@@ -113,6 +113,8 @@ export function apply(ctx: ClientContext): void {
close: () => { section.close() },
setId: (id: string) => { section.setId(id) },
setContent: (content: string) => { section.setContent(content) },
setName: (name: string) => { section.setName(name) },
setDescription: (description: string) => { section.setDescription(description) },
save: () => section.save(),
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
remove: () => section.remove(),

View File

@@ -4,7 +4,9 @@
export type AgentPresetSettingsKey =
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
| 'duplicate' | 'delete' | 'newPreset' | 'presetName' | 'presetNamePlaceholder' | 'copyOf'
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
| 'inUse' | 'noDescription'
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
| 'idRequired' | 'idInvalid' | 'idTaken'
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
@@ -30,8 +32,14 @@ export const en: Record<AgentPresetSettingsKey, string> = {
duplicate: 'Duplicate',
delete: 'Delete',
newPreset: 'New preset',
presetName: 'Preset name',
presetNamePlaceholder: 'my-agent',
presetId: 'Identifier',
presetIdPlaceholder: 'my-agent',
displayName: 'Name',
displayNamePlaceholder: 'Shown in the picker',
displayDescription: 'Description',
displayDescriptionPlaceholder: 'One sentence on what this preset is for',
inUse: 'In use',
noDescription: 'No description.',
copyOf: 'Copied from',
composition: 'Composition (cordis.yml)',
readOnlyNotice: 'This preset ships with the deployment and cannot be edited. Duplicate it to make your own.',
@@ -40,9 +48,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
cancel: 'Cancel',
close: 'Close',
retry: 'Retry',
idRequired: 'Name the preset.',
idRequired: 'Give the preset an identifier.',
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
idTaken: 'A preset with this name already exists.',
idTaken: 'A preset with this identifier already exists.',
deleteTitle: 'Delete this preset?',
deleteDescription:
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
@@ -69,8 +77,14 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
duplicate: '复制',
delete: '删除',
newPreset: '新建预设',
presetName: '预设名称',
presetNamePlaceholder: 'my-agent',
presetId: '标识符',
presetIdPlaceholder: 'my-agent',
displayName: '名称',
displayNamePlaceholder: '选择器中显示的名字',
displayDescription: '描述',
displayDescriptionPlaceholder: '一句话说明这个预设做什么',
inUse: '当前使用',
noDescription: '暂无描述。',
copyOf: '复制自',
composition: '组装(cordis.yml)',
readOnlyNotice: '该预设随部署提供,不可编辑。复制一份即可改成自己的。',
@@ -81,7 +95,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
retry: '重试',
idRequired: '请填写预设名称。',
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
idTaken: '同名预设已存在。',
idTaken: '该标识符已被占用。',
deleteTitle: '删除该预设?',
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
deleteConfirm: '删除',

View File

@@ -17,8 +17,12 @@ 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. */
/** Preset id and directory name; the display name falls back to it. */
id: string
/** Display name the preset published, absent when it published none. */
name?: string
/** One sentence on what the preset is for. */
description?: 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. */
@@ -42,6 +46,10 @@ export interface PresetDraft {
writable: boolean
/** Whether a save is in flight. */
saving: boolean
/** Display name being edited; empty means the picker falls back to the id. */
name: string
/** Description being edited. */
description: string
/** The last save failure, cleared by the next edit. */
error: string | null
}
@@ -172,7 +180,7 @@ export class AgentPresetSectionController {
this.set({ error: response.result.error.message })
return
}
const { content, writable } = response.result.value
const { content, writable, name, description } = response.result.value
this.set({
draft: {
id: creating ? '' : source,
@@ -182,6 +190,10 @@ export class AgentPresetSectionController {
// A copy is always writable: it lands in the local root regardless of
// where the text came from.
writable: creating || writable,
// A copy starts from the source's text but must be renamed, or two
// rows would present themselves identically.
name: creating ? '' : name ?? '',
description: description ?? '',
saving: false,
error: null,
},
@@ -212,6 +224,22 @@ export class AgentPresetSectionController {
this.patchDraft({ content, error: null })
}
/**
* Rename the draft.
* @param name - the display name typed into the editor.
*/
setName(name: string): void {
this.patchDraft({ name, error: null })
}
/**
* Replace the draft's description.
* @param description - the description typed into the editor.
*/
setDescription(description: string): void {
this.patchDraft({ description, error: null })
}
/**
* Save the open draft, then re-read the roster.
*
@@ -225,7 +253,12 @@ export class AgentPresetSectionController {
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 })
const response = await this.api.agentPresets.write({
agentPreset: draft.id,
content: draft.content,
name: draft.name,
description: draft.description,
})
if (!response.result.ok) {
this.patchDraft({ saving: false, error: response.result.error.message })
return