refactor(ui-agent-preset): keep the row's picker shared, the hero chip its own

The chip renders name and description on two lines with its own icon and
alignment, so it is not the same control as the settings row — the shared
picker carries the row, and the chip keeps its shape.
This commit is contained in:
Yichen Jiang
2026-08-07 16:01:43 +08:00
parent b4213fac6f
commit 93ad74cab5
3 changed files with 67 additions and 37 deletions

View File

@@ -52,7 +52,11 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR
// every session shares the host composition — the row simply does not exist.
if (state.status === 'unavailable') return null
const busy = state.status === 'loading' || state.status === 'saving'
const label = state.currentValue === '' ? t('loading') : state.currentValue
// The metadata name is what every other surface shows — the id is the
// addressing, not the label. A preset that names itself nothing falls back
// to its id, which is then all there is to say about it.
const chosen = state.options.find(option => option.id === state.currentValue)
const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue)
const description: string = state.error ?? t('description')
return (

View File

@@ -1,44 +1,50 @@
/**
* Composer seat for the session's agent preset.
* The agent-preset chip on the new-session screen, beside the workspace
* picker.
*
* The switch exists only while the conversation has not started: after the
* first turn the session's history was produced under this preset's tools, so
* the seat becomes a plain label rather than offering a choice it cannot honor.
* It lives here rather than in the composer because the choice is only
* available before a conversation starts: once a turn has run, the session's
* history was produced under that preset's tools and the host refuses to swap
* them. A control that spends most of its life disabled belongs on the screen
* where it still works.
*
* The menu opens on the staged choice, which starts as the deployment default.
* Picking stages; the choice reaches a session when one becomes current.
*/
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'
// Type-only: pulls the ui-conversation SlotMap merge (the agentPreset seat).
import { IconChevronDownOutline14, IconThinkOutline16, 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'
import { PresetMenu } from './PresetMenu.tsx'
import css from './AgentPresetSeat.module.css'
/** Registration-side business face for the composer seat. */
/** Registration-side business face for the hero chip. */
export interface AgentPresetSeatInjected {
hooks: {
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
}
/** Load the roster and this session's state when the seat first renders. */
/** Read the roster when the chip first renders. */
load: () => Promise<void>
/** Switch this session to another preset. */
/** Stage one preset for the next session. */
select: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetSeatProps =
PropsRuntime<'conversation.input.agentPreset'>
PropsRuntime<'conversation.hero.agentPreset'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetSeatInjected>
/**
* Render the session's agent-preset seat.
* @param props - composed slot props; `locked` is the composer's own busy state.
* @returns the seat, or null when the deployment composes no presets.
* Render the new-session agent-preset chip.
* @param props - composed slot props.
* @returns the chip, or null when the deployment composes no presets.
*/
export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) {
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
const state = useAgentPresetSeat(snapshot => snapshot)
const [open, setOpen] = useState(false)
@@ -46,34 +52,49 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }:
void load()
}, [load])
useEffect(() => {
if (state.switchable) return
setOpen(false)
}, [state.switchable])
// 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
// Past the first turn the preset is a fact about this session, not a
// control — showing a disabled menu would suggest it could still be changed.
if (!state.switchable) {
return <span className={`${css.seat} ${css.locked}`} title={t('lockedHint')}>{state.current}</span>
}
const chosen = state.options.find(option => option.id === state.current)
return (
<PresetMenu
options={state.options}
selectedId={state.current}
label={state.current}
userTrustLabel={t('userTrust')}
buttonClassName={css.seat}
chevronClassName={css.chevron}
disabled={locked || state.busy}
title={state.error ?? t('seatHint')}
<Menu
open={open}
onOpenChange={setOpen}
onSelect={(id) => { void select(id) }}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({
id: option.id,
// Name and description together: the id alone never said what a
// preset does, which is the whole reason the metadata exists.
label: (
<span className={css.item}>
<span className={css.itemName}>{option.name ?? option.id}</span>
<span className={css.itemDesc}>{option.description ?? t('noDescription')}</span>
</span>
),
}))}
selectedId={state.current}
onSelect={(id) => {
setOpen(false)
void select(id)
}}
align="start"
portal
anchor={(
<button
type="button"
className={css.seat}
aria-haspopup="menu"
aria-expanded={open}
title={state.error ?? t('seatHint')}
disabled={state.busy}
onClick={() => { setOpen(value => !value) }}
>
<IconThinkOutline16 className={css.seatIcon} />
{chosen?.name ?? state.current}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
)
}

View File

@@ -53,7 +53,12 @@ export function PresetMenu({
onClose={() => { onOpenChange(false) }}
items={options.map(option => ({
id: option.id,
label: option.trust === 'user' ? `${option.id} · ${userTrustLabel}` : option.id,
// The metadata name is what every surface shows; the id is addressing,
// not a label. A preset that names itself nothing falls back to its id,
// which is then all there is to say about it.
label: option.trust === 'user'
? `${option.name ?? option.id} · ${userTrustLabel}`
: option.name ?? option.id,
}))}
selectedId={selectedId}
onSelect={(id) => {