From f2a27c1b3563531e182d6dd8f8af4094b24a645d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 5 Jun 2026 20:01:39 -0500 Subject: [PATCH 1/6] fix(desktop): make composer message queue reliable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/app/chat/composer/index.tsx | 81 ++++++++------ .../src/app/chat/composer/queue-panel.tsx | 7 +- .../app/session/hooks/use-message-stream.ts | 15 ++- .../session/hooks/use-prompt-actions.test.tsx | 105 +++++++++++++++++- .../app/session/hooks/use-prompt-actions.ts | 63 ++++++----- .../src/components/assistant-ui/thread.tsx | 12 +- 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 ++++--- 12 files changed, 243 insertions(+), 116 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 2288a7b7f82b..ee4acf5c0245 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) @@ -724,7 +719,24 @@ export function ChatBar({ return } + // Empty Enter while busy is a no-op — interrupting is an explicit gesture + // (the Stop button or Esc), never a stray Enter after sending. With a + // payload, submitDraft queues it; that's still wanted. + if (busy && !hasComposerPayload) { + return + } + submitDraft() + + return + } + + // Esc interrupts the running turn (Stop button parity). No trigger popover + // is open here — that case returns above — so Esc is unambiguous. + if (event.key === 'Escape' && busy) { + event.preventDefault() + triggerHaptic('cancel') + void Promise.resolve(onCancel()) } } @@ -978,41 +990,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 }) ) { @@ -1053,12 +1064,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()) } diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 1ecdd1fd1865..2b9f0b86d6dd 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -45,6 +45,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN {entries.map(entry => { const isEditing = editingId === entry.id const attachmentsCount = entry.attachments.length + const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow return (
- + {!collapsed && ( -
+
{entries.map(entry => { const isEditing = editingId === entry.id const attachmentsCount = entry.attachments.length @@ -50,7 +50,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN return (
Date: Fri, 5 Jun 2026 20:13:29 -0500 Subject: [PATCH 5/6] style(desktop): trim queue-ux comments to house style --- apps/desktop/src/app/chat/composer/index.tsx | 20 +++++++------------ .../app/session/hooks/use-prompt-actions.ts | 17 +++++++--------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index e81e5f1cf630..4ad9e85d1c1d 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -719,9 +719,8 @@ export function ChatBar({ return } - // Empty Enter while busy is a no-op — interrupting is an explicit gesture - // (the Stop button or Esc), never a stray Enter after sending. With a - // payload, submitDraft queues it; that's still wanted. + // 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 } @@ -731,8 +730,7 @@ export function ChatBar({ return } - // Esc interrupts the running turn (Stop button parity). No trigger popover - // is open here — that case returns above — so Esc is unambiguous. + // Esc interrupts the running turn (Stop-button parity). if (event.key === 'Escape' && busy) { event.preventDefault() triggerHaptic('cancel') @@ -1291,14 +1289,10 @@ export function ChatBar({ )} {activeQueueSessionKey && queuedPrompts.length > 0 && ( - // Floated above the composer (out of flow) so the queue never - // inflates the composer's measured height — otherwise the thread - // reserves extra bottom padding and the chat visibly resizes as you - // queue. Cursor-style: the list overlays the (faded) chat instead. - // Sits flush on the composer's top edge (shared border). The Root - // has pt-2 (0.5rem) above the visible surface, so we overlap down by - // that much (-mb-2) to land the panel's borderless bottom right on - // the surface's top border. Capped height + internal scroll. + // 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. Cursor-style overlay.
Date: Fri, 5 Jun 2026 20:15:05 -0500 Subject: [PATCH 6/6] style(desktop): drop 'Cursor' references from comments --- apps/desktop/src/app/chat/composer/index.tsx | 2 +- .../gateway/hooks/use-gateway-boot.test.tsx | 265 ++++++++++++++++++ .../app/session/hooks/use-prompt-actions.ts | 2 +- .../gateway-connecting-overlay.test.tsx | 143 ++++++++++ 4 files changed, 410 insertions(+), 2 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 4ad9e85d1c1d..73c140c57c89 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1292,7 +1292,7 @@ export function ChatBar({ // 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. Cursor-style overlay. + // edge; capped + scrollable. Overlays the chat instead of pushing it.
void + +// Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect() +// touches: readyState, add/removeEventListener('open'|'error'|'close'), close(). +class FakeWebSocket { + static OPEN = 1 + static CLOSED = 3 + // Flipped by the test: 'open' = next socket connects; 'fail' = next socket + // errors (a dead remote). Mirrors a VPS going away after the first connect. + static mode: 'open' | 'fail' = 'open' + static instances: FakeWebSocket[] = [] + + readyState = 0 + private listeners: Record> = {} + + constructor(public url: string) { + FakeWebSocket.instances.push(this) + const willOpen = FakeWebSocket.mode === 'open' + // Resolve on the next microtask/macrotask so connect()'s promise wiring is + // in place before open/error fires (matches real async socket handshake). + setTimeout(() => { + if (willOpen) { + this.readyState = FakeWebSocket.OPEN + this.emit('open', {}) + } else { + this.readyState = FakeWebSocket.CLOSED + this.emit('error', {}) + } + }, 0) + } + + addEventListener(type: string, fn: Listener) { + ;(this.listeners[type] ??= new Set()).add(fn) + } + + removeEventListener(type: string, fn: Listener) { + this.listeners[type]?.delete(fn) + } + + close() { + this.readyState = FakeWebSocket.CLOSED + this.emit('close', {}) + } + + // Force-drop an open socket, as a sleeping laptop / restarted remote would. + drop() { + this.readyState = FakeWebSocket.CLOSED + this.emit('close', {}) + } + + private emit(type: string, ev: unknown) { + for (const fn of this.listeners[type] ?? []) fn(ev) + } +} + +function fakeDesktop() { + const conn = { + authMode: 'token' as const, + baseUrl: 'https://vps.example.com', + profile: 'default', + token: 't', + wsUrl: 'wss://vps.example.com/api/ws?token=t' + } + + return { + getConnection: vi.fn(async () => conn), + getGatewayWsUrl: vi.fn(async () => conn.wsUrl), + getBootProgress: vi.fn(async () => ({ + error: null, + fakeMode: false, + message: '', + phase: 'init', + progress: 0, + running: true, + timestamp: Date.now() + })), + onBootProgress: vi.fn(() => () => undefined), + onBackendExit: vi.fn(() => () => undefined), + onPowerResume: vi.fn(() => () => undefined), + onWindowStateChanged: vi.fn(() => () => undefined), + touchBackend: vi.fn(async () => undefined), + profile: { get: vi.fn(async () => ({ profile: 'default' })) } + } +} + +function Harness() { + useGatewayBoot({ + handleGatewayEvent: () => undefined, + onConnectionReady: () => undefined, + onGatewayReady: () => undefined, + refreshHermesConfig: async () => undefined, + refreshSessions: async () => undefined + }) + + return null +} + +const originalWebSocket = globalThis.WebSocket + +beforeEach(() => { + vi.useFakeTimers() + FakeWebSocket.mode = 'open' + FakeWebSocket.instances = [] + ;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket + ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop() + $gatewayState.set('idle') + $desktopBoot.set({ + error: null, + fakeMode: false, + message: '', + phase: 'init', + progress: 0, + running: true, + timestamp: Date.now(), + visible: true + }) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + ;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket + delete (window as { hermesDesktop?: unknown }).hermesDesktop +}) + +// Let pending microtasks (awaits) AND the queued 0ms socket open/error fire. +async function flushAsync() { + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) +} + +// Drive the exponential backoff forward by its full cap so the next scheduled +// reconnect attempt actually runs (1s,2s,4s,8s,15s,15s…). Returns after the +// attempt's async work settles. +async function advanceBackoff() { + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) +} + +describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => { + it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => { + // The report's actual path: a fresh launch pointed at an unreachable VPS. + // startHermes()'s remote branch awaits waitForHermes() for 45s before it + // throws, so the renderer's `await desktop.getConnection()` stays pending + // that whole window. During it: gatewayState is still 'idle' (connect was + // never reached) and boot.error is null → connecting=true → the fullscreen + // CONNECTING overlay, latched, blocking Settings. + let rejectConn: (e: Error) => void = () => undefined + const desktop = fakeDesktop() + desktop.getConnection = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectConn = reject + }) + ) + ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop + + render() + await flushAsync() + + // getConnection is still pending — the dead-VPS wait. No socket was ever + // created, gatewayState never left idle, boot.error is null. + expect(FakeWebSocket.instances).toHaveLength(0) + expect($gatewayState.get()).not.toBe('open') + expect($desktopBoot.get().error).toBeNull() + // ^ connecting === true here → fullscreen CONNECTING, no Settings. + + // After ~45s waitForHermes gives up and getConnection rejects → boot() + // catch → failDesktopBoot → the BootFailureOverlay recovery surface. + await act(async () => { + rejectConn(new Error('Hermes backend did not become ready: timeout')) + await vi.advanceTimersByTimeAsync(0) + }) + + expect($desktopBoot.get().error).toBeTruthy() + }) + + it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => { + render() + await flushAsync() + + // Initial boot connected. + expect($gatewayState.get()).toBe('open') + expect($desktopBoot.get().error).toBeNull() + expect(FakeWebSocket.instances).toHaveLength(1) + + // The remote VPS goes away: drop the live socket, and make every reopen + // fail from here on. + FakeWebSocket.mode = 'fail' + act(() => FakeWebSocket.instances[0].drop()) + await flushAsync() + + // Burn a couple backoff cycles BEFORE the escalation threshold (<6 attempts, + // ~the first ~15s). This is the window where stock and fixed behave the + // same: socket down, hook retrying, gatewayState non-open, boot.error still + // null → CONNECTING covers the screen with no recovery surface. (Past ~45s + // the fix raises boot.error; that's asserted in the next test.) + await advanceBackoff() + + expect($gatewayState.get()).not.toBe('open') + expect($desktopBoot.get().error).toBeNull() + // It is actively retrying, not idle — more sockets were minted. + expect(FakeWebSocket.instances.length).toBeGreaterThan(1) + }) + + it('FIX: after the prolonged drop the hook raises a recoverable boot error (the escape hatch)', async () => { + render() + await flushAsync() + expect($desktopBoot.get().error).toBeNull() + + FakeWebSocket.mode = 'fail' + act(() => FakeWebSocket.instances[0].drop()) + await flushAsync() + + // Walk the backoff past the >=6 attempt threshold (~45s of failures). + for (let i = 0; i < 8; i += 1) { + await advanceBackoff() + } + + // The hook surfaced the recoverable error → BootFailureOverlay (Use local + // gateway / Sign in / Retry) becomes reachable instead of CONNECTING. + expect($desktopBoot.get().error).toBeTruthy() + }) + + it('FIX: a successful reconnect clears the recoverable error', async () => { + render() + await flushAsync() + + FakeWebSocket.mode = 'fail' + act(() => FakeWebSocket.instances[0].drop()) + await flushAsync() + for (let i = 0; i < 8; i += 1) { + await advanceBackoff() + } + expect($desktopBoot.get().error).toBeTruthy() + + // The remote comes back: next reconnect attempt opens. + FakeWebSocket.mode = 'open' + await advanceBackoff() + + expect($gatewayState.get()).toBe('open') + expect($desktopBoot.get().error).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 38652d6ba023..ba5d40512280 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -697,7 +697,7 @@ export function usePromptActions({ setAwaitingResponse(false) - // Cursor-style: interrupting keeps whatever was already generated and just + // Interrupting keeps whatever was already generated and just // stops — no "[interrupted]" marker. A pending/streaming message with no // body text is dropped entirely so we never leave an empty bubble behind. const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) => diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx new file mode 100644 index 000000000000..eef3b371e27e --- /dev/null +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -0,0 +1,143 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $desktopBoot } from '@/store/boot' +import { $desktopOnboarding } from '@/store/onboarding' +import { $gatewayState, setGatewayState } from '@/store/session' + +import { BootFailureOverlay } from './boot-failure-overlay' +import { GatewayConnectingOverlay } from './gateway-connecting-overlay' + +// Repro for the "remote gateway → stuck on CONNECTING, no way to settings" +// report. The connecting overlay (z-1200, full-screen, pointer-events on) is +// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape +// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" / +// "Retry" — only renders when `boot.error` is set. +// +// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the +// INITIAL boot() throws. After the first successful connect (bootCompleted), +// any later socket drop goes through scheduleReconnect(), which loops FOREVER +// against the dead remote and never sets boot.error. So gatewayState sits at +// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay +// never appears, settings unreachable. + +function resetStores() { + setGatewayState('idle') + $desktopBoot.set({ + error: null, + fakeMode: false, + message: 'ready', + phase: 'renderer.ready', + progress: 100, + running: false, + timestamp: Date.now(), + visible: false + }) + $desktopOnboarding.set({ + configured: true, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false, + firstRunSkipped: false, + manual: false + }) +} + +beforeEach(resetStores) +afterEach(cleanup) + +// The connecting overlay renders "CONN" + a scrambled tail inside one +// uppercase span; match that node specifically so the recovery overlay's +// "Lost connection…" copy doesn't read as a false positive. +const isConnectingShown = () => + screen.queryAllByText((_, el) => /^CONN[/\\|\-_=+<>~:*A-Z]*$/.test(el?.textContent?.trim() ?? '')).length > 0 +const isRecoveryShown = () => + Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i)) + +describe('connecting overlay vs recovery surface', () => { + it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => { + // failDesktopBoot() ran: error set, gateway never opened. + $desktopBoot.set({ ...$desktopBoot.get(), error: 'Hermes backend did not become ready', running: false, visible: true }) + setGatewayState('error') + + render( + <> + + + + ) + + expect(isRecoveryShown()).toBe(true) + // Connecting overlay bows out when boot.error is set. + expect(isConnectingShown()).toBe(false) + }) + + it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => { + // 1. Initial boot succeeded: gateway opened, boot completed (no error). + setGatewayState('open') + const { rerender } = render( + <> + + + + ) + expect(isConnectingShown()).toBe(false) + + // 2. The remote VPS socket drops (sleep/wake, remote restart, network). + // bootCompleted is true, so useGatewayBoot routes this through + // scheduleReconnect() — boot.error stays NULL. + setGatewayState('closed') + rerender( + <> + + + + ) + + // The connecting overlay reappears and latches... + expect(isConnectingShown()).toBe(true) + // ...with NO recovery surface, because boot.error was never set. + expect(isRecoveryShown()).toBe(false) + + // 3. Reconnect loops forever against the dead remote: gatewayState bounces + // closed → error → closed, boot.error never gets set. The user is + // pinned on CONNECTING with no path to Settings indefinitely. + setGatewayState('error') + rerender( + <> + + + + ) + expect($desktopBoot.get().error).toBeNull() + expect(isConnectingShown()).toBe(true) + expect(isRecoveryShown()).toBe(false) + }) + + it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => { + // Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of + // failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI + // from the dead-end CONNECTING overlay to the recovery surface. + setGatewayState('error') + $desktopBoot.set({ + ...$desktopBoot.get(), + error: 'Lost connection to the Hermes gateway and could not reconnect.', + running: false, + visible: true + }) + + render( + <> + + + + ) + + // Escape hatch is now reachable; the connecting overlay bows out. + expect(isRecoveryShown()).toBe(true) + expect(screen.getByText(/use local gateway/i)).toBeTruthy() + expect(isConnectingShown()).toBe(false) + }) +})