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:
@@ -1,15 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The two picker surfaces: the General-settings row that names the default for
|
||||
* later sessions, and the composer seat that names this one's — the seat stops
|
||||
* being a control once the conversation starts, because the history from there
|
||||
* on was produced under the preset's tools.
|
||||
* The three conversation-adjacent surfaces: the General-settings row naming the
|
||||
* default for later sessions, the new-session chip naming the next one's, and
|
||||
* the session header's read-only label. The split is the host's rule — a
|
||||
* session's history is produced under its preset's tools, so the choice is
|
||||
* only ever offered before one starts.
|
||||
*/
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
@@ -30,8 +33,10 @@ const ROW_READY: AgentPresetSettingsState = {
|
||||
|
||||
const SEAT_READY: AgentPresetSeatState = {
|
||||
current: 'standard',
|
||||
options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
|
||||
switchable: true,
|
||||
options: [
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user' },
|
||||
],
|
||||
busy: false,
|
||||
error: null,
|
||||
}
|
||||
@@ -47,18 +52,37 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}, locked = false) {
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
locked,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
roster: Partial<AgentPresetSettingsState> = {},
|
||||
) {
|
||||
// The chip and the label read the same roster, metadata included.
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>({
|
||||
...ROW_READY, options: SEAT_READY.options, ...roster,
|
||||
})
|
||||
const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } })
|
||||
const load = vi.fn(() => Promise.resolve())
|
||||
const view = render(<AgentPresetLabel {...({
|
||||
load,
|
||||
sessionId: 's1',
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useAgentPresets: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetLabelProps)} />)
|
||||
return { load, view }
|
||||
}
|
||||
|
||||
describe('the General-settings row', () => {
|
||||
it('reads the roster once and shows the current default', async () => {
|
||||
const actions = renderRow()
|
||||
@@ -140,85 +164,65 @@ describe('the General-settings row', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composer seat', () => {
|
||||
it('reads the roster once and offers the session\'s preset', async () => {
|
||||
describe('the new-session chip', () => {
|
||||
it('reads the roster once and shows the staged preset by name', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('standard')
|
||||
expect(screen.getByRole('button').textContent).toContain('标准模式')
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
it('switches the session and closes the menu', () => {
|
||||
it('offers each preset with what it is for', () => {
|
||||
renderSeat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// The id alone never said what a preset does; the description is the
|
||||
// whole reason a preset can publish metadata at all.
|
||||
expect(screen.getByText('完整的编码 agent。')).toBeTruthy()
|
||||
// A preset that published none still reads as a row, with its id standing
|
||||
// in for the name.
|
||||
expect(screen.getByText(en.noDescription)).toBeTruthy()
|
||||
expect(screen.getByText('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the id when the staged preset published no name', () => {
|
||||
renderSeat({ current: 'mine' })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain('mine')
|
||||
})
|
||||
|
||||
it('stages the picked preset and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
fireEvent.click(screen.getByText('mine'))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('becomes a plain label once the conversation has started', () => {
|
||||
renderSeat({ switchable: false })
|
||||
|
||||
// Not a disabled menu: that would suggest the preset could still change.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('closes an open menu the moment the session stops being blank', () => {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>(SEAT_READY)
|
||||
render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...SEAT_READY, switchable: false }) })
|
||||
|
||||
expect(screen.getByTitle(en.lockedHint)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the trigger while the composer or a switch is busy', () => {
|
||||
it('disables the trigger while a switch is in flight', () => {
|
||||
renderSeat({ busy: true })
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
cleanup()
|
||||
|
||||
renderSeat({}, true)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failed switch on the trigger', () => {
|
||||
it('shows a refused switch on the trigger', () => {
|
||||
renderSeat({ error: 'session has already started' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
|
||||
})
|
||||
|
||||
it('renders nothing before the roster arrives or when there is none', () => {
|
||||
const empty = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, options: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const empty = renderSeat({ options: [] })
|
||||
expect(empty).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const unresolved = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, current: '' })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(unresolved.container.firstChild).toBeNull()
|
||||
renderSeat({ current: '' })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
@@ -230,3 +234,42 @@ describe('the composer seat', () => {
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session-header label', () => {
|
||||
it('names the preset the session runs, and never offers a switch', async () => {
|
||||
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
|
||||
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
|
||||
// A control here would promise a switch the host refuses outright.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式')
|
||||
})
|
||||
|
||||
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
|
||||
renderLabel({ blank: true, agentPreset: 'mine' })
|
||||
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('mine')
|
||||
})
|
||||
|
||||
it('shows the id until the roster resolves it', () => {
|
||||
renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] })
|
||||
|
||||
// The session's own summary is the authority on which preset it runs; the
|
||||
// roster only supplies the display name, and its arrival is a later frame.
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('renders nothing, and reads no roster, when the session records no preset', async () => {
|
||||
const absent = renderLabel({ blank: true })
|
||||
expect(absent.view.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
// A session the list has not caught up to is the same answer: a deployment
|
||||
// that composes no presets must not pay for a roster read per header.
|
||||
const unknown = renderLabel(undefined)
|
||||
expect(unknown.view.container.firstChild).toBeNull()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(absent.load).not.toHaveBeenCalled()
|
||||
expect(unknown.load).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user