diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 517a856e9a..fb4b56490c 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -11,7 +11,7 @@ import { useEffect } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSettingsState } from './settings-store.ts' @@ -57,7 +57,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index a4e4c50309..93d9f2b6fe 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -36,6 +36,59 @@ color: var(--dsw-alias-label-primary); } +/* Introduce cue: the icon eases in on an overshoot-free expo curve, then the + name's characters fade up on a stagger (delays set inline per character). + All chars occupy their width from the start, so nothing reflows mid-run. */ +.introIcon { + animation: seat-icon-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes seat-icon-in { + from { + opacity: 0; + transform: scale(0.5); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +/* Wraps the staggered characters into one flex item, so the chip's gap + applies around the name as a whole rather than between characters. */ +.introText { + display: inline-block; + white-space: pre; +} + +.introChar { + display: inline-block; + white-space: pre; + opacity: 0; + animation: seat-char-in 0.4s ease-out forwards; +} + +@keyframes seat-char-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .introIcon, + .introChar { + animation: none; + opacity: 1; + } +} + .chevron { flex: none; color: var(--dsw-alias-label-caption); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index f4357870bb..84734dccfc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -15,7 +15,7 @@ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' @@ -32,8 +32,17 @@ export interface AgentPresetSeatInjected { load: () => Promise /** Stage one preset for the next session. */ select: (id: string) => Promise + /** Clear the one-shot introduce cue once the chip has played it. */ + introduced: () => void } +/* Introduce timeline: the icon eases in first; the name's characters start + fading up once the icon has mostly landed, one every stagger tick, each + taking the fade duration to settle. The cue clears after the last one. */ +const INTRO_TEXT_DELAY_MS = 300 +const INTRO_CHAR_STAGGER_MS = 60 +const INTRO_CHAR_FADE_MS = 400 + /** Full component props. */ export type AgentPresetSeatProps = PropsRuntime<'conversation.hero.agentPreset'> @@ -45,7 +54,7 @@ export type AgentPresetSeatProps = * @param props - composed slot props. * @returns the chip, or null when the deployment composes no presets. */ -export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { +export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) { const state = useAgentPresetSeat(snapshot => snapshot) const [open, setOpen] = useState(false) @@ -53,12 +62,52 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr void load() }, [load]) - // Nothing to choose between: the deployment composes no presets and every - // session shares the host composition. - if (state.options.length === 0 || state.current === '') return null - const chosen = state.options.find(option => option.id === state.current) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) + const label = chosenText?.name ?? state.current + const ready = state.options.length > 0 && state.current !== '' + + // The introduce cue: the pick was staged from another screen (the settings + // creator entry), so the chip announces it — the icon eases in and each + // character of the name fades up on a stagger (CSS owns the motion; this + // effect only arms it and acknowledges the cue once the run is over). + const [introducing, setIntroducing] = useState(false) + useEffect(() => { + if (!state.introduce || !ready) return + const characters = Array.from(label) + if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + introduced() + return + } + setIntroducing(true) + const done = window.setTimeout(() => { + setIntroducing(false) + introduced() + }, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS) + return () => { window.clearTimeout(done) } + }, [state.introduce, ready, label, introduced]) + + // Nothing to choose between: the deployment composes no presets and every + // session shares the host composition. + if (!ready) return null + + // One wrapper span: the chip is a flex row with a gap, so loose character + // spans would each pick up the gap between them. + const shownLabel = introducing + ? ( + + {Array.from(label).map((character, index) => ( + + {character} + + ))} + + ) + : label return ( { setOpen(value => !value) }} > - - {chosenText?.name ?? state.current} + + {shownLabel} )} 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 f29bf7cdf5..79dca43f7b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -363,6 +363,7 @@ create button vacated. Dashed like the Models page's add affordances: it reads as a place a preset will appear, not a command. */ .creatorButton { + box-sizing: border-box; align-self: stretch; display: flex; align-items: center; @@ -372,17 +373,18 @@ border: 1px dashed var(--dsw-alias-border-l3); border-radius: 12px; font: inherit; - font-size: 13px; - background: none; - color: inherit; + font-size: 14px; + line-height: 22px; + background: transparent; + color: var(--dsw-alias-label-primary); cursor: pointer; } .creatorButton:hover:not(:disabled) { - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-interactive-bg-hover); } .creatorButton:disabled { - opacity: 0.5; + opacity: 0.4; 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 f5a31fcdf8..4580f436bc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -171,6 +171,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { ) } + /* The guided alternative to copying: the self-referential preset can + read this very composition and author a new one in conversation. + Offered only where that preset is actually on the roster and a + session can be landed; without a writable root the draft could + never be discovered, so the reason rides the disabled button. */ + const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') + ? ( + + ) + : null + return (

{t('nav')}

@@ -180,147 +204,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const group = state.rows .filter(row => row.trust === trust) .map(row => ({ row, text: presetDisplayText(row, t) })) - if (group.length === 0) return null + // The custom group is where a preset of one's own will appear, so it + // stays on screen even while empty: heading plus the creator entry. + const tail = trust === 'user' ? creatorButton : null + if (group.length === 0 && tail === null) return null return (

{heading}

-
    - {group.map(({ row, text }) => ( -
  • - {/* The card body IS the control: picking a preset is the + {group.length === 0 ? null : ( +
      + {group.map(({ row, text }) => ( +
    • + {/* 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. A broken preset cannot compose a session, so its body is disabled and the card says why instead of offering it. */} - -
      - {/* Shipped presets are the compositions a copy starts + {text.description ?? t('noDescription')} + {row.broken === undefined + ? null + : {row.broken}} + {row.id} + +
      + {/* Shipped presets are the compositions a copy starts from, so READING one is the point; a custom preset is edited in its files instead, which the location action leads to. A broken shipped preset has no readable composition to offer, so its viewer is withheld; a broken custom one keeps the location action — the files are where it gets fixed. */} - {row.trust === 'system' - ? row.broken === undefined - ? ( + {row.trust === 'system' + ? row.broken === undefined + ? ( + + ) + : null + : ( + )} + + {row.trust === 'user' + ? ( + ) - : null + : null} +
      + {state.revealedPaths[row.id] === undefined + ? null : ( - +

      + {t('revealedPathLabel')} + {state.revealedPaths[row.id]} +

      )} - - {row.trust === 'user' - ? ( - - ) - : null} -
      - {state.revealedPaths[row.id] === undefined - ? null - : ( -

      - {t('revealedPathLabel')} - {state.revealedPaths[row.id]} -

      - )} -
    • - ))} -
    +
  • + ))} +
+ )} + {tail}
) })} - {/* The guided alternative to copying: the self-referential preset can - read this very composition and author a new one in conversation. - Offered only where that preset is actually on the roster and a - session can be landed; without a writable root the draft could - never be discovered, so the reason rides the disabled button. */} - {props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') - ? ( - - ) - : null} seat.load(), select: (id: string) => seat.select(id), + introduced: () => { seat.introduced() }, }) const labelInjected = (): AgentPresetLabelInjected => ({ @@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void { // on: the chip's list-change applier composes the blank session the // workspace connect produces or reuses. creatorDraft = () => { - seat.stage('cordis') + // The introduce cue makes the chip announce the pick the user never + // made on this screen — the stage happened back in settings. + seat.stage('cordis', true) scope.workspaces.startSession() } const chip = scope.slots.register({ diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 27a414e4a3..ab973ec5b5 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -26,10 +26,16 @@ export interface AgentPresetSeatState { /** A rejected apply's message, cleared by the next attempt. */ error: string | null busy: boolean + /** + * One-shot cue that the chip should introduce itself (the creator-draft + * entry staged the pick from another screen, so the user never touched the + * chip); the renderer clears it via `introduced()` once played. + */ + introduce: boolean } const INITIAL: AgentPresetSeatState = { - options: [], current: '', error: null, busy: false, + options: [], current: '', error: null, busy: false, introduce: false, } /** One session's identity and whether it has started. */ @@ -121,10 +127,18 @@ export class AgentPresetSeatController { * list-change applier, which fires when the started session becomes * current. * @param id - the preset to stage. + * @param introduce - true when the stage came from another screen and the + * chip should announce itself on the session it lands on. */ - stage(id: string): void { + stage(id: string, introduce = false): void { this.staged = id - this.set({ current: id, error: null }) + this.set({ current: id, error: null, introduce }) + } + + /** Acknowledge the introduction cue once the chip has played it. */ + introduced(): void { + if (!this.store.getSnapshot().introduce) return + this.set({ introduce: false }) } /** diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 8a37a7af43..b63b9ce63c 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = { ], busy: false, error: null, + introduce: false, } function renderRow(state: Partial = {}) { @@ -56,7 +57,11 @@ function renderRow(state: Partial = {}) { function renderSeat(state: Partial = {}) { const store = createSnapshotStore({ ...SEAT_READY, ...state }) - const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + const actions = { + load: vi.fn(() => Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + introduced: vi.fn(), + } render({permissionGlyph(currentValue)} )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} - - - + {/* Same glyph + open rotation as the sibling ModelSelect trigger; + class on the svg itself — an inline wrapper span leaves + baseline descent under the icon and floats it off-center. */} + } /> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 02f4913751..354adc4454 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_ds_agent_preset_outline_16. The three node interiors knock out to transparency via mask so the glyph sits on any background. */ +export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + + +) + /** ic_ds_browse_outline_16 */ export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 9163e68ba8..04e8c98cd6 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -73,7 +73,7 @@ z-index: 1; display: flex; width: 800px; - height: min(800px, calc(100vh - 48px)); + height: min(824px, calc(100vh - 48px)); max-width: calc(100vw - 48px); border-radius: 24px; overflow: hidden; @@ -205,11 +205,11 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */ +/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */ .options { flex: 1; min-height: 0; - padding: 0 24px 8px; + padding: 0 24px 24px; overflow-y: auto; } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 54e0e0dbb7..de00fa372e 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -14,7 +14,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' import { - IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, + IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' @@ -22,7 +22,7 @@ import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return - if (id === 'agent-presets') return + if (id === 'agent-presets') return return } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index a98d8ea26e..67310853a2 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -84,7 +84,7 @@ gap: 8px; height: 60px; padding: 8px 0 8px 4px; - margin-bottom: 16px; + margin-bottom: 8px; box-sizing: border-box; overflow: hidden; } @@ -157,8 +157,8 @@ color: var(--dsw-alias-label-primary); } -/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the - rail's plain icon control. */ +/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off + corners); collapsed it renders as the rail's plain icon control. */ .newSession { flex: none; display: flex; @@ -170,7 +170,7 @@ margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsw-alias-button-elevated-fill); color: var(--dsw-alias-label-primary); font-size: 14px; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 3a6ace14a5..2a730245e7 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -519,8 +519,14 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) + // Visibility needs evidence of children (entries, summary-known descendants, + // or a failed load worth retrying). A bare loading catalog is not evidence: + // selecting any session schedules a refresh whose loading snapshot would + // otherwise flash the action in and out on childless sessions. const visible = presentedCatalog !== undefined - && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) + && (presentedCatalog.state === 'error' + || presentedCatalog.entries.length > 0 + || descendantCount > 0) useEffect(() => { if (visible || !open) return setOpen(false) diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c300e423e6..ae6c0f07ee 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -522,22 +522,23 @@ describe('SubagentCatalogAction', () => { expect(staleEmpty.openChild).not.toHaveBeenCalled() }) - it('renders empty loading and fallback error states without focusable rows', async () => { + it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => { + // Selecting any session schedules a catalog refresh; a loading snapshot + // with no other evidence of children must not flash the action in. const loading = props(catalog({ entries: [], state: 'loading' })) const view = render() - const trigger = screen.getByRole('button', { name: /0 个子代理/ }) - fireEvent.click(trigger) - expect(screen.getByText('正在加载子代理…')).toBeTruthy() - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - await Promise.resolve() - expect(screen.getByRole('tree')).toBeTruthy() - fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) + expect(screen.queryByRole('button')).toBeNull() view.unmount() const failed = props(catalog({ entries: [], state: 'error', error: null })) render() - fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ })) + const trigger = screen.getByRole('button', { name: /0 个子代理/ }) + fireEvent.click(trigger) expect(screen.getByText('无法加载子代理')).toBeTruthy() + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + await Promise.resolve() + expect(screen.getByRole('tree')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) }) it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 6052b5075f..6c6c44c2c7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -64,7 +64,8 @@ line-height: 20px; } -/* Search input: 38px capsule (figma 133:7649); rail state renders it as the +/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off + corners); rail state renders it as the region's search control. Upstream binds a dedicated design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token pinned to the static scale mirrors it. */ @@ -79,7 +80,7 @@ padding: 0 14px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsh-search-input-fill); color: var(--dsw-alias-label-caption); overflow: hidden;