fix(desktop): show steer glyph in busy composer (#69612)

* fix(desktop): show steer glyph in busy composer

Restore the steering-wheel glyph when a typed draft redirects a live turn.
Add a real Electron E2E regression test for stop, steer, attachment queueing,
and a paused queue after Stop.

* feat(desktop): add queue action beside steer

Restore an explicit queue action in the former steering slot while a typed
correction is ready. Keep the primary steering-wheel action for redirecting
the active turn.

* fix(desktop): retain dictation beside queued drafts

Keep the dictation microphone visible when a typed busy draft exposes the
separate queue action. Cover the combined controls in the real Electron flow.

* fix(desktop): place queue beside the busy action

Keep the Queue message control after the read-aloud toggle and directly before
the shared Stop/Steer action. Cover the rendered control order in Electron E2E.
This commit is contained in:
ethernet 2026-07-22 19:29:38 -04:00 committed by GitHub
parent 433673067e
commit 6283a33a50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 124 additions and 13 deletions

View file

@ -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') })
})
})

View file

@ -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<MockSe
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
if (includesBlockingClarifyTrigger(parsed.messages)) {
if (stream) {
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
} else {
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
}
return
}
if (isQueueStopTrigger) {
const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1]
_queueStopIndex++

View file

@ -3,7 +3,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@ -48,6 +48,7 @@ export function ComposerControls({
state,
voiceStatus,
onDictate,
onQueue,
onToggleAutoSpeak
}: {
autoSpeak: boolean
@ -61,6 +62,7 @@ export function ComposerControls({
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => 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({
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{busyAction === 'steer' ? (
<Tip label={c.queueMessage}>
<Button
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onQueue}
size="icon"
type="button"
variant="ghost"
>
<Layers3 className={iconSize.sm} />
</Button>
</Tip>
) : null}
{showVoicePrimary ? (
<Tip label={c.startVoice}>
<Button
@ -107,6 +122,8 @@ export function ComposerControls({
{busy ? (
busyAction === 'queue' ? (
<Layers3 className={iconSize.sm} />
) : busyAction === 'steer' ? (
<SteeringWheel className={iconSize.sm} />
) : (
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
)

View file

@ -200,5 +200,14 @@ export function useComposerSubmit({
})
}
return { dispatchSubmit, steerDraft, submitDraft }
const queueDraft = () => {
if (disabled || !busy) {
return
}
queueCurrentDraft()
focusInput()
}
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
}

View file

@ -238,7 +238,7 @@ export function ChatBar({
// The submit engine — the orchestration seam where draft + queue meet. Owns
// the submit decision tree, the send-with-restore primitive, and steer.
const { steerDraft, submitDraft } = useComposerSubmit({
const { queueDraft, steerDraft, submitDraft } = useComposerSubmit({
activeQueueSessionKey,
activeQueueSessionKeyRef,
attachments,
@ -738,6 +738,7 @@ export function ChatBar({
disabled={disabled}
hasComposerPayload={hasComposerPayload}
onDictate={dictate}
onQueue={queueDraft}
onToggleAutoSpeak={handleToggleAutoSpeak}
state={state}
voiceStatus={voiceStatus}