diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml index b4d2e6a6f7..f54750872a 100644 --- a/apps/cli/config/agent-presets/cordis/preset.yml +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -1,2 +1,3 @@ name: 创造模式 description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 +order: 3 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 957796b6a0..4c9c3b3d7f 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,2 +1,3 @@ name: 极简模式 description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +order: 2 diff --git a/apps/cli/config/agent-presets/standard/preset.yml b/apps/cli/config/agent-presets/standard/preset.yml index 090576f0f2..8eddfbde48 100644 --- a/apps/cli/config/agent-presets/standard/preset.yml +++ b/apps/cli/config/agent-presets/standard/preset.yml @@ -1,2 +1,3 @@ name: 标准模式 description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 +order: 1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3761927cd7..822fecf79b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -139,7 +139,7 @@ export interface PresetRoot { 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` diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index ae296a7022..cfde11ee90 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -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 { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 18fc0a646d..f8ee745a54 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -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 ( +
+
+ + + {draft.creating + ? `${t('newPreset')} · ${t('copyOf')} ${draft.source}` + : `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.source : draft.name}`} + +
+ +
+ ) } return ( @@ -226,7 +245,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { - )} + {( + + )} { props.confirmDelete(null) }} diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 3d323636ea..3dc6c5fa02 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -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 = { 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 = { displayDescription: '描述', displayDescriptionPlaceholder: '一句话说明这个预设做什么', inUse: '当前使用', + backToList: '全部预设', builtInGroup: '内置', customGroup: '自定义', noDescription: '暂无描述。', diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 34e441cb1c..e24cf8f467 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -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 }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8fc1ce28a8..06c0e4e123 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1641,7 +1641,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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', @@ -2237,7 +2237,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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', diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 0d7def1437..ec456ef779 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -61,7 +61,12 @@ export async function scanRoot(root: PresetRoot): Promise { const metadata = await readPresetMetadata(directory) 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 + }) } /** diff --git a/packages/preset/agent-presets/src/metadata.ts b/packages/preset/agent-presets/src/metadata.ts index d551a4a287..aa964e0ada 100644 --- a/packages/preset/agent-presets/src/metadata.ts +++ b/packages/preset/agent-presets/src/metadata.ts @@ -30,6 +30,12 @@ export interface PresetMetadata { readonly name?: string /** One sentence on what this preset is for. */ 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. */ @@ -68,9 +74,13 @@ export async function readPresetMetadata(directory: string): Promise const name = text(record.name) const description = text(record.description) + const order = typeof record.order === 'number' && Number.isFinite(record.order) + ? record.order + : undefined return { ...name === undefined ? {} : { name }, ...description === undefined ? {} : { description }, + ...order === undefined ? {} : { order }, } } @@ -85,9 +95,11 @@ export async function readPresetMetadata(directory: string): Promise { + 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', () => { it('reports one preset per directory holding a composition, ordered by id', async () => { const found = await scanRoot(SYSTEM) diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts index 23155ffd90..31727a2f6a 100644 --- a/packages/preset/agent-presets/tests/metadata.spec.ts +++ b/packages/preset/agent-presets/tests/metadata.spec.ts @@ -67,6 +67,17 @@ describe('reading display metadata', () => { 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 () => { const dir = await presetDir('name: mine\nid: standard\ntrust: system\n')