Merge origin/master into worktree/web-plugin-config

Master gave the agent-preset nav its own glyph; this branch's plugins glyph
sits beside it, and both keep their own icon. Master's stricter
no-base-to-string rule also reaches this branch's new provider test, whose
fetch-call assertion now names the types it reads instead of stringifying an
unknown body.
This commit is contained in:
Yichen Jiang
2026-08-11 11:38:46 +08:00
154 changed files with 2000 additions and 485 deletions

View File

@@ -5,7 +5,7 @@
align-items: center;
gap: 4px;
max-width: 180px;
padding: 0 8px;
padding: 0 2px 0 0;
height: 22px;
border-radius: 6px;
background: var(--dsw-alias-fill-tsp-secondary);

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 size={14} className={css.icon} />
{text?.name ?? preset}
</span>
)

View File

@@ -9,7 +9,7 @@
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
border-radius: 16px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
@@ -36,6 +36,61 @@
color: var(--dsw-alias-label-primary);
}
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
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.15s 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,6 +32,29 @@ 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 CSS animation shares this
duration); the name's characters start fading up the moment it lands, each
taking the fade duration to settle. The cue clears after the last one. The
stagger is capped twice: per tick for short CJK names, and by one shared
reveal window so a long Latin name finishes in the same time as its CJK
counterpart instead of dragging the run out per character. */
const INTRO_TEXT_DELAY_MS = 150
const INTRO_CHAR_STAGGER_MS = 40
const INTRO_TEXT_REVEAL_MS = 200
const INTRO_CHAR_FADE_MS = 400
/**
* Per-character start offset for the introduce reveal.
* @param count - character count of the shown preset name.
* @returns milliseconds between successive character starts.
*/
function introStaggerMs(count: number): number {
if (count <= 1) return 0
return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1))
}
/** Full component props. */
@@ -45,7 +68,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 +76,54 @@ 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 - 1) * introStaggerMs(characters.length) + 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 characters = Array.from(label)
const stagger = introStaggerMs(characters.length)
const shownLabel = introducing
? (
<span className={css.introText}>
{characters.map((character, index) => (
<span
key={index}
className={css.introChar}
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * stagger}ms` }}
>
{character}
</span>
))}
</span>
)
: label
return (
<Menu
@@ -95,8 +160,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

@@ -26,6 +26,12 @@
gap: 10px;
}
/* Group-to-group breathing room: the section's 12px gap plus 20px reads the
two rosters as separate blocks (32px total). */
.group + .group {
margin-top: 20px;
}
.groupHead {
margin: 0;
font-size: 12px;
@@ -363,6 +369,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 +379,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 })
}
/**

View File

@@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => {
expect(section.startCreatorDraft).toBeDefined()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(workspaces.starts).toHaveLength(1)
// A cross-screen stage carries the introduce cue; the chip acknowledges
// it once, and a repeat acknowledgement leaves the snapshot untouched.
expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true)
seat.introduced()
const acknowledged = seat.hooks.agentPresetSeat.getSnapshot()
expect(acknowledged.introduce).toBe(false)
seat.introduced()
expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged)
conversation()
})

View File

@@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = {
],
busy: false,
error: null,
introduce: false,
}
function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
@@ -56,7 +57,11 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
const store = createSnapshotStore<AgentPresetSeatState>({ ...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(<AgentPresetSeat {...({
...actions,
useAgentPresetSeat: bindSnapshotSelector(store),
@@ -272,6 +277,94 @@ describe('the new-session chip', () => {
})
})
describe('the chip introduce cue', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
/** Character spans carry inline animation delays; nothing else does. */
function delayedChars(): HTMLElement[] {
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
}
it('reveals a long Latin name inside the shared window, then acknowledges', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }],
introduce: true,
})
// Eleven characters split the 200ms window into 20ms steps, where the
// fixed 40ms tick would have doubled the run for a Latin name.
const chars = delayedChars()
expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode')
expect(chars[0]!.style.animationDelay).toBe('150ms')
expect(chars[1]!.style.animationDelay).toBe('170ms')
expect(chars[10]!.style.animationDelay).toBe('350ms')
// 150 delay + 200 window + 400 fade: acknowledged only once the last
// character has settled, and the label is plain text again after.
act(() => { vi.advanceTimersByTime(749) })
expect(actions.introduced).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(1) })
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
it('keeps the per-tick cap for a short CJK name', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: '创造模式' }],
introduce: true,
})
// Four characters fit under the window, so the 40ms tick applies as-is.
const chars = delayedChars()
expect(chars).toHaveLength(4)
expect(chars[1]!.style.animationDelay).toBe('190ms')
expect(chars[3]!.style.animationDelay).toBe('270ms')
})
it('starts a one-character name with no stagger at all', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
vi.useFakeTimers()
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: 'C' }],
introduce: true,
})
expect(delayedChars()[0]!.style.animationDelay).toBe('150ms')
act(() => { vi.advanceTimersByTime(550) })
expect(actions.introduced).toHaveBeenCalledTimes(1)
})
it('skips the run under reduced motion and acknowledges at once', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
const actions = renderSeat({ introduce: true })
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
it('acknowledges an empty staged name without arming a run', () => {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
const actions = renderSeat({
current: 'creator',
options: [{ id: 'creator', trust: 'user', name: '' }],
introduce: true,
})
expect(actions.introduced).toHaveBeenCalledTimes(1)
expect(delayedChars()).toHaveLength(0)
})
})
describe('the session-header label', () => {
it('names the preset the session runs, and never offers a switch', async () => {
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })

View File

@@ -253,6 +253,20 @@ describe('the preset list', () => {
expect(actions.close).toHaveBeenCalledTimes(1)
})
it('keeps the empty custom group on screen: heading plus the creator entry', () => {
renderSection({
rows: [
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式' },
{ id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' },
],
})
// No member yet, but the place where one's own preset will appear stays.
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy()
expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull()
})
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
renderSection()
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()

View File

@@ -60,7 +60,7 @@ export const zh = {
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '探索未之境',
'hero.headline': '探索未之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',

View File

@@ -263,8 +263,9 @@
.composerHero {
position: relative; /* .heroGlow positioning context */
align-self: center;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px;
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
8 above the card (its margin-top restores 12 under the hero chrome). */
gap: 8px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
/* Card cap + both clearances: the hero input card lands at exactly the same
@@ -292,7 +293,9 @@
.heroWorkspaceRow {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
margin-top: 4px;
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
the card's inner controls below. */
padding-left: 20px;

View File

@@ -105,7 +105,7 @@
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
border-radius: 16px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;

View File

@@ -51,6 +51,9 @@
}
.chevron {
/* inline-flex, not inline: an inline seat reserves baseline descent under
the svg and floats the glyph off-center in the 28px trigger. */
display: inline-flex;
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;

View File

@@ -358,7 +358,7 @@ describe('ConversationRoot resident composer', () => {
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
@@ -382,7 +382,7 @@ describe('ConversationRoot resident composer', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('探索未之境')).toBeNull()
expect(b.view.queryByText('探索未之境')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
@@ -407,7 +407,7 @@ describe('ConversationRoot resident composer', () => {
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByText('探索未之境')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
@@ -425,7 +425,7 @@ describe('ConversationRoot resident composer', () => {
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByText('探索未之境')).toBeNull()
expect(b.view.queryByText('探索未之境')).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})

View File

@@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_agent_preset_outline_16 (figma extract): node interiors knock out to transparency via mask, so the glyph sits on any fill. */
export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_agent_preset_16" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect width="16" height="16" fill="white" />
<circle cx="7.9995" cy="3.28319" r="1.712" fill="black" />
<circle cx="3.51122" cy="11.3855" r="1.712" fill="black" />
<circle cx="12.4878" cy="11.3855" r="1.712" fill="black" />
</mask>
<path
mask="url(#mask0_agent_preset_16)"
d="M12.2881 11.0425C12.6002 11.3723 13.0413 11.5786 13.5312 11.5786L13.5342 11.5776C13.1476 12.3233 12.6119 12.9785 11.9639 13.5005C10.9327 14.3309 9.6199 14.8286 8.19336 14.8286C7.29864 14.8285 6.45056 14.6313 5.6875 14.2808C6.08309 14.0281 6.36707 13.6189 6.45215 13.1392C6.99022 13.3561 7.57767 13.476 8.19336 13.4761C9.30019 13.4761 10.3157 13.0915 11.1152 12.4478C11.5935 12.0626 11.9924 11.5848 12.2881 11.0425ZM4.14746 4.36475C4.25569 4.83228 4.55488 5.2247 4.95898 5.4585C4.07956 6.30639 3.53144 7.49605 3.53125 8.81396C3.53125 9.69534 3.77613 10.5202 4.20117 11.2231C3.74959 11.3817 3.38395 11.7232 3.19531 12.1597C2.5541 11.2032 2.17969 10.052 2.17969 8.81396C2.17989 7.05087 2.93868 5.4646 4.14746 4.36475ZM8.19336 2.80029C8.85717 2.80029 9.49784 2.90834 10.0967 3.10791C12.3237 3.85044 13.9725 5.86061 14.1846 8.28369C13.9832 8.20048 13.7627 8.15382 13.5312 8.15381C13.2802 8.15381 13.042 8.20907 12.8271 8.30615C12.6281 6.47264 11.3666 4.95616 9.66895 4.39014C9.2063 4.236 8.70989 4.15186 8.19336 4.15186C7.96112 4.15189 7.7329 4.16981 7.50977 4.20264C7.51947 4.12886 7.52637 4.05348 7.52637 3.97705C7.52628 3.56604 7.3811 3.18914 7.13965 2.89404C7.48183 2.83352 7.83381 2.80033 8.19336 2.80029Z"
fill="currentColor"
/>
<path
d="M9.1123 3.28271C9.11205 2.66858 8.61322 2.17041 7.99902 2.17041C7.38504 2.17067 6.88697 2.66874 6.88672 3.28271C6.88672 3.89691 7.38489 4.39574 7.99902 4.396C8.61338 4.396 9.1123 3.89707 9.1123 3.28271ZM10.3115 3.28271C10.3115 4.55981 9.27612 5.59521 7.99902 5.59521C6.72214 5.59496 5.6875 4.55965 5.6875 3.28271C5.68776 2.00599 6.7223 0.971447 7.99902 0.971191C9.27596 0.971191 10.3113 2.00584 10.3115 3.28271Z"
fill="currentColor"
/>
<path
d="M4.62402 11.385C4.62377 10.7709 4.12494 10.2727 3.51074 10.2727C2.89676 10.273 2.39869 10.771 2.39844 11.385C2.39844 11.9992 2.89661 12.498 3.51074 12.4983C4.1251 12.4983 4.62402 11.9994 4.62402 11.385ZM5.82324 11.385C5.82324 12.6621 4.78784 13.6975 3.51074 13.6975C2.23386 13.6973 1.19922 12.6619 1.19922 11.385C1.19947 10.1083 2.23402 9.07374 3.51074 9.07349C4.78768 9.07349 5.82299 10.1081 5.82324 11.385Z"
fill="currentColor"
/>
<path
d="M13.6006 11.385C13.6003 10.7709 13.1015 10.2727 12.4873 10.2727C11.8733 10.273 11.3753 10.771 11.375 11.385C11.375 11.9992 11.8732 12.498 12.4873 12.4983C13.1017 12.4983 13.6006 11.9994 13.6006 11.385ZM14.7998 11.385C14.7998 12.6621 13.7644 13.6975 12.4873 13.6975C11.2104 13.6973 10.1758 12.6619 10.1758 11.385C10.176 10.1083 11.2106 9.07374 12.4873 9.07349C13.7642 9.07349 14.7995 10.1081 14.7998 11.385Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_browse_outline_16 */
export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(67)
it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(68)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

View File

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

View File

@@ -14,8 +14,8 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconCloseOutline16, IconDataOutline16, IconPersonalizationOutline16,
IconSettingsOutline16, IconThinkOutline16,
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
IconPersonalizationOutline16, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css'
@@ -23,7 +23,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 <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
if (id === 'plugins') return <IconPersonalizationOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}

View File

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

View File

@@ -54,7 +54,6 @@
max-height: min(560px, calc(100vh - 140px));
padding: 4px;
overflow: auto;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-specific-menu);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);

View File

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

View File

@@ -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(<SubagentCatalogAction {...loading} />)
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(<SubagentCatalogAction {...failed} />)
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 () => {

View File

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