feat(web): choose the agent preset on the new-session screen

The composer seat spent nearly all its life disabled: a session's
composition is fixed once a turn has run. Move the choice to the
new-session screen beside the workspace picker, where it still works,
and let the session header report what a running session runs.

The hero pick is staged rather than applied — that screen precedes the
session it belongs to. It lands when a session becomes current and is
still blank, which covers both the session a workspace connect creates
and the blank one it reuses; riding `sessions.create` would miss the
second. It is spent on first use, matching the workspace picker.

Fix the durability the header field claimed but never had: `agentPreset`
was declared on `SessionHeader` and dropped by the JSONL header line, the
SQLite sessions row, the derived query index, and the cold list
projection, so every resumed session came back composed from nothing.

Add the web e2e lane that would have caught it — the one lane that mounts
the shipped roster, which needed `cordis:group` in the scaffold's Loader
builtins, as `mountRootInclude` already registers.
This commit is contained in:
Yichen Jiang
2026-08-05 18:51:11 +08:00
parent 4bb9836abe
commit 2b8a0a8cff
52 changed files with 1238 additions and 399 deletions

View File

@@ -165,6 +165,7 @@ export function apply(ctx: Context): void {
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
},
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
selectWorkspace: async (workspaceId) => {
@@ -228,7 +229,6 @@ 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

@@ -61,6 +61,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* reads the global workspace list.
*/
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
/**
* The agent-preset chip beside the workspace picker on the new-session
* screen. Root scope: no session exists yet, so the choice is staged for
* the next one rather than applied to a current one.
*/
'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps }
// 'conversation.input.overlay' merges in ui-slash (dedup ruling: the
// dependency direction is the hard constraint — ui-slash cannot import
// this package, while this package's input contract already imports
@@ -102,12 +108,6 @@ 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 }
}
/**
@@ -129,6 +129,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Owner share of the hero agent-preset chip: the shell supplies nothing. */
export interface HeroAgentPresetOwnerProps {
/** Marker field: the chip owns its own roster, staging, and menu state. */
children?: never
}
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer seat (whole `'conversation.composer'` chain output).
* Supplied for every real session (hero/settling/active) so the composer
* keeps one tree seat across the blank → active flip; the header stays
* outside that wrapper as ordinary column chrome (`flex: none`), while
* active CSS sticks the seat to the bottom of the same scrollport so wheel
* over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/** Header actions derive their state from the standard session/global kit. */
export interface ConversationHeaderActionOwnerProps {}
@@ -331,7 +353,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' | 'conversation.input.agentPreset'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& InjectFace<ComposerBarInjected>
& PropsLocale<'conversation'>
@@ -361,6 +383,7 @@ export type ConversationSlotProps =
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
| 'conversation.hero.workspace'
| 'conversation.hero.agentPreset'
>
& ConversationInjected
& PropsLocale<'conversation'>

View File

@@ -117,6 +117,7 @@ export function ConversationRoot({
},
onClose: () => { setPickerOpen(false) },
})}
{renderSlot('conversation.hero.agentPreset', {})}
</div>
)

View File

@@ -512,7 +512,6 @@ 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

@@ -85,9 +85,11 @@ describe('apply wiring', () => {
expect(conversationHeader?.store).toBe(conversationSession?.store)
expect(details?.store).toBe(conversationSession?.store)
expect(chatView?.store).toBe(conversationSession?.store)
// The hero workspace picker hole rides the conversation entry's children
// declaration (the empty-state occupant is gone).
// The hero holes ride the conversation entry's children declaration (the
// empty-state occupant is gone). Both are root-scoped: the new-session
// screen precedes the session either would belong to.
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
await b.runtime.dispose()
})

View File

@@ -693,7 +693,7 @@ describe('command launcher chrome and control seats', () => {
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
// Every seat dispatched, nothing rendered.
expect(slotCalls.map(c => c.key)).toEqual([
'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model',
'conversation.input.plan', 'conversation.input.model',
])
expect(view.queryByLabelText('Plan mode')).toBeNull()
expect(view.queryByLabelText('Model')).toBeNull()

View File

@@ -407,6 +407,9 @@ describe('ConversationRoot resident composer', () => {
const chip = b.view.getByRole('button', { name: '选择工作区' })
expect((chip as HTMLButtonElement).disabled).toBe(false)
expect(b.slotCalls).toContain('conversation.hero.workspace')
// The agent-preset chip sits in the same row, for the same reason: both
// choices are only open before the first message.
expect(b.slotCalls).toContain('conversation.hero.agentPreset')
})
it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {