Merge branch 'main' into fix/client-image-resize-threshold

This commit is contained in:
mattdaniell 2026-03-31 14:12:54 +10:30 committed by GitHub
commit 4b14978473
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
246 changed files with 15929 additions and 2880 deletions

View file

@ -140,6 +140,55 @@ export function useMessagesOperations() {
);
}
type OptionalMessagesOps = Pick<
MessagesViewContextValue,
'ask' | 'regenerate' | 'handleContinue' | 'getMessages' | 'setMessages'
>;
const NOOP_OPS: OptionalMessagesOps = {
ask: () => {},
regenerate: () => {},
handleContinue: () => {},
getMessages: () => undefined,
setMessages: () => {},
};
/**
* Hook for components that need message operations but may render outside MessagesViewProvider
* (e.g. the /search route). Returns no-op stubs when the provider is absent UI actions will
* be silently discarded rather than crashing. Callers must use optional chaining on
* `getMessages()` results, as it returns `undefined` outside the provider.
*/
export function useOptionalMessagesOperations(): OptionalMessagesOps {
const context = useContext(MessagesViewContext);
const ask = context?.ask;
const regenerate = context?.regenerate;
const handleContinue = context?.handleContinue;
const getMessages = context?.getMessages;
const setMessages = context?.setMessages;
return useMemo(
() => ({
ask: ask ?? NOOP_OPS.ask,
regenerate: regenerate ?? NOOP_OPS.regenerate,
handleContinue: handleContinue ?? NOOP_OPS.handleContinue,
getMessages: getMessages ?? NOOP_OPS.getMessages,
setMessages: setMessages ?? NOOP_OPS.setMessages,
}),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
}
/**
* Hook for components that need conversation data but may render outside MessagesViewProvider
* (e.g. the /search route). Returns `undefined` for both fields when the provider is absent.
*/
export function useOptionalMessagesConversation() {
const context = useContext(MessagesViewContext);
const conversation = context?.conversation;
const conversationId = context?.conversationId;
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}
/** Hook for components that only need message state */
export function useMessagesState() {
const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext();

View file

@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import {
useOptionalMessagesOperations,
useOptionalMessagesConversation,
} from '../MessagesViewContext';
describe('useOptionalMessagesOperations', () => {
it('returns noop stubs when rendered outside MessagesViewProvider', () => {
const { result } = renderHook(() => useOptionalMessagesOperations());
expect(result.current.ask).toBeInstanceOf(Function);
expect(result.current.regenerate).toBeInstanceOf(Function);
expect(result.current.handleContinue).toBeInstanceOf(Function);
expect(result.current.getMessages).toBeInstanceOf(Function);
expect(result.current.setMessages).toBeInstanceOf(Function);
});
it('noop stubs do not throw when called', () => {
const { result } = renderHook(() => useOptionalMessagesOperations());
expect(() => result.current.ask({} as never)).not.toThrow();
expect(() => result.current.regenerate({} as never)).not.toThrow();
expect(() => result.current.handleContinue({} as never)).not.toThrow();
expect(() => result.current.setMessages([])).not.toThrow();
});
it('getMessages returns undefined outside the provider', () => {
const { result } = renderHook(() => useOptionalMessagesOperations());
expect(result.current.getMessages()).toBeUndefined();
});
it('returns stable references across re-renders', () => {
const { result, rerender } = renderHook(() => useOptionalMessagesOperations());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});
describe('useOptionalMessagesConversation', () => {
it('returns undefined fields when rendered outside MessagesViewProvider', () => {
const { result } = renderHook(() => useOptionalMessagesConversation());
expect(result.current.conversation).toBeUndefined();
expect(result.current.conversationId).toBeUndefined();
});
it('returns stable references across re-renders', () => {
const { result, rerender } = renderHook(() => useOptionalMessagesConversation());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});

View file

@ -355,6 +355,28 @@ export type TOptions = {
export type TAskFunction = (props: TAskProps, options?: TOptions) => void;
/**
* Stable context object passed from non-memo'd wrapper components (Message, MessageContent)
* to memo'd inner components (MessageRender, ContentRender) via props.
*
* This avoids subscribing to ChatContext inside memo'd components, which would bypass React.memo
* and cause unnecessary re-renders when `isSubmitting` changes during streaming.
*
* The `isSubmitting` property should use a getter backed by a ref so it returns the current
* value at call-time (for callback guards) without being a reactive dependency.
*/
export type TMessageChatContext = {
ask: (...args: Parameters<TAskFunction>) => void;
index: number;
regenerate: (message: t.TMessage, options?: { addedConvo?: t.TConversation | null }) => void;
conversation: t.TConversation | null;
latestMessageId: string | undefined;
latestMessageDepth: number | undefined;
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
/** Should be a getter backed by a ref — reads current value without triggering re-renders */
readonly isSubmitting: boolean;
};
export type TMessageProps = {
conversation?: t.TConversation | null;
messageId?: string | null;

View file

@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { memo, useCallback, useRef } from 'react';
import { MicOff } from 'lucide-react';
import { useToastContext, TooltipAnchor, ListeningIcon, Spinner } from '@librechat/client';
import { useLocalize, useSpeechToText, useGetAudioSettings } from '~/hooks';
@ -7,7 +7,7 @@ import { globalAudioId } from '~/common';
import { cn } from '~/utils';
const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external';
export default function AudioRecorder({
export default memo(function AudioRecorder({
disabled,
ask,
methods,
@ -26,10 +26,12 @@ export default function AudioRecorder({
const { speechToTextEndpoint } = useGetAudioSettings();
const existingTextRef = useRef<string>('');
const isSubmittingRef = useRef(isSubmitting);
isSubmittingRef.current = isSubmitting;
const onTranscriptionComplete = useCallback(
(text: string) => {
if (isSubmitting) {
if (isSubmittingRef.current) {
showToast({
message: localize('com_ui_speech_while_submitting'),
status: 'error',
@ -52,7 +54,7 @@ export default function AudioRecorder({
existingTextRef.current = '';
}
},
[ask, reset, showToast, localize, isSubmitting, speechToTextEndpoint],
[ask, reset, showToast, localize, speechToTextEndpoint],
);
const setText = useCallback(
@ -125,4 +127,4 @@ export default function AudioRecorder({
}
/>
);
}
});

View file

@ -3,6 +3,8 @@ import { useWatch } from 'react-hook-form';
import { TextareaAutosize } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common';
import {
useChatContext,
useChatFormContext,
@ -35,7 +37,30 @@ import BadgeRow from './BadgeRow';
import Mention from './Mention';
import store from '~/store';
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
interface ChatFormProps {
index: number;
/** From ChatContext — individual values so memo can compare them */
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
conversation: TConversation | null;
isSubmitting: boolean;
filesLoading: boolean;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
newConversation: ConvoGenerator;
handleStopGenerating: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
const ChatForm = memo(function ChatForm({
index,
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
setFilesLoading,
newConversation,
handleStopGenerating,
}: ChatFormProps) {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useFocusChatEffect(textAreaRef);
@ -65,15 +90,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const { requiresKey } = useRequiresKey();
const methods = useChatFormContext();
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const {
generateConversation,
conversation: addedConvo,
@ -120,6 +136,15 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
}
}, [isCollapsed]);
const handleTextareaFocus = useCallback(() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}, [handleFocusOrClick]);
const handleTextareaBlur = useCallback(() => {
setIsTextAreaFocused(false);
}, []);
useAutoSave({
files,
setFiles,
@ -253,7 +278,12 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
handleSaveBadges={handleSaveBadges}
setBadges={setBadges}
/>
<FileFormChat conversation={conversation} />
<FileFormChat
conversation={conversation}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
/>
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<div
@ -284,11 +314,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
onFocus={handleTextareaFocus}
onBlur={handleTextareaBlur}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
@ -315,7 +342,13 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)}
>
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat conversation={conversation} disableInputs={disableInputs} />
<AttachFileChat
conversation={conversation}
disableInputs={disableInputs}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
/>
</div>
<BadgeRow
showEphemeralBadges={
@ -360,5 +393,77 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
</form>
);
});
ChatForm.displayName = 'ChatForm';
export default ChatForm;
/**
* Wrapper that subscribes to ChatContext and passes stable individual values
* to the memo'd ChatForm. This prevents ChatForm from re-rendering on every
* streaming chunk it only re-renders when the specific values it uses change.
*/
function ChatFormWrapper({ index = 0 }: { index?: number }) {
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
setFilesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
/**
* Stabilize conversation reference: only update when rendering-relevant fields change,
* not on every metadata update (e.g., title generation during streaming).
*/
const hasMessages = (conversation?.messages?.length ?? 0) > 0;
const stableConversation = useMemo(
() => conversation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[
conversation?.conversationId,
conversation?.endpoint,
conversation?.endpointType,
conversation?.agent_id,
conversation?.assistant_id,
conversation?.spec,
conversation?.useResponsesApi,
conversation?.model,
hasMessages,
],
);
/** Stabilize function refs so they never trigger ChatForm re-renders */
const handleStopRef = useRef(handleStopGenerating);
handleStopRef.current = handleStopGenerating;
const stableHandleStop = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => handleStopRef.current(e),
[],
);
const newConvoRef = useRef(newConversation);
newConvoRef.current = newConversation;
const stableNewConversation: ConvoGenerator = useCallback(
(...args: Parameters<ConvoGenerator>): ReturnType<ConvoGenerator> =>
newConvoRef.current(...args),
[],
);
return (
<ChatForm
index={index}
files={files}
setFiles={setFiles}
conversation={stableConversation}
isSubmitting={isSubmitting}
filesLoading={filesLoading}
setFilesLoading={setFilesLoading}
newConversation={stableNewConversation}
handleStopGenerating={stableHandleStop}
/>
);
}
ChatFormWrapper.displayName = 'ChatFormWrapper';
export default ChatFormWrapper;

View file

@ -52,4 +52,4 @@ const CollapseChat = ({
);
};
export default CollapseChat;
export default React.memo(CollapseChat);

View file

@ -1,14 +1,33 @@
import React, { useRef } from 'react';
import { FileUpload, TooltipAnchor, AttachmentIcon } from '@librechat/client';
import { useLocalize, useFileHandling } from '~/hooks';
import type { TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
import { useFileHandlingNoChatContext, useLocalize } from '~/hooks';
import { cn } from '~/utils';
const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
const AttachFile = ({
disabled,
files,
setFiles,
setFilesLoading,
conversation,
}: {
disabled?: boolean | null;
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
conversation: TConversation | null;
}) => {
const localize = useLocalize();
const inputRef = useRef<HTMLInputElement>(null);
const isUploadDisabled = disabled ?? false;
const { handleFileChange } = useFileHandling();
const { handleFileChange } = useFileHandlingNoChatContext(undefined, {
files,
setFiles,
setFilesLoading,
conversation,
});
return (
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>

View file

@ -9,6 +9,7 @@ import {
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
import { useGetFileConfig, useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers';
import AttachFileMenu from './AttachFileMenu';
@ -17,9 +18,15 @@ import AttachFile from './AttachFile';
function AttachFileChat({
disableInputs,
conversation,
files,
setFiles,
setFilesLoading,
}: {
disableInputs: boolean;
conversation: TConversation | null;
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
const { endpoint } = conversation ?? { endpoint: null };
@ -90,7 +97,15 @@ function AttachFileChat({
);
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;
return (
<AttachFile
disabled={disableInputs}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
conversation={conversation}
/>
);
} else if ((isAgents || endpointSupportsFiles) && !isUploadDisabled) {
return (
<AttachFileMenu
@ -101,6 +116,10 @@ function AttachFileChat({
agentId={conversation?.agent_id}
endpointFileConfig={endpointFileConfig}
useResponsesApi={useResponsesApi}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
conversation={conversation}
/>
);
}

View file

@ -23,15 +23,16 @@ import {
bedrockDocumentExtensions,
isDocumentSupportedProvider,
} from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import type { EndpointFileConfig, TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
import {
useAgentToolPermissions,
useAgentCapabilities,
useGetAgentsConfig,
useFileHandling,
useFileHandlingNoChatContext,
useLocalize,
} from '~/hooks';
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
import { SharePointPickerDialog } from '~/components/SharePoint';
import { useGetStartupConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
@ -53,6 +54,10 @@ interface AttachFileMenuProps {
endpointType?: EModelEndpoint | string;
endpointFileConfig?: EndpointFileConfig;
useResponsesApi?: boolean;
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
conversation: TConversation | null;
}
const AttachFileMenu = ({
@ -63,6 +68,10 @@ const AttachFileMenu = ({
conversationId,
endpointFileConfig,
useResponsesApi,
files,
setFiles,
setFilesLoading,
conversation,
}: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
@ -72,10 +81,17 @@ const AttachFileMenu = ({
ephemeralAgentByConvoId(conversationId),
);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { handleFileChange } = useFileHandling();
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
toolResource,
const { handleFileChange } = useFileHandlingNoChatContext(undefined, {
files,
setFiles,
setFilesLoading,
conversation,
});
const { handleSharePointFiles, isProcessing, downloadProgress } =
useSharePointFileHandlingNoChatContext(
{ toolResource },
{ files, setFiles, setFilesLoading, conversation },
);
const { agentsConfig } = useGetAgentsConfig();
const { data: startupConfig } = useGetStartupConfig();

View file

@ -1,16 +1,30 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TConversation } from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { useFileHandling } from '~/hooks';
import type { ExtendedFile, FileSetter } from '~/common';
import { useFileHandlingNoChatContext } from '~/hooks';
import FileRow from './FileRow';
import store from '~/store';
function FileFormChat({ conversation }: { conversation: TConversation | null }) {
const { files, setFiles, setFilesLoading } = useChatContext();
function FileFormChat({
conversation,
files,
setFiles,
setFilesLoading,
}: {
conversation: TConversation | null;
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
const { abortUpload } = useFileHandling();
const { abortUpload } = useFileHandlingNoChatContext(undefined, {
files,
setFiles,
setFilesLoading,
conversation,
});
const isRTL = chatDirection === 'rtl';

View file

@ -59,7 +59,13 @@ function renderComponent(conversation: Record<string, unknown> | null, disableIn
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileChat conversation={conversation as never} disableInputs={disableInputs} />
<AttachFileChat
conversation={conversation as never}
disableInputs={disableInputs}
files={new Map()}
setFiles={() => {}}
setFilesLoading={() => {}}
/>
</RecoilRoot>
</QueryClientProvider>,
);

View file

@ -9,13 +9,14 @@ jest.mock('~/hooks', () => ({
useAgentToolPermissions: jest.fn(),
useAgentCapabilities: jest.fn(),
useGetAgentsConfig: jest.fn(),
useFileHandling: jest.fn(),
useFileHandlingNoChatContext: jest.fn(),
useLocalize: jest.fn(),
}));
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
__esModule: true,
default: jest.fn(),
useSharePointFileHandlingNoChatContext: jest.fn(),
}));
jest.mock('~/data-provider', () => ({
@ -52,6 +53,7 @@ jest.mock('@librechat/client', () => {
),
AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }),
SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }),
useToastContext: () => ({ showToast: jest.fn() }),
};
});
@ -66,11 +68,14 @@ jest.mock('@ariakit/react', () => {
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig;
const mockUseFileHandling = jest.requireMock('~/hooks').useFileHandling;
const mockUseFileHandlingNoChatContext = jest.requireMock('~/hooks').useFileHandlingNoChatContext;
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
const mockUseSharePointFileHandling = jest.requireMock(
'~/hooks/Files/useSharePointFileHandling',
).default;
const mockUseSharePointFileHandlingNoChatContext = jest.requireMock(
'~/hooks/Files/useSharePointFileHandling',
).useSharePointFileHandlingNoChatContext;
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@ -92,12 +97,15 @@ function setupMocks(overrides: { provider?: string } = {}) {
codeEnabled: false,
});
mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} });
mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() });
mockUseSharePointFileHandling.mockReturnValue({
mockUseFileHandlingNoChatContext.mockReturnValue({ handleFileChange: jest.fn() });
const sharePointReturnValue = {
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
});
error: null,
};
mockUseSharePointFileHandling.mockReturnValue(sharePointReturnValue);
mockUseSharePointFileHandlingNoChatContext.mockReturnValue(sharePointReturnValue);
mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } });
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
@ -110,7 +118,14 @@ function renderMenu(props: Record<string, unknown> = {}) {
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileMenu conversationId="test-convo" {...props} />
<AttachFileMenu
conversationId="test-convo"
files={new Map()}
setFiles={() => {}}
setFilesLoading={() => {}}
conversation={null}
{...props}
/>
</RecoilRoot>
</QueryClientProvider>,
);

View file

@ -1,8 +1,15 @@
import { memo } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function StopButton({ stop, setShowStopButton }) {
export default memo(function StopButton({
stop,
setShowStopButton,
}: {
stop: (e: React.MouseEvent<HTMLButtonElement>) => void;
setShowStopButton: (value: boolean) => void;
}) {
const localize = useLocalize();
return (
@ -34,4 +41,4 @@ export default function StopButton({ stop, setShowStopButton }) {
}
></TooltipAnchor>
);
}
});

View file

@ -1,8 +1,9 @@
import { memo } from 'react';
import AddedConvo from './AddedConvo';
import type { TConversation } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
export default function TextareaHeader({
export default memo(function TextareaHeader({
addedConvo,
setAddedConvo,
}: {
@ -17,4 +18,4 @@ export default function TextareaHeader({
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
</div>
);
}
});

View file

@ -49,19 +49,47 @@ export default function ToolCall({
}
}, [autoExpand, hasOutput]);
const parsedAuthUrl = useMemo(() => {
if (!auth) {
return null;
}
try {
return new URL(auth);
} catch {
return null;
}
}, [auth]);
const { function_name, domain, isMCPToolCall, mcpServerName } = useMemo(() => {
if (typeof name !== 'string') {
return { function_name: '', domain: null, isMCPToolCall: false, mcpServerName: '' };
}
if (name.includes(Constants.mcp_delimiter)) {
const [func, server] = name.split(Constants.mcp_delimiter);
const parts = name.split(Constants.mcp_delimiter);
const func = parts[0];
const server = parts.slice(1).join(Constants.mcp_delimiter);
const displayName = func === 'oauth' ? server : func;
return {
function_name: func || '',
function_name: displayName || '',
domain: server && (server.replaceAll(actionDomainSeparator, '.') || null),
isMCPToolCall: true,
mcpServerName: server || '',
};
}
if (parsedAuthUrl) {
const redirectUri = parsedAuthUrl.searchParams.get('redirect_uri') || '';
const mcpMatch = redirectUri.match(/\/api\/mcp\/([^/]+)\/oauth\/callback/);
if (mcpMatch?.[1]) {
return {
function_name: mcpMatch[1],
domain: null,
isMCPToolCall: true,
mcpServerName: mcpMatch[1],
};
}
}
const [func, _domain] = name.includes(actionDelimiter)
? name.split(actionDelimiter)
: [name, ''];
@ -71,25 +99,20 @@ export default function ToolCall({
isMCPToolCall: false,
mcpServerName: '',
};
}, [name]);
}, [name, parsedAuthUrl]);
const toolIconType = useMemo(() => getToolIconType(name), [name]);
const mcpIconMap = useMCPIconMap();
const mcpIconUrl = isMCPToolCall ? mcpIconMap.get(mcpServerName) : undefined;
const actionId = useMemo(() => {
if (isMCPToolCall || !auth) {
if (isMCPToolCall || !parsedAuthUrl) {
return '';
}
try {
const url = new URL(auth);
const redirectUri = url.searchParams.get('redirect_uri') || '';
const match = redirectUri.match(/\/api\/actions\/([^/]+)\/oauth\/callback/);
return match?.[1] || '';
} catch {
return '';
}
}, [auth, isMCPToolCall]);
const redirectUri = parsedAuthUrl.searchParams.get('redirect_uri') || '';
const match = redirectUri.match(/\/api\/actions\/([^/]+)\/oauth\/callback/);
return match?.[1] || '';
}, [parsedAuthUrl, isMCPToolCall]);
const handleOAuthClick = useCallback(async () => {
if (!auth) {
@ -132,21 +155,8 @@ export default function ToolCall({
);
const authDomain = useMemo(() => {
const authURL = auth ?? '';
if (!authURL) {
return '';
}
try {
const url = new URL(authURL);
return url.hostname;
} catch (e) {
logger.error(
'client/src/components/Chat/Messages/Content/ToolCall.tsx - Failed to parse auth URL',
e,
);
return '';
}
}, [auth]);
return parsedAuthUrl?.hostname ?? '';
}, [parsedAuthUrl]);
const progress = useProgress(initialProgress);
const showCancelled = cancelled || (errorState && !output);

View file

@ -3,11 +3,11 @@ import { ChevronDown } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import { useOptionalMessagesOperations } from '~/Providers';
import { useLocalize, useExpandCollapse } from '~/hooks';
import UIResourceCarousel from './UIResourceCarousel';
import { useMessagesOperations } from '~/Providers';
import { OutputRenderer } from './ToolOutput';
import { handleUIAction, cn } from '~/utils';
import { OutputRenderer } from './ToolOutput';
function isSimpleObject(obj: unknown): obj is Record<string, string | number | boolean | null> {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
@ -102,7 +102,7 @@ export default function ToolCallInfo({
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const { ask } = useMessagesOperations();
const { ask } = useOptionalMessagesOperations();
const [showParams, setShowParams] = useState(false);
const { style: paramsExpandStyle, ref: paramsExpandRef } = useExpandCollapse(showParams);

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import { useMessagesOperations } from '~/Providers';
import { useOptionalMessagesOperations } from '~/Providers';
import { handleUIAction } from '~/utils';
interface UIResourceCarouselProps {
@ -13,7 +13,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
const [showRightArrow, setShowRightArrow] = useState(true);
const [isContainerHovered, setIsContainerHovered] = useState(false);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const { ask } = useMessagesOperations();
const { ask } = useOptionalMessagesOperations();
const handleScroll = React.useCallback(() => {
if (!scrollContainerRef.current) return;

View file

@ -3,7 +3,11 @@ import { render, screen } from '@testing-library/react';
import Markdown from '../Markdown';
import { RecoilRoot } from 'recoil';
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useLocalize } from '~/hooks';
@ -12,8 +16,8 @@ import { useLocalize } from '~/hooks';
jest.mock('~/Providers', () => ({
...jest.requireActual('~/Providers'),
useMessageContext: jest.fn(),
useMessagesConversation: jest.fn(),
useMessagesOperations: jest.fn(),
useOptionalMessagesConversation: jest.fn(),
useOptionalMessagesOperations: jest.fn(),
}));
jest.mock('~/data-provider');
jest.mock('~/hooks');
@ -26,11 +30,11 @@ jest.mock('@mcp-ui/client', () => ({
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
typeof useOptionalMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
typeof useOptionalMessagesOperations
>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId

View file

@ -1,6 +1,6 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { Tools } from 'librechat-data-provider';
import { Tools, Constants } from 'librechat-data-provider';
import { render, screen, fireEvent } from '@testing-library/react';
import ToolCall from '../ToolCall';
@ -53,9 +53,20 @@ jest.mock('../ToolCallInfo', () => ({
jest.mock('../ProgressText', () => ({
__esModule: true,
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
default: ({
onClick,
inProgressText,
finishedText,
subtitle,
}: {
onClick?: () => void;
inProgressText?: string;
finishedText?: string;
subtitle?: string;
}) => (
<div data-testid="progress-text" onClick={onClick}>
{finishedText || inProgressText}
{subtitle && <span data-testid="subtitle">{subtitle}</span>}
</div>
),
}));
@ -346,6 +357,141 @@ describe('ToolCall', () => {
});
});
describe('MCP OAuth detection', () => {
const d = Constants.mcp_delimiter;
it('should detect MCP OAuth from delimiter in tool-call name', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
name={`oauth${d}my-server`}
initialProgress={0.5}
isSubmitting={true}
auth="https://auth.example.com"
/>,
);
const subtitle = screen.getByTestId('subtitle');
expect(subtitle.textContent).toBe('via my-server');
});
it('should preserve full server name when it contains the delimiter substring', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
name={`oauth${d}foo${d}bar`}
initialProgress={0.5}
isSubmitting={true}
auth="https://auth.example.com"
/>,
);
const subtitle = screen.getByTestId('subtitle');
expect(subtitle.textContent).toBe(`via foo${d}bar`);
});
it('should display server name (not "oauth") as function_name for OAuth tool calls', () => {
renderWithRecoil(
<ToolCall
{...mockProps}
name={`oauth${d}my-server`}
initialProgress={1}
isSubmitting={false}
output="done"
auth="https://auth.example.com"
/>,
);
const progressText = screen.getByTestId('progress-text');
expect(progressText.textContent).toContain('Completed my-server');
expect(progressText.textContent).not.toContain('Completed oauth');
});
it('should display server name even when auth is cleared (post-completion)', () => {
// After OAuth completes, createOAuthEnd re-emits the toolCall without auth.
// The display should still show the server name, not literal "oauth".
renderWithRecoil(
<ToolCall
{...mockProps}
name={`oauth${d}my-server`}
initialProgress={1}
isSubmitting={false}
output="done"
/>,
);
const progressText = screen.getByTestId('progress-text');
expect(progressText.textContent).toContain('Completed my-server');
expect(progressText.textContent).not.toContain('Completed oauth');
});
it('should fallback to auth URL redirect_uri when name lacks delimiter', () => {
const authUrl =
'https://oauth.example.com/authorize?redirect_uri=' +
encodeURIComponent('https://app.example.com/api/mcp/my-server/oauth/callback');
renderWithRecoil(
<ToolCall
{...mockProps}
name="bare_name"
initialProgress={0.5}
isSubmitting={true}
auth={authUrl}
/>,
);
const subtitle = screen.getByTestId('subtitle');
expect(subtitle.textContent).toBe('via my-server');
});
it('should display server name (not raw tool-call ID) in fallback path finished text', () => {
const authUrl =
'https://oauth.example.com/authorize?redirect_uri=' +
encodeURIComponent('https://app.example.com/api/mcp/my-server/oauth/callback');
renderWithRecoil(
<ToolCall
{...mockProps}
name="bare_name"
initialProgress={1}
isSubmitting={false}
output="done"
auth={authUrl}
/>,
);
const progressText = screen.getByTestId('progress-text');
expect(progressText.textContent).toContain('Completed my-server');
expect(progressText.textContent).not.toContain('bare_name');
});
it('should show normalized server name when it contains _mcp_ after prefixing', () => {
// Server named oauth@mcp@server normalizes to oauth_mcp_server,
// gets prefixed to oauth_mcp_oauth_mcp_server. Client parses:
// func="oauth", server="oauth_mcp_server". Visually awkward but
// semantically correct — the normalized name IS oauth_mcp_server.
renderWithRecoil(
<ToolCall
{...mockProps}
name={`oauth${d}oauth${d}server`}
initialProgress={0.5}
isSubmitting={true}
auth="https://auth.example.com"
/>,
);
const subtitle = screen.getByTestId('subtitle');
expect(subtitle.textContent).toBe(`via oauth${d}server`);
});
it('should not misidentify non-MCP action auth as MCP via fallback', () => {
const authUrl =
'https://oauth.example.com/authorize?redirect_uri=' +
encodeURIComponent('https://app.example.com/api/actions/xyz/oauth/callback');
renderWithRecoil(
<ToolCall
{...mockProps}
name="action_name"
initialProgress={0.5}
isSubmitting={true}
auth={authUrl}
/>,
);
expect(screen.queryByTestId('subtitle')).not.toBeInTheDocument();
});
});
describe('A11Y-04: screen reader status announcements', () => {
it('includes sr-only aria-live region for status announcements', () => {
renderWithRecoil(

View file

@ -25,7 +25,7 @@ jest.mock('~/hooks', () => ({
}));
jest.mock('~/Providers', () => ({
useMessagesOperations: () => ({
useOptionalMessagesOperations: () => ({
ask: jest.fn(),
}),
}));

View file

@ -13,10 +13,10 @@ jest.mock('@mcp-ui/client', () => ({
),
}));
// Mock useMessagesOperations hook
// Mock useOptionalMessagesOperations hook
const mockAsk = jest.fn();
jest.mock('~/Providers', () => ({
useMessagesOperations: () => ({
useOptionalMessagesOperations: () => ({
ask: mockAsk,
}),
}));

View file

@ -1,5 +1,5 @@
import React from 'react';
import { useMessageProcess } from '~/hooks';
import { useMessageProcess, useMemoizedChatContext } from '~/hooks';
import type { TMessageProps } from '~/common';
import MessageRender from './ui/MessageRender';
import MultiMessage from './MultiMessage';
@ -23,10 +23,11 @@ const MessageContainer = React.memo(function MessageContainer({
});
export default function Message(props: TMessageProps) {
const { conversation, handleScroll } = useMessageProcess({
const { conversation, handleScroll, isSubmitting } = useMessageProcess({
message: props.message,
});
const { message, currentEditId, setCurrentEditId } = props;
const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting);
if (!message || typeof message !== 'object') {
return null;
@ -38,7 +39,11 @@ export default function Message(props: TMessageProps) {
<>
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender {...props} />
<MessageRender
{...props}
isSubmitting={effectiveIsSubmitting}
chatContext={chatContext}
/>
</div>
</MessageContainer>
<MultiMessage

View file

@ -2,7 +2,7 @@ import React, { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common';
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
@ -17,12 +17,73 @@ import store from '~/store';
type MessageRenderProps = {
message?: TMessage;
/**
* Effective isSubmitting: false for non-latest messages, real value for latest.
* Computed by the wrapper (Message.tsx) so this memo'd component only re-renders
* when the value actually matters.
*/
isSubmitting?: boolean;
/** Stable context object from wrapper — avoids ChatContext subscription inside memo */
chatContext: TMessageChatContext;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>;
/**
* Custom comparator for React.memo: compares `message` by key fields instead of reference
* because `buildTree` creates new message objects on every streaming update for ALL messages,
* even when only the latest message's text changed.
*/
function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRenderProps): boolean {
if (prev.isSubmitting !== next.isSubmitting) {
return false;
}
if (prev.chatContext !== next.chatContext) {
return false;
}
if (prev.siblingIdx !== next.siblingIdx) {
return false;
}
if (prev.siblingCount !== next.siblingCount) {
return false;
}
if (prev.currentEditId !== next.currentEditId) {
return false;
}
if (prev.setSiblingIdx !== next.setSiblingIdx) {
return false;
}
if (prev.setCurrentEditId !== next.setCurrentEditId) {
return false;
}
const prevMsg = prev.message;
const nextMsg = next.message;
if (prevMsg === nextMsg) {
return true;
}
if (!prevMsg || !nextMsg) {
return prevMsg === nextMsg;
}
return (
prevMsg.messageId === nextMsg.messageId &&
prevMsg.text === nextMsg.text &&
prevMsg.error === nextMsg.error &&
prevMsg.unfinished === nextMsg.unfinished &&
prevMsg.depth === nextMsg.depth &&
prevMsg.isCreatedByUser === nextMsg.isCreatedByUser &&
(prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) &&
prevMsg.content === nextMsg.content &&
prevMsg.model === nextMsg.model &&
prevMsg.endpoint === nextMsg.endpoint &&
prevMsg.iconURL === nextMsg.iconURL &&
prevMsg.feedback?.rating === nextMsg.feedback?.rating &&
(prevMsg.files?.length ?? 0) === (nextMsg.files?.length ?? 0)
);
}
const MessageRender = memo(function MessageRender({
message: msg,
siblingIdx,
@ -31,6 +92,7 @@ const MessageRender = memo(function MessageRender({
currentEditId,
setCurrentEditId,
isSubmitting = false,
chatContext,
}: MessageRenderProps) {
const localize = useLocalize();
const {
@ -52,6 +114,7 @@ const MessageRender = memo(function MessageRender({
message: msg,
currentEditId,
setCurrentEditId,
chatContext,
});
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
@ -63,8 +126,6 @@ const MessageRender = memo(function MessageRender({
[hasNoChildren, msg?.depth, latestMessageDepth],
);
const isLatestMessage = msg?.messageId === latestMessageId;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const iconData: TMessageIcon = useMemo(
() => ({
@ -92,10 +153,10 @@ const MessageRender = memo(function MessageRender({
messageId,
isLatestMessage,
isExpanded: false as const,
isSubmitting: effectiveIsSubmitting,
isSubmitting,
conversationId: conversation?.conversationId,
}),
[messageId, conversation?.conversationId, effectiveIsSubmitting, isLatestMessage],
[messageId, conversation?.conversationId, isSubmitting, isLatestMessage],
);
if (!msg) {
@ -165,7 +226,7 @@ const MessageRender = memo(function MessageRender({
message={msg}
enterEdit={enterEdit}
error={!!(msg.error ?? false)}
isSubmitting={effectiveIsSubmitting}
isSubmitting={isSubmitting}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
@ -173,7 +234,7 @@ const MessageRender = memo(function MessageRender({
/>
</MessageContext.Provider>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
{hasNoChildren && isSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
@ -187,7 +248,7 @@ const MessageRender = memo(function MessageRender({
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
isSubmitting={chatContext.isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
@ -202,7 +263,7 @@ const MessageRender = memo(function MessageRender({
</div>
</div>
);
});
}, areMessageRenderPropsEqual);
MessageRender.displayName = 'MessageRender';
export default MessageRender;

View file

@ -1,8 +1,8 @@
import React from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import { handleUIAction } from '~/utils';
import { useOptionalMessagesConversation, useOptionalMessagesOperations } from '~/Providers';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessagesConversation, useMessagesOperations } from '~/Providers';
import { handleUIAction } from '~/utils';
import { useLocalize } from '~/hooks';
interface MCPUIResourceProps {
@ -13,19 +13,14 @@ interface MCPUIResourceProps {
};
}
/**
* Component that renders an MCP UI resource based on its resource ID.
* Works in both main app and share view.
*/
/** Renders an MCP UI resource based on its resource ID. Works in chat, share, and search views. */
export function MCPUIResource(props: MCPUIResourceProps) {
const { resourceId } = props.node.properties;
const localize = useLocalize();
const { ask } = useMessagesOperations();
const { conversation } = useMessagesConversation();
const { ask } = useOptionalMessagesOperations();
const { conversationId } = useOptionalMessagesConversation();
const conversationResourceMap = useConversationUIResources(
conversation?.conversationId ?? undefined,
);
const conversationResourceMap = useConversationUIResources(conversationId ?? undefined);
const uiResource = conversationResourceMap.get(resourceId ?? '');

View file

@ -1,8 +1,8 @@
import React, { useMemo } from 'react';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessagesConversation } from '~/Providers';
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
import type { UIResource } from 'librechat-data-provider';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
import { useOptionalMessagesConversation } from '~/Providers';
interface MCPUIResourceCarouselProps {
node: {
@ -12,16 +12,11 @@ interface MCPUIResourceCarouselProps {
};
}
/**
* Component that renders multiple MCP UI resources in a carousel.
* Works in both main app and share view.
*/
/** Renders multiple MCP UI resources in a carousel. Works in chat, share, and search views. */
export function MCPUIResourceCarousel(props: MCPUIResourceCarouselProps) {
const { conversation } = useMessagesConversation();
const { conversationId } = useOptionalMessagesConversation();
const conversationResourceMap = useConversationUIResources(
conversation?.conversationId ?? undefined,
);
const conversationResourceMap = useConversationUIResources(conversationId ?? undefined);
const uiResources = useMemo(() => {
const { resourceIds = [] } = props.node.properties;

View file

@ -2,7 +2,11 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResource } from '../MCPUIResource';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
import { useLocalize } from '~/hooks';
import { handleUIAction } from '~/utils';
@ -22,11 +26,11 @@ jest.mock('@mcp-ui/client', () => ({
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
typeof useOptionalMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
typeof useOptionalMessagesOperations
>;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;

View file

@ -2,7 +2,11 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
// Mock dependencies
jest.mock('~/Providers');
@ -19,11 +23,11 @@ jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
typeof useOptionalMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
typeof useOptionalMessagesOperations
>;
describe('MCPUIResourceCarousel', () => {

View file

@ -2,7 +2,7 @@ import { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common';
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
@ -16,12 +16,72 @@ import store from '~/store';
type ContentRenderProps = {
message?: TMessage;
/**
* Effective isSubmitting: false for non-latest messages, real value for latest.
* Computed by the wrapper (MessageContent.tsx) so this memo'd component only re-renders
* when the value actually matters.
*/
isSubmitting?: boolean;
/** Stable context object from wrapper — avoids ChatContext subscription inside memo */
chatContext: TMessageChatContext;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>;
/**
* Custom comparator for React.memo: compares `message` by key fields instead of reference
* because `buildTree` creates new message objects on every streaming update for ALL messages.
*/
function areContentRenderPropsEqual(prev: ContentRenderProps, next: ContentRenderProps): boolean {
if (prev.isSubmitting !== next.isSubmitting) {
return false;
}
if (prev.chatContext !== next.chatContext) {
return false;
}
if (prev.siblingIdx !== next.siblingIdx) {
return false;
}
if (prev.siblingCount !== next.siblingCount) {
return false;
}
if (prev.currentEditId !== next.currentEditId) {
return false;
}
if (prev.setSiblingIdx !== next.setSiblingIdx) {
return false;
}
if (prev.setCurrentEditId !== next.setCurrentEditId) {
return false;
}
const prevMsg = prev.message;
const nextMsg = next.message;
if (prevMsg === nextMsg) {
return true;
}
if (!prevMsg || !nextMsg) {
return prevMsg === nextMsg;
}
return (
prevMsg.messageId === nextMsg.messageId &&
prevMsg.text === nextMsg.text &&
prevMsg.error === nextMsg.error &&
prevMsg.unfinished === nextMsg.unfinished &&
prevMsg.depth === nextMsg.depth &&
prevMsg.isCreatedByUser === nextMsg.isCreatedByUser &&
(prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) &&
prevMsg.content === nextMsg.content &&
prevMsg.model === nextMsg.model &&
prevMsg.endpoint === nextMsg.endpoint &&
prevMsg.iconURL === nextMsg.iconURL &&
prevMsg.feedback?.rating === nextMsg.feedback?.rating &&
(prevMsg.attachments?.length ?? 0) === (nextMsg.attachments?.length ?? 0)
);
}
const ContentRender = memo(function ContentRender({
message: msg,
siblingIdx,
@ -30,6 +90,7 @@ const ContentRender = memo(function ContentRender({
currentEditId,
setCurrentEditId,
isSubmitting = false,
chatContext,
}: ContentRenderProps) {
const localize = useLocalize();
const { attachments, searchResults } = useAttachments({
@ -55,6 +116,7 @@ const ContentRender = memo(function ContentRender({
searchResults,
currentEditId,
setCurrentEditId,
chatContext,
});
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
@ -66,8 +128,6 @@ const ContentRender = memo(function ContentRender({
);
const hasNoChildren = !(msg?.children?.length ?? 0);
const isLatestMessage = msg?.messageId === latestMessageId;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const iconData: TMessageIcon = useMemo(
() => ({
@ -158,13 +218,13 @@ const ContentRender = memo(function ContentRender({
searchResults={searchResults}
setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isSubmitting={effectiveIsSubmitting}
isSubmitting={isSubmitting}
isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
{hasNoChildren && isSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
@ -178,7 +238,7 @@ const ContentRender = memo(function ContentRender({
message={msg}
isEditing={edit}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
isSubmitting={chatContext.isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
@ -193,7 +253,7 @@ const ContentRender = memo(function ContentRender({
</div>
</div>
);
});
}, areContentRenderPropsEqual);
ContentRender.displayName = 'ContentRender';
export default ContentRender;

View file

@ -1,5 +1,5 @@
import React from 'react';
import { useMessageProcess } from '~/hooks';
import { useMessageProcess, useMemoizedChatContext } from '~/hooks';
import type { TMessageProps } from '~/common';
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
@ -28,6 +28,7 @@ export default function MessageContent(props: TMessageProps) {
message: props.message,
});
const { message, currentEditId, setCurrentEditId } = props;
const { chatContext, effectiveIsSubmitting } = useMemoizedChatContext(message, isSubmitting);
if (!message || typeof message !== 'object') {
return null;
@ -39,7 +40,11 @@ export default function MessageContent(props: TMessageProps) {
<>
<MessageContainer handleScroll={handleScroll}>
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<ContentRender {...props} isSubmitting={isSubmitting} />
<ContentRender
{...props}
isSubmitting={effectiveIsSubmitting}
chatContext={chatContext}
/>
</div>
</MessageContainer>
<MultiMessage

View file

@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import {
@ -122,9 +122,12 @@ export default function useAddedResponse() {
],
);
return {
conversation,
setConversation,
generateConversation,
};
return useMemo(
() => ({
conversation,
setConversation,
generateConversation,
}),
[conversation, setConversation, generateConversation],
);
}

View file

@ -1,6 +1,6 @@
export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable';
export { default as useSetFilesToDelete } from './useSetFilesToDelete';
export { default as useFileHandling } from './useFileHandling';
export { default as useFileHandling, useFileHandlingNoChatContext } from './useFileHandling';
export { default as useFileDeletion } from './useFileDeletion';
export { default as useUpdateFiles } from './useUpdateFiles';
export { default as useDragHelpers } from './useDragHelpers';

View file

@ -5,6 +5,7 @@ export { default as useSubmitMessage } from './useSubmitMessage';
export type { ContentMetadataResult } from './useContentMetadata';
export { default as useExpandCollapse } from './useExpandCollapse';
export { default as useMessageActions } from './useMessageActions';
export { default as useMemoizedChatContext } from './useMemoizedChatContext';
export { default as useMessageProcess } from './useMessageProcess';
export { default as useMessageHelpers } from './useMessageHelpers';
export { default as useCopyToClipboard } from './useCopyToClipboard';

View file

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Tools } from 'librechat-data-provider';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import { useMessagesOperations } from '~/Providers';
import { useOptionalMessagesOperations } from '~/Providers';
import store from '~/store';
/**
@ -16,7 +16,7 @@ import store from '~/store';
export function useConversationUIResources(
conversationId: string | undefined,
): Map<string, UIResource> {
const { getMessages } = useMessagesOperations();
const { getMessages } = useOptionalMessagesOperations();
const conversationAttachmentsMap = useRecoilValue(
store.conversationAttachmentsSelector(conversationId),

View file

@ -0,0 +1,80 @@
import { useRef, useMemo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageChatContext } from '~/common/types';
import { useChatContext } from '~/Providers';
/**
* Creates a stable `TMessageChatContext` object for memo'd message components.
*
* Subscribes to `useChatContext()` internally (intended to be called from non-memo'd
* wrapper components like `Message` and `MessageContent`), then produces:
* - A `chatContext` object that stays referentially stable during streaming
* (uses a getter for `isSubmitting` backed by a ref)
* - A stable `conversation` reference that only updates when rendering-relevant fields change
* - An `effectiveIsSubmitting` value (false for non-latest messages)
*/
export default function useMemoizedChatContext(
message: TMessage | null | undefined,
isSubmitting: boolean,
) {
const chatCtx = useChatContext();
const isSubmittingRef = useRef(isSubmitting);
isSubmittingRef.current = isSubmitting;
/**
* Stabilize conversation: only update when rendering-relevant fields change,
* not on every metadata update (e.g., title generation).
*/
const stableConversation = useMemo(
() => chatCtx.conversation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[
chatCtx.conversation?.conversationId,
chatCtx.conversation?.endpoint,
chatCtx.conversation?.endpointType,
chatCtx.conversation?.model,
chatCtx.conversation?.agent_id,
chatCtx.conversation?.assistant_id,
],
);
/**
* `isSubmitting` is included in deps so that chatContext gets a new reference
* when streaming starts/ends (2x per session). This ensures HoverButtons
* re-renders to update regenerate/edit button visibility via useGenerationsByLatest.
* The getter pattern is still valuable: callbacks reading chatContext.isSubmitting
* at call-time always get the current value even between these re-renders.
*/
const chatContext: TMessageChatContext = useMemo(
() => ({
ask: chatCtx.ask,
index: chatCtx.index,
regenerate: chatCtx.regenerate,
conversation: stableConversation,
latestMessageId: chatCtx.latestMessageId,
latestMessageDepth: chatCtx.latestMessageDepth,
handleContinue: chatCtx.handleContinue,
get isSubmitting() {
return isSubmittingRef.current;
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
chatCtx.ask,
chatCtx.index,
chatCtx.regenerate,
stableConversation,
chatCtx.latestMessageId,
chatCtx.latestMessageDepth,
chatCtx.handleContinue,
isSubmitting, // intentional: forces new reference on streaming start/end so HoverButtons re-renders
],
);
const messageId = message?.messageId ?? null;
const isLatestMessage = messageId === chatCtx.latestMessageId;
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
return { chatContext, effectiveIsSubmitting };
}

View file

@ -11,7 +11,8 @@ import {
TUpdateFeedbackRequest,
} from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import type { TMessageChatContext } from '~/common/types';
import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import useCopyToClipboard from './useCopyToClipboard';
import { useAuthContext } from '~/hooks/AuthContext';
import { useGetAddedConvo } from '~/hooks/Chat';
@ -23,24 +24,33 @@ export type TMessageActions = Pick<
'message' | 'currentEditId' | 'setCurrentEditId'
> & {
searchResults?: { [key: string]: SearchResultData };
/**
* Stable context object passed from wrapper components to avoid subscribing
* to ChatContext inside memo'd components (which would bypass React.memo).
* The `isSubmitting` property uses a getter backed by a ref, so it always
* returns the current value at call-time without triggering re-renders.
*/
chatContext: TMessageChatContext;
};
export default function useMessageActions(props: TMessageActions) {
const localize = useLocalize();
const { user } = useAuthContext();
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const { message, currentEditId, setCurrentEditId, searchResults } = props;
const { message, currentEditId, setCurrentEditId, searchResults, chatContext } = props;
const {
ask,
index,
regenerate,
isSubmitting,
conversation,
latestMessageId,
latestMessageDepth,
handleContinue,
} = useChatContext();
// NOTE: isSubmitting is intentionally NOT destructured here.
// chatContext.isSubmitting is a getter backed by a ref — destructuring
// would capture a one-time snapshot. Always access via chatContext.isSubmitting.
} = chatContext;
const getAddedConvo = useGetAddedConvo();
@ -98,13 +108,18 @@ export default function useMessageActions(props: TMessageActions) {
}
}, [agentsMap, conversation?.agent_id, conversation?.endpoint, message?.model]);
/**
* chatContext.isSubmitting is a getter backed by the wrapper's ref,
* so it always returns the current value at call-time even for
* non-latest messages that don't re-render during streaming.
*/
const regenerateMessage = useCallback(() => {
if ((isSubmitting && isCreatedByUser === true) || !message) {
if ((chatContext.isSubmitting && isCreatedByUser === true) || !message) {
return;
}
regenerate(message, { addedConvo: getAddedConvo() });
}, [isSubmitting, isCreatedByUser, message, regenerate, getAddedConvo]);
}, [chatContext, isCreatedByUser, message, regenerate, getAddedConvo]);
const copyToClipboard = useCopyToClipboard({ text, content, searchResults });

View file

@ -1,6 +1,6 @@
import throttle from 'lodash/throttle';
import { Constants } from 'librechat-data-provider';
import { useEffect, useRef, useCallback, useMemo } from 'react';
import { useEffect, useRef, useMemo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils';
import { useMessagesViewContext } from '~/Providers';
@ -56,24 +56,25 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
}
}, [hasNoChildren, message, setLatestMessage, conversation?.conversationId]);
const handleScroll = useCallback(
(event: unknown | TouchEvent | WheelEvent) => {
throttle(() => {
/** Use ref for isSubmitting to stabilize handleScroll across isSubmitting changes */
const isSubmittingRef = useRef(isSubmitting);
isSubmittingRef.current = isSubmitting;
const handleScroll = useMemo(
() =>
throttle((event: unknown) => {
logger.log(
'message_scrolling',
`useMessageProcess: setting abort scroll to ${isSubmitting}, handleScroll event`,
`useMessageProcess: setting abort scroll to ${isSubmittingRef.current}, handleScroll event`,
event,
);
if (isSubmitting) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
}, 500)();
},
[isSubmitting, setAbortScroll],
setAbortScroll(isSubmittingRef.current);
}, 500),
[setAbortScroll],
);
useEffect(() => () => handleScroll.cancel(), [handleScroll]);
return {
handleScroll,
isSubmitting,

View file

@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react';
import { Constants, ErrorTypes, LocalStorageKeys } from 'librechat-data-provider';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import type { TSubmission } from 'librechat-data-provider';
type SSEEventListener = (e: Partial<MessageEvent> & { responseCode?: number }) => void;
@ -34,7 +34,13 @@ jest.mock('sse.js', () => ({
}));
const mockSetQueryData = jest.fn();
const mockQueryClient = { setQueryData: mockSetQueryData };
const mockInvalidateQueries = jest.fn();
const mockRemoveQueries = jest.fn();
const mockQueryClient = {
setQueryData: mockSetQueryData,
invalidateQueries: mockInvalidateQueries,
removeQueries: mockRemoveQueries,
};
jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
@ -63,6 +69,7 @@ jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => ({ data: { balance: { enabled: false } } }),
useGetUserBalance: () => ({ refetch: jest.fn() }),
queueTitleGeneration: jest.fn(),
streamStatusQueryKey: (conversationId: string) => ['streamStatus', conversationId],
}));
const mockErrorHandler = jest.fn();
@ -162,6 +169,11 @@ describe('useResumableSSE - 404 error path', () => {
beforeEach(() => {
mockSSEInstances.length = 0;
localStorage.clear();
mockErrorHandler.mockClear();
mockClearStepMaps.mockClear();
mockSetIsSubmitting.mockClear();
mockInvalidateQueries.mockClear();
mockRemoveQueries.mockClear();
});
const seedDraft = (conversationId: string) => {
@ -200,19 +212,18 @@ describe('useResumableSSE - 404 error path', () => {
unmount();
});
it('calls errorHandler with STREAM_EXPIRED error type on 404', async () => {
it('invalidates message cache and clears stream status on 404 instead of showing error', async () => {
const { unmount } = await render404Scenario(CONV_ID);
expect(mockErrorHandler).toHaveBeenCalledTimes(1);
const call = mockErrorHandler.mock.calls[0][0];
expect(call.data).toBeDefined();
const parsed = JSON.parse(call.data.text);
expect(parsed.type).toBe(ErrorTypes.STREAM_EXPIRED);
expect(call.submission).toEqual(
expect.objectContaining({
conversation: expect.objectContaining({ conversationId: CONV_ID }),
}),
);
expect(mockErrorHandler).not.toHaveBeenCalled();
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['messages', CONV_ID],
});
expect(mockRemoveQueries).toHaveBeenCalledWith({
queryKey: ['streamStatus', CONV_ID],
});
expect(mockClearStepMaps).toHaveBeenCalled();
expect(mockSetIsSubmitting).toHaveBeenCalledWith(false);
unmount();
});

View file

@ -16,7 +16,12 @@ import {
} from 'librechat-data-provider';
import type { TMessage, TPayload, TSubmission, EventSubmission } from 'librechat-data-provider';
import type { EventHandlerParams } from './useEventHandlers';
import { useGetStartupConfig, useGetUserBalance, queueTitleGeneration } from '~/data-provider';
import {
useGetUserBalance,
useGetStartupConfig,
queueTitleGeneration,
streamStatusQueryKey,
} from '~/data-provider';
import type { ActiveJobsResponse } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import useEventHandlers from './useEventHandlers';
@ -343,18 +348,20 @@ export default function useResumableSSE(
/* @ts-ignore - sse.js types don't expose responseCode */
const responseCode = e.responseCode;
// 404 means job doesn't exist (completed/deleted) - don't retry
// 404 → job completed & was cleaned up; messages are persisted in DB.
// Invalidate cache once so react-query refetches instead of showing an error.
if (responseCode === 404) {
console.log('[ResumableSSE] Stream not found (404) - job completed or expired');
const convoId = currentSubmission.conversation?.conversationId;
console.log('[ResumableSSE] Stream 404, invalidating messages for:', convoId);
sse.close();
removeActiveJob(currentStreamId);
clearAllDrafts(currentSubmission.conversation?.conversationId);
errorHandler({
data: {
text: JSON.stringify({ type: ErrorTypes.STREAM_EXPIRED }),
} as unknown as Parameters<typeof errorHandler>[0]['data'],
submission: currentSubmission as EventSubmission,
});
clearAllDrafts(convoId);
clearStepMaps();
if (convoId) {
queryClient.invalidateQueries({ queryKey: [QueryKeys.messages, convoId] });
queryClient.removeQueries({ queryKey: streamStatusQueryKey(convoId) });
}
setIsSubmitting(false);
setShowStopButton(false);
setStreamId(null);
reconnectAttemptRef.current = 0;
@ -544,6 +551,7 @@ export default function useResumableSSE(
startupConfig?.balance?.enabled,
balanceQuery,
removeActiveJob,
queryClient,
],
);

View file

@ -125,7 +125,11 @@ export default function useResumeOnLoad(
conversationId !== Constants.NEW_CONVO &&
processedConvoRef.current !== conversationId; // Don't re-check processed convos
const { data: streamStatus, isSuccess } = useStreamStatus(conversationId, shouldCheck);
const {
data: streamStatus,
isSuccess,
isFetching,
} = useStreamStatus(conversationId, shouldCheck);
useEffect(() => {
console.log('[ResumeOnLoad] Effect check', {
@ -135,6 +139,7 @@ export default function useResumeOnLoad(
hasCurrentSubmission: !!currentSubmission,
currentSubmissionConvoId: currentSubmission?.conversation?.conversationId,
isSuccess,
isFetching,
streamStatusActive: streamStatus?.active,
streamStatusStreamId: streamStatus?.streamId,
processedConvoRef: processedConvoRef.current,
@ -171,8 +176,9 @@ export default function useResumeOnLoad(
);
}
// Wait for stream status query to complete
if (!isSuccess || !streamStatus) {
// Wait for stream status query to complete (including background refetches
// that may replace a stale cached result with fresh data)
if (!isSuccess || !streamStatus || isFetching) {
console.log('[ResumeOnLoad] Waiting for stream status query');
return;
}
@ -183,15 +189,12 @@ export default function useResumeOnLoad(
return;
}
// Check if there's an active job to resume
// DON'T mark as processed here - only mark when we actually create a submission
// This prevents stale cache data from blocking subsequent resume attempts
if (!streamStatus.active || !streamStatus.streamId) {
console.log('[ResumeOnLoad] No active job to resume for:', conversationId);
processedConvoRef.current = conversationId;
return;
}
// Mark as processed NOW - we verified there's an active job and will create submission
processedConvoRef.current = conversationId;
console.log('[ResumeOnLoad] Found active job, creating submission...', {
@ -241,6 +244,7 @@ export default function useResumeOnLoad(
submissionConvoId,
currentSubmission,
isSuccess,
isFetching,
streamStatus,
getMessages,
setSubmission,