diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts index 9a55d9fc8ee..a759577d4f7 100644 --- a/apps/desktop/e2e/chat.spec.ts +++ b/apps/desktop/e2e/chat.spec.ts @@ -86,7 +86,7 @@ test.describe('chat interaction with mock backend', () => { await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app }) }) - test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => { + test('offers stop, redirect, and queue actions while busy', async ({}, testInfo) => { const page = fixture!.page const composer = page.locator('[contenteditable="true"]').first() const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') @@ -106,7 +106,7 @@ test.describe('chat interaction with mock backend', () => { await composer.click() await composer.type('please answer tersely') - await expect(primary).toHaveAttribute('aria-label', /Steer/) + await expect(primary).toHaveAttribute('aria-label', /Redirect/) await expect(dictation).toBeVisible() await expect(speakReplies).toBeVisible() await expect(queue).toBeVisible() @@ -120,10 +120,10 @@ test.describe('chat interaction with mock backend', () => { expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex) expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message')) expect(controlLabels.indexOf('Queue message')).toBeLessThan( - controlLabels.findIndex(label => label?.startsWith('Steer')) + controlLabels.findIndex(label => label?.startsWith('Redirect')) ) - await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') }) - await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('busy-composer-redirect.png') }) + await expect(primary.locator('svg.tabler-icon-git-branch')).toBeVisible() await queue.click() await expect(primary).toHaveAttribute('aria-label', 'Stop') diff --git a/apps/desktop/e2e/correction-session-switch.spec.ts b/apps/desktop/e2e/correction-session-switch.spec.ts index dd32caa61e7..9a7f2efcf53 100644 --- a/apps/desktop/e2e/correction-session-switch.spec.ts +++ b/apps/desktop/e2e/correction-session-switch.spec.ts @@ -29,14 +29,13 @@ async function send(page: Page, text: string): Promise { await page.keyboard.press('Enter') } -async function steer(page: Page, text: string): Promise { +async function redirect(page: Page, text: string): Promise { const composer = page.locator('[contenteditable="true"]').first() const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') await composer.waitFor({ state: 'visible', timeout: 15_000 }) await composer.click() await composer.type(text, { delay: 5 }) - await expect(primary).toHaveAttribute('aria-label', /Steer/) await primary.click() } @@ -157,9 +156,9 @@ test.describe('correction session switch', () => { await waitForTranscriptText(page, TOOL_STARTED) await waitForTranscriptText(page, ORIGINAL_PROMPT) - // The historical session redirects while a foreground terminal task is - // running. Use the visible Steer action to cover the real composer path. - await steer(page, CORRECTION) + // Redirect immediately while the foreground terminal task is still live. + // Waiting for an old control label here races the action into a plain Send. + await redirect(page, CORRECTION) await waitForTranscriptText(page, CORRECTION) const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page)) diff --git a/apps/desktop/e2e/queue-turn-boundary.spec.ts b/apps/desktop/e2e/queue-turn-boundary.spec.ts index 2308d321a55..ffeeddcf4c0 100644 --- a/apps/desktop/e2e/queue-turn-boundary.spec.ts +++ b/apps/desktop/e2e/queue-turn-boundary.spec.ts @@ -12,9 +12,9 @@ import { expect, test, type Page } from './test' import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' import { MOCK_REPLY } from './mock-server' -const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE' +const ACTIVE_PROMPT = 'write me a short story' const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED' -const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION' +const STEER_PROMPT = 'actually just say hi' async function send(page: Page, text: string): Promise { const composer = page.locator('[contenteditable="true"]').first() @@ -100,7 +100,7 @@ test.describe('queued prompt turn boundary', () => { await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1) }) - test('places a steer prompt before the reply it redirects', async () => { + test('redirect replaces the live turn once and restarts inference with the correction', async () => { const { mock, page } = fixture! await send(page, ACTIVE_PROMPT) @@ -114,6 +114,9 @@ test.describe('queued prompt turn boundary', () => { { timeout: 60_000 } ) - expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY]) + const messages = await transcriptMessageOrder(page) + expect(steerTurnOrder(messages)).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY]) + await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === ACTIVE_PROMPT || prompt === STEER_PROMPT)) + .toEqual([ACTIVE_PROMPT, STEER_PROMPT]) }) }) \ No newline at end of file diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index f15b8d0befc..77f73127054 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -130,10 +130,13 @@ auxiliary: await fixture.mock.waitForHeldCompletion() await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible() + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(queued) const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') await expect(primary).toHaveAttribute('aria-label', 'Queue message') - await send(page, queued) + await page.keyboard.press('Enter') await expect(page.getByText('1 Queued')).toBeVisible() expect(fixture.mock.heldCompletionCount()).toBe(1) expect(receivedUserTexts()).not.toContain(queued) diff --git a/apps/desktop/src/app/chat/composer/busy-input-mode.ts b/apps/desktop/src/app/chat/composer/busy-input-mode.ts new file mode 100644 index 00000000000..56609a9bf8e --- /dev/null +++ b/apps/desktop/src/app/chat/composer/busy-input-mode.ts @@ -0,0 +1,44 @@ +export type BusyInputMode = 'interrupt' | 'queue' | 'steer' +export type BusyComposerAction = 'redirect' | 'queue' | 'steer' | 'stop' + +export const normalizeBusyInputMode = (value: unknown): BusyInputMode => + value === 'queue' || value === 'steer' ? value : 'interrupt' + +/** + * Maps the persisted cross-surface busy-input policy to Desktop's visible + * composer action. `interrupt` is the historical config spelling; capable + * desktop sessions implement it as an active-turn redirect. + */ +export function resolveBusyComposerAction({ + busy, + canRedirect, + canSteer, + compacting, + hasPayload, + mode +}: { + busy: boolean + canRedirect: boolean + canSteer: boolean + compacting: boolean + hasPayload: boolean + mode: BusyInputMode +}): BusyComposerAction { + if (!busy) { + return 'stop' + } + + if (mode === 'queue') { + return hasPayload ? 'queue' : 'stop' + } + + if (!compacting && mode === 'interrupt' && canRedirect) { + return 'redirect' + } + + if (!compacting && mode === 'steer' && canSteer) { + return 'steer' + } + + return hasPayload ? 'queue' : 'stop' +} diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index 90d38d27495..15240c80140 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -71,9 +71,21 @@ describe('ComposerControls shortcut tooltips', () => { await expectShortcutTooltip('Steer the current run', '↵') }) - it('shows Ctrl+Enter for Queue', async () => { + it('shows Enter for Queue when queue is the configured busy action', async () => { renderControls({ busy: true, busyAction: 'queue' }) + await expectShortcutTooltip('Queue message', '↵') + }) + + it('shows Redirect on Enter', async () => { + renderControls({ busy: true, busyAction: 'redirect' }) + + await expectShortcutTooltip('Redirect the current run', '↵') + }) + + it('shows the secondary Queue shortcut beside Redirect', async () => { + renderControls({ busy: true, busyAction: 'redirect' }) + await expectShortcutTooltip('Queue message', 'Ctrl+↵') }) }) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 996b962f544..ce8482e85f2 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -3,7 +3,7 @@ import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' +import { AudioLines, GitBranch, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' @@ -53,7 +53,7 @@ export function ComposerControls({ }: { autoSpeak: boolean busy: boolean - busyAction: 'steer' | 'queue' | 'stop' + busyAction: 'redirect' | 'steer' | 'queue' | 'stop' canSubmit: boolean compactModelPill?: boolean conversation: ConversationProps @@ -73,15 +73,16 @@ export function ComposerControls({ } const showVoicePrimary = !busy && !hasComposerPayload - const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop + const busyLabel = + busyAction === 'queue' ? c.queueMessage : busyAction === 'redirect' ? c.redirect : busyAction === 'steer' ? c.steer : c.stop return (
- {busyAction === 'steer' ? ( - }> + {busyAction === 'redirect' || busyAction === 'steer' ? ( + }>