From 0cbcc75935629f6b21a900b4246c4a6ef4eb406c Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 20:21:41 -0500 Subject: [PATCH] fix(desktop): reliable composer message queue (#40221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): make composer message queue reliable The queue felt 'dumb' because of three real bugs: 1. Drained-after-interrupt sends went silent. cancelRun sets interrupted:true and nothing reset it; submitPromptText's optimistic seed preserved it, and the message stream drops every delta while interrupted. So Send-now-while-busy and any interrupt+drain submitted the next turn into a muted session. Fix: a fresh submit is a new turn — seed interrupted:false. 2. Back-to-back queue drains stalled. The drain fires on the busy->false settle edge, but busyRef (synced from the busy store by a separate effect) can still read true on that same edge, so the drained send hit the busy guard, returned false, and the entry was never removed. Fix: fromQueue sends bypass the busyRef guard (the queue drain lock serializes them); the user path keeps the guard. 3. Double-enter-to-interrupt killed single non-queue turns. The hidden 450ms timer meant a natural double-tap after sending stopped the agent. Fix: empty Enter while busy is a no-op; interrupting is explicit — Stop button or Esc. Also: clean stop (no [interrupted] marker), Send-now works while busy (promote + interrupt + auto-drain), settle on the interrupted completion path. Adds regression tests and unblocks the prompt-actions suite by completing its stale @/hermes mock. * fix(desktop): float the queue panel as an overlay so the chat doesn't resize The queue list rendered in-flow inside the composer root, so its height fed --composer-measured-height (the composer rect drives the thread's bottom padding + last-message clearance). Queuing a message grew that rect and the whole chat visibly resized. Anchor the panel out of flow above the composer (absolute bottom-full, capped at 40vh with internal scroll). It no longer contributes to the measured height, so the thread layout stays put and the list overlays the (already faded) chat. Still collapsible via the panel's own disclosure header. * fix(desktop): queue panel collapsed by default + shared border with composer - Default the queue disclosure to collapsed (compact 'N queued' pill) instead of expanded. - Drop the gap and merge the panel into the composer: square bottom corners, no bottom border/radius, and overlap down by the Root's pt-2 (-mb-2) so the panel's borderless bottom lands on the composer surface's top border — one continuous bordered shape. * style(desktop): tighten queue panel padding * style(desktop): trim queue-ux comments to house style * style(desktop): drop 'Cursor' references from comments --- apps/desktop/src/app/chat/composer/index.tsx | 85 +++--- .../src/app/chat/composer/queue-panel.tsx | 17 +- .../gateway/hooks/use-gateway-boot.test.tsx | 265 ++++++++++++++++++ .../app/session/hooks/use-message-stream.ts | 15 +- .../session/hooks/use-prompt-actions.test.tsx | 105 ++++++- .../app/session/hooks/use-prompt-actions.ts | 60 ++-- .../src/components/assistant-ui/thread.tsx | 12 +- .../gateway-connecting-overlay.test.tsx | 143 ++++++++++ apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/chat-runtime.ts | 1 - apps/desktop/src/store/composer-queue.test.ts | 31 +- apps/desktop/src/store/composer-queue.ts | 41 ++- 14 files changed, 656 insertions(+), 122 deletions(-) create mode 100644 apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx create mode 100644 apps/desktop/src/components/gateway-connecting-overlay.test.tsx diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 51ed8cae8ad..e594863abd0 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -27,6 +27,7 @@ import { $composerAttachments, clearComposerAttachments, type ComposerAttachment import { $queuedPromptsBySession, enqueueQueuedPrompt, + promoteQueuedPrompt, type QueuedPromptEntry, removeQueuedPrompt, shouldAutoDrainOnSettle, @@ -136,12 +137,6 @@ 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) @@ -729,7 +724,22 @@ export function ChatBar({ return } + // Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc), + // never a stray Enter after sending. With a payload, submitDraft queues it. + if (busy && !hasComposerPayload) { + return + } + submitDraft() + + return + } + + // Esc interrupts the running turn (Stop-button parity). + if (event.key === 'Escape' && busy) { + event.preventDefault() + triggerHaptic('cancel') + void Promise.resolve(onCancel()) } } @@ -983,41 +993,40 @@ export function ChatBar({ ) const sendQueuedNow = useCallback( - (id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)), - [queueEdit, runDrain] + (id: string) => { + if (!activeQueueSessionKey || id === queueEdit?.entryId) { + return false + } + + if (busy) { + // Promote to the head, then interrupt. The gateway always emits a + // settle (message.complete + session.info running:false) when the + // turn unwinds, and the busy→false auto-drain below sends this entry. + promoteQueuedPrompt(activeQueueSessionKey, id) + triggerHaptic('selection') + void Promise.resolve(onCancel()) + + return true + } + + return runDrain(entries => entries.find(e => e.id === id)) + }, + [activeQueueSessionKey, busy, onCancel, queueEdit, runDrain] ) - // 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. + // Auto-drain on busy → false (turn settled). Queued turns always flow once + // the session is idle again — whether the turn finished naturally or the + // user interrupted it. Interrupting to reach a queued message is the whole + // point of the queue, so we never suppress the drain. To cancel queued + // turns, the user deletes them from the panel. useEffect(() => { const wasBusy = previousBusyRef.current previousBusyRef.current = busy - // 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 - } - - 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 }) ) { @@ -1058,12 +1067,8 @@ export function ChatBar({ } else if (hasComposerPayload) { queueCurrentDraft() } 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 + // Stop button (the only way to reach here while busy with an empty + // composer — empty Enter is short-circuited in the keydown handler). triggerHaptic('cancel') void Promise.resolve(onCancel()) } @@ -1298,7 +1303,11 @@ export function ChatBar({ )} {activeQueueSessionKey && queuedPrompts.length > 0 && ( -
+ // Out of flow so the queue never inflates the composer's measured + // height (that drives thread bottom padding → chat resizes on + // queue). Overlaps -mb-2 onto the surface's top border for a shared + // edge; capped + scrollable. Overlays the chat instead of pushing it. +
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) { const { t } = useI18n() const c = t.composer - const [collapsed, setCollapsed] = useState(false) + const [collapsed, setCollapsed] = useState(true) if (entries.length === 0) { return null } return ( -
+
{!collapsed && ( -
+
{entries.map(entry => { const isEditing = editingId === entry.id const attachmentsCount = entry.attachments.length + const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow return (
- +