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

@@ -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'] = {

View File

@@ -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<Partial<SessionProjectionMap>>
/** User interaction currently blocking this session, derived from live mux frames. */

View File

@@ -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) {

View File

@@ -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'] = {

View File

@@ -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

View File

@@ -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.

View File

@@ -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 是文件系统行为;本表层只在名单提供的范围内做选择。

View File

@@ -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:^",

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),
})
}
}
}

View File

@@ -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)
})
})

View File

@@ -20,6 +20,9 @@
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},

View File

@@ -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) {

View File

@@ -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<ComposerBarInjected>
& PropsLocale<'conversation'>

View File

@@ -512,6 +512,7 @@ export function InputBar({
</div>
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.agentPreset', { locked })}
{renderSlot('conversation.input.model', { locked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}

View File

@@ -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()
})

View File

@@ -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 },
}
}

View File

@@ -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<Wire<SessionSummary>>

View File

@@ -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

3
pnpm-lock.yaml generated
View File

@@ -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