mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 04:14:42 +00:00
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:
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');
|
||||
});
|
||||
});
|
||||
@@ -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), []);
|
||||
|
||||
Reference in new issue
Block a user