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` / 自定义.
This commit is contained in:
@@ -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;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
transition: border-color .16s, background .16s;
|
||||
}
|
||||
|
||||
.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-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;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
padding: 14px 16px 12px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
/* The default preset is the one in use; it reads as selected rather than
|
||||
merely badged. */
|
||||
.cardActive {
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border-color: var(--dsw-alias-label-tertiary);
|
||||
.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;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.defaultBadge {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user