From 742ecb527a1a3e6affceb22368bc063cf218463e Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 23 Jul 2026 00:40:45 -0400 Subject: [PATCH] feat(desktop): honor busy input mode --- apps/desktop/e2e/chat.spec.ts | 10 +- .../e2e/correction-session-switch.spec.ts | 9 +- apps/desktop/e2e/queue-turn-boundary.spec.ts | 11 ++- ...session-compression-and-queue-stop.spec.ts | 5 +- .../src/app/chat/composer/busy-input-mode.ts | 44 +++++++++ .../src/app/chat/composer/controls.test.tsx | 14 ++- .../src/app/chat/composer/controls.tsx | 26 ++--- .../hooks/use-composer-submit.test.tsx | 41 +++++++- .../composer/hooks/use-composer-submit.ts | 99 +++++++++++-------- apps/desktop/src/app/chat/composer/index.tsx | 37 ++++--- apps/desktop/src/app/chat/composer/types.ts | 3 + apps/desktop/src/app/settings/constants.ts | 8 +- apps/desktop/src/app/settings/helpers.test.ts | 11 +++ .../src/app/settings/keybind-settings.tsx | 21 ++-- apps/desktop/src/components/ui/tooltip.tsx | 8 +- apps/desktop/src/i18n/en.ts | 3 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 5 +- apps/desktop/src/lib/keybinds/actions.ts | 2 +- 19 files changed, 263 insertions(+), 95 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/busy-input-mode.ts 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' ? ( + }>