diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx index 178c2a6ada..9056f32266 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -7,9 +7,9 @@ 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, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { AgentPresetSettingsState } from './settings-store.ts' import type { AgentPresetSettingsKey } from './locales.ts' +import { PresetMenu } from './PresetMenu.tsx' import css from './AgentPresetRow.module.css' /** Registration-side business face for the host-backed preference. */ @@ -52,12 +52,7 @@ 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' - // The metadata name is what every other surface shows — the new-session chip, - // the session header, the preset cards — so this row shows it too; 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 label = state.currentValue === '' ? t('loading') : state.currentValue const description: string = state.error ?? t('description') return ( @@ -66,38 +61,17 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR
{t('title')}
{description}
- { setOpen(false) }} - // A locally authored preset is exactly as privileged as the plugins it - // names, so the list says which rows are local rather than presenting - // every preset as shipped and vetted. - items={state.options.map(option => ({ - id: option.id, - label: option.trust === 'user' - ? `${option.name ?? option.id} · ${t('userTrust')}` - : option.name ?? option.id, - }))} + { - setOpen(false) - void select(id) - }} - align="end" - portal - anchor={( - - )} + label={label} + userTrustLabel={t('userTrust')} + buttonClassName={css.selector} + chevronClassName={css.chevron} + disabled={busy || !state.writable || state.options.length === 0} + open={open} + onOpenChange={setOpen} + onSelect={(id) => { void select(id) }} /> ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 8e18471fbc..352ca66274 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -1,50 +1,44 @@ /** - * The agent-preset chip on the new-session screen, beside the workspace - * picker. + * Composer seat for the session's agent preset. * - * 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. + * 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. */ 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' -// Type-only: pulls the ui-conversation SlotMap merge (the hero seat). +// Type-only: pulls the ui-conversation SlotMap merge (the agentPreset 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 hero chip. */ +/** Registration-side business face for the composer seat. */ export interface AgentPresetSeatInjected { hooks: { /** Seat snapshot bound by the renderer as useAgentPresetSeat. */ agentPresetSeat: SnapshotStore } - /** Read the roster when the chip first renders. */ + /** Load the roster and this session's state when the seat first renders. */ load: () => Promise - /** Stage one preset for the next session. */ + /** Switch this session to another preset. */ select: (id: string) => Promise } /** Full component props. */ export type AgentPresetSeatProps = - PropsRuntime<'conversation.hero.agentPreset'> + PropsRuntime<'conversation.input.agentPreset'> & PropsLocale<'settings.agentPreset'> & InjectFace /** - * Render the new-session agent-preset chip. - * @param props - composed slot props. - * @returns the chip, or null when the deployment composes no presets. + * 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. */ -export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { +export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) { const state = useAgentPresetSeat(snapshot => snapshot) const [open, setOpen] = useState(false) @@ -52,49 +46,34 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr 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 - const chosen = state.options.find(option => option.id === state.current) + // 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 {state.current} + } return ( - { 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: ( - - {option.name ?? option.id} - {option.description ?? t('noDescription')} - - ), - }))} + { - setOpen(false) - void select(id) - }} - align="start" - portal - anchor={( - - )} + label={state.current} + userTrustLabel={t('userTrust')} + buttonClassName={css.seat} + chevronClassName={css.chevron} + disabled={locked || state.busy} + title={state.error ?? t('seatHint')} + open={open} + onOpenChange={setOpen} + onSelect={(id) => { void select(id) }} /> ) } diff --git a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx new file mode 100644 index 0000000000..bdf22ee56f --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx @@ -0,0 +1,81 @@ +/** + * The preset picker both surfaces render: a menu of presets over a button + * naming the current one. + * + * The settings row and the composer seat differ in where they sit, what they + * call the current value, and when they refuse a pick — not in how the picker + * itself behaves. Trust is the one thing the list always says: a locally + * authored preset is exactly as privileged as the plugins it names, so the + * label marks it rather than presenting every preset as shipped and vetted. + */ + +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { AgentPresetOption } from './settings-store.ts' + +/** What one surface passes to the shared picker. */ +export interface PresetMenuProps { + /** Presets to offer, in roster order. */ + options: readonly AgentPresetOption[] + /** The preset the button names and the menu marks selected. */ + selectedId: string + /** Text on the button; the surfaces word a pending roster differently. */ + label: string + /** Suffix marking a locally authored preset in the menu. */ + userTrustLabel: string + /** Class for the trigger button, owned by the calling surface. */ + buttonClassName: string | undefined + /** Class for the chevron, owned by the calling surface. */ + chevronClassName: string | undefined + /** Whether the trigger refuses interaction. */ + disabled: boolean + /** Native tooltip, absent where the surface offers none. */ + title?: string + /** Whether the menu is open — the surface owns this so it can force it shut. */ + open: boolean + /** Report the menu's next open state. */ + onOpenChange: (open: boolean) => void + /** Called with the picked preset once the menu has closed. */ + onSelect: (id: string) => void +} + +/** + * Render the preset picker. + * @param props - the calling surface's copy, styling, and handlers. + * @returns the menu and its trigger. + */ +export function PresetMenu({ + options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName, + disabled, title, open, onOpenChange, onSelect, +}: PresetMenuProps) { + return ( + { onOpenChange(false) }} + items={options.map(option => ({ + id: option.id, + label: option.trust === 'user' ? `${option.id} · ${userTrustLabel}` : option.id, + }))} + selectedId={selectedId} + onSelect={(id) => { + onOpenChange(false) + onSelect(id) + }} + align="end" + portal + anchor={( + + )} + /> + ) +}