Hold a queued send while an answer is streaming

A queued send flushed by calling submitNow directly, which skipped the
loading check in canSubmit. submitNow clears the composer before the
consumer runs, and the consumer ignores a submit while status is
'loading'. If a retry or an edit was streaming when the attachments
settled, the question was cleared and never sent.

useArmedSend takes a canFlush flag. The composer passes whether it can
take a submit right now (not loading, not recording or transcribing).
The armed send stays queued until that holds, then goes out.
This commit is contained in:
arc53-machine committed 2026-09-28 10:05:39 +01:00
1 parent a9c0082e9a
commit be6fe8d0c2
4 files changed
+66 -8

No files matched your search

@@ -136,4 +136,29 @@ describe('MessageInput send with a failed attachment', () => {
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it('keeps a queued question while another answer is streaming', async () => {
store.dispatch(addAttachment(att({ status: 'processing', progress: 30 })));
await render();
await type('Queued question');
await pressEnter();
expect(onSubmit).not.toHaveBeenCalled();
// A retry starts streaming, then the file settles.
await render(true);
await act(async () => {
store.dispatch({
type: 'upload/updateAttachment',
payload: { id: 'ok-1', updates: { status: 'completed' } },
});
});
expect(onSubmit).not.toHaveBeenCalled();
expect(textarea().value).toBe('Queued question');
// The stream ends: the held send goes out.
await render(false);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith('Queued question');
});
});
+10 -3
View File
@@ -1584,11 +1584,11 @@ export default function MessageInput({
const hasSubmittableContent =
Boolean(value.trim()) ||
(allowSendWithoutText && attachments.some((a) => a.status !== 'failed'));
const canSubmit =
hasSubmittableContent &&
const composerIdle =
!loading &&
recordingState !== 'recording' &&
recordingState !== 'transcribing';
const canSubmit = hasSubmittableContent && composerIdle;
const submitNow = () => {
const failed = attachments.filter((a) => a.status === 'failed');
@@ -1620,7 +1620,14 @@ export default function MessageInput({
readiness: sendReadiness,
arm: armSend,
cancel: cancelArmedSend,
} = useArmedSend({ attachments, onFlush: submitNow });
} = useArmedSend({
attachments,
onFlush: submitNow,
// A queued send that settles while another answer streams would be
// refused by the consumer after the composer was already cleared; hold
// it until the composer can take a submit again.
canFlush: composerIdle,
});
// Adopt a question queued outside the composer: seed the input, arm,
// and hand the wait to the standard banner. If the attachments already
@@ -66,13 +66,15 @@ type HookApi = ReturnType<typeof useArmedSend>;
function Host({
attachments,
onFlush,
canFlush,
api,
}: {
attachments: Attachment[];
onFlush: () => void;
canFlush?: boolean;
api: { current: HookApi | null };
}) {
const hook = useArmedSend({ attachments, onFlush });
const hook = useArmedSend({ attachments, onFlush, canFlush });
api.current = hook;
return null;
}
@@ -96,10 +98,15 @@ describe('useArmedSend', () => {
container.remove();
});
const render = async (attachments: Attachment[]) => {
const render = async (attachments: Attachment[], canFlush?: boolean) => {
await act(async () => {
root.render(
<Host attachments={attachments} onFlush={onFlush} api={api} />,
<Host
attachments={attachments}
onFlush={onFlush}
canFlush={canFlush}
api={api}
/>,
);
});
};
@@ -139,6 +146,21 @@ describe('useArmedSend', () => {
expect(onFlush).toHaveBeenCalledTimes(1);
});
it('holds a ready flush until canFlush turns true', async () => {
await render([att({ status: 'processing' })]);
await act(async () => api.current!.arm());
// Files resolved, but another answer is streaming: flushing now would
// clear the composer while the submit is refused.
await render([att()], false);
expect(onFlush).not.toHaveBeenCalled();
expect(api.current!.armed).toBe(true);
await render([att()], true);
expect(onFlush).toHaveBeenCalledTimes(1);
expect(api.current!.armed).toBe(false);
});
it('cancel disarms and prevents the auto-flush', async () => {
await render([att({ status: 'uploading', progress: 10 })]);
await act(async () => api.current!.arm());
@@ -19,9 +19,13 @@ export function getSendReadiness(attachments: Attachment[]): SendReadiness {
export function useArmedSend({
attachments,
onFlush,
canFlush = true,
}: {
attachments: Attachment[];
onFlush: () => void;
// False while the composer can't take a submit (an answer is streaming):
// the armed send holds until it can, instead of flushing into a refusal.
canFlush?: boolean;
}) {
const [armed, setArmed] = useState(false);
// Latest-closure ref so the flush submits the current composer value,
@@ -37,12 +41,12 @@ export function useArmedSend({
flushedRef.current = false;
return;
}
if (readiness.state === 'ready' && !flushedRef.current) {
if (readiness.state === 'ready' && canFlush && !flushedRef.current) {
flushedRef.current = true;
setArmed(false);
flushRef.current();
}
}, [armed, readiness.state]);
}, [armed, readiness.state, canFlush]);
const arm = useCallback(() => setArmed(true), []);
const cancel = useCallback(() => setArmed(false), []);