refactor: extract the composer restore path into its own hook

Putting a message back into the composer is the one path here that can destroy
text outright: the reclaimed-steer restore reports whether it took the words,
and a `true` it has not earned makes the caller drop them. Every condition it
weighs is about time passing between a click and its answer — a draft typed
since, a file staged since, a chat navigated away from, a question pause that
began, the composer unmounting — which is precisely what a component holding a
dozen other concerns makes untestable.

Moved verbatim to `useComposerRestore` alongside the unguarded `editToComposer`
it delegates to, and covered: thirteen cases, one per refusal and one per way
the guard must NOT refuse. ChatForm drops 120 lines.

Also removes ten translation keys left behind by the composer rebuild.
This commit is contained in:
Marco Beretta 2026-07-29 04:12:39 +02:00
parent 4d4a2b85ae
commit 3253b02e5e
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 410 additions and 141 deletions

View file

@ -1,7 +1,7 @@
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { TextareaAutosize } from '@librechat/client';
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TMessage, TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common';
@ -23,6 +23,7 @@ import {
useAssistantsMapContext,
BadgeRowProvider,
} from '~/Providers';
import useComposerRestore from '~/hooks/Input/useComposerRestore';
import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import AskUserQuestionPopover from './AskUserQuestionPopover';
import useComposerItems from '~/hooks/Input/useComposerItems';
@ -33,7 +34,6 @@ import DuringRunSendButton from './DuringRunSendButton';
import useDictation from '~/hooks/Input/useDictation';
import { useGetStartupConfig } from '~/data-provider';
import useSteering from '~/hooks/Chat/useSteering';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import SkillsCommand from './SkillsCommand';
@ -202,58 +202,14 @@ const ChatForm = memo(function ChatForm({
}),
[submitMessage],
);
/** Chip "Edit message" restore: quote chips + skill picks merge back into
* their compose-time atoms (the chips above the textarea re-render them). */
const restoreComposerContext = useRecoilCallback(
({ set }) =>
(context?: QueuedMessageContext) => {
const { quotes, manualSkills } = context ?? {};
if (quotes != null && quotes.length > 0) {
set(store.pendingQuotesByConvoId(conversationId), (prev) => [
...new Set([...prev, ...quotes]),
]);
}
if (manualSkills != null && manualSkills.length > 0) {
set(store.pendingManualSkillsByConvoId(conversationId), (prev) => [
...new Set([...prev, ...manualSkills]),
]);
}
},
[conversationId],
);
/** Chip "Edit message": the text replaces the composer draft and the chip's
* attachments merge back into the composer file map (already uploaded, so
* they restore as completed entries same shape as draft recovery). */
const editToComposer = useCallback(
(text: string, chipFiles?: TMessage['files'], context?: QueuedMessageContext) => {
methods.setValue('text', text, { shouldDirty: true });
if (chipFiles != null && chipFiles.length > 0) {
setFiles((prev) => {
const next = new Map(prev);
for (const file of chipFiles) {
if (!file.file_id) {
continue;
}
next.set(file.file_id, {
file_id: file.file_id,
filename: file.filename,
filepath: file.filepath,
type: file.type ?? '',
height: file.height,
width: file.width,
size: file.bytes ?? 0,
progress: 1,
attached: true,
});
}
return next;
});
}
restoreComposerContext(context);
textAreaRef.current?.focus();
},
[methods, setFiles, restoreComposerContext],
);
const { editToComposer, restoreReclaimedSteer } = useComposerRestore({
conversationId,
methods,
files,
setFiles,
textAreaRef,
answerModeActive: answerMode.active,
});
const steering = useSteering({
index,
conversationId,
@ -267,83 +223,6 @@ const ChatForm = memo(function ChatForm({
stopGenerating,
});
/** Read at call time, not captured: a reclaim resolves into the callback from
* the render it was clicked in, so the closure's `conversationId` is the OLD
* chat comparing it against itself would pass while `methods` (one form,
* reused across conversations) writes into the chat now on screen. */
const liveConversationIdRef = useRef(conversationId);
liveConversationIdRef.current = conversationId;
/** Same reason: attachments staged after the click must be seen. */
const liveFilesRef = useRef(files);
liveFilesRef.current = files;
/** Same reason: the run can pause on `ask_user_question` mid-reclaim. */
const liveAnswerModeRef = useRef(answerMode.active);
liveAnswerModeRef.current = answerMode.active;
/** A reclaim can resolve after this form unmounts (left the route, closed the
* pane). Its refs still hold the origin chat, so the restore would pass its
* checks and write into a dead form reporting success and making the caller
* drop the steer, losing the text. Track mount so the restore refuses and the
* caller queues it instead. */
const composerMountedRef = useRef(true);
useEffect(
() => () => {
composerMountedRef.current = false;
},
[],
);
/** A draft is anything the user has staged, not just typed: `editToComposer`
* MERGES the steer's attachments into the composer's file map and its quotes
* and skill picks into their atoms, so restoring over staged context would
* glue the two submissions together. */
const hasStagedComposerContext = useRecoilCallback(
({ snapshot }) =>
(convoId: string) =>
snapshot.getLoadable(store.pendingQuotesByConvoId(convoId)).getValue().length > 0 ||
snapshot.getLoadable(store.pendingManualSkillsByConvoId(convoId)).getValue().length > 0,
[],
);
/**
* `editToComposer` for a steer whose reclaim was a round-trip: by the time it
* resolves the composer may have moved on. Refuses (returning false, so the
* caller re-homes the words instead of dropping them) rather than overwrite a
* draft the user has since staged, or drop a steer into whatever chat they
* navigated to.
*/
const restoreReclaimedSteer = useCallback(
(
text: string,
steerFiles: TMessage['files'],
context: QueuedMessageContext,
originConversationId: string,
): boolean => {
if (!composerMountedRef.current) {
return false;
}
const liveConversationId = liveConversationIdRef.current;
if (originConversationId !== liveConversationId) {
return false;
}
/** Answer mode owns the composer: `onSubmit` hands its text to
* `answerMode.submitText` before any send/steer routing, so restoring
* here would turn the steer into the tool's answer on the next Enter. */
if (liveAnswerModeRef.current) {
return false;
}
if (
(methods.getValues('text') ?? '').trim().length > 0 ||
(liveFilesRef.current?.size ?? 0) > 0 ||
hasStagedComposerContext(liveConversationId)
) {
return false;
}
editToComposer(text, steerFiles, context);
return true;
},
[methods, editToComposer, hasStagedComposerContext],
);
/** /Ctrl+Enter = the non-default during-run action, /Alt+Enter =
* interrupt & send the counterpart of Enter's `submitDuringRun`. */
const handleDuringRunModifier = useCallback(

View file

@ -0,0 +1,217 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { act, renderHook } from '@testing-library/react';
import type { MutableSnapshot } from 'recoil';
import type { ExtendedFile } from '~/common';
import useComposerRestore from '../useComposerRestore';
import store from '~/store';
/**
* The guarded restore is the one path in the composer that can destroy text
* outright: it reports back whether it took the words, and a `true` it did not
* earn makes the caller drop them. Every refusal below is a round-trip that
* resolved into a composer that had moved on since the click.
*/
const CONVO_ID = 'convo-restore';
const OTHER_CONVO = 'convo-elsewhere';
const stagedFile = (file_id: string): ExtendedFile =>
({ file_id, progress: 1, size: 1 }) as ExtendedFile;
function setup({
conversationId = CONVO_ID,
draft = '',
files = new Map<string, ExtendedFile>(),
answerModeActive = false,
initialize,
}: {
conversationId?: string;
draft?: string;
files?: Map<string, ExtendedFile>;
answerModeActive?: boolean;
initialize?: (snapshot: MutableSnapshot) => void;
} = {}) {
let text = draft;
const setFiles = jest.fn();
const methods = {
setValue: jest.fn((_name: string, value: string) => {
text = value;
}),
getValues: jest.fn(() => text),
};
const textAreaRef = { current: { focus: jest.fn() } };
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot initializeState={initialize}>{children}</RecoilRoot>
);
const view = renderHook(
(props: { conversationId: string; answerModeActive: boolean }) =>
useComposerRestore({
conversationId: props.conversationId,
methods: methods as never,
files,
setFiles,
textAreaRef: textAreaRef as never,
answerModeActive: props.answerModeActive,
}),
{ wrapper, initialProps: { conversationId, answerModeActive } },
);
return { ...view, methods, setFiles, textAreaRef, currentText: () => text };
}
const reclaim = (result: { current: ReturnType<typeof useComposerRestore> }, origin = CONVO_ID) =>
result.current.restoreReclaimedSteer('reclaimed words', [], {}, origin);
describe('useComposerRestore', () => {
describe('restoreReclaimedSteer', () => {
it('takes the words into an empty composer', () => {
const { result, currentText, textAreaRef } = setup();
let taken = false;
act(() => {
taken = reclaim(result);
});
expect(taken).toBe(true);
expect(currentText()).toBe('reclaimed words');
expect(textAreaRef.current.focus).toHaveBeenCalled();
});
it('refuses rather than overwrite a draft typed while the reclaim was in flight', () => {
const { result, currentText } = setup({ draft: 'something I was writing' });
let taken = true;
act(() => {
taken = reclaim(result);
});
expect(taken).toBe(false);
expect(currentText()).toBe('something I was writing');
});
/* Whitespace is not a draft: refusing on it would strand the words for the
sake of a stray space. */
it('takes the words over a whitespace-only draft', () => {
const { result } = setup({ draft: ' \n ' });
let taken = false;
act(() => {
taken = reclaim(result);
});
expect(taken).toBe(true);
});
it('refuses when a file has been staged since', () => {
const { result } = setup({ files: new Map([['f1', stagedFile('f1')]]) });
let taken = true;
act(() => {
taken = reclaim(result);
});
expect(taken).toBe(false);
});
/* A quote or a skill pick is staged context the restore would MERGE with,
gluing two submissions together the box being empty is not enough. */
it.each([
[
'a quote',
(snapshot: MutableSnapshot) =>
snapshot.set(store.pendingQuotesByConvoId(CONVO_ID), ['an excerpt']),
],
[
'a skill pick',
(snapshot: MutableSnapshot) =>
snapshot.set(store.pendingManualSkillsByConvoId(CONVO_ID), ['writer']),
],
])('refuses when %s is staged', (_label, initialize) => {
const { result } = setup({ initialize });
let taken = true;
act(() => {
taken = reclaim(result);
});
expect(taken).toBe(false);
});
/* One form object is reused across conversations, so a restore that does
not check would write the old chat's steer into the new chat's box. */
it('refuses once the user has navigated to another chat', () => {
const { result, rerender, currentText } = setup();
rerender({ conversationId: OTHER_CONVO, answerModeActive: false });
let taken = true;
act(() => {
taken = reclaim(result, CONVO_ID);
});
expect(taken).toBe(false);
expect(currentText()).toBe('');
});
/* The origin is read live, not captured: the same steer restored after the
user came BACK to its chat is still its chat. */
it('takes the words again once the user returns to the origin chat', () => {
const { result, rerender } = setup();
rerender({ conversationId: OTHER_CONVO, answerModeActive: false });
rerender({ conversationId: CONVO_ID, answerModeActive: false });
let taken = false;
act(() => {
taken = reclaim(result, CONVO_ID);
});
expect(taken).toBe(true);
});
/* Answer mode owns the composer: `onSubmit` hands its text to the paused
question, so a restore here becomes the tool's answer on the next Enter. */
it('refuses while a question pause owns the composer', () => {
const { result, rerender, currentText } = setup();
/* The pause can begin mid-reclaim, so the flag is read live rather than
captured when the restore was handed out. */
rerender({ conversationId: CONVO_ID, answerModeActive: true });
let taken = true;
act(() => {
taken = reclaim(result);
});
expect(taken).toBe(false);
expect(currentText()).toBe('');
});
/* Its refs still hold the origin chat after unmount, so every check above
would pass and the restore would report success into a dead form. */
it('refuses after the composer has unmounted', () => {
const { result, unmount } = setup();
unmount();
let taken = true;
act(() => {
taken = reclaim(result);
});
expect(taken).toBe(false);
});
});
describe('editToComposer', () => {
it('replaces the draft outright, unlike the guarded restore', () => {
const { result, currentText } = setup({ draft: 'half a thought' });
act(() => {
result.current.editToComposer('the queued message');
});
expect(currentText()).toBe('the queued message');
});
it('restores attachments as already-uploaded entries', () => {
const { result, setFiles } = setup();
act(() => {
result.current.editToComposer('with a file', [
{ file_id: 'f9', filename: 'notes.pdf', filepath: '/f9', type: 'application/pdf' },
]);
});
const next = setFiles.mock.calls[0][0](new Map()) as Map<string, ExtendedFile>;
expect(next.get('f9')).toEqual(
expect.objectContaining({ file_id: 'f9', progress: 1, attached: true }),
);
});
/* Skipped rather than stored under `undefined`, which would put an
unremovable card in the tray. */
it('skips an attachment with no id', () => {
const { result, setFiles } = setup();
act(() => {
result.current.editToComposer('with a broken file', [{ filename: 'ghost' } as never]);
});
const next = setFiles.mock.calls[0][0](new Map()) as Map<string, ExtendedFile>;
expect(next.size).toBe(0);
});
});
});

View file

@ -0,0 +1,183 @@
import { useRef, useEffect, useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import type { QueuedMessageContext } from '~/hooks/Chat/useSteering';
import type { ExtendedFile, FileSetter } from '~/common';
import type { useChatFormContext } from '~/Providers';
import store from '~/store';
export interface ComposerRestore {
/** Chip "Edit message": replaces the draft outright. The caller has already
* decided this is wanted, so nothing is guarded. */
editToComposer: (text: string, files?: TMessage['files'], context?: QueuedMessageContext) => void;
/** The same restore for a steer whose reclaim was a round-trip, guarded
* against everything that can change while it is in flight. Returns whether
* the words were taken, so a refusal can be re-homed rather than dropped. */
restoreReclaimedSteer: (
text: string,
files: TMessage['files'],
context: QueuedMessageContext,
originConversationId: string,
) => boolean;
}
interface UseComposerRestoreParams {
conversationId: string;
methods: ReturnType<typeof useChatFormContext>;
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
/** A paused `ask_user_question` owns the composer; see `restoreReclaimedSteer`. */
answerModeActive: boolean;
}
/**
* Putting a message back into the composer: the queue rail's "Edit message",
* and the guarded restore a reclaimed steer resolves into.
*
* Extracted from `ChatForm` because the guarded path is the only one in the
* composer that can destroy text outright, and every one of its conditions is
* about time passing between a click and its answer which is exactly what a
* component holding half a dozen other concerns makes impossible to test.
*/
export default function useComposerRestore({
conversationId,
methods,
files,
setFiles,
textAreaRef,
answerModeActive,
}: UseComposerRestoreParams): ComposerRestore {
/** Chip "Edit message" restore: quote chips + skill picks merge back into
* their compose-time atoms (the chips above the textarea re-render them). */
const restoreComposerContext = useRecoilCallback(
({ set }) =>
(context?: QueuedMessageContext) => {
const { quotes, manualSkills } = context ?? {};
if (quotes != null && quotes.length > 0) {
set(store.pendingQuotesByConvoId(conversationId), (prev) => [
...new Set([...prev, ...quotes]),
]);
}
if (manualSkills != null && manualSkills.length > 0) {
set(store.pendingManualSkillsByConvoId(conversationId), (prev) => [
...new Set([...prev, ...manualSkills]),
]);
}
},
[conversationId],
);
/** The text replaces the composer draft and the chip's attachments merge back
* into the composer file map (already uploaded, so they restore as completed
* entries same shape as draft recovery). */
const editToComposer = useCallback(
(text: string, chipFiles?: TMessage['files'], context?: QueuedMessageContext) => {
methods.setValue('text', text, { shouldDirty: true });
if (chipFiles != null && chipFiles.length > 0) {
setFiles((prev) => {
const next = new Map(prev);
for (const file of chipFiles) {
if (!file.file_id) {
continue;
}
next.set(file.file_id, {
file_id: file.file_id,
filename: file.filename,
filepath: file.filepath,
type: file.type ?? '',
height: file.height,
width: file.width,
size: file.bytes ?? 0,
progress: 1,
attached: true,
});
}
return next;
});
}
restoreComposerContext(context);
textAreaRef.current?.focus();
},
[methods, setFiles, restoreComposerContext, textAreaRef],
);
/** Read at call time, not captured: a reclaim resolves into the callback from
* the render it was clicked in, so the closure's `conversationId` is the OLD
* chat comparing it against itself would pass while `methods` (one form,
* reused across conversations) writes into the chat now on screen. */
const liveConversationIdRef = useRef(conversationId);
liveConversationIdRef.current = conversationId;
/** Same reason: attachments staged after the click must be seen. */
const liveFilesRef = useRef(files);
liveFilesRef.current = files;
/** Same reason: the run can pause on `ask_user_question` mid-reclaim. */
const liveAnswerModeRef = useRef(answerModeActive);
liveAnswerModeRef.current = answerModeActive;
/** A reclaim can resolve after the composer unmounts (left the route, closed
* the pane). Its refs still hold the origin chat, so the restore would pass
* its checks and write into a dead form reporting success and making the
* caller drop the steer, losing the text. Track mount so the restore refuses
* and the caller queues it instead. */
const mountedRef = useRef(true);
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
/** A draft is anything the user has staged, not just typed: `editToComposer`
* MERGES the steer's attachments into the composer's file map and its quotes
* and skill picks into their atoms, so restoring over staged context would
* glue the two submissions together. */
const hasStagedContext = useRecoilCallback(
({ snapshot }) =>
(convoId: string) =>
snapshot.getLoadable(store.pendingQuotesByConvoId(convoId)).getValue().length > 0 ||
snapshot.getLoadable(store.pendingManualSkillsByConvoId(convoId)).getValue().length > 0,
[],
);
/**
* `editToComposer` for a steer whose reclaim was a round-trip: by the time it
* resolves the composer may have moved on. Refuses (returning false, so the
* caller re-homes the words instead of dropping them) rather than overwrite a
* draft the user has since staged, or drop a steer into whatever chat they
* navigated to.
*/
const restoreReclaimedSteer = useCallback(
(
text: string,
steerFiles: TMessage['files'],
context: QueuedMessageContext,
originConversationId: string,
): boolean => {
if (!mountedRef.current) {
return false;
}
const liveConversationId = liveConversationIdRef.current;
if (originConversationId !== liveConversationId) {
return false;
}
/** Answer mode owns the composer: `onSubmit` hands its text to
* `answerMode.submitText` before any send/steer routing, so restoring
* here would turn the steer into the tool's answer on the next Enter. */
if (liveAnswerModeRef.current) {
return false;
}
if (
(methods.getValues('text') ?? '').trim().length > 0 ||
(liveFilesRef.current?.size ?? 0) > 0 ||
hasStagedContext(liveConversationId)
) {
return false;
}
editToComposer(text, steerFiles, context);
return true;
},
[methods, editToComposer, hasStagedContext],
);
return { editToComposer, restoreReclaimedSteer };
}

View file

@ -869,7 +869,6 @@
"com_ui_artifacts_mode_default_info": "Standard artifact instructions: the agent renders React, HTML, SVG, Markdown, and Mermaid inline.",
"com_ui_artifacts_mode_shadcn": "shadcn/ui",
"com_ui_artifacts_mode_shadcn_info": "Adds shadcn/ui component-library instructions so the agent can build polished interfaces from prebuilt components.",
"com_ui_artifacts_options": "Artifacts Options",
"com_ui_artifacts_subtext": "Lets the agent render React, HTML, SVG, Markdown, and Mermaid as interactive artifacts in a side panel instead of plain code blocks.",
"com_ui_ascending": "Asc",
"com_ui_ask_answer_error": "Your answer couldn't be sent. Try again.",
@ -1101,7 +1100,6 @@
"com_ui_custom": "Custom",
"com_ui_custom_header_name": "Custom Header Name",
"com_ui_custom_prompt": "Custom prompt",
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
"com_ui_dark_theme_enabled": "Dark theme enabled",
"com_ui_date": "Date",
"com_ui_date_april": "April",
@ -1323,7 +1321,6 @@
"com_ui_import_conversation_success": "Conversations imported successfully",
"com_ui_import_conversation_upload_error": "Error uploading file. Please try again.",
"com_ui_importing": "Importing",
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
"com_ui_input": "Input",
"com_ui_instructions": "Instructions",
"com_ui_interrupt_send": "Interrupt & send",
@ -1613,8 +1610,6 @@
"com_ui_queue_send": "Queue message for after the response",
"com_ui_queued_attachment_count": "{{0}} attachments queued with this message",
"com_ui_queued_messages": "Queued messages",
"com_ui_quote_selections": "{{0}} selections",
"com_ui_quotes_queued": "Quotes added for your next message",
"com_ui_ran_n_agents": "Ran {{0}} agents",
"com_ui_read_aloud": "Read aloud",
"com_ui_read_file": "Read {{0}}",
@ -1649,12 +1644,10 @@
"com_ui_remote_agents_allow_share_public": "Allow users to grant API access to agents to all users",
"com_ui_remote_agents_allow_use": "Allow users to create API keys and query agents remotely",
"com_ui_remove_agent_from_chain": "Remove {{0}} from chain",
"com_ui_remove_all_quotes": "Remove all selections",
"com_ui_remove_from_project": "Remove from project",
"com_ui_remove_queued": "Remove message",
"com_ui_remove_quote": "Remove quote",
"com_ui_remove_skill": "Remove skill",
"com_ui_remove_skill_var": "Remove {{0}}",
"com_ui_remove_user": "Remove {{0}}",
"com_ui_remove_var": "Remove {{0}}",
"com_ui_rename": "Rename",
@ -1702,7 +1695,6 @@
"com_ui_running_n_agents": "Running {{0}} agents",
"com_ui_sandbox_starting": "Starting sandbox environment",
"com_ui_save": "Save",
"com_ui_save_badge_changes": "Save badge changes?",
"com_ui_save_changes": "Save Changes",
"com_ui_save_key_error": "Failed to save API key. Please try again.",
"com_ui_save_key_success": "API key saved successfully",
@ -1889,7 +1881,6 @@
"com_ui_skills_filter": "Filter skills",
"com_ui_skills_load_error": "Failed to load skills",
"com_ui_skills_manual_invoked": "Manually invoked skills",
"com_ui_skills_queued": "Skills queued for next submission",
"com_ui_skills_use_all": "Use all skills",
"com_ui_skills_use_all_hint": "The agent can use every skill available to you, including skills added in the future.",
"com_ui_skip": "Skip",
@ -2114,7 +2105,6 @@
"com_ui_weekend_morning": "Happy weekend",
"com_ui_write": "Writing",
"com_ui_writing_command": "Writing command",
"com_ui_x_selected": "{{0}} selected",
"com_ui_xhigh": "Extra High",
"com_ui_yes": "Yes",
"com_ui_you": "You",