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:
@@ -1,2 +1,3 @@
|
|||||||
name: 创造模式
|
name: 创造模式
|
||||||
description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||||
|
order: 3
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
name: 极简模式
|
name: 极简模式
|
||||||
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||||
|
order: 2
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
name: 标准模式
|
name: 标准模式
|
||||||
description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||||
|
order: 1
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export interface PresetRoot {
|
|||||||
export type PresetTrust = 'system' | 'user'
|
export type PresetTrust = 'system' | 'user'
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/preset/agent-presets/src/types.ts:33`](../packages/preset/agent-presets/src/types.ts)
|
Source: [`packages/preset/agent-presets/src/types.ts:35`](../packages/preset/agent-presets/src/types.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-agent-spine-demo`
|
## `@deepseek-ai/dsh-agent-spine-demo`
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
|
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;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@
|
|||||||
transition: border-color .16s, background .16s;
|
transition: border-color .16s, background .16s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.card:hover:not(.cardActive) {
|
.card:hover:not(.cardActive) {
|
||||||
border-color: var(--dsw-alias-label-dimmed);
|
border-color: var(--dsw-alias-label-dimmed);
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,7 @@
|
|||||||
|
|
||||||
/* The card body is the control that picks the preset. */
|
/* The card body is the control that picks the preset. */
|
||||||
.cardMain {
|
.cardMain {
|
||||||
|
flex: 1;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -111,11 +116,25 @@
|
|||||||
line-height: 1.4;
|
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 {
|
.inUse {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 11px;
|
background: var(--dsw-alias-label-primary);
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-bg-layer-3);
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardDesc {
|
.cardDesc {
|
||||||
@@ -123,6 +142,7 @@
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardId {
|
.cardId {
|
||||||
@@ -133,6 +153,7 @@
|
|||||||
|
|
||||||
.cardFoot {
|
.cardFoot {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
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
|
/* Icon-only actions: the label rides `title` so the row stays quiet until
|
||||||
someone reaches for it. */
|
someone reaches for it. */
|
||||||
.iconButton {
|
.iconButton {
|
||||||
|
position: relative;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -162,6 +184,29 @@
|
|||||||
outline-offset: -1px;
|
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) {
|
.iconDanger:hover:not(:disabled) {
|
||||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||||
color: var(--dsw-alias-state-error-primary);
|
color: var(--dsw-alias-state-error-primary);
|
||||||
@@ -255,11 +300,38 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addCard {
|
.editorBar {
|
||||||
border: 1px solid var(--dsw-alias-border-l2);
|
display: flex;
|
||||||
border-radius: 12px;
|
align-items: baseline;
|
||||||
background: var(--dsw-alias-bg-layer-3);
|
gap: 12px;
|
||||||
padding: 14px 16px;
|
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 {
|
.addButton {
|
||||||
|
|||||||
@@ -174,13 +174,32 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
|||||||
|
|
||||||
const { draft } = state
|
const { draft } = state
|
||||||
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
|
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
|
||||||
const editorActions = {
|
// Editing replaces the list rather than hanging off the end of it: the form
|
||||||
close: props.close,
|
// is tall, and a column of the card grid is far too narrow to hold it.
|
||||||
save: props.save,
|
if (draft !== null) {
|
||||||
setContent: props.setContent,
|
const editorActions = {
|
||||||
setDescription: props.setDescription,
|
close: props.close,
|
||||||
setId: props.setId,
|
save: props.save,
|
||||||
setName: props.setName,
|
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 (
|
return (
|
||||||
@@ -226,7 +245,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.iconButton}
|
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')}
|
aria-label={row.trust === 'user' ? t('edit') : t('view')}
|
||||||
onClick={() => { void props.open(row.id) }}
|
onClick={() => { void props.open(row.id) }}
|
||||||
>
|
>
|
||||||
@@ -237,7 +256,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.iconButton}
|
className={css.iconButton}
|
||||||
title={t('duplicate')}
|
data-tip={t('duplicate')}
|
||||||
aria-label={t('duplicate')}
|
aria-label={t('duplicate')}
|
||||||
onClick={() => { void props.createFrom(row.id) }}
|
onClick={() => { void props.createFrom(row.id) }}
|
||||||
>
|
>
|
||||||
@@ -250,7 +269,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${css.iconButton} ${css.iconDanger}`}
|
className={`${css.iconButton} ${css.iconDanger}`}
|
||||||
title={t('delete')}
|
data-tip={t('delete')}
|
||||||
aria-label={t('delete')}
|
aria-label={t('delete')}
|
||||||
onClick={() => { props.confirmDelete(row.id) }}
|
onClick={() => { props.confirmDelete(row.id) }}
|
||||||
>
|
>
|
||||||
@@ -259,31 +278,22 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
|||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
{draft !== null && !draft.creating && draft.source === row.id
|
|
||||||
? <Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
|
|
||||||
: null}
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{draft !== null && draft.creating
|
{(
|
||||||
? (
|
<button
|
||||||
<div className={css.addCard}>
|
type="button"
|
||||||
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
|
className={css.addButton}
|
||||||
</div>
|
disabled={!state.authorable || state.rows.length === 0}
|
||||||
)
|
onClick={() => { void props.createFrom() }}
|
||||||
: (
|
>
|
||||||
<button
|
{`+ ${t('newPreset')}`}
|
||||||
type="button"
|
</button>
|
||||||
className={css.addButton}
|
)}
|
||||||
disabled={!state.authorable || state.rows.length === 0}
|
|
||||||
onClick={() => { void props.createFrom() }}
|
|
||||||
>
|
|
||||||
{`+ ${t('newPreset')}`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Modal
|
<Modal
|
||||||
open={state.pendingDelete !== null}
|
open={state.pendingDelete !== null}
|
||||||
onClose={() => { props.confirmDelete(null) }}
|
onClose={() => { props.confirmDelete(null) }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type AgentPresetSettingsKey =
|
|||||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
||||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||||
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
|
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
|
||||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' | 'backToList'
|
||||||
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
|
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
|
||||||
| 'idRequired' | 'idInvalid' | 'idTaken'
|
| 'idRequired' | 'idInvalid' | 'idTaken'
|
||||||
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
||||||
@@ -39,6 +39,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
|||||||
displayDescription: 'Description',
|
displayDescription: 'Description',
|
||||||
displayDescriptionPlaceholder: 'One sentence on what this preset is for',
|
displayDescriptionPlaceholder: 'One sentence on what this preset is for',
|
||||||
inUse: 'In use',
|
inUse: 'In use',
|
||||||
|
backToList: 'All presets',
|
||||||
builtInGroup: 'Built-in',
|
builtInGroup: 'Built-in',
|
||||||
customGroup: 'Custom',
|
customGroup: 'Custom',
|
||||||
noDescription: 'No description.',
|
noDescription: 'No description.',
|
||||||
@@ -86,6 +87,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
|||||||
displayDescription: '描述',
|
displayDescription: '描述',
|
||||||
displayDescriptionPlaceholder: '一句话说明这个预设做什么',
|
displayDescriptionPlaceholder: '一句话说明这个预设做什么',
|
||||||
inUse: '当前使用',
|
inUse: '当前使用',
|
||||||
|
backToList: '全部预设',
|
||||||
builtInGroup: '内置',
|
builtInGroup: '内置',
|
||||||
customGroup: '自定义',
|
customGroup: '自定义',
|
||||||
noDescription: '暂无描述。',
|
noDescription: '暂无描述。',
|
||||||
|
|||||||
@@ -200,14 +200,19 @@ describe('the composition editor', () => {
|
|||||||
writable: true, name: '我的预设', description: '', saving: false, error: null,
|
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 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')
|
expect(editor).toHaveProperty('value', '- id: tool-read\n')
|
||||||
fireEvent.change(editor, { target: { value: '- id: tool-edit\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.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
|
||||||
|
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves and cancels through the controller', () => {
|
it('saves and cancels through the controller', () => {
|
||||||
@@ -237,6 +242,20 @@ describe('the composition editor', () => {
|
|||||||
expect(screen.getByText(en.close)).toBeTruthy()
|
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', () => {
|
it('names a new preset and says what it was copied from', () => {
|
||||||
const actions = renderSection({
|
const actions = renderSection({
|
||||||
draft: { ...draft, id: '', source: 'standard', creating: true },
|
draft: { ...draft, id: '', source: 'standard', creating: true },
|
||||||
|
|||||||
@@ -1641,7 +1641,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AgentPreset',
|
name: 'AgentPreset',
|
||||||
declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n}',
|
declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AgentSetup',
|
name: 'AgentSetup',
|
||||||
@@ -2237,7 +2237,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'PresetMetadata',
|
name: 'PresetMetadata',
|
||||||
declaration: 'export interface PresetMetadata {\n readonly name?: string;\n readonly description?: string;\n}',
|
declaration: 'export interface PresetMetadata {\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'PresetTrust',
|
name: 'PresetTrust',
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
|||||||
const metadata = await readPresetMetadata(directory)
|
const metadata = await readPresetMetadata(directory)
|
||||||
found.push({ id: child.name, trust: root.trust, path, ...metadata })
|
found.push({ id: child.name, trust: root.trust, path, ...metadata })
|
||||||
}
|
}
|
||||||
return found.sort((left, right) => left.id.localeCompare(right.id))
|
// Declared order first so the shipped set reads by capability; everything
|
||||||
|
// else falls back to the id, which keeps authored presets stable.
|
||||||
|
return found.sort((left, right) => {
|
||||||
|
const byOrder = (left.order ?? Number.POSITIVE_INFINITY) - (right.order ?? Number.POSITIVE_INFINITY)
|
||||||
|
return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export interface PresetMetadata {
|
|||||||
readonly name?: string
|
readonly name?: string
|
||||||
/** One sentence on what this preset is for. */
|
/** One sentence on what this preset is for. */
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
/**
|
||||||
|
* Position within its group; lower comes first. A preset that declares
|
||||||
|
* none sorts after every preset that does, then by id — so the shipped set
|
||||||
|
* can read in capability order while authored ones stay alphabetical.
|
||||||
|
*/
|
||||||
|
readonly order?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A non-empty trimmed string, or undefined for anything else. */
|
/** A non-empty trimmed string, or undefined for anything else. */
|
||||||
@@ -68,9 +74,13 @@ export async function readPresetMetadata(directory: string): Promise<PresetMetad
|
|||||||
const record = parsed as Record<string, unknown>
|
const record = parsed as Record<string, unknown>
|
||||||
const name = text(record.name)
|
const name = text(record.name)
|
||||||
const description = text(record.description)
|
const description = text(record.description)
|
||||||
|
const order = typeof record.order === 'number' && Number.isFinite(record.order)
|
||||||
|
? record.order
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
...name === undefined ? {} : { name },
|
...name === undefined ? {} : { name },
|
||||||
...description === undefined ? {} : { description },
|
...description === undefined ? {} : { description },
|
||||||
|
...order === undefined ? {} : { order },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +95,11 @@ export async function readPresetMetadata(directory: string): Promise<PresetMetad
|
|||||||
export function renderPresetMetadata(metadata: PresetMetadata): string | undefined {
|
export function renderPresetMetadata(metadata: PresetMetadata): string | undefined {
|
||||||
const name = text(metadata.name)
|
const name = text(metadata.name)
|
||||||
const description = text(metadata.description)
|
const description = text(metadata.description)
|
||||||
if (name === undefined && description === undefined) return undefined
|
const { order } = metadata
|
||||||
|
if (name === undefined && description === undefined && order === undefined) return undefined
|
||||||
return yaml.dump({
|
return yaml.dump({
|
||||||
...name === undefined ? {} : { name },
|
...name === undefined ? {} : { name },
|
||||||
...description === undefined ? {} : { description },
|
...description === undefined ? {} : { description },
|
||||||
|
...order === undefined ? {} : { order },
|
||||||
}, { lineWidth: -1 })
|
}, { lineWidth: -1 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface AgentPreset {
|
|||||||
readonly name?: string
|
readonly name?: string
|
||||||
/** One sentence on what this preset is for, when it published one. */
|
/** One sentence on what this preset is for, when it published one. */
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
/** Declared position within its group; absent sorts after those that declare one. */
|
||||||
|
readonly order?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One directory scanned for preset subdirectories. */
|
/** One directory scanned for preset subdirectories. */
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
|||||||
const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
|
const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
|
||||||
const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
|
const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
|
||||||
|
|
||||||
|
describe('display order', () => {
|
||||||
|
it('puts declared order first, then everything else by id', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'dsh-order-'))
|
||||||
|
for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) {
|
||||||
|
await mkdir(join(root, id), { recursive: true })
|
||||||
|
await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
|
||||||
|
await writeFile(join(root, id, 'preset.yml'), `order: ${String(order)}\n`)
|
||||||
|
}
|
||||||
|
for (const id of ['bravo', 'yankee']) {
|
||||||
|
await mkdir(join(root, id), { recursive: true })
|
||||||
|
await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await scanRoot({ path: root, trust: 'system' })
|
||||||
|
|
||||||
|
// The shipped set reads by capability; presets that declare nothing stay
|
||||||
|
// alphabetical behind them rather than interleaving unpredictably.
|
||||||
|
expect(found.map(preset => preset.id)).toEqual(['zulu', 'alpha', 'bravo', 'yankee'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('preset discovery', () => {
|
describe('preset discovery', () => {
|
||||||
it('reports one preset per directory holding a composition, ordered by id', async () => {
|
it('reports one preset per directory holding a composition, ordered by id', async () => {
|
||||||
const found = await scanRoot(SYSTEM)
|
const found = await scanRoot(SYSTEM)
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ describe('reading display metadata', () => {
|
|||||||
expect(await readPresetMetadata(dir)).toEqual({ name: '极简模式' })
|
expect(await readPresetMetadata(dir)).toEqual({ name: '极简模式' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reads a declared order', async () => {
|
||||||
|
const dir = await presetDir('name: 标准模式\norder: 1\n')
|
||||||
|
|
||||||
|
expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', order: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an order that is not a finite number', async () => {
|
||||||
|
expect(await readPresetMetadata(await presetDir('order: first\n'))).toEqual({})
|
||||||
|
expect(await readPresetMetadata(await presetDir('order: .inf\n'))).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
it('cannot carry identity or trust', async () => {
|
it('cannot carry identity or trust', async () => {
|
||||||
const dir = await presetDir('name: mine\nid: standard\ntrust: system\n')
|
const dir = await presetDir('name: mine\nid: standard\ntrust: system\n')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user