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. */