Compare commits

...
Author SHA1 Message Date
Brooklyn Nicholson 155ba901e3 fix(desktop): queue composer drafts during gateway reconnect instead of silently dropping Enter
After initial boot the CONNECTING overlay is intentionally suppressed so a
transient socket drop (a fresh session, sleep/wake, or a post-update reboot)
keeps the composer editable while `disabled = !gatewayOpen`. But the Enter path
returned early on `disabled` with zero feedback — type, hit Enter, nothing
happens, no error, no hint (the "can't send anything, no errors" report).

Route a drafted Enter during reconnect into the existing queue (mirroring the
`busy` branch) and gate the bounded auto-drain on the gateway being open, so the
queued draft waits instead of spinning failed sends and then flushes the instant
the socket reopens. Empty Enter stays a no-op.
2026-07-13 01:40:22 -04:00
6 changed files with 92 additions and 25 deletions
@@ -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(
<Harness
disabled
onCancel={vi.fn()}
onDrain={onDrain}
onQueue={vi.fn()}
onQueue={onQueue}
onSubmit={onSubmit}
queued={['queued-1']}
/>
@@ -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(
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={onQueue} onSubmit={onSubmit} />
)
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()
})
})
@@ -28,6 +28,7 @@ interface UseComposerQueueArgs {
clearDraft: () => void
draftRef: RefObject<string>
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.
@@ -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
@@ -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
}
+12 -5
View File
@@ -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)
})
})
+7 -1
View File
@@ -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. */