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 8d1fcecf7e..ae296a7022 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/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; + 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; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index b5fdf2aead..18fc0a646d 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/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 {

{t('nav')}

{t('sectionIntro')}

{state.error === null ? null :

{state.error}

} - + + ) + })} {draft !== null && draft.creating ? (
diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 613ff59b08..3d323636ea 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' + | '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 = { 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 = { 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 = { description: '对此后新建的会话生效。运行中的会话保持它开始时的 preset。', loading: '正在加载预设…', error: '无法加载 Agent 预设。', - userTrust: '本地', + userTrust: '自定义', seatHint: '本会话的 Agent 预设 —— 发送第一条消息前可切换', lockedHint: '会话开始后,其 Agent 预设即固定', nav: 'Agent 预设', @@ -84,6 +86,8 @@ export const zh: Record = { displayDescription: '描述', displayDescriptionPlaceholder: '一句话说明这个预设做什么', inUse: '当前使用', + builtInGroup: '内置', + customGroup: '自定义', noDescription: '暂无描述。', copyOf: '复制自', composition: '组装(cordis.yml)', diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 0ae4eb88cf..34e441cb1c 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/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') })