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

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