mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🎛️ perf: Narrow Composer Subscriptions to Streaming State (#14333)
* 🎛️ 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
This commit is contained in:
parent
9e245aced4
commit
71fa24a6ea
13 changed files with 490 additions and 34 deletions
|
|
@ -26,6 +26,27 @@ function AskUserQuestionPopoverContent({
|
|||
}: {
|
||||
conversationId: string;
|
||||
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
|
||||
}) {
|
||||
const ask = useAskAnswerMode(conversationId);
|
||||
|
||||
if (!ask.popoverVisible || !ask.liveAsk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AskUserQuestionPopoverPanel ask={ask} textAreaRef={textAreaRef} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof useAskAnswerMode>;
|
||||
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<SharedLinkButton
|
||||
share={share}
|
||||
conversationId={conversationId}
|
||||
targetMessageId={latestMessage?.messageId ?? undefined}
|
||||
targetMessageId={latestMessageId ?? undefined}
|
||||
showQR={showQR}
|
||||
setShowQR={setShowQR}
|
||||
setSharedLink={setSharedLink}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ let mockMessages: TMessage[] | undefined;
|
|||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useSteerMessageMutation: () => ({ mutate: mockMutate }),
|
||||
useGetMessagesByConvoId: () => ({ data: mockMessages }),
|
||||
useGetMessagesByConvoId: (_id: string, config?: { select?: (messages: unknown) => unknown }) => ({
|
||||
data: config?.select ? config.select(mockMessages) : mockMessages,
|
||||
}),
|
||||
useMarkFilesUsageMutation: () => ({ mutate: mockMarkUsage }),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean>(
|
||||
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
|
||||
|
|
|
|||
74
client/src/hooks/Input/useAskAnswerMode.spec.ts
Normal file
74
client/src/hooks/Input/useAskAnswerMode.spec.ts
Normal file
|
|
@ -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<typeof findLiveAskUserQuestion>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -61,13 +61,15 @@ const askAnswerCheckedAtom = atom<number[]>({
|
|||
*/
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement>) => {
|
||||
const latestMessage = getLatestMessage();
|
||||
if (!latestMessage) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -157,7 +158,7 @@ const useHandleKeyUp = ({
|
|||
event.preventDefault();
|
||||
element.click();
|
||||
},
|
||||
[latestMessage],
|
||||
[getLatestMessage],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
|
|
|||
|
|
@ -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<TMessage[]>(
|
||||
[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<TMessage[]>(
|
||||
[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<TMessage[]>(
|
||||
[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<TMessage[]>(
|
||||
[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<TMessage[]>(
|
||||
[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<TMessage[]>(
|
||||
[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<TMessage[]>(
|
||||
[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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TMessage, 'messageId' | 'error' | 'isCreatedByUser'>;
|
||||
|
||||
/**
|
||||
* 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<TMessage[]>([QueryKeys.messages, messagesQueryId]) ?? [];
|
||||
return selectActiveBranchTail(messages, conversationId, (parentId) =>
|
||||
snapshot.getLoadable(store.messagesSiblingIdxFamily(parentId)).getValue(),
|
||||
);
|
||||
},
|
||||
[queryClient, conversationId, messagesQueryId],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue