Skip to content

Commit 873330b

Browse files
lia-by-librechat[bot]Lia
andauthored
⏮️ fix: Keep the Last Keystrokes When a Run Finishes (#15987)
* ⏮️ fix: Keep the Last Keystrokes When a Run Finishes * 🧪 fix: Restore the Draft Mocks After the Real-Storage Block --------- Co-authored-by: Lia <lia@librechat.ai>
1 parent 52293e1 commit 873330b

2 files changed

Lines changed: 197 additions & 6 deletions

File tree

client/src/hooks/Input/useAutoSave.spec.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,183 @@ describe('useAutoSave — debounced autosave', () => {
422422
});
423423
});
424424

425+
describe('useAutoSave — typing as a run finishes', () => {
426+
/** Real storage for these, because the loss is in what the record holds at the moment the key
427+
* changes: a mock that answers every read with the same string cannot express it. */
428+
const actualUtils = jest.requireActual('~/utils');
429+
430+
const getInputListener = (textAreaRef: React.RefObject<HTMLTextAreaElement>) =>
431+
(textAreaRef.current!.addEventListener as unknown as jest.Mock).mock.calls.find(
432+
([event]) => event === 'input',
433+
)![1] as (e: unknown) => void;
434+
435+
/** Types into the composer the way the browser does: the value is already there when the event
436+
* fires, so a debounced write reads it whether or not the event carried it. */
437+
const type = (textAreaRef: React.RefObject<HTMLTextAreaElement>, value: string) => {
438+
textAreaRef.current!.value = value;
439+
getInputListener(textAreaRef)({ target: { value } });
440+
};
441+
442+
beforeEach(() => {
443+
jest.useFakeTimers();
444+
mockGetDraft.mockImplementation(actualUtils.getDraft);
445+
mockSetDraft.mockImplementation(actualUtils.setDraft);
446+
});
447+
448+
afterEach(() => {
449+
jest.useRealTimers();
450+
/** Hand the rest of the suite its stubs back. An implementation set here outlives the block,
451+
* and a real `setDraft` leaking into a later test writes records that test never asked for. */
452+
mockGetDraft.mockReset();
453+
mockSetDraft.mockReset();
454+
});
455+
456+
/** The reported bug. The composer is keyed under PENDING while a run streams and under the
457+
* conversation once it ends, and the key change tore off whatever the 25ms debounce had not
458+
* written yet: the pending record still held the previous flush, run end migrated that record,
459+
* and the restore put it back over the composer. Every keystroke since the last flush was
460+
* silently rolled back mid-sentence. */
461+
it('keeps the keystrokes the debounce had not written when the run ends', () => {
462+
const textAreaRef = makeTextAreaRef();
463+
const { rerender } = renderHook(
464+
({ isSubmitting }: { isSubmitting: boolean }) =>
465+
useAutoSave({
466+
isSubmitting,
467+
conversationId: 'convo-1',
468+
textAreaRef,
469+
files: new Map(),
470+
setFiles: jest.fn(),
471+
}),
472+
{ initialProps: { isSubmitting: true } },
473+
);
474+
475+
act(() => {
476+
type(textAreaRef, 'my follow up');
477+
jest.advanceTimersByTime(50);
478+
});
479+
480+
/** Still typing when the response lands, inside the debounce window. */
481+
act(() => {
482+
type(textAreaRef, 'my follow up question');
483+
rerender({ isSubmitting: false });
484+
});
485+
486+
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'my follow up question');
487+
expect(actualUtils.getDraft('convo-1')).toBe('my follow up question');
488+
});
489+
490+
it('keeps the whole message when the run ends before anything was written', () => {
491+
const textAreaRef = makeTextAreaRef();
492+
const { rerender } = renderHook(
493+
({ isSubmitting }: { isSubmitting: boolean }) =>
494+
useAutoSave({
495+
isSubmitting,
496+
conversationId: 'convo-1',
497+
textAreaRef,
498+
files: new Map(),
499+
setFiles: jest.fn(),
500+
}),
501+
{ initialProps: { isSubmitting: true } },
502+
);
503+
504+
act(() => {
505+
type(textAreaRef, 'a whole sentence typed quickly');
506+
rerender({ isSubmitting: false });
507+
});
508+
509+
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'a whole sentence typed quickly');
510+
});
511+
512+
/** A draft of one character is deliberately not persisted, so the record cannot speak for the
513+
* composer here. The composer still can, and it is what the user is looking at. */
514+
it('keeps a single character typed as the run ends', () => {
515+
const textAreaRef = makeTextAreaRef();
516+
const { rerender } = renderHook(
517+
({ isSubmitting }: { isSubmitting: boolean }) =>
518+
useAutoSave({
519+
isSubmitting,
520+
conversationId: 'convo-1',
521+
textAreaRef,
522+
files: new Map(),
523+
setFiles: jest.fn(),
524+
}),
525+
{ initialProps: { isSubmitting: true } },
526+
);
527+
528+
act(() => {
529+
type(textAreaRef, 'k');
530+
rerender({ isSubmitting: false });
531+
});
532+
533+
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'k');
534+
});
535+
536+
/** The same key change in the other direction. `useSubmitMessage` asks and then resets the form
537+
* in one handler, so the composer is already empty when the render that flips to PENDING lands:
538+
* the write flushed on the way out records the emptiness. Were it to record the sent text, run
539+
* end would migrate it straight back into the composer as a duplicate of the message. */
540+
it('does not keep the sent text as a draft when submitting mid-keystroke', () => {
541+
const textAreaRef = makeTextAreaRef();
542+
const { rerender } = renderHook(
543+
({ isSubmitting }: { isSubmitting: boolean }) =>
544+
useAutoSave({
545+
isSubmitting,
546+
conversationId: 'convo-1',
547+
textAreaRef,
548+
files: new Map(),
549+
setFiles: jest.fn(),
550+
}),
551+
{ initialProps: { isSubmitting: false } },
552+
);
553+
554+
act(() => {
555+
type(textAreaRef, 'sent message');
556+
});
557+
558+
/** Submit: `ask` then `methods.reset()`, batched into the render that starts the run. */
559+
act(() => {
560+
textAreaRef.current!.value = '';
561+
rerender({ isSubmitting: true });
562+
});
563+
564+
expect(actualUtils.getDraft('convo-1')).toBe('');
565+
expect(actualUtils.getDraft(Constants.PENDING_CONVO)).toBe('');
566+
});
567+
568+
/** The other side of the same key change, and the reason the in-flight write was dropped rather
569+
* than flushed: a steer consumes the composer and clears it programmatically, and run end must
570+
* not put the just-sent text back. An empty composer has nothing to defend, so the record wins. */
571+
it('does not resurrect text a steer consumed as the run ended', () => {
572+
const textAreaRef = makeTextAreaRef();
573+
const { rerender } = renderHook(
574+
({ isSubmitting }: { isSubmitting: boolean }) =>
575+
useAutoSave({
576+
isSubmitting,
577+
conversationId: 'convo-1',
578+
textAreaRef,
579+
files: new Map(),
580+
setFiles: jest.fn(),
581+
}),
582+
{ initialProps: { isSubmitting: true } },
583+
);
584+
585+
act(() => {
586+
type(textAreaRef, 'steered message');
587+
jest.advanceTimersByTime(50);
588+
});
589+
590+
/** The steer took the text and emptied the composer, and dropped the pending draft with it. */
591+
act(() => {
592+
actualUtils.clearAllDrafts(Constants.PENDING_CONVO);
593+
type(textAreaRef, '');
594+
rerender({ isSubmitting: false });
595+
});
596+
597+
expect(mockSetValue).toHaveBeenLastCalledWith('text', '');
598+
expect(actualUtils.getDraft('convo-1')).toBe('');
599+
});
600+
});
601+
425602
describe('useAutoSave — side-by-side pending drafts', () => {
426603
const pane0PendingId = Constants.PENDING_CONVO;
427604
const pane1PendingId = `${Constants.PENDING_CONVO}:1`;

client/src/hooks/Input/useAutoSave.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,15 @@ export const useAutoSave = ({
264264
if (textArea) {
265265
textArea.removeEventListener('input', eventListener);
266266
}
267-
handleInputFast.cancel();
268-
handleInputSlow.cancel();
267+
/** Land the write instead of dropping it. This runs whenever the draft key changes and when
268+
* the composer goes away, and a keystroke inside the debounce window has nothing else to
269+
* persist it: cancelling here is what tore the end off a message being typed as a run
270+
* finished, since the key moves from PENDING to the conversation at that moment and the
271+
* record left behind was one flush old. Flushing is safe for the case the cancel was
272+
* protecting — `saveLatest` reads the composer at flush time, so a steer that consumed the
273+
* text and emptied the composer flushes the emptiness, not the message it just sent. */
274+
handleInputFast.flush();
275+
handleInputSlow.flush();
269276
};
270277
}, [activeStorageId, saveDrafts, textAreaRef]);
271278

@@ -344,10 +351,17 @@ export const useAutoSave = ({
344351
nextConversationId = pendingDraftId;
345352
} else if (pendingOwned) {
346353
pendingDestinationRef.current = null;
347-
// Move the pending text draft to the new conversationId, falling back to the current
348-
// text area value when there was no pending draft to carry over
349-
if (!migrateTextDraft(pendingDraftId, conversationId) && textAreaRef?.current?.value) {
350-
setDraft({ id: conversationId, value: textAreaRef.current.value });
354+
/** Move the pending text draft to the new conversationId, then let the composer correct
355+
* it. Both describe the same composer and the record is a debounced copy of it, so when
356+
* they disagree the record is simply older, and restoring it would roll the user's last
357+
* keystrokes back mid-sentence. `persistExact` because a one-character message is still
358+
* the user's message, and the ordinary threshold would refuse to keep it. An empty
359+
* composer defers to the record instead: a steer consumes the text and clears the
360+
* composer programmatically, and run end must not undo that. */
361+
migrateTextDraft(pendingDraftId, conversationId);
362+
const liveText = textAreaRef?.current?.value ?? '';
363+
if (liveText !== '' && getDraft(conversationId) !== liveText) {
364+
setDraft({ id: conversationId, value: liveText, persistExact: true });
351365
}
352366
filesDraftId = migrateFilesDraft(pendingDraftId, conversationId);
353367
} else {

0 commit comments

Comments
 (0)