fix(agent-presets,web): broken presets are roster rows, not gaps
A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
This commit is contained in:
@@ -67,6 +67,35 @@
|
||||
border-color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* A broken preset reads as damaged before anything else: the card cannot be
|
||||
picked, so its border carries the warning the disabled body cannot. */
|
||||
.cardBroken {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.cardBroken:hover {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.brokenBadge {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* The discovery-reported reason, verbatim: it names the file and the fix. */
|
||||
.cardBrokenReason {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The card body is the control that picks the preset. */
|
||||
.cardMain {
|
||||
flex: 1;
|
||||
|
||||
@@ -177,49 +177,67 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<h3 className={css.groupHead}>{heading}</h3>
|
||||
<ul className={css.cards}>
|
||||
{group.map(row => (
|
||||
<li key={row.id} className={row.isDefault ? `${css.card} ${css.cardActive}` : css.card}>
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
common act, so it should not hide behind a small button.
|
||||
The action row sits outside it — nesting buttons is
|
||||
invalid, and these act on the card rather than select it. */}
|
||||
invalid, and these act on the card rather than select it.
|
||||
A broken preset cannot compose a session, so its body is
|
||||
disabled and the card says why instead of offering it. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
|
||||
title={row.isDefault ? t('inUse') : t('setDefault')}
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{row.name ?? row.id}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{row.description ?? t('noDescription')}</span>
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
from, so READING one is the point; a custom preset is
|
||||
edited in its files instead, which the location action
|
||||
leads to. */}
|
||||
leads to. A broken shipped preset has no readable
|
||||
composition to offer, so its viewer is withheld; a
|
||||
broken custom one keeps the location action — the
|
||||
files are where it gets fixed. */}
|
||||
{row.trust === 'system'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
</button>
|
||||
)
|
||||
? row.broken === undefined
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
@@ -234,8 +252,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable}
|
||||
data-tip={state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AgentPresetSettingsKey =
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
| 'brokenBadge' | 'brokenNoCopy'
|
||||
| 'composition' | 'cancel' | 'close' | 'retry'
|
||||
| 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
|
||||
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
|
||||
@@ -40,6 +41,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
brokenBadge: 'Broken',
|
||||
brokenNoCopy: 'Broken presets cannot be duplicated',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.cordis.yml)',
|
||||
cancel: 'Cancel',
|
||||
@@ -90,6 +93,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
brokenBadge: '已损坏',
|
||||
brokenNoCopy: '预设已损坏,无法复制',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -33,6 +33,13 @@ export interface PresetRow {
|
||||
trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
isDefault: boolean
|
||||
/**
|
||||
* Why the preset cannot compose a session, absent when it can. A broken
|
||||
* row renders marked and unselectable — its directory still occupies the
|
||||
* id, so deleting it (or fixing the files) is the way out, and this page
|
||||
* is where both of those live.
|
||||
*/
|
||||
broken?: string
|
||||
}
|
||||
|
||||
/** The copy dialog: a new id and optional display name over a fixed source. */
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface RosterPreset {
|
||||
name?: string
|
||||
/** One sentence on what the preset is for. */
|
||||
description?: string
|
||||
/** Why the preset cannot compose a session, absent when it can. */
|
||||
broken?: string
|
||||
}
|
||||
|
||||
/** The roster the host answered with. */
|
||||
@@ -134,19 +136,24 @@ export async function beginRosterRead<S extends { status: string; error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The roster entries as every surface renders them.
|
||||
* The roster entries as the pickers render them: healthy presets only.
|
||||
*
|
||||
* The chip, the row, and the management section all show the same three
|
||||
* facts, and `exactOptionalPropertyTypes` makes "absent" and "present as
|
||||
* undefined" different shapes — so the spread dance belongs in one place
|
||||
* rather than once per store.
|
||||
* The chip and the row exist to choose the NEXT session's composition, and a
|
||||
* broken preset cannot compose one — offering it would defer the discovery
|
||||
* of that fact to a failed session start. The management section renders the
|
||||
* full roster (broken rows included) from its own store instead.
|
||||
*
|
||||
* The chip, the row, and the management section all show the same facts, and
|
||||
* `exactOptionalPropertyTypes` makes "absent" and "present as undefined"
|
||||
* different shapes — so the spread dance belongs in one place rather than
|
||||
* once per store.
|
||||
* @param presets - the roster the host answered with.
|
||||
* @returns one option per preset, in roster order.
|
||||
* @returns one option per selectable preset, in roster order.
|
||||
*/
|
||||
export function presetOptions(
|
||||
presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string }[],
|
||||
presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string; broken?: string }[],
|
||||
): AgentPresetOption[] {
|
||||
return presets.map(preset => ({
|
||||
return presets.filter(preset => preset.broken === undefined).map(preset => ({
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
|
||||
Reference in New Issue
Block a user