feat(web): order the shipped presets, and edit one on its own screen

The picker listed presets alphabetically by id, so the shipped set read
cordis, minimal, standard — reverse order of capability. A preset may now
declare `order` in its metadata; the shipped three declare 1/2/3 and read
standard, minimal, cordis. A preset that declares none sorts behind those
that do, then by id, so authored presets stay stable.

Editing had nowhere good to live. Inside a card it was squeezed into a
~268px column; hanging off the end of the grid it was orphaned from the card
it edits. It now replaces the list: a back link, what is being edited, and
the form at full width. One thing on screen at a time, which is what the
form's height wanted all along.

Cards in different grid rows sized independently, so a short description made
a short card. `grid-auto-rows: 1fr` makes every row the same height.

The trust badge lost its pill when the card CSS was rewritten, and `In use`
never had one; both are tags now. Icon labels moved from `title` to a drawn
tooltip — the native one waits about a second, which reads as nothing
happening.
This commit is contained in:
Yichen Jiang
2026-08-05 17:04:57 +08:00
parent 4f41608cbc
commit 64fc536982
14 changed files with 202 additions and 45 deletions

View File

@@ -52,6 +52,9 @@
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
/* Every row the same height, so a short description does not make its card
shorter than the one beside it. */
grid-auto-rows: 1fr;
gap: 12px;
}
@@ -64,6 +67,7 @@
transition: border-color .16s, background .16s;
}
.card:hover:not(.cardActive) {
border-color: var(--dsw-alias-label-dimmed);
}
@@ -76,6 +80,7 @@
/* The card body is the control that picks the preset. */
.cardMain {
flex: 1;
appearance: none;
border: 0;
background: none;
@@ -111,11 +116,25 @@
line-height: 1.4;
}
.badge,
.inUse {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
}
.badge {
border: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary);
}
.inUse {
margin-left: auto;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.cardDesc {
@@ -123,6 +142,7 @@
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
flex: 1;
min-height: 42px;
}
.cardId {
@@ -133,6 +153,7 @@
.cardFoot {
display: flex;
justify-content: flex-end;
gap: 2px;
padding: 6px 10px;
border-top: 1px solid var(--dsw-alias-border-l2);
@@ -141,6 +162,7 @@
/* Icon-only actions: the label rides `title` so the row stays quiet until
someone reaches for it. */
.iconButton {
position: relative;
appearance: none;
border: 0;
border-radius: 7px;
@@ -162,6 +184,29 @@
outline-offset: -1px;
}
.iconButton::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 3px 8px;
border-radius: 6px;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
font-size: 11px;
line-height: 17px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity .12s;
}
.iconButton:hover::after,
.iconButton:focus-visible::after {
opacity: 1;
}
.iconDanger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
@@ -255,11 +300,38 @@
gap: 8px;
}
.addCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
padding: 14px 16px;
.editorBar {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 4px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.backButton {
appearance: none;
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.backButton:hover {
color: var(--dsw-alias-label-primary);
}
.backButton:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 2px;
border-radius: 4px;
}
.editorTitle {
font-size: 14px;
font-weight: 600;
}
.addButton {

View File

@@ -174,13 +174,32 @@ 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,
setDescription: props.setDescription,
setId: props.setId,
setName: props.setName,
// Editing replaces the list rather than hanging off the end of it: the form
// is tall, and a column of the card grid is far too narrow to hold it.
if (draft !== null) {
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}>
<div className={css.editorBar}>
<button type="button" className={css.backButton} onClick={() => { props.close() }}>
{`${t('backToList')}`}
</button>
<span className={css.editorTitle}>
{draft.creating
? `${t('newPreset')} · ${t('copyOf')} ${draft.source}`
: `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.source : draft.name}`}
</span>
</div>
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
</div>
)
}
return (
@@ -226,7 +245,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<button
type="button"
className={css.iconButton}
title={row.trust === 'user' ? t('edit') : t('view')}
data-tip={row.trust === 'user' ? t('edit') : t('view')}
aria-label={row.trust === 'user' ? t('edit') : t('view')}
onClick={() => { void props.open(row.id) }}
>
@@ -237,7 +256,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<button
type="button"
className={css.iconButton}
title={t('duplicate')}
data-tip={t('duplicate')}
aria-label={t('duplicate')}
onClick={() => { void props.createFrom(row.id) }}
>
@@ -250,7 +269,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
title={t('delete')}
data-tip={t('delete')}
aria-label={t('delete')}
onClick={() => { props.confirmDelete(row.id) }}
>
@@ -259,31 +278,22 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
)
: null}
</div>
{draft !== null && !draft.creating && draft.source === row.id
? <Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
: null}
</li>
))}
</ul>
</section>
)
})}
{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>
)}
{(
<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) }}

View File

@@ -6,7 +6,7 @@ export type AgentPresetSettingsKey =
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' | 'backToList'
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
| 'idRequired' | 'idInvalid' | 'idTaken'
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
@@ -39,6 +39,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
displayDescription: 'Description',
displayDescriptionPlaceholder: 'One sentence on what this preset is for',
inUse: 'In use',
backToList: 'All presets',
builtInGroup: 'Built-in',
customGroup: 'Custom',
noDescription: 'No description.',
@@ -86,6 +87,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
displayDescription: '描述',
displayDescriptionPlaceholder: '一句话说明这个预设做什么',
inUse: '当前使用',
backToList: '全部预设',
builtInGroup: '内置',
customGroup: '自定义',
noDescription: '暂无描述。',

View File

@@ -200,14 +200,19 @@ describe('the composition editor', () => {
writable: true, name: '我的预设', description: '', saving: false, error: null,
}
it('opens under the row it belongs to and edits through the controller', () => {
it('replaces the list while editing, and returns to it', () => {
const actions = renderSection({ draft })
const editor = within(rowFor('mine')).getByLabelText(en.composition)
// The form is tall and a card column is ~268px: squeezing it into one is
// unusable, and hanging it off the end orphans it from the card it edits.
expect(screen.queryByRole('heading', { name: en.builtInGroup })).toBeNull()
const editor = screen.getByLabelText(en.composition)
expect(editor).toHaveProperty('value', '- id: tool-read\n')
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
fireEvent.click(screen.getByRole('button', { name: `${en.backToList}` }))
expect(actions.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
expect(actions.close).toHaveBeenCalledTimes(1)
})
it('saves and cancels through the controller', () => {
@@ -237,6 +242,20 @@ describe('the composition editor', () => {
expect(screen.getByText(en.close)).toBeTruthy()
})
it('titles the panel by what it is doing', () => {
renderSection({ draft })
expect(screen.getByText(`${en.edit} · 我的预设`)).toBeTruthy()
cleanup()
// An unnamed draft falls back to what it was copied from.
renderSection({ draft: { ...draft, name: '' } })
expect(screen.getByText(`${en.edit} · mine`)).toBeTruthy()
cleanup()
renderSection({ draft: { ...draft, writable: false } })
expect(screen.getByText(`${en.view} · 我的预设`)).toBeTruthy()
})
it('names a new preset and says what it was copied from', () => {
const actions = renderSection({
draft: { ...draft, id: '', source: 'standard', creating: true },