From 71fa24a6ea1e4559f83387b4509506eb544ba753 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 21 Jul 2026 08:28:38 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20perf:=20Narrow=20Compos?= =?UTF-8?q?er=20Subscriptions=20to=20Streaming=20State=20(#14333)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🎛️ perf: Narrow Composer Subscriptions to Streaming State * ✅ test: Cover Composer Subscription Refactor's Behavioral Changes Add regression tests for the previously-uncovered changed behavior in the composer-subscriptions refactor: - useLatestMessageMeta: exact projected field set, null on empty cache, and referential stability + no re-render across token-only cache writes. - useGetLatestMessage: call-time tail read, stable callback identity with no re-render on cache writes, Recoil-snapshot sibling-branch resolution, null with no conversation. - useSubmitMessage: reads the tail at call time and appends it to root when missing (and does not when present or absent) — the reconcile branch the prior test skipped via an early return. - useHandleKeyUp: ArrowUp in an empty composer clicks the latest message's edit control, with the null / missing-control / non-empty-composer guards. - useAskAnswerMode (new spec): liveAsk is projected through the findLiveAskUserQuestion select, null when empty/disabled. * 🎨 style: Fix import order in useLatestMessage spec --- .../Chat/Input/AskUserQuestionPopover.tsx | 26 ++- .../ConvoOptions/ShareButton.tsx | 6 +- .../hooks/Chat/__tests__/useSteering.spec.tsx | 4 +- client/src/hooks/Chat/useSteering.ts | 15 +- .../src/hooks/Input/useAskAnswerMode.spec.ts | 74 ++++++++ client/src/hooks/Input/useAskAnswerMode.ts | 12 +- client/src/hooks/Input/useHandleKeyUp.spec.ts | 76 +++++++- client/src/hooks/Input/useHandleKeyUp.ts | 7 +- client/src/hooks/Input/useTextarea.ts | 4 +- .../__tests__/useLatestMessage.spec.tsx | 167 +++++++++++++++++- .../__tests__/useSubmitMessage.spec.ts | 56 +++++- client/src/hooks/Messages/useLatestMessage.ts | 70 +++++++- client/src/hooks/Messages/useSubmitMessage.ts | 7 +- 13 files changed, 490 insertions(+), 34 deletions(-) create mode 100644 client/src/hooks/Input/useAskAnswerMode.spec.ts diff --git a/client/src/components/Chat/Input/AskUserQuestionPopover.tsx b/client/src/components/Chat/Input/AskUserQuestionPopover.tsx index 0e01a70ccd..7135f1c403 100644 --- a/client/src/components/Chat/Input/AskUserQuestionPopover.tsx +++ b/client/src/components/Chat/Input/AskUserQuestionPopover.tsx @@ -26,6 +26,27 @@ function AskUserQuestionPopoverContent({ }: { conversationId: string; textAreaRef?: React.RefObject; +}) { + const ask = useAskAnswerMode(conversationId); + + if (!ask.popoverVisible || !ask.liveAsk) { + return null; + } + + return ; +} + +/** + * Split from the gate above so the per-keystroke `useWatch` subscription only + * exists while the popover is actually visible — the invisible popover was + * re-rendering (to null) on every composer keystroke. + */ +function AskUserQuestionPopoverPanel({ + ask, + textAreaRef, +}: { + ask: ReturnType; + textAreaRef?: React.RefObject; }) { const localize = useLocalize(); const { control } = useChatFormContext(); @@ -47,11 +68,10 @@ function AskUserQuestionPopoverContent({ skip, dismiss, collapse, - popoverVisible, handlePopoverKeyDown, - } = useAskAnswerMode(conversationId); + } = ask; - if (!popoverVisible || !liveAsk) { + if (!liveAsk) { return null; } diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index d1dfeb162d..0806e82eab 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -3,7 +3,7 @@ import { QRCodeSVG } from 'qrcode.react'; import { Copy, CopyCheck } from 'lucide-react'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; import { OGDialogTemplate, Button, Spinner, OGDialog, Checkbox, Label } from '@librechat/client'; -import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; +import { useLatestMessageId } from '~/hooks/Messages/useLatestMessage'; import { useLocalize, useCopyToClipboard } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; import SharedLinkButton from './SharedLinkButton'; @@ -38,7 +38,7 @@ export default function ShareButton({ setAnnouncement(''); }, 1000); }; - const latestMessage = useLatestMessage(0); + const latestMessageId = useLatestMessageId(0); const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); const shareId = share?.shareId ?? ''; @@ -61,7 +61,7 @@ export default function ShareButton({ ({ useSteerMessageMutation: () => ({ mutate: mockMutate }), - useGetMessagesByConvoId: () => ({ data: mockMessages }), + useGetMessagesByConvoId: (_id: string, config?: { select?: (messages: unknown) => unknown }) => ({ + data: config?.select ? config.select(mockMessages) : mockMessages, + }), useMarkFilesUsageMutation: () => ({ mutate: mockMarkUsage }), })); diff --git a/client/src/hooks/Chat/useSteering.ts b/client/src/hooks/Chat/useSteering.ts index 5b2dca6f12..2e5467464b 100644 --- a/client/src/hooks/Chat/useSteering.ts +++ b/client/src/hooks/Chat/useSteering.ts @@ -134,13 +134,16 @@ export default function useSteering({ const duringRunActive = enabled && isSubmitting && !answerModeActive; const queueKey = hasRealConvoId ? conversationId : Constants.NEW_CONVO; - const { data: messages } = useGetMessagesByConvoId(hasRealConvoId ? conversationId : '', { - enabled: hasRealConvoId, - }); - const pausedOnApproval = useMemo( - () => (duringRunActive ? hasLiveToolApproval(messages) : false), - [duringRunActive, messages], + /** Boolean `select` so streaming deltas don't notify this subscription: + * structural sharing only re-renders the composer when the flag flips. */ + const { data: liveToolApproval } = useGetMessagesByConvoId( + hasRealConvoId ? conversationId : '', + { + enabled: hasRealConvoId, + select: hasLiveToolApproval, + }, ); + const pausedOnApproval = duringRunActive ? (liveToolApproval ?? false) : false; /** Whether a steer can reach the live run right now — independent of the * user's default action, so the per-send menu can always override to diff --git a/client/src/hooks/Input/useAskAnswerMode.spec.ts b/client/src/hooks/Input/useAskAnswerMode.spec.ts new file mode 100644 index 0000000000..00b67a0f38 --- /dev/null +++ b/client/src/hooks/Input/useAskAnswerMode.spec.ts @@ -0,0 +1,74 @@ +jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() })); +jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({ + useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }), + useResumeSubmit: () => ({ submitAskAnswer: jest.fn() }), +})); +jest.mock('~/Providers', () => ({ useOptionalChatFormContext: () => undefined })); +jest.mock('~/utils', () => ({ getAskAnswerDraftId: (id: string) => `draft-${id}` })); +jest.mock('recoil', () => ({ + atom: (cfg: unknown) => cfg, + useRecoilState: () => [[], jest.fn()], + useRecoilValue: () => false, +})); +jest.mock('~/store', () => ({ __esModule: true, default: { saveDrafts: 'saveDrafts' } })); + +import { renderHook } from '@testing-library/react'; +import { useGetMessagesByConvoId } from '~/data-provider'; +import { findLiveAskUserQuestion } from '~/utils/approval'; +import useAskAnswerMode from './useAskAnswerMode'; + +const mockUseGetMessages = useGetMessagesByConvoId as jest.Mock; + +const liveAsk = { + actionId: 'a1', + question: { question: 'Pick one', options: [], multiSelect: false }, +} as unknown as ReturnType; + +describe('useAskAnswerMode', () => { + beforeEach(() => jest.clearAllMocks()); + + it('projects the live ask via the findLiveAskUserQuestion select over the conversation cache', () => { + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + + const { result } = renderHook(() => useAskAnswerMode('conversation-1')); + + expect(mockUseGetMessages).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ enabled: true, select: findLiveAskUserQuestion }), + ); + expect(result.current.liveAsk).toBe(liveAsk); + expect(result.current.active).toBe(true); + expect(result.current.popoverVisible).toBe(true); + }); + + it('is inactive when the select finds no live ask', () => { + mockUseGetMessages.mockReturnValue({ data: undefined }); + + const { result } = renderHook(() => useAskAnswerMode('conversation-1')); + + expect(result.current.liveAsk).toBeNull(); + expect(result.current.active).toBe(false); + expect(result.current.popoverVisible).toBe(false); + }); + + it('disables the query and forces liveAsk null for a new (unsaved) conversation', () => { + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + + const { result } = renderHook(() => useAskAnswerMode('new')); + + expect(mockUseGetMessages).toHaveBeenCalledWith( + '', + expect.objectContaining({ enabled: false }), + ); + expect(result.current.liveAsk).toBeNull(); + expect(result.current.active).toBe(false); + }); + + it('forces liveAsk null when there is no conversation id', () => { + mockUseGetMessages.mockReturnValue({ data: liveAsk }); + + const { result } = renderHook(() => useAskAnswerMode(null)); + + expect(result.current.liveAsk).toBeNull(); + }); +}); diff --git a/client/src/hooks/Input/useAskAnswerMode.ts b/client/src/hooks/Input/useAskAnswerMode.ts index 681c0ad2bd..db642258f6 100644 --- a/client/src/hooks/Input/useAskAnswerMode.ts +++ b/client/src/hooks/Input/useAskAnswerMode.ts @@ -61,13 +61,15 @@ const askAnswerCheckedAtom = atom({ */ export default function useAskAnswerMode(conversationId?: string | null) { const enabled = conversationId != null && conversationId !== 'new'; - const { data: messages } = useGetMessagesByConvoId(enabled ? conversationId : '', { + /** `select` projects straight to the live pause: streaming deltas leave the + * settled ask part untouched, so structural sharing keeps this null (or the + * same ask object) and the subscription stays quiet until a pause actually + * starts or resolves. */ + const { data: liveAskData } = useGetMessagesByConvoId(enabled ? conversationId : '', { enabled, + select: findLiveAskUserQuestion, }); - const liveAsk = useMemo( - () => (enabled ? findLiveAskUserQuestion(messages) : null), - [enabled, messages], - ); + const liveAsk = enabled ? (liveAskData ?? null) : null; const [dismissedIds, setDismissedIds] = useRecoilState(dismissedAskActionsAtom); const [collapsedIds, setCollapsedIds] = useRecoilState(collapsedAskActionsAtom); const [selected, setSelected] = useRecoilState(askAnswerSelectionAtom); diff --git a/client/src/hooks/Input/useHandleKeyUp.spec.ts b/client/src/hooks/Input/useHandleKeyUp.spec.ts index 51b6cf8554..c50b63e8d5 100644 --- a/client/src/hooks/Input/useHandleKeyUp.spec.ts +++ b/client/src/hooks/Input/useHandleKeyUp.spec.ts @@ -84,13 +84,16 @@ jest.mock('~/hooks/Agents/useAgentCapabilities', () => ); jest.mock('~/hooks/Messages/useLatestMessage', () => ({ - useLatestMessage: jest.fn(() => null), + useGetLatestMessage: jest.fn(() => () => null), })); import React from 'react'; import { renderHook, act } from '@testing-library/react'; +import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage'; import useHandleKeyUp from './useHandleKeyUp'; +const mockUseGetLatestMessage = useGetLatestMessage as jest.Mock; + const makeTextAreaRef = (value = '', selectionStart?: number) => { const ref = { current: { @@ -517,4 +520,75 @@ describe('useHandleKeyUp', () => { expect(setShowSkillsPopover).toHaveBeenCalledWith(false); }); }); + + describe('ArrowUp edits the latest message (call-time tail read)', () => { + const parentMessageId = 'user-msg-1'; + let editButton: HTMLButtonElement | null = null; + + const mountEditButton = (id = `edit-${parentMessageId}`) => { + editButton = document.createElement('button'); + editButton.id = id; + document.body.appendChild(editButton); + return jest.spyOn(editButton, 'click'); + }; + + afterEach(() => { + editButton?.remove(); + editButton = null; + mockUseGetLatestMessage.mockReturnValue(() => null); + }); + + it('clicks the edit control for the latest message parent on ArrowUp in an empty composer', () => { + mockUseGetLatestMessage.mockReturnValue(() => ({ + messageId: 'assistant-1', + parentMessageId, + })); + const click = mountEditButton(); + const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('', 0)); + const event = makeKeyEvent('ArrowUp'); + + act(() => handleKeyUp(event)); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(click).toHaveBeenCalled(); + }); + + it('does nothing when there is no latest message', () => { + mockUseGetLatestMessage.mockReturnValue(() => null); + const click = mountEditButton(); + const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('', 0)); + const event = makeKeyEvent('ArrowUp'); + + act(() => handleKeyUp(event)); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(click).not.toHaveBeenCalled(); + }); + + it('does not preventDefault when the edit control is absent', () => { + mockUseGetLatestMessage.mockReturnValue(() => ({ + messageId: 'assistant-1', + parentMessageId: 'missing', + })); + const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('', 0)); + const event = makeKeyEvent('ArrowUp'); + + act(() => handleKeyUp(event)); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('ignores ArrowUp when the composer has text', () => { + const reader = jest.fn(() => ({ messageId: 'assistant-1', parentMessageId })); + mockUseGetLatestMessage.mockReturnValue(reader); + mountEditButton(); + const { handleKeyUp } = renderUseHandleKeyUp(makeTextAreaRef('draft', 5)); + const event = makeKeyEvent('ArrowUp'); + + act(() => handleKeyUp(event)); + + expect(reader).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); }); diff --git a/client/src/hooks/Input/useHandleKeyUp.ts b/client/src/hooks/Input/useHandleKeyUp.ts index fa94e68f07..29e30d0e55 100644 --- a/client/src/hooks/Input/useHandleKeyUp.ts +++ b/client/src/hooks/Input/useHandleKeyUp.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions, isAssistantsEndpoint } from 'librechat-data-provider'; +import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage'; import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities'; import useGetAgentsConfig from '~/hooks/Agents/useGetAgentsConfig'; import useHasAccess from '~/hooks/Roles/useHasAccess'; -import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import store from '~/store'; /** Event keys that shouldn't trigger a command */ @@ -70,7 +70,7 @@ const useHandleKeyUp = ({ }); const { agentsConfig } = useGetAgentsConfig(); const { skillsEnabled } = useAgentCapabilities(agentsConfig?.capabilities); - const latestMessage = useLatestMessage(index); + const getLatestMessage = useGetLatestMessage(index); const endpoint = useRecoilValue(store.effectiveEndpointByIndex(index)); const setShowMentionPopover = useSetRecoilState(store.showMentionPopoverFamily(index)); const setShowPlusPopover = useSetRecoilState(store.showPlusPopoverFamily(index)); @@ -146,6 +146,7 @@ const useHandleKeyUp = ({ const handleUpArrow = useCallback( (event: React.KeyboardEvent) => { + const latestMessage = getLatestMessage(); if (!latestMessage) { return; } @@ -157,7 +158,7 @@ const useHandleKeyUp = ({ event.preventDefault(); element.click(); }, - [latestMessage], + [getLatestMessage], ); /** diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts index 85043fa36e..3a8ad42f0b 100644 --- a/client/src/hooks/Input/useTextarea.ts +++ b/client/src/hooks/Input/useTextarea.ts @@ -19,7 +19,7 @@ import { checkIfScrollable, } from '~/utils'; import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext'; -import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; +import { useLatestMessageMeta } from '~/hooks/Messages/useLatestMessage'; import useFileUploadRouter from '~/hooks/Files/useFileUploadRouter'; import { useAgentsMapContext } from '~/Providers/AgentsMapContext'; import useGetSender from '~/hooks/Conversations/useGetSender'; @@ -81,7 +81,7 @@ export default function useTextarea({ }, [customShortcuts]); const { index, conversation, isSubmitting, filesLoading, setFilesLoading } = useChatContext(); - const latestMessage = useLatestMessage(index); + const latestMessage = useLatestMessageMeta(index); const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index)); const { endpoint = '' } = conversation || {}; diff --git a/client/src/hooks/Messages/__tests__/useLatestMessage.spec.tsx b/client/src/hooks/Messages/__tests__/useLatestMessage.spec.tsx index 391168e26c..89f769c07d 100644 --- a/client/src/hooks/Messages/__tests__/useLatestMessage.spec.tsx +++ b/client/src/hooks/Messages/__tests__/useLatestMessage.spec.tsx @@ -4,8 +4,13 @@ import { RecoilRoot, type MutableSnapshot } from 'recoil'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryKeys, type TConversation, type TMessage } from 'librechat-data-provider'; +import { + useLatestMessage, + useLatestMessageId, + useLatestMessageMeta, + useGetLatestMessage, +} from '~/hooks/Messages/useLatestMessage'; import { getBranchSiblingIndexesForTarget, getMessageBranchSiblingParentIds } from '~/utils'; -import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage'; import store from '~/store'; function createQueryClient() { @@ -238,6 +243,166 @@ describe('useLatestMessage', () => { }); }); +describe('useLatestMessageMeta', () => { + it('projects only messageId, error, and isCreatedByUser for the branch tail', () => { + const queryClient = createQueryClient(); + const erroredAssistant = { ...assistantMessage, error: true } as TMessage; + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, erroredAssistant], + ); + + const { result } = renderHook(() => useLatestMessageMeta(0), { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + }), + }); + + expect(result.current).toEqual({ + messageId: assistantMessage.messageId, + error: true, + isCreatedByUser: false, + }); + }); + + it('returns null when there is no active conversation', () => { + const { result } = renderHook(() => useLatestMessageMeta(0), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeNull(); + }); + + it('stays referentially stable and does not re-render across token-only cache writes', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, assistantMessage], + ); + let renderCount = 0; + + const { result } = renderHook( + () => { + renderCount += 1; + return useLatestMessageMeta(0); + }, + { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + }), + }, + ); + + const firstMeta = result.current; + expect(firstMeta).toEqual({ + messageId: assistantMessage.messageId, + error: undefined, + isCreatedByUser: false, + }); + + act(() => { + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, { ...assistantMessage, text: 'Hi there, still streaming' }], + ); + }); + + expect(result.current).toBe(firstMeta); + expect(renderCount).toBe(1); + }); +}); + +describe('useGetLatestMessage', () => { + it('reads the current branch tail at call time', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, assistantMessage], + ); + + const { result } = renderHook(() => useGetLatestMessage(0), { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + }), + }); + + expect(result.current()).toEqual( + expect.objectContaining({ messageId: assistantMessage.messageId }), + ); + }); + + it('keeps a stable callback and reads fresh data without re-rendering across token writes', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, assistantMessage], + ); + let renderCount = 0; + + const { result } = renderHook( + () => { + renderCount += 1; + return useGetLatestMessage(0); + }, + { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + }), + }, + ); + + const reader = result.current; + + act(() => { + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, { ...assistantMessage, text: 'Streamed tail' }], + ); + }); + + /** Call-time reader has no cache subscription: no re-render, stable identity... */ + expect(renderCount).toBe(1); + expect(result.current).toBe(reader); + /** ...yet invoking it returns the freshly-written tail. */ + expect(result.current()).toEqual( + expect.objectContaining({ messageId: assistantMessage.messageId, text: 'Streamed tail' }), + ); + }); + + it('resolves the active branch tail from the Recoil sibling snapshot', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [ + userMessage, + olderAssistantMessage, + olderFollowUpUserMessage, + olderFollowUpAssistantMessage, + assistantMessage, + ], + ); + + const { result } = renderHook(() => useGetLatestMessage(0), { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + set(store.messagesSiblingIdxFamily(userMessage.messageId), 1); + }), + }); + + expect(result.current()).toEqual( + expect.objectContaining({ messageId: olderFollowUpAssistantMessage.messageId }), + ); + }); + + it('returns null when there is no active conversation', () => { + const { result } = renderHook(() => useGetLatestMessage(0), { + wrapper: createWrapper(), + }); + + expect(result.current()).toBeNull(); + }); +}); + describe('getMessageBranchSiblingParentIds', () => { it('returns only parent keys that have branch choices', () => { expect( diff --git a/client/src/hooks/Messages/__tests__/useSubmitMessage.spec.ts b/client/src/hooks/Messages/__tests__/useSubmitMessage.spec.ts index 3e99477aeb..37015359a9 100644 --- a/client/src/hooks/Messages/__tests__/useSubmitMessage.spec.ts +++ b/client/src/hooks/Messages/__tests__/useSubmitMessage.spec.ts @@ -1,7 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers'; -import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; +import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { useAuthContext } from '~/hooks/AuthContext'; import useSubmitMessage from '../useSubmitMessage'; @@ -27,7 +27,7 @@ jest.mock('~/hooks/AuthContext', () => ({ })); jest.mock('~/hooks/Messages/useLatestMessage', () => ({ - useLatestMessage: jest.fn(), + useGetLatestMessage: jest.fn(), })); jest.mock('~/store', () => ({ @@ -44,7 +44,7 @@ const mockUseChatContext = useChatContext as jest.Mock; const mockUseChatFormContext = useChatFormContext as jest.Mock; const mockUseAddedChatContext = useAddedChatContext as jest.Mock; const mockUseAuthContext = useAuthContext as jest.Mock; -const mockUseLatestMessage = useLatestMessage as jest.Mock; +const mockUseGetLatestMessage = useGetLatestMessage as jest.Mock; describe('useSubmitMessage', () => { const ask = jest.fn(); @@ -59,7 +59,7 @@ describe('useSubmitMessage', () => { mockUseAuthContext.mockReturnValue({ user: { id: 'user-1' } }); mockUseAddedChatContext.mockReturnValue({ conversation: null }); mockUseChatFormContext.mockReturnValue({ reset, getValues: jest.fn(() => '') }); - mockUseLatestMessage.mockReturnValue({ messageId: 'assistant-message' }); + mockUseGetLatestMessage.mockReturnValue(() => ({ messageId: 'assistant-message' })); getMessages.mockReturnValue([{ messageId: 'assistant-message' }]); mockUseChatContext.mockReturnValue({ ask, @@ -82,4 +82,52 @@ describe('useSubmitMessage', () => { expect(submitted).toBe(false); expect(reset).not.toHaveBeenCalled(); }); + + it('reads the tail at call time and appends it to root when missing', () => { + const rootMessages = [{ messageId: 'root-user' }]; + const latest = { messageId: 'assistant-tail', text: 'tail' }; + const reader = jest.fn(() => latest); + mockUseGetLatestMessage.mockReturnValue(reader); + getMessages.mockReturnValue(rootMessages); + ask.mockReturnValue(true); + + const { result } = renderHook(() => useSubmitMessage()); + act(() => { + result.current.submitMessage({ text: 'hello' }); + }); + + expect(reader).toHaveBeenCalled(); + expect(setMessages).toHaveBeenCalledWith([...rootMessages, latest]); + expect(ask).toHaveBeenCalled(); + expect(reset).toHaveBeenCalled(); + }); + + it('does not append when the latest message is already in root', () => { + const latest = { messageId: 'assistant-tail' }; + mockUseGetLatestMessage.mockReturnValue(() => latest); + getMessages.mockReturnValue([latest]); + ask.mockReturnValue(true); + + const { result } = renderHook(() => useSubmitMessage()); + act(() => { + result.current.submitMessage({ text: 'hello' }); + }); + + expect(setMessages).not.toHaveBeenCalled(); + expect(ask).toHaveBeenCalled(); + }); + + it('does not append when there is no latest message', () => { + mockUseGetLatestMessage.mockReturnValue(() => null); + getMessages.mockReturnValue([{ messageId: 'root-user' }]); + ask.mockReturnValue(true); + + const { result } = renderHook(() => useSubmitMessage()); + act(() => { + result.current.submitMessage({ text: 'hello' }); + }); + + expect(setMessages).not.toHaveBeenCalled(); + expect(ask).toHaveBeenCalled(); + }); }); diff --git a/client/src/hooks/Messages/useLatestMessage.ts b/client/src/hooks/Messages/useLatestMessage.ts index ef8fce1362..7766f40e0d 100644 --- a/client/src/hooks/Messages/useLatestMessage.ts +++ b/client/src/hooks/Messages/useLatestMessage.ts @@ -1,8 +1,8 @@ import { useCallback } from 'react'; -import { selectorFamily, useRecoilValue } from 'recoil'; import { useParams } from 'react-router-dom'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Constants, QueryKeys } from 'librechat-data-provider'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { selectorFamily, useRecoilValue, useRecoilCallback } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; import { getMessageBranchSiblingParentIds, selectActiveBranchTail } from '~/utils'; import store from '~/store'; @@ -127,3 +127,69 @@ export function useLatestMessageId( return useMessagesCacheSelect(messagesQueryId, select); } + +export type TLatestMessageMeta = Pick; + +/** + * Metadata-only projection of the branch tail. Streaming deltas leave these + * fields untouched, so React Query's structural sharing keeps the selected + * object referentially stable and consumers skip the per-delta re-renders a + * full `useLatestMessage` subscription would cause. + */ +export function useLatestMessageMeta( + index: string | number, + messagesQueryIdOverride?: string | null, +): TLatestMessageMeta | null { + const conversationId = useRecoilValue(store.conversationIdByIndex(index)); + const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride); + const siblingIndexes = useLatestMessageSiblingIndexes(messagesQueryId, conversationId); + const select = useCallback( + (messages: TMessage[]): TLatestMessageMeta | null => { + const tail = selectActiveBranchTail( + messages, + conversationId, + (parentId) => siblingIndexes[getParentLookupKey(parentId)] ?? 0, + ); + if (!tail) { + return null; + } + return { + messageId: tail.messageId, + error: tail.error, + isCreatedByUser: tail.isCreatedByUser, + }; + }, + [conversationId, siblingIndexes], + ); + + return useMessagesCacheSelect(messagesQueryId, select); +} + +/** + * Call-time reader for the branch tail: no cache subscription at all, so + * callbacks that only need the latest message when invoked keep a stable + * identity across streaming deltas. + */ +export function useGetLatestMessage( + index: string | number, + messagesQueryIdOverride?: string | null, +): () => TMessage | null { + const queryClient = useQueryClient(); + const conversationId = useRecoilValue(store.conversationIdByIndex(index)); + const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride); + + return useRecoilCallback( + ({ snapshot }) => + (): TMessage | null => { + if (!messagesQueryId) { + return null; + } + const messages = + queryClient.getQueryData([QueryKeys.messages, messagesQueryId]) ?? []; + return selectActiveBranchTail(messages, conversationId, (parentId) => + snapshot.getLoadable(store.messagesSiblingIdxFamily(parentId)).getValue(), + ); + }, + [queryClient, conversationId, messagesQueryId], + ); +} diff --git a/client/src/hooks/Messages/useSubmitMessage.ts b/client/src/hooks/Messages/useSubmitMessage.ts index b0e28c5718..57d9419029 100644 --- a/client/src/hooks/Messages/useSubmitMessage.ts +++ b/client/src/hooks/Messages/useSubmitMessage.ts @@ -3,7 +3,7 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { replaceSpecialVars } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers'; -import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; +import { useGetLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { useAuthContext } from '~/hooks/AuthContext'; import { mainTextareaId } from '~/common'; import store from '~/store'; @@ -13,7 +13,7 @@ export default function useSubmitMessage() { const methods = useChatFormContext(); const { conversation: addedConvo } = useAddedChatContext(); const { ask, index, getMessages, setMessages } = useChatContext(); - const latestMessage = useLatestMessage(index); + const getLatestMessage = useGetLatestMessage(index); const autoSendPrompts = useRecoilValue(store.autoSendPrompts); const setActivePrompt = useSetRecoilState(store.activePromptByIndex(index)); @@ -28,6 +28,7 @@ export default function useSubmitMessage() { if (!data) { return console.warn('No data provided to submitMessage'); } + const latestMessage = getLatestMessage(); const rootMessages = getMessages(); const isLatestInRootMessages = rootMessages?.some( (message) => message.messageId === latestMessage?.messageId, @@ -54,7 +55,7 @@ export default function useSubmitMessage() { } methods.reset(); }, - [ask, methods, addedConvo, setMessages, getMessages, latestMessage], + [ask, methods, addedConvo, setMessages, getMessages, getLatestMessage], ); const submitPrompt = useCallback(