diff --git a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx index ff01bf6fd37..11f08fb4bd6 100644 --- a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +++ b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx @@ -54,10 +54,6 @@ function Harness({ } const submitDraft = () => { - if (disabled) { - return - } - const editor = editorRef.current if (editor) { @@ -72,6 +68,16 @@ function Harness({ const text = draftRef.current const payloadPresent = text.trim().length > 0 || attachments.length > 0 + if (disabled) { + // Gateway down: queue the draft instead of dropping it (mirrors the real + // queueCurrentDraft, which no-ops on an empty payload). + if (payloadPresent) { + onQueue(text) + } + + return + } + if (busy) { if (payloadPresent) { onQueue(text) @@ -93,6 +99,10 @@ function Harness({ const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0 if (disabled) { + if (hasLivePayload) { + submitDraft() + } + return } @@ -207,16 +217,17 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', expect(onSubmit).not.toHaveBeenCalled() }) - it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => { + it('queues a reconnect draft on Enter (not send/drain) so it flushes when the gateway returns', async () => { const onSubmit = vi.fn() const onDrain = vi.fn() + const onQueue = vi.fn() const { getByTestId } = render( @@ -230,8 +241,32 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', fireEvent.keyDown(editor, { key: 'Enter' }) }) - expect(editor.textContent).toBe('draft while reconnecting') - expect(onDrain).not.toHaveBeenCalled() + // The gateway is down, so the message can't send — but it must NOT be + // silently dropped (the #-bug: "type, hit Enter, nothing happens, no + // error"). It queues, and the gateway-open-gated auto-drain sends it later. + expect(onQueue).toHaveBeenCalledWith('draft while reconnecting') expect(onSubmit).not.toHaveBeenCalled() + expect(onDrain).not.toHaveBeenCalled() + }) + + it('treats an empty Enter while reconnecting as a no-op (no phantom queue entry)', async () => { + const onQueue = vi.fn() + const onSubmit = vi.fn() + const onDrain = vi.fn() + + const { getByTestId } = render( + + ) + + const editor = getByTestId('editor') + + await act(async () => { + editor.textContent = '' + fireEvent.keyDown(editor, { key: 'Enter' }) + }) + + expect(onQueue).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(onDrain).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index c40d56a4826..336d9ad9d82 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -28,6 +28,7 @@ interface UseComposerQueueArgs { clearDraft: () => void draftRef: RefObject focusInput: () => void + gatewayConnected: boolean loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void onCancel: ChatBarProps['onCancel'] onSubmit: ChatBarProps['onSubmit'] @@ -52,6 +53,7 @@ export function useComposerQueue({ clearDraft, draftRef, focusInput, + gatewayConnected, loadIntoComposer, onCancel, onSubmit, @@ -258,7 +260,7 @@ export function useComposerQueue({ // a stale-session 404) can't strand the entry permanently nor spin-loop. The // drain lock serializes sends; a remount/reconnect resets the failure counts. const autoDrainNext = useCallback(() => { - if (busy || drainingQueueRef.current || !activeQueueSessionKey) { + if (busy || !gatewayConnected || drainingQueueRef.current || !activeQueueSessionKey) { return } @@ -289,7 +291,7 @@ export function useComposerQueue({ } }) .catch(onFail) - }, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t]) + }, [activeQueueSessionKey, busy, gatewayConnected, pickDrainHead, queuedPrompts, runDrain, t]) // Re-key on a runtime session-id change. A stable stored id (queueSessionKey) // never churns, so a change there is a real session switch and must NOT @@ -306,14 +308,15 @@ export function useComposerQueue({ migrateQueuedPrompts(prev, activeQueueSessionKey) }, [activeQueueSessionKey, queueSessionKey]) - // Queued turns flow whenever the session is idle — on the busy→false settle - // edge, on mount/reconnect, and after a re-key — so a swallowed edge can't - // strand them. To cancel queued turns, the user deletes them from the panel. + // Queued turns flow whenever the session is idle AND the gateway is open — on + // the busy→false settle edge, on mount/reconnect, on the socket reopening, and + // after a re-key — so a swallowed edge can't strand them. To cancel queued + // turns, the user deletes them from the panel. useEffect(() => { - if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) { + if (shouldAutoDrain({ isBusy: busy, isConnected: gatewayConnected, queueLength: queuedPrompts.length })) { autoDrainNext() } - }, [autoDrainNext, busy, queuedPrompts.length]) + }, [autoDrainNext, busy, gatewayConnected, queuedPrompts.length]) // Queue-edit cleanup: on session swap the scope effect already stashed the // edit snapshot; only restore into the composer when still on the same scope. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index 2dc0ef8047f..314dbf20bcb 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -108,10 +108,6 @@ export function useComposerSubmit({ ) const submitDraft = () => { - if (disabled) { - return - } - // Source the text from the DOM editor, not React state. The AUI composer // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a // render, so on fast typing or IME composition the final keystroke(s) may @@ -131,6 +127,17 @@ export function useComposerSubmit({ } } + // Gateway isn't open (a post-boot reconnect keeps the composer editable by + // design). Don't silently drop the Enter — queue the draft so it shows as + // pending and the bounded auto-drain flushes it the instant the socket + // reopens, instead of the message vanishing with no feedback. + if (disabled) { + queueCurrentDraft() + focusInput() + + return + } + const text = draftRef.current const payloadPresent = text.trim().length > 0 || attachments.length > 0 diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1f5df46eb2a..994f868ccc4 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -128,6 +128,7 @@ export function ChatBar({ const { t } = useI18n() const gatewayState = useStore($gatewayState) + const gatewayConnected = gatewayState === 'open' const reconnecting = gatewayState === 'closed' || gatewayState === 'error' const inputDisabled = disabled && !reconnecting @@ -178,6 +179,7 @@ export function ChatBar({ clearDraft, draftRef, focusInput, + gatewayConnected, loadIntoComposer, onCancel, onSubmit, @@ -567,7 +569,14 @@ export function ChatBar({ const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0 + // Gateway down (a post-boot reconnect keeps the composer editable): don't + // silently swallow the Enter. Route a real draft into submitDraft, which + // queues it so the auto-drain flushes it the instant the socket reopens. if (disabled) { + if (hasLivePayload) { + submitDraft() + } + return } diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts index 8012e2870f0..a1fc3326341 100644 --- a/apps/desktop/src/store/composer-queue.test.ts +++ b/apps/desktop/src/store/composer-queue.test.ts @@ -150,21 +150,28 @@ describe('migrateQueuedPrompts', () => { }) describe('shouldAutoDrain', () => { - it('drains whenever idle with a non-empty queue', () => { - expect(shouldAutoDrain({ isBusy: false, queueLength: 1 })).toBe(true) + it('drains whenever idle and connected with a non-empty queue', () => { + expect(shouldAutoDrain({ isBusy: false, isConnected: true, queueLength: 1 })).toBe(true) }) it('drains on mount/reconnect with no observed busy edge', () => { // The whole point of dropping the edge: a remount resets the busy ref, so an // edge-gated drain would strand the entry. Idle + non-empty must still fire. - expect(shouldAutoDrain({ isBusy: false, queueLength: 2 })).toBe(true) + expect(shouldAutoDrain({ isBusy: false, isConnected: true, queueLength: 2 })).toBe(true) }) it('does not drain mid-turn', () => { - expect(shouldAutoDrain({ isBusy: true, queueLength: 1 })).toBe(false) + expect(shouldAutoDrain({ isBusy: true, isConnected: true, queueLength: 1 })).toBe(false) }) it('does not drain an empty queue', () => { - expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false) + expect(shouldAutoDrain({ isBusy: false, isConnected: true, queueLength: 0 })).toBe(false) + }) + + it('does not drain while the gateway is closed, then flushes when it reopens', () => { + // A draft queued during a post-boot reconnect must wait — not spin failed + // sends — and drain the moment the socket is open again. + expect(shouldAutoDrain({ isBusy: false, isConnected: false, queueLength: 1 })).toBe(false) + expect(shouldAutoDrain({ isBusy: false, isConnected: true, queueLength: 1 })).toBe(true) }) }) diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts index 922e990fdce..c9bd609a300 100644 --- a/apps/desktop/src/store/composer-queue.ts +++ b/apps/desktop/src/store/composer-queue.ts @@ -243,6 +243,7 @@ export const migrateQueuedPrompts = (fromKey: string | null | undefined, toKey: /** Inputs to {@link shouldAutoDrain}. */ export interface AutoDrainInput { isBusy: boolean + isConnected: boolean queueLength: number } @@ -255,8 +256,13 @@ export interface AutoDrainInput { * busy ref to the current value, swallowing the settle edge — an edge-gated * drain would then strand the entry forever. The caller's drain lock * (`drainingQueueRef`) serializes sends so being edge-free can't double-submit. + * + * Gated on `isConnected` so a draft queued while the gateway is closed (a + * post-boot reconnect keeps the composer editable) waits instead of spinning + * failed sends, then flushes the moment the socket reopens. */ -export const shouldAutoDrain = ({ isBusy, queueLength }: AutoDrainInput): boolean => !isBusy && queueLength > 0 +export const shouldAutoDrain = ({ isBusy, isConnected, queueLength }: AutoDrainInput): boolean => + !isBusy && isConnected && queueLength > 0 /** Auto-drain attempts for one entry before we stop retrying and toast. The * entry stays queued for a manual send; a remount/reconnect resets the count. */