Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring

# Conflicts:
#	packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx
#	packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx
This commit is contained in:
Yichen Jiang
2026-08-07 15:59:59 +08:00
3 changed files with 128 additions and 94 deletions

View File

@@ -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
<div className={css.title}>{t('title')}</div>
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
</div>
<Menu
open={open}
onClose={() => { 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,
}))}
<PresetMenu
options={state.options}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)
void select(id)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
disabled={busy || !state.writable || state.options.length === 0}
onClick={() => { setOpen(value => !value) }}
>
{label}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
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) }}
/>
</div>
)

View File

@@ -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<AgentPresetSeatState>
}
/** Read the roster when the chip first renders. */
/** Load the roster and this session's state when the seat first renders. */
load: () => Promise<void>
/** Stage one preset for the next session. */
/** Switch this session to another preset. */
select: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetSeatProps =
PropsRuntime<'conversation.hero.agentPreset'>
PropsRuntime<'conversation.input.agentPreset'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetSeatInjected>
/**
* 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 <span className={`${css.seat} ${css.locked}`} title={t('lockedHint')}>{state.current}</span>
}
return (
<Menu
open={open}
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>
),
}))}
<PresetMenu
options={state.options}
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>
)}
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) }}
/>
)
}

View File

@@ -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 (
<Menu
open={open}
onClose={() => { 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={(
<button
type="button"
className={buttonClassName}
aria-haspopup="menu"
aria-expanded={open}
{...title === undefined ? {} : { title }}
disabled={disabled}
onClick={() => { onOpenChange(!open) }}
>
{label}
<IconChevronDownOutline14 className={chevronClassName} />
</button>
)}
/>
)
}