Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155ba901e3 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user