diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts index fb18b943abda..9a55d9fc8eeb 100644 --- a/apps/desktop/e2e/chat.spec.ts +++ b/apps/desktop/e2e/chat.spec.ts @@ -8,13 +8,10 @@ * Prerequisite: `npm run build` must have been run so dist/ exists. */ -import { test } from './test' +import { expect, test } from './test' -import { - type MockBackendFixture, - setupMockBackend, - waitForAppReady, -} from './fixtures' +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server' import { expectVisualSnapshot } from './visual-snapshot' let fixture: MockBackendFixture | null = null @@ -61,7 +58,7 @@ test.describe('chat interaction with mock backend', () => { return (body.textContent ?? '').includes('Hello, can you hear me?') }, undefined, - { timeout: 15_000 }, + { timeout: 15_000 } ) // Wait for the mock response to appear. The canned reply is: @@ -81,11 +78,61 @@ test.describe('chat interaction with mock backend', () => { return text.includes('mock inference server') || text.includes('boot chain is working') }, undefined, - { timeout: 60_000 }, + { timeout: 60_000 } ) }) test('screenshot of chat with messages', async () => { await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app }) }) + + test('offers stop, steer, 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"]') + const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]') + const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]') + const speakReplies = page.locator( + '[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]' + ) + + await composer.click() + await composer.type(BLOCKING_CLARIFY_TRIGGER) + await page.keyboard.press('Enter') + await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 }) + + await expect(primary).toHaveAttribute('aria-label', 'Stop') + await expect(primary.locator('span')).toHaveClass(/bg-current/) + + await composer.click() + await composer.type('please answer tersely') + await expect(primary).toHaveAttribute('aria-label', /Steer/) + await expect(dictation).toBeVisible() + await expect(speakReplies).toBeVisible() + await expect(queue).toBeVisible() + await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible() + const controlLabels = await page + .locator('[data-slot="composer-root"] button') + .evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label'))) + const speakRepliesIndex = controlLabels.findIndex( + label => label === 'Read replies aloud' || label === 'Stop reading replies aloud' + ) + 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')) + ) + await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') }) + await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible() + + await queue.click() + await expect(primary).toHaveAttribute('aria-label', 'Stop') + await expect(queue).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') }) + await expect(page.getByText('1 Queued')).toBeVisible() + + await primary.click() + await expect(page.getByText('1 Queued — paused')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') }) + }) }) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index dd476e8de72e..52d1db7c9c1a 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -193,6 +193,34 @@ const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [ { text: 'The paused task completed.' }, ] +/** + * A marker that makes the mock emit a real blocking clarify tool call. Tests + * use it to hold a turn open while exercising busy-composer interactions. + */ +export const BLOCKING_CLARIFY_TRIGGER = 'E2E_BLOCKING_CLARIFY_TRIGGER' +export const BLOCKING_CLARIFY_QUESTION = 'Keep this test turn running?' + +const BLOCKING_CLARIFY_TURN: ScriptedTurn = { + text: '', + toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }], +} + +function includesBlockingClarifyTrigger(value: unknown): boolean { + if (typeof value === 'string') { + return value.includes(BLOCKING_CLARIFY_TRIGGER) + } + + if (Array.isArray(value)) { + return value.some(includesBlockingClarifyTrigger) + } + + if (value && typeof value === 'object') { + return Object.values(value).some(includesBlockingClarifyTrigger) + } + + return false +} + /** * Start the mock server on an ephemeral port. * @@ -286,6 +314,15 @@ export function startMockServer(options: MockServerOptions = {}): Promise void + onQueue: () => void onToggleAutoSpeak: () => void }) { const { t } = useI18n() @@ -71,8 +73,6 @@ export function ComposerControls({ } const showVoicePrimary = !busy && !hasComposerPayload - // steer + stop both interrupt the live turn (the difference is whether a - // correction rides along), so they share the stop glyph; only queue differs. const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop return ( @@ -80,6 +80,21 @@ export function ComposerControls({ + {busyAction === 'steer' ? ( + + + + ) : null} {showVoicePrimary ? (