Ver Fonte

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.
Yichen Jiang há 2 meses atrás
pai
commit
64fc536982

+ 1 - 0
apps/cli/config/agent-presets/cordis/preset.yml

@@ -1,2 +1,3 @@
 name: 创造模式
 description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
+order: 3

+ 1 - 0
apps/cli/config/agent-presets/minimal/preset.yml

@@ -1,2 +1,3 @@
 name: 极简模式
 description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
+order: 2

+ 1 - 0
apps/cli/config/agent-presets/standard/preset.yml

@@ -1,2 +1,3 @@
 name: 标准模式
 description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
+order: 1

+ 1 - 1
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`
 

+ 79 - 7
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 {
-  margin-left: auto;
+  border-radius: 999px;
+  padding: 1px 8px;
   font-size: 11px;
-  color: var(--dsw-alias-label-tertiary);
+  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;
+  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 {

+ 39 - 29
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 (
+      <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) }}

+ 3 - 1
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<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: '暂无描述。',

+ 21 - 2
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 },

+ 2 - 2
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',

+ 6 - 1
packages/preset/agent-presets/src/discovery.ts

@@ -61,7 +61,12 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
     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
+  })
 }
 
 /**

+ 13 - 1
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<PresetMetad
   const record = parsed as Record<string, unknown>
   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<PresetMetad
 export function renderPresetMetadata(metadata: PresetMetadata): string | undefined {
   const name = text(metadata.name)
   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({
     ...name === undefined ? {} : { name },
     ...description === undefined ? {} : { description },
+    ...order === undefined ? {} : { order },
   }, { lineWidth: -1 })
 }

+ 2 - 0
packages/preset/agent-presets/src/types.ts

@@ -19,6 +19,8 @@ export interface AgentPreset {
   readonly name?: string
   /** One sentence on what this preset is for, when it published one. */
   readonly description?: string
+  /** Declared position within its group; absent sorts after those that declare one. */
+  readonly order?: number
 }
 
 /** One directory scanned for preset subdirectories. */

+ 21 - 0
packages/preset/agent-presets/tests/discovery.spec.ts

@@ -9,6 +9,27 @@ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' 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', () => {
   it('reports one preset per directory holding a composition, ordered by id', async () => {
     const found = await scanRoot(SYSTEM)

+ 11 - 0
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')