Parcourir la source

feat(web): make the preset card the control that picks it

Four changes to the Agent 预设 section, all from the same reading: choosing
a preset is the common act and everything else is occasional.

Picking is now clicking the card rather than hunting a small "Set as
default" button. The card body is the button; the action row sits outside it
because nesting buttons is invalid and those actions operate ON a card
rather than select it. The body's accessible name is set explicitly —
otherwise it is the whole card read aloud, title through id.

Actions are icon-only with their label on hover and on `aria-label`, so four
controls stop competing with the content for attention.

Built-in and custom presets are separated under headings, and the trust
badge moved up beside the title where it qualifies the name. A group with no
members renders no heading.

`Local` reads as a location; these are the user's own, so the badge and the
heading both say `Custom` / 自定义.
Yichen Jiang il y a 2 mois
Parent
commit
4f41608cbc

+ 74 - 45
packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css

@@ -31,35 +31,77 @@
 
 /* Cards, not rows: a preset is a thing you pick, and the description is the
    part that tells them apart — a row would bury it beside the actions. */
+.group {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.groupHead {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  letter-spacing: .06em;
+  text-transform: uppercase;
+  color: var(--dsw-alias-label-tertiary);
+}
+
 .cards {
   list-style: none;
   margin: 0;
   padding: 0;
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
+  grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
   gap: 12px;
 }
 
 .card {
   border: 1px solid var(--dsw-alias-border-l2);
   border-radius: 12px;
-  padding: 14px 16px;
   display: flex;
   flex-direction: column;
-  gap: 8px;
   background: var(--dsw-alias-bg-layer-3);
+  transition: border-color .16s, background .16s;
 }
 
-/* The default preset is the one in use; it reads as selected rather than
-   merely badged. */
+.card:hover:not(.cardActive) {
+  border-color: var(--dsw-alias-label-dimmed);
+}
+
+/* The default preset reads as selected, not merely badged. */
 .cardActive {
   background: var(--dsw-alias-bg-layer-2);
-  border-color: var(--dsw-alias-label-tertiary);
+  border-color: var(--dsw-alias-label-primary);
+}
+
+/* The card body is the control that picks the preset. */
+.cardMain {
+  appearance: none;
+  border: 0;
+  background: none;
+  font: inherit;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 14px 16px 12px;
+  border-radius: 12px 12px 0 0;
+}
+
+.cardMain:disabled {
+  cursor: default;
+}
+
+.cardMain:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: -2px;
 }
 
 .cardHead {
   display: flex;
-  align-items: baseline;
+  align-items: center;
   gap: 8px;
 }
 
@@ -77,19 +119,12 @@
 }
 
 .cardDesc {
-  margin: 0;
   font-size: 13px;
   line-height: 1.55;
   color: var(--dsw-alias-label-secondary);
   flex: 1;
 }
 
-.cardMeta {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
 .cardId {
   font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
   font-size: 11px;
@@ -98,35 +133,41 @@
 
 .cardFoot {
   display: flex;
-  padding-top: 4px;
+  gap: 2px;
+  padding: 6px 10px;
   border-top: 1px solid var(--dsw-alias-border-l2);
 }
 
-.badge,
-.defaultBadge {
-  border-radius: 999px;
-  padding: 2px 8px;
-  font-size: 11px;
-  line-height: 16px;
+/* Icon-only actions: the label rides `title` so the row stays quiet until
+   someone reaches for it. */
+.iconButton {
+  appearance: none;
+  border: 0;
+  border-radius: 7px;
+  padding: 6px;
+  background: none;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
 }
 
-.badge {
-  border: 1px solid var(--dsw-alias-border-l2);
-  color: var(--dsw-alias-label-tertiary);
+.iconButton:hover:not(:disabled) {
+  background: var(--dsw-alias-bg-layer-1);
+  color: var(--dsw-alias-label-primary);
 }
 
-.defaultBadge {
-  background: var(--dsw-alias-brand-primary);
-  color: var(--dsw-alias-label-primary-foreground);
+.iconButton:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: -1px;
 }
 
-.rowActions {
-  display: inline-flex;
-  gap: 4px;
-  flex-wrap: wrap;
-  align-items: center;
+.iconDanger:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover-danger);
+  color: var(--dsw-alias-state-error-primary);
 }
 
+
 .secondaryButton {
   border: none;
   border-radius: 7px;
@@ -138,24 +179,12 @@
   cursor: pointer;
 }
 
-.dangerButton {
-  border: none;
-  border-radius: 7px;
-  padding: 5px 8px;
-  background: none;
-  color: var(--dsw-alias-state-error-primary);
-  font: inherit;
-  font-size: 12.5px;
-  cursor: pointer;
-}
 
-.secondaryButton:hover:not(:disabled),
-.dangerButton:hover:not(:disabled) {
+.secondaryButton:hover:not(:disabled) {
   background: var(--dsw-alias-bg-layer-1);
 }
 
 .secondaryButton:disabled,
-.dangerButton:disabled,
 .addButton:disabled {
   opacity: 0.5;
   cursor: default;

+ 79 - 59
packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx

@@ -10,7 +10,9 @@
 
 import { useEffect } from 'react'
 import type { ReactNode } from 'react'
-import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
+import {
+  Button, IconBrowseOutline16, IconCopyOutline16, IconEditOutline16, IconTrashOutline16, Modal,
+} from '@deepseek-ai/dsh-client-ui-primitives'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts'
@@ -186,68 +188,86 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
       <h2 className={css.title}>{t('nav')}</h2>
       <p className={css.intro}>{t('sectionIntro')}</p>
       {state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
-      <ul className={css.cards}>
-        {state.rows.map(row => (
-          <li key={row.id} className={row.isDefault ? `${css.card} ${css.cardActive}` : css.card}>
-            <div className={css.cardHead}>
-              <span className={css.cardName}>{row.name ?? row.id}</span>
-              {row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
-            </div>
-            <p className={css.cardDesc}>{row.description ?? t('noDescription')}</p>
-            <div className={css.cardMeta}>
-              <span className={css.badge}>{row.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
-              <code className={css.cardId}>{row.id}</code>
-            </div>
-            <div className={css.cardFoot}>
-              <span className={css.rowActions}>
-                {row.isDefault
-                  ? null
-                  : (
+      {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
+        const group = state.rows.filter(row => row.trust === trust)
+        if (group.length === 0) return null
+        return (
+          <section key={trust} className={css.group}>
+            <h3 className={css.groupHead}>{heading}</h3>
+            <ul className={css.cards}>
+              {group.map(row => (
+                <li key={row.id} className={row.isDefault ? `${css.card} ${css.cardActive}` : css.card}>
+                  {/* The card body IS the control: picking a preset is the
+                      common act, so it should not hide behind a small button.
+                      The action row sits outside it — nesting buttons is
+                      invalid, and these act on the card rather than select it. */}
+                  <button
+                    type="button"
+                    className={css.cardMain}
+                    aria-pressed={row.isDefault}
+                    disabled={row.isDefault}
+                    // Without this the name is the whole card read aloud —
+                    // title, badge, description, id.
+                    aria-label={`${row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
+                    title={row.isDefault ? t('inUse') : t('setDefault')}
+                    onClick={() => { void props.makeDefault(row.id) }}
+                  >
+                    <span className={css.cardHead}>
+                      <span className={css.cardName}>{row.name ?? row.id}</span>
+                      <span className={css.badge}>
+                        {row.trust === 'user' ? t('userTrust') : t('builtIn')}
+                      </span>
+                      {row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
+                    </span>
+                    <span className={css.cardDesc}>{row.description ?? t('noDescription')}</span>
+                    <code className={css.cardId}>{row.id}</code>
+                  </button>
+                  <div className={css.cardFoot}>
                     <button
                       type="button"
-                      className={css.secondaryButton}
-                      onClick={() => { void props.makeDefault(row.id) }}
+                      className={css.iconButton}
+                      title={row.trust === 'user' ? t('edit') : t('view')}
+                      aria-label={row.trust === 'user' ? t('edit') : t('view')}
+                      onClick={() => { void props.open(row.id) }}
                     >
-                      {t('setDefault')}
+                      {row.trust === 'user' ? <IconEditOutline16 /> : <IconBrowseOutline16 />}
                     </button>
-                  )}
-                <button
-                  type="button"
-                  className={css.secondaryButton}
-                  onClick={() => { void props.open(row.id) }}
-                >
-                  {row.trust === 'user' ? t('edit') : t('view')}
-                </button>
-                {state.authorable
-                  ? (
-                    <button
-                      type="button"
-                      className={css.secondaryButton}
-                      onClick={() => { void props.createFrom(row.id) }}
-                    >
-                      {t('duplicate')}
-                    </button>
-                  )
-                  : null}
-                {row.trust === 'user'
-                  ? (
-                    <button
-                      type="button"
-                      className={css.dangerButton}
-                      onClick={() => { props.confirmDelete(row.id) }}
-                    >
-                      {t('delete')}
-                    </button>
-                  )
-                  : null}
-              </span>
-            </div>
-            {draft !== null && !draft.creating && draft.source === row.id
-              ? <Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
-              : null}
-          </li>
-        ))}
-      </ul>
+                    {state.authorable
+                      ? (
+                        <button
+                          type="button"
+                          className={css.iconButton}
+                          title={t('duplicate')}
+                          aria-label={t('duplicate')}
+                          onClick={() => { void props.createFrom(row.id) }}
+                        >
+                          <IconCopyOutline16 />
+                        </button>
+                      )
+                      : null}
+                    {row.trust === 'user'
+                      ? (
+                        <button
+                          type="button"
+                          className={`${css.iconButton} ${css.iconDanger}`}
+                          title={t('delete')}
+                          aria-label={t('delete')}
+                          onClick={() => { props.confirmDelete(row.id) }}
+                        >
+                          <IconTrashOutline16 />
+                        </button>
+                      )
+                      : 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}>

+ 7 - 3
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'
+  | 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
   | 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
   | 'idRequired' | 'idInvalid' | 'idTaken'
   | 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
@@ -17,7 +17,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
   description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.',
   loading: 'Loading presets…',
   error: 'Could not load agent presets.',
-  userTrust: 'Local',
+  userTrust: 'Custom',
   seatHint: 'Agent preset for this session — switchable until you send the first message',
   lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
   nav: 'Agent presets',
@@ -39,6 +39,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
   displayDescription: 'Description',
   displayDescriptionPlaceholder: 'One sentence on what this preset is for',
   inUse: 'In use',
+  builtInGroup: 'Built-in',
+  customGroup: 'Custom',
   noDescription: 'No description.',
   copyOf: 'Copied from',
   composition: 'Composition (cordis.yml)',
@@ -64,7 +66,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
   description: '对此后新建的会话生效。运行中的会话保持它开始时的 preset。',
   loading: '正在加载预设…',
   error: '无法加载 Agent 预设。',
-  userTrust: '本地',
+  userTrust: '自定义',
   seatHint: '本会话的 Agent 预设 —— 发送第一条消息前可切换',
   lockedHint: '会话开始后,其 Agent 预设即固定',
   nav: 'Agent 预设',
@@ -84,6 +86,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
   displayDescription: '描述',
   displayDescriptionPlaceholder: '一句话说明这个预设做什么',
   inUse: '当前使用',
+  builtInGroup: '内置',
+  customGroup: '自定义',
   noDescription: '暂无描述。',
   copyOf: '复制自',
   composition: '组装(cordis.yml)',

+ 39 - 9
packages/client/ui-agent-preset/tests/section.spec.tsx

@@ -98,35 +98,65 @@ describe('the preset list', () => {
     expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
   })
 
+  it('separates built-in presets from custom ones', () => {
+    renderSection()
+
+    // Two different things: one set ships with the deployment and is
+    // read-only, the other is the user's own.
+    expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy()
+    expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
+    expect(within(rowFor('standard')).getByText(en.builtIn)).toBeTruthy()
+    expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
+  })
+
+  it('shows no group heading for a set nobody has', () => {
+    renderSection({ rows: [{ id: 'standard', trust: 'system', isDefault: true }] })
+
+    expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull()
+  })
+
+  it('picks a preset by clicking its card, and the one in use is inert', () => {
+    const actions = renderSection()
+
+    const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: 标准模式` })
+    expect(inUse).toHaveProperty('disabled', true)
+    fireEvent.click(inUse)
+
+    // Clicking the card IS the choice; the preset already in use cannot be
+    // re-picked, so the click reaches nothing.
+    expect(actions.makeDefault).not.toHaveBeenCalled()
+  })
+
   it('offers Edit for a local preset and View for a shipped one', () => {
     renderSection()
 
-    expect(within(rowFor('mine')).getByText(en.edit)).toBeTruthy()
+    expect(within(rowFor('mine')).getByRole('button', { name: en.edit })).toBeTruthy()
     // A shipped composition is readable but not editable, and the label is
     // what says so before the editor opens.
-    expect(within(rowFor('standard')).getByText(en.view)).toBeTruthy()
+    expect(within(rowFor('standard')).getByRole('button', { name: en.view })).toBeTruthy()
   })
 
   it('offers Delete only for a locally authored preset', () => {
     renderSection()
 
-    expect(within(rowFor('mine')).getByText(en.delete)).toBeTruthy()
-    expect(within(rowFor('standard')).queryByText(en.delete)).toBeNull()
+    expect(within(rowFor('mine')).getByRole('button', { name: en.delete })).toBeTruthy()
+    expect(within(rowFor('standard')).queryByRole('button', { name: en.delete })).toBeNull()
   })
 
   it('hides duplication and disables creation when nothing is writable', () => {
     renderSection({ authorable: false })
 
-    expect(screen.queryByText(en.duplicate)).toBeNull()
+    expect(screen.queryByRole('button', { name: en.duplicate })).toBeNull()
     expect(screen.getByText(`+ ${en.newPreset}`)).toHaveProperty('disabled', true)
   })
 
   it('routes the row actions to the controller', () => {
     const actions = renderSection()
 
-    fireEvent.click(within(rowFor('mine')).getByText(en.setDefault))
-    fireEvent.click(within(rowFor('mine')).getByText(en.edit))
-    fireEvent.click(within(rowFor('mine')).getByText(en.duplicate))
+    // The card body is the control that picks a preset.
+    fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` }))
+    fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.edit }))
+    fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.duplicate }))
     fireEvent.click(screen.getByText(`+ ${en.newPreset}`))
 
     expect(actions.makeDefault).toHaveBeenCalledWith('mine')
@@ -259,7 +289,7 @@ describe('deleting a preset', () => {
   it('asks before deleting', () => {
     const actions = renderSection()
 
-    fireEvent.click(within(rowFor('mine')).getByText(en.delete))
+    fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.delete }))
 
     expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
   })