diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 6c3825f821..19943e4f01 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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( diff --git a/client/src/hooks/Input/__tests__/useComposerRestore.spec.tsx b/client/src/hooks/Input/__tests__/useComposerRestore.spec.tsx new file mode 100644 index 0000000000..5741f048a2 --- /dev/null +++ b/client/src/hooks/Input/__tests__/useComposerRestore.spec.tsx @@ -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(), + answerModeActive = false, + initialize, +}: { + conversationId?: string; + draft?: string; + files?: Map; + 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 }) => ( + {children} + ); + 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 }, 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; + 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; + expect(next.size).toBe(0); + }); + }); +}); diff --git a/client/src/hooks/Input/useComposerRestore.ts b/client/src/hooks/Input/useComposerRestore.ts new file mode 100644 index 0000000000..a635b4a64f --- /dev/null +++ b/client/src/hooks/Input/useComposerRestore.ts @@ -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; + files: Map; + setFiles: FileSetter; + textAreaRef: React.RefObject; + /** 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 }; +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 52034a3862..9bae1b17e1 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",