mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🍽️ 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:
parent
eccc7d58e9
commit
f1b9c5f091
4 changed files with 155 additions and 15 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue