🍽️ chore: Drop Pending Composer Draft When Steering or Queuing (#14289)

* 🧹 fix: Drop Pending Composer Draft When Steering or Queuing

A during-run submit takes the composer text into a steer or a queued item
and clears the composer via the form's `reset()`. That clear is
programmatic, so it never fires the `input` event `useAutoSave` listens
on, leaving the autosaved draft (keyed under `PENDING_CONVO` for the
duration of the run) behind.

When the run ends, `useAutoSave` migrates a surviving pending draft onto
the real conversation id and restores it into the textarea. The result:
a queued message that was successfully auto-sent by the run-end drain
immediately resurfaced as the composer draft, and persisted there under
the conversation key across reloads.

Consume the pending draft at the three composer-origin entry points
(steer, queue, interrupt & send), mirroring the existing
takeComposerFiles/takeComposerContext consumption helpers. Only a
consumed submit clears it — a refused one (empty text, uploads in
flight) leaves the draft intact.

* 🔒 fix: Flush The Live Composer Value On Debounced Autosave

Codex round 1: the 25ms debounced autosave captured the textarea value at
event time, so a write scheduled just before a during-run steer/queue
could land after the composer was consumed and cleared — rewriting the
just-sent text back into the PENDING_CONVO draft and defeating the clear.

Read the value at flush time instead. When the composer was cleared in
the debounce window the pending write now removes the draft rather than
resurrecting it, and an untouched composer saves exactly as before.
This commit is contained in:
Danny Avila 2026-07-15 13:12:49 -04:00 committed by GitHub
parent eccc7d58e9
commit f1b9c5f091
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 155 additions and 15 deletions

View file

@ -1,7 +1,7 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, type MutableSnapshot } from 'recoil';
import { Constants, ContentTypes, EModelEndpoint } from 'librechat-data-provider';
import { Constants, ContentTypes, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import useSteering from '../useSteering';
import store from '~/store';
@ -805,4 +805,60 @@ describe('useSteering', () => {
expect(result.current.pendingSkills).toEqual(['skill-1']);
});
});
describe('composer draft consumption', () => {
/** `useAutoSave` drafts under PENDING_CONVO for the whole run, and the
* composer clears via the form's programmatic `reset()` which fires no
* `input` event, so nothing else drops the draft. Left behind, run end
* migrates it onto the conversation and restores it into the textarea,
* resurfacing text the user already sent. */
const pendingDraftKey = `${LocalStorageKeys.TEXT_DRAFT}${Constants.PENDING_CONVO}`;
const stageDraft = () => localStorage.setItem(pendingDraftKey, 'ZHJhZnRlZCB0ZXh0');
beforeEach(() => {
localStorage.clear();
});
it('drops the pending draft when queueing from the composer', () => {
stageDraft();
const { result } = setup();
act(() => {
result.current.queueFromComposer('queued follow up');
});
expect(result.current.queueKey).toBe(CONVO_ID);
expect(localStorage.getItem(pendingDraftKey)).toBeNull();
});
it('drops the pending draft when steering from the composer', () => {
stageDraft();
const { result } = setup();
act(() => {
result.current.steerFromComposer('steered text');
});
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(pendingDraftKey)).toBeNull();
});
it('drops the pending draft on interrupt & send', () => {
stageDraft();
const { result } = setup();
act(() => {
result.current.interruptAndSend('interrupting text');
});
expect(localStorage.getItem(pendingDraftKey)).toBeNull();
});
it('keeps the pending draft when the submit is refused', () => {
// Nothing left the composer, so its draft must survive: an empty
// submission and an in-flight upload both refuse without consuming.
stageDraft();
const { result } = setup({ filesLoading: true });
act(() => {
expect(result.current.queueFromComposer('held by upload')).toBe(false);
expect(result.current.submitDuringRun(' ')).toBe(false);
});
expect(localStorage.getItem(pendingDraftKey)).toBe('ZHJhZnRlZCB0ZXh0');
});
});
});

View file

@ -11,8 +11,8 @@ import {
useSteerMessageMutation,
useMarkFilesUsageMutation,
} from '~/data-provider';
import { carriedSteerContext, clearAllDrafts } from '~/utils';
import { useSetFilesToDelete } from '~/hooks/Files';
import { carriedSteerContext } from '~/utils';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
@ -331,6 +331,18 @@ export default function useSteering({
[conversationId],
);
/** Consumes the composer's autosaved draft once its text has been taken into
* a steer or queued item. The composer clears via the form's `reset()`,
* which is programmatic and never fires the `input` event `useAutoSave`
* listens on so the draft would outlive the submit. It is keyed under
* `PENDING_CONVO` here (every caller is gated on `duringRunActive`, which
* requires `isSubmitting` and rules out the answer-mode draft key), and
* run end migrates a surviving pending draft onto the conversation and
* restores it resurfacing text the user already sent. */
const takeComposerDraft = useCallback(() => {
clearAllDrafts(Constants.PENDING_CONVO);
}, []);
const removeQueued = useRecoilCallback(
({ set }) =>
(id: string) => {
@ -484,9 +496,13 @@ export default function useSteering({
if (trimmed.length === 0 || filesLoading || !hasRealConvoId) {
return false;
}
return submitSteer(trimmed, takeComposerFiles());
const consumed = submitSteer(trimmed, takeComposerFiles());
if (consumed) {
takeComposerDraft();
}
return consumed;
},
[filesLoading, hasRealConvoId, takeComposerFiles, submitSteer],
[filesLoading, hasRealConvoId, takeComposerFiles, takeComposerDraft, submitSteer],
);
/** Composer-originated queue: carries the composer's attachments, quote
@ -498,9 +514,10 @@ export default function useSteering({
return false;
}
enqueue(trimmed, { files: takeComposerFiles(), ...takeComposerContext() });
takeComposerDraft();
return true;
},
[filesLoading, enqueue, takeComposerFiles, takeComposerContext],
[filesLoading, enqueue, takeComposerFiles, takeComposerContext, takeComposerDraft],
);
/** Retry a failed chip through the normal steer path. */
@ -596,6 +613,7 @@ export default function useSteering({
return false;
}
enqueue(trimmed, { front: true, files: takeComposerFiles(), ...takeComposerContext() });
takeComposerDraft();
armDrainAfterAbort();
stopGenerating();
return true;
@ -605,6 +623,7 @@ export default function useSteering({
enqueue,
takeComposerFiles,
takeComposerContext,
takeComposerDraft,
armDrainAfterAbort,
stopGenerating,
],

View file

@ -215,3 +215,67 @@ describe('useAutoSave — ask-answer draft swap', () => {
expect(mockSetValue).toHaveBeenLastCalledWith('text', 'half-typed answer');
});
});
describe('useAutoSave — debounced autosave', () => {
/** Grabs the `input` listener the hook registered on the textarea. */
const getInputListener = (textAreaRef: React.RefObject<HTMLTextAreaElement>) =>
(textAreaRef.current!.addEventListener as unknown as jest.Mock).mock.calls.find(
([event]) => event === 'input',
)![1] as (e: unknown) => void;
afterEach(() => {
jest.useRealTimers();
});
it('flushes the live composer value, not the value captured when typing', () => {
jest.useFakeTimers();
// A run is active, so the draft is keyed under PENDING_CONVO.
const textAreaRef = makeTextAreaRef('queued follow up');
renderHook(() =>
useAutoSave({
isSubmitting: true,
conversationId: 'convo-1',
textAreaRef,
files: new Map(),
setFiles: jest.fn(),
}),
);
act(() => {
getInputListener(textAreaRef)({ target: { value: 'queued follow up' } });
});
// A during-run steer/queue took the text and cleared the composer inside
// the 25ms debounce window. The in-flight write must not resurrect it:
// run end migrates a surviving pending draft back into the textarea.
textAreaRef.current!.value = '';
act(() => {
jest.advanceTimersByTime(50);
});
expect(mockSetDraft).toHaveBeenLastCalledWith({
id: Constants.PENDING_CONVO,
value: '',
});
});
it('still saves typed text when the composer is untouched', () => {
jest.useFakeTimers();
const textAreaRef = makeTextAreaRef('still typing');
renderHook(() =>
useAutoSave({
conversationId: 'convo-1',
textAreaRef,
files: new Map(),
setFiles: jest.fn(),
}),
);
act(() => {
getInputListener(textAreaRef)({ target: { value: 'still typing' } });
jest.advanceTimersByTime(50);
});
expect(mockSetDraft).toHaveBeenLastCalledWith({ id: 'convo-1', value: 'still typing' });
});
});

View file

@ -109,17 +109,18 @@ export const useAutoSave = ({
return;
}
/** Saves the composer's value AT FLUSH TIME rather than the value captured
* when the event fired. A during-run steer/queue consumes the text and
* clears the composer programmatically, so a write still in flight would
* otherwise land after the submit and restore the just-sent text. */
const saveLatest = () =>
setDraft({ id: conversationId, value: textAreaRef?.current?.value ?? '' });
/** Use shorter debounce for saving text (25ms) to capture rapid typing */
const handleInputFast = debounce(
(value: string) => setDraft({ id: conversationId, value }),
25,
);
const handleInputFast = debounce(saveLatest, 25);
/** Use longer debounce for clearing empty values (850ms) to prevent accidental draft loss */
const handleInputSlow = debounce(
(value: string) => setDraft({ id: conversationId, value }),
850,
);
const handleInputSlow = debounce(saveLatest, 850);
const eventListener = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
@ -132,9 +133,9 @@ export const useAutoSave = ({
/** If empty, use long delay to prevent accidental clearing
* Otherwise use short delay to capture rapid typing */
if (value === '') {
handleInputSlow(value);
handleInputSlow();
} else {
handleInputFast(value);
handleInputFast();
}
};