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:
@@ -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 {
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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: '暂无描述。',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user