diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d41d25d1a23..37360f39f05 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -31,6 +31,7 @@ import { enqueueQueuedPrompt, type QueuedPromptEntry, removeQueuedPrompt, + shouldAutoDrainOnSettle, updateQueuedPrompt } from '@/store/composer-queue' import { $messages } from '@/store/session' @@ -124,6 +125,12 @@ export function ChatBar({ const draftRef = useRef(draft) const previousBusyRef = useRef(busy) const drainingQueueRef = useRef(false) + // Set when the user explicitly interrupts the running turn via the Stop + // button (busy + empty composer). It suppresses the next busy→false + // auto-drain so an explicit Stop actually halts instead of immediately + // firing the head of the queue. The queue is preserved; the user resumes + // it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow. + const userInterruptedRef = useRef(false) const urlInputRef = useRef(null) const [urlOpen, setUrlOpen] = useState(false) @@ -859,26 +866,42 @@ export function ChatBar({ [queueEdit, runDrain] ) - const interruptAndSendNextQueued = useCallback(async () => { - if (queuedPrompts.length === 0) { - return false - } - - await Promise.resolve(onCancel()) - - return drainNextQueued() - }, [drainNextQueued, onCancel, queuedPrompts.length]) - - // Auto-drain on busy → false (turn settled). + // Auto-drain on busy → false (turn settled). An explicit user interrupt + // (Stop button) sets userInterruptedRef so we skip exactly one auto-drain: + // the user asked to halt, so we must not immediately re-send the queue. + // The queued turns stay intact and the user resumes them on demand. useEffect(() => { const wasBusy = previousBusyRef.current previousBusyRef.current = busy - if (busy || !wasBusy || queuedPrompts.length === 0) { + // Clear the interrupt latch when a new turn starts (false → true). This + // guards the sub-frame race where a Stop click lands after busy already + // flipped false (button not yet unmounted): the stale latch can no longer + // survive into the next turn and wrongly suppress its natural auto-drain. + if (busy && !wasBusy) { + userInterruptedRef.current = false + return } - void drainNextQueued() + const interrupted = userInterruptedRef.current + + // Consume the interrupt latch on any settle so a later natural completion + // is not wrongly suppressed. + if (!busy && wasBusy && interrupted) { + userInterruptedRef.current = false + } + + if ( + shouldAutoDrainOnSettle({ + isBusy: busy, + queueLength: queuedPrompts.length, + userInterrupted: interrupted, + wasBusy + }) + ) { + void drainNextQueued() + } }, [busy, drainNextQueued, queuedPrompts.length]) // Clean up queue edit when its target disappears (session swap or external delete). @@ -901,9 +924,13 @@ export function ChatBar({ } else if (busy) { if (hasComposerPayload) { queueCurrentDraft() - } else if (queuedPrompts.length > 0) { - void interruptAndSendNextQueued() } else { + // Stop button: an explicit interrupt must actually halt the running + // turn. Mark the interrupt so the busy→false auto-drain effect skips + // re-sending the queue — otherwise a queued follow-up would fire the + // instant we cancel and Stop would appear to "never work". Queued + // turns are preserved; the user sends them on demand. + userInterruptedRef.current = true triggerHaptic('cancel') void Promise.resolve(onCancel()) } diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts index 9f15232aecb..c00cc4c2b21 100644 --- a/apps/desktop/src/store/composer-queue.test.ts +++ b/apps/desktop/src/store/composer-queue.test.ts @@ -8,6 +8,7 @@ import { enqueueQueuedPrompt, getQueuedPrompts, removeQueuedPrompt, + shouldAutoDrainOnSettle, updateQueuedPrompt, updateQueuedPromptText } from './composer-queue' @@ -100,3 +101,37 @@ describe('composer queue store', () => { expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me') }) }) + +describe('shouldAutoDrainOnSettle', () => { + const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true } + + it('drains the next queued prompt when a turn completes naturally', () => { + expect(shouldAutoDrainOnSettle(base)).toBe(true) + }) + + it('does NOT drain when the user explicitly interrupted (Stop button)', () => { + // Regression: previously the Stop button "never worked" because cancelling + // a turn flipped busy → false and the queue immediately re-fired its head. + expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false) + }) + + it('does not drain when the queue is empty', () => { + expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false) + }) + + it('does not drain when interrupted even if the queue is also empty', () => { + expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false) + }) + + it('ignores steady busy state (no true → false transition)', () => { + expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false) + }) + + it('ignores busy entry (false → true, not a settle)', () => { + expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false) + }) + + it('ignores steady idle state (was not busy)', () => { + expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts index 3f231fb7b2a..9bc3f9270d6 100644 --- a/apps/desktop/src/store/composer-queue.ts +++ b/apps/desktop/src/store/composer-queue.ts @@ -188,3 +188,39 @@ export const clearQueuedPrompts = (key: string | null | undefined) => { writeSession(sid, []) } + +/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */ +export interface AutoDrainSettleInput { + wasBusy: boolean + isBusy: boolean + queueLength: number + userInterrupted: boolean +} + +/** + * Decide whether the composer should auto-drain the next queued prompt when a + * turn settles (busy transitions true → false). + * + * The queue auto-advances when a turn *completes naturally*, but must NOT + * advance when the user *explicitly interrupted* the turn via the Stop button. + * Conflating the two made the Stop button appear to "never work": cancelling a + * turn flipped busy → false, the queue immediately re-fired its head, and the + * agent kept running. An explicit interrupt means stop — the queued turns are + * preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the + * per-row "send now" arrow). + */ +export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => { + const { isBusy, queueLength, userInterrupted, wasBusy } = params + + // Only react to a true → false transition; ignore steady state and entry. + if (isBusy || !wasBusy) { + return false + } + + // An explicit Stop suppresses exactly one auto-drain. + if (userInterrupted) { + return false + } + + return queueLength > 0 +}