feat(web): pick a session's agent preset from the composer

A seat in the composer tool row, left of the model select, showing the preset
THIS session runs — its own recorded one, not the deployment default, because a
resumed session runs what it was created with. `SessionSummary` carries
`agentPreset` for that, alongside `cwd` and `origin`.

The switch exists only while the conversation has not started. After the first
turn the seat becomes a plain label rather than a disabled menu: a greyed
control reads as "temporarily unavailable", when in fact the choice is gone for
good. The seat never asks in that state, and the host refuses independently
with `agent-preset-locked`, so a stale client cannot slip one past it.

A rejected switch restores the previous value and surfaces the host's message
rather than leaving the seat showing something the session is not running.
This commit is contained in:
Yichen Jiang
2026-08-04 10:54:13 +08:00
parent bf4356cf35
commit a2ab09003f
23 changed files with 430 additions and 11 deletions

View File

@@ -0,0 +1,34 @@
/* Agent-preset seat: a compact selector pill in the composer tool row. */
.seat {
display: inline-flex;
align-items: center;
gap: 2px;
height: 28px;
padding: 0 6px 0 8px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.seat:hover:not(:disabled) {
background: var(--dsw-alias-fill-tsp-secondary);
}
.seat:disabled {
cursor: default;
color: var(--dsw-alias-label-quaternary);
}
.chevron {
flex: none;
opacity: 0.6;
}
.locked {
cursor: default;
}

View File

@@ -0,0 +1,95 @@
/**
* Composer seat for the session's agent preset.
*
* 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, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
// 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 css from './AgentPresetSeat.module.css'
/** Registration-side business face for the composer seat. */
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. */
load: () => Promise<void>
/** Switch this session to another preset. */
select: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetSeatProps =
PropsRuntime<'conversation.input.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.
*/
export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) {
const state = useAgentPresetSeat(snapshot => snapshot)
const [open, setOpen] = useState(false)
useEffect(() => {
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>
}
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({
id: option.id,
label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
}))}
selectedId={state.current}
onSelect={(id) => {
setOpen(false)
void select(id)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.seat}
aria-haspopup="menu"
aria-expanded={open}
title={state.error ?? t('seatHint')}
disabled={locked || state.busy}
onClick={() => { setOpen(value => !value) }}
>
{state.current}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
)
}

View File

@@ -12,13 +12,18 @@
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetRow } from './AgentPresetRow.tsx'
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
import { AgentPresetSeatController } from './seat-store.ts'
import { en, zh } from './locales.ts'
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
export type { AgentPresetSeatState } from './seat-store.ts'
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
@@ -54,6 +59,37 @@ export function apply(ctx: ClientContext): void {
return () => { for (const dispose of disposers) dispose() }
}, 'ui-agent-preset: settings refresh')
// The composer seat: one controller per session, because the switch and the
// "may it still switch" bit are both per-session facts.
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
const api = (scope.get('connection') as ConnectionHandle).api
const seats = new Map<SessionId, AgentPresetSeatController>()
const seatFor = (sessionId: SessionId): AgentPresetSeatController => {
const existing = seats.get(sessionId)
if (existing !== undefined) return existing
const created = new AgentPresetSeatController(api, sessionId, () => {
const summary = scope.sessions.list.getSnapshot().byId[sessionId]
return summary === undefined
? undefined
: { blank: summary.blank, ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset } }
})
seats.set(sessionId, created)
return created
}
scope.effect(() => scope.slots.register({
name: 'conversation.input.agentPreset',
locale: 'settings.agentPreset',
inject: (sessionId: SessionId): AgentPresetSeatInjected => {
const seat = seatFor(sessionId)
return {
hooks: { agentPresetSeat: seat.store },
load: () => seat.load(),
select: (id: string) => seat.select(id),
}
},
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
})
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'agent-preset',

View File

@@ -2,7 +2,7 @@
/** Locale keys this row renders. */
export type AgentPresetSettingsKey =
| 'title' | 'description' | 'loading' | 'error' | 'userTrust'
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
/** English copy. */
export const en: Record<AgentPresetSettingsKey, string> = {
@@ -11,6 +11,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
loading: 'Loading presets…',
error: 'Could not load agent presets.',
userTrust: 'Local',
seatHint: 'Agent preset for this session — switchable until you send the first message',
lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
}
/** Simplified Chinese copy. */
@@ -20,4 +22,6 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
loading: '正在加载 preset…',
error: '无法加载 agent preset。',
userTrust: '本地',
seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
lockedHint: '会话开始后,其 agent preset 即固定',
}

View File

@@ -0,0 +1,100 @@
/**
* Composer-seat controller: what one session may switch to, and whether it
* still may.
*
* A session's composition is fixed once its conversation starts, so the seat
* reads the session's own `blank` bit rather than a local guess — the host
* enforces the same rule and answers `agent-preset-locked` to a late attempt.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import {
createSnapshotStore, type SessionId, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { AgentPresetOption } from './settings-store.ts'
/** Composer-seat snapshot for one session. */
export interface AgentPresetSeatState {
/** Presets the deployment supplies; empty means the seat renders nothing. */
options: readonly AgentPresetOption[]
/** The preset this session runs, empty until the roster and summary load. */
current: string
/** False once the conversation has started — the switch is gone for good. */
switchable: boolean
/** A rejected switch's message, cleared by the next attempt. */
error: string | null
busy: boolean
}
const INITIAL: AgentPresetSeatState = {
options: [], current: '', switchable: false, error: null, busy: false,
}
/** Reads what one session may switch to and performs the switch. */
export class AgentPresetSeatController {
/** Seat snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
constructor(
private readonly api: IApiClient,
private readonly sessionId: SessionId,
/** Reads this session's blank bit and recorded preset from the session list. */
private readonly summary: () => { blank: boolean; agentPreset?: string } | undefined,
) {}
private set(patch: Partial<AgentPresetSeatState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
/**
* Load the roster and reconcile with this session's own state.
* @returns once the snapshot reflects the host.
*/
async load(): Promise<void> {
const summary = this.summary()
try {
const response = await this.api.agentPresets.list({})
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
const presets = response.result.value.presets
this.set({
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
// The session's recorded preset wins over the roster default: a
// resumed session runs what it was created with, not what the
// deployment now prefers.
current: summary?.agentPreset ?? presets.find(preset => preset.isDefault)?.id ?? '',
switchable: summary?.blank ?? false,
error: null,
})
} catch (error) {
this.set({ error: error instanceof Error ? error.message : String(error) })
}
}
/**
* Switch this session to another preset.
* @param id - the preset to compose the session's agent from.
* @returns once the switch settled; a rejection leaves the previous value.
*/
async select(id: string): Promise<void> {
const before = this.store.getSnapshot()
if (before.busy || id === before.current || !before.switchable) return
this.set({ busy: true, error: null, current: id })
try {
const response = await this.api.agentPresets.select({ sessionId: this.sessionId, agentPreset: id })
if (!response.result.ok) {
this.set({ busy: false, current: before.current, error: response.result.error.message })
return
}
this.set({ busy: false, current: response.result.value.agentPreset })
} catch (error) {
this.set({
busy: false,
current: before.current,
error: error instanceof Error ? error.message : String(error),
})
}
}
}