From a2ab09003f0520b5b8d137cdb1e6a759ed421b3d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 10:54:13 +0800 Subject: [PATCH] feat(web): pick a session's agent preset from the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/connection/tests/fake-api.ts | 2 + .../runtime/src/client/sessions/lineage.ts | 2 + .../runtime/src/client/sessions/service.ts | 7 ++ packages/client/runtime/tests/fake-api.ts | 2 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 7 +- packages/client/ui-agent-preset/README.zh.md | 7 +- packages/client/ui-agent-preset/package.json | 5 +- .../src/client/AgentPresetSeat.module.css | 34 ++++++ .../src/client/AgentPresetSeat.tsx | 95 +++++++++++++++++ .../ui-agent-preset/src/client/index.ts | 38 ++++++- .../ui-agent-preset/src/client/locales.ts | 6 +- .../ui-agent-preset/src/client/seat-store.ts | 100 ++++++++++++++++++ .../tests/settings-store.spec.ts | 98 +++++++++++++++++ packages/client/ui-agent-preset/tsconfig.json | 3 + .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/slots.ts | 8 +- .../src/client/skeleton/InputBar.tsx | 1 + .../ui-conversation/tests/input-bar.spec.tsx | 8 +- packages/host/apiproxy/src/api-proxy.ts | 2 + .../host/apiproxy/src/api/sessions.schema.ts | 1 + packages/host/apiproxy/src/api/sessions.ts | 7 ++ pnpm-lock.yaml | 3 + 23 files changed, 430 insertions(+), 11 deletions(-) create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx create mode 100644 packages/client/ui-agent-preset/src/client/seat-store.ts diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index faa433e67e..48812e4fd6 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -169,6 +169,8 @@ export class FakeApiClient implements IApiClient { readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 69094f2964..cf8fa0834d 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -25,6 +25,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string + /** Agent preset the session's agent was composed from (summary passthrough). */ + agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> /** User interaction currently blocking this session, derived from live mux frames. */ diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b1b271e702..b199c0724a 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -45,6 +45,12 @@ export interface SessionSummary { /** Human-facing label: durable title, project basename, then session id. */ displayTitle: string cwd?: string + /** + * Agent preset this session's agent was composed from; absent when the + * deployment composes no presets. A composer seat shows what the session + * actually runs rather than the deployment's current default. + */ + agentPreset?: string parentId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' @@ -629,6 +635,7 @@ export class SessionsService implements ISessions { ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + ...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}), } } if (current !== undefined && currentAddress !== undefined) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 9694ea9cfb..b715fb329b 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -204,6 +204,8 @@ export class FakeApiClient implements IApiClient { readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index aee8c88bb2..d25bf7b502 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: c22d5a40659bf6300f133ff902fbc3bf8bc75276 -README.zh.md: 6b7e43adcdfe7d6de781511edb5d91a4c45cf11e +README.md: 322fc7c8ba6eb621f27cb09475079e3d5bccf03f +README.zh.md: 6eece3248d7f3c3652a30579f907eaf5f888f35f diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index ecfa962cdc..14921afb7b 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -8,6 +8,12 @@ The agent-preset surface: one General-settings row choosing which [preset](../.. A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with. +## The composer seat + +A second surface, in the composer tool row left of the model select: the preset THIS session runs. It shows the session's own recorded preset rather than the deployment default, because a resumed session runs what it was created with. + +The switch exists only while the conversation has not started. After the first turn the seat becomes a plain label — offering a disabled menu would suggest the choice is merely unavailable rather than gone. The host enforces the same rule and answers `agent-preset-locked`, so a stale client cannot slip a switch past it. + ## What it reads and writes Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. @@ -30,6 +36,5 @@ No direct invalidation. Changing the default never touches a running session's p ## Known Limitations and Deferred Work -- **No per-session choice at creation** — this row sets the default only. The wire already carries `agentPreset` on `session.create`, so a session-start surface can offer the choice; that surface does not exist yet. - **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names. - **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 915777875e..06807199e9 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -8,6 +8,12 @@ agent preset 表层:General 设置中的一行,用于选择新建会话据 会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。 +## composer 座位 + +第二个表层,位于 composer 工具行、模型选择器左侧:**本会话**所运行的 preset。它显示会话自身记录的 preset 而非部署默认值,因为被恢复的会话运行的是它创建时的那一份。 + +切换只在对话尚未开始时存在。第一个轮次之后,该座位变为纯文本标签——展示一个禁用的菜单会让人以为这个选择只是暂时不可用,而非已经消失。宿主执行同一条规则并返回 `agent-preset-locked`,因此过期的客户端无法绕过它。 + ## 它读什么、写什么 选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。 @@ -30,6 +36,5 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre ## Known Limitations and Deferred Work -- **创建时无法逐会话选择** —— 本行只设置默认值。wire 上 `session.create` 已经携带 `agentPreset`,因此会话开启表层可以提供该选择;该表层尚不存在。 - **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。 - **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index e82613cc0e..5022fa8614 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -26,7 +26,8 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime" + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web" }, @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -49,6 +51,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css new file mode 100644 index 0000000000..f63bbf60d6 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -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; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx new file mode 100644 index 0000000000..3e8d08479a --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -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 + } + /** Load the roster and this session's state when the seat first renders. */ + load: () => Promise + /** Switch this session to another preset. */ + select: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetSeatProps = + PropsRuntime<'conversation.input.agentPreset'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * 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 {state.current} + } + + return ( + { 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={( + + )} + /> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 2101e69630..6b23c63c8d 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -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() + 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', diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 6bcc2767d4..cfeb7e5ff5 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -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 = { @@ -11,6 +11,8 @@ export const en: Record = { 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 = { loading: '正在加载 preset…', error: '无法加载 agent preset。', userTrust: '本地', + seatHint: '本会话的 agent preset —— 发送第一条消息前可切换', + lockedHint: '会话开始后,其 agent preset 即固定', } diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts new file mode 100644 index 0000000000..98193c14de --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -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 = 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): 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 { + 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 { + 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), + }) + } + } +} diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts index cd6a38ee74..e8d8c19d62 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.spec.ts @@ -10,6 +10,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, } from '../src/client/settings-store.ts' +import { AgentPresetSeatController } from '../src/client/seat-store.ts' interface Recorded { ns: string; patch: unknown } @@ -120,3 +121,100 @@ describe('the agent-preset settings controller', () => { expect(state.error).toBe('host down') }) }) + +describe('the composer seat controller', () => { + /** A seat over a fixed session summary. */ + function seat( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + summary: { blank: boolean; agentPreset?: string } | undefined, + options: { writes?: Recorded[]; failSelect?: string } = {}, + ): AgentPresetSeatController { + const api = { + agentPresets: { + list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }), + select: (payload: { agentPreset: string }) => { + options.writes?.push({ ns: 'select', patch: payload.agentPreset }) + return Promise.resolve(options.failSelect === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } }) + }, + }, + } as unknown as IApiClient + return new AgentPresetSeatController(api, 's1' as never, () => summary) + } + + const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'core-web', trust: 'system', isDefault: false }, + ] + + it('shows what the session runs, not the deployment default', async () => { + const controller = seat(ROSTER, { blank: true, agentPreset: 'core-web' }) + + await controller.load() + + // A resumed session runs what it was created with; showing `standard` + // because it is the current default would be a lie about this session. + expect(controller.store.getSnapshot().current).toBe('core-web') + expect(controller.store.getSnapshot().switchable).toBe(true) + }) + + it('falls back to the roster default when the session records none', async () => { + const controller = seat(ROSTER, { blank: true }) + + await controller.load() + + expect(controller.store.getSnapshot().current).toBe('standard') + }) + + it('is not switchable once the conversation has started', async () => { + const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }) + + await controller.load() + + expect(controller.store.getSnapshot().switchable).toBe(false) + }) + + it('refuses to switch a session that already started', async () => { + const writes: Recorded[] = [] + const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }, { writes }) + await controller.load() + + await controller.select('core-web') + + // The host enforces the same rule; the seat simply never asks. + expect(writes).toEqual([]) + expect(controller.store.getSnapshot().current).toBe('standard') + }) + + it('switches a blank session and keeps the host\'s answer', async () => { + const writes: Recorded[] = [] + const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + + await controller.select('core-web') + + expect(writes).toEqual([{ ns: 'select', patch: 'core-web' }]) + expect(controller.store.getSnapshot().current).toBe('core-web') + }) + + it('restores the previous value when the host rejects the switch', async () => { + const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { failSelect: 'already started' }) + await controller.load() + + await controller.select('core-web') + + const state = controller.store.getSnapshot() + expect(state.current).toBe('standard') + expect(state.error).toBe('already started') + }) + + it('reports no options when the session is unknown to the list yet', async () => { + const controller = seat([], undefined) + + await controller.load() + + expect(controller.store.getSnapshot().options).toEqual([]) + expect(controller.store.getSnapshot().switchable).toBe(false) + }) +}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 3d17153642..9f22f84a4a 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../runtime" }, + { + "path": "../ui-conversation" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..1a074dac1e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -228,6 +228,7 @@ export function apply(ctx: Context): void { children: { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, + 'conversation.input.agentPreset': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId | undefined): ComposerBarInjected => { if (sessionId === undefined) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..81468a6b2f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -102,6 +102,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * empty-until-registered contract as the plan seat. */ 'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } + /** + * The agent-preset seat in the composer tool row, left of the model. + * Same empty-until-registered contract as the other two; its owner + * decides on its own whether the session may still switch. + */ + 'conversation.input.agentPreset': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } } /** @@ -325,7 +331,7 @@ export interface InputControlOwnerProps { /** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> - & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> + & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model' | 'conversation.input.agentPreset'> & InjectFace & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..9687582427 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -512,6 +512,7 @@ export function InputBar({
{rightItems} + {renderSlot('conversation.input.agentPreset', { locked })} {renderSlot('conversation.input.model', { locked })} {/* {machineBusy && } */} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..116aeeb26d 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -686,13 +686,15 @@ describe('strips and variants', () => { }) describe('command launcher chrome and control seats', () => { - it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('命令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() - // Both seats dispatched, nothing rendered. - expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) + // Every seat dispatched, nothing rendered. + expect(slotCalls.map(c => c.key)).toEqual([ + 'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model', + ]) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 24ca249f5b..27e44a65dc 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -260,11 +260,13 @@ function sessionListFields(header: SessionHeader): { parentSessionId?: SessionId origin?: 'subagent' cwd?: string + agentPreset?: string } { return { ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }, ...header.origin === undefined ? {} : { origin: header.origin }, ...header.cwd === undefined ? {} : { cwd: header.cwd }, + ...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }, } } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a3c7845aad..1ee2c9b1ac 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -55,6 +55,7 @@ export const sessionSummarySchema = z.object({ parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), cwd: z.string().optional(), + agentPreset: z.string().optional(), projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), }) as unknown as z.ZodType> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 0427be392a..893d3260a6 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -156,6 +156,13 @@ export interface SessionSummary { origin?: 'subagent' /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string + /** + * Agent preset this session's agent was composed from (header passthrough); + * absent when the deployment composes no presets. A surface offering a + * switch reads this to show what the session actually runs rather than what + * the deployment currently defaults to. + */ + agentPreset?: string /** * Projection baseline for this row, with zero log loads: attached sessions * read the registry's live watermark cut; cold sessions read the persisted diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad50acbb2b..257eee1465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1446,6 +1446,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives