wip(web): agent preset UI flow — creator intro, custom group, subagent flash, chrome polish

This commit is contained in:
Yif
2026-08-10 21:03:12 +08:00
parent 8164161522
commit d9d2b11b9f
16 changed files with 330 additions and 160 deletions

View File

@@ -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 (
<span className={css.label} title={text?.description ?? t('headerHint')}>
<IconThinkOutline16 className={css.icon} />
<IconAgentPresetOutline16 className={css.icon} />
{text?.name ?? preset}
</span>
)

View File

@@ -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);

View File

@@ -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<void>
/** Stage one preset for the next session. */
select: (id: string) => Promise<void>
/** 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
? (
<span className={css.introText}>
{Array.from(label).map((character, index) => (
<span
key={index}
className={css.introChar}
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * INTRO_CHAR_STAGGER_MS}ms` }}
>
{character}
</span>
))}
</span>
)
: label
return (
<Menu
@@ -95,8 +144,8 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
disabled={state.busy}
onClick={() => { setOpen(value => !value) }}
>
<IconThinkOutline16 className={css.seatIcon} />
{chosenText?.name ?? state.current}
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
{shownLabel}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}

View File

@@ -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;
}

View File

@@ -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')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null
return (
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
@@ -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 (
<section key={trust} className={css.group}>
<h3 className={css.groupHead}>{heading}</h3>
<ul className={css.cards}>
{group.map(({ row, text }) => (
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
>
{/* The card body IS the control: picking a preset is the
{group.length === 0 ? null : (
<ul className={css.cards}>
{group.map(({ row, text }) => (
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: 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.
A broken preset cannot compose a session, so its body is
disabled and the card says why instead of offering it. */}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{text.name}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{text.name}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* Shipped presets are the compositions a copy starts
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* 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
? (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${text.name}`}
onClick={() => { void props.view(row.id) }}
>
<IconBrowseOutline16 />
</button>
)
: null
: (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${text.name}`}
onClick={() => { void props.view(row.id) }}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconBrowseOutline16 />
<IconFolderOpenOutline16 />
</button>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${text.name}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={`${t('delete')}: ${text.name}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
</button>
)
: null
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<button
type="button"
className={css.iconButton}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconFolderOpenOutline16 />
</button>
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${text.name}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={`${t('delete')}: ${text.name}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
</button>
)
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
</li>
))}
</ul>
</li>
))}
</ul>
)}
{tail}
</section>
)
})}
{/* 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')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null}
<CopyDialog
state={state}
t={t}

View File

@@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void {
hooks: { agentPresetSeat: seat.store },
load: () => 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({

View File

@@ -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 })
}
/**