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={(
- { setOpen(value => !value) }}
- >
- {label}
-
-
- )}
+ 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={(
- { setOpen(value => !value) }}
- >
-
- {chosen?.name ?? state.current}
-
-
- )}
+ 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={(
+ { onOpenChange(!open) }}
+ >
+ {label}
+
+
+ )}
+ />
+ )
+}