- {hasInfo && (
+ {hasInfo && shouldRenderBody && (
diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
index 85ea2f778e..46daffdaef 100644
--- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
@@ -10,7 +10,13 @@ import type {
FunctionToolCall,
} from 'librechat-data-provider';
import type { PartWithIndex } from './ParallelContent';
-import { cn, getToolDisplayLabel, getBatchActivityLabelPart, getActivityLabelText } from '~/utils';
+import {
+ cn,
+ getToolDisplayLabel,
+ hasPendingApprovalInPart,
+ getBatchActivityLabelPart,
+ getActivityLabelText,
+} from '~/utils';
import { useLocalize, useExpandCollapse, scheduleMessageContentLayoutReconcile } from '~/hooks';
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
import { isBashProgrammaticToolCall } from './routing';
@@ -25,27 +31,6 @@ interface ToolMeta {
hasOutput: boolean;
}
-type ToolCallWithNestedContent = Agents.ToolCall & {
- subagent_content?: TMessageContentParts[];
-};
-
-function hasPendingApprovalInPart(part: TMessageContentParts): boolean {
- if (part.type !== ContentTypes.TOOL_CALL) {
- return false;
- }
- const toolCall = part[ContentTypes.TOOL_CALL] as ToolCallWithNestedContent | undefined;
- if (!toolCall) {
- return false;
- }
- if (toolCall.approval != null && (toolCall.output?.length ?? 0) === 0) {
- return true;
- }
- return (
- Array.isArray(toolCall.subagent_content) &&
- toolCall.subagent_content.some(hasPendingApprovalInPart)
- );
-}
-
function getToolMeta(part: TMessageContentParts): ToolMeta | null {
if (part.type !== ContentTypes.TOOL_CALL) {
return null;
diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx
index 970512da71..e6e53a82b0 100644
--- a/client/src/components/Chat/Messages/Content/WebSearch.tsx
+++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx
@@ -9,8 +9,8 @@ import type {
PartMetadata,
} from 'librechat-data-provider';
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
+import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import { StackedFavicons } from '~/components/Web/Sources';
-import { useLocalize, useExpandCollapse } from '~/hooks';
import { useToolCallIntent } from './Parts/intent';
import { useSearchContext } from '~/Providers';
import cn from '~/utils/cn';
@@ -208,6 +208,7 @@ export default function WebSearch({
const sourceCount = allSources.length;
const [showSourceList, setShowSourceList] = useState(() => autoExpand && sourceCount > 0);
const { style: sourceExpandStyle, ref: sourceExpandRef } = useExpandCollapse(showSourceList);
+ const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(showSourceList);
useEffect(() => {
if (autoExpand && sourceCount > 0) {
@@ -216,6 +217,7 @@ export default function WebSearch({
}, [autoExpand, sourceCount]);
const handleToggleSources = () => {
+ mountBody();
setShowSourceList((prev) => {
const next = !prev;
if (next) {
@@ -272,31 +274,33 @@ export default function WebSearch({
)}
{hasSourceData && (
-
+
-
+ {shouldRenderBody && (
+
+ )}
)}
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx
index b4f7d237a2..9576230b4d 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ActivityPhaseGroup.test.tsx
@@ -13,8 +13,10 @@ jest.mock('~/hooks/Messages/useSmoothStreaming', () => ({
jest.mock('~/hooks', () => {
const expandCollapse = jest.requireActual('~/hooks/Messages/useExpandCollapse');
+ const lazyCollapseBody = jest.requireActual('~/hooks/Messages/useLazyCollapseBody');
return {
useExpandCollapse: expandCollapse.default,
+ useLazyCollapseBody: lazyCollapseBody.default,
EXPAND_TRANSITION: expandCollapse.EXPAND_TRANSITION,
scheduleMessageContentLayoutReconcile: (target: HTMLElement | null) =>
mockScheduleLayoutReconcile(target),
@@ -192,4 +194,72 @@ describe('ActivityPhaseGroup', () => {
expect(screen.getByText(LABEL)).toHaveClass('text-left');
expect(pendingFrames()).toBe(0);
});
+
+ test('keeps a collapsed history phase body unmounted until expanded', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: LABEL }));
+ expect(screen.getByTestId('phase-content')).toBeInTheDocument();
+ });
+
+ test('releases the body only after the collapse transition completes', () => {
+ render(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: LABEL });
+ fireEvent.click(trigger);
+ expect(screen.getByTestId('phase-content')).toBeInTheDocument();
+
+ fireEvent.click(trigger);
+ expect(screen.getByTestId('phase-content')).toBeInTheDocument();
+
+ fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
+ expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
+ });
+
+ test('a pending approval retains the collapsed body until it resolves', () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: LABEL });
+ fireEvent.click(trigger);
+ fireEvent.click(trigger);
+ fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
+ expect(screen.getByTestId('phase-content')).toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+ expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
+ });
+
+ test('the entrance fold keeps the body mounted, then releases it after settling', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('phase-content')).toBeInTheDocument();
+
+ flushFrames();
+ flushFrames();
+
+ fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
+ expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
+ });
});
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx
index 9d9d1a5830..bb53b13b3d 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx
@@ -16,6 +16,7 @@ jest.mock('~/hooks', () => ({
style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' },
ref: { current: null },
}),
+ useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default,
useProgress: (initial: number) => (initial >= 1 ? 1 : initial),
scheduleMessageContentLayoutReconcile: jest.fn(() => jest.fn()),
}));
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx
index 664a1c28cc..b735bc0493 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx
@@ -9,6 +9,7 @@ jest.mock('~/utils', () => ({
mapAttachments: () => ({}),
filterAttachmentsForPart: (attachments: unknown) => attachments,
groupSequentialToolCalls: jest.fn(),
+ hasPendingApprovalInPart: jest.requireActual('~/utils/groupToolCalls').hasPendingApprovalInPart,
}));
jest.mock('~/Providers', () => {
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx
index 463bb24bc1..967a66cfa4 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx
@@ -31,6 +31,7 @@ jest.mock('~/hooks', () => ({
},
ref: { current: null },
}),
+ useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default,
}));
jest.mock('~/hooks/MCP', () => {
@@ -291,24 +292,19 @@ describe('ToolCall', () => {
});
describe('tool call info visibility', () => {
- it('should toggle tool call info expand/collapse when clicking header', () => {
+ it('should mount tool call info only after expanding via the header', () => {
renderWithRecoil(
);
- // ToolCallInfo is always in the DOM (CSS expand/collapse), but initially collapsed
- const toolCallInfo = screen.getByTestId('tool-call-info');
- expect(toolCallInfo).toBeInTheDocument();
+ // Collapsed info stays unmounted until the first expansion
+ expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
- // The expand wrapper starts collapsed (showInfo=false, autoExpand=false)
- const expandWrapper = toolCallInfo.closest('[style]')?.parentElement;
- expect(expandWrapper).toBeDefined();
-
- // Click to expand
fireEvent.click(screen.getByTestId('progress-text'));
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
});
it('should pass input and output props to ToolCallInfo', () => {
renderWithRecoil(
);
+ fireEvent.click(screen.getByTestId('progress-text'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
@@ -375,6 +371,7 @@ describe('ToolCall', () => {
describe('edge cases', () => {
it('should handle undefined args', () => {
renderWithRecoil(
);
+ fireEvent.click(screen.getByTestId('progress-text'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
@@ -383,6 +380,7 @@ describe('ToolCall', () => {
it('should handle null output', () => {
renderWithRecoil(
);
+ fireEvent.click(screen.getByTestId('progress-text'));
const toolCallInfo = screen.getByTestId('tool-call-info');
const props = JSON.parse(toolCallInfo.textContent!);
@@ -391,9 +389,9 @@ describe('ToolCall', () => {
it('should handle simple function name without domain', () => {
renderWithRecoil(
);
+ fireEvent.click(screen.getByTestId('progress-text'));
- const toolCallInfo = screen.getByTestId('tool-call-info');
- expect(toolCallInfo).toBeInTheDocument();
+ expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
});
it('should handle complex nested attachments', () => {
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
index 0426219539..2e916958be 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx
@@ -67,6 +67,7 @@ jest.mock('~/utils', () => ({
* so stubbing them out would hide the header logic under test. */
getBatchActivityLabelPart: jest.requireActual('~/utils/activityLabels').getBatchActivityLabelPart,
getActivityLabelText: jest.requireActual('~/utils/activityLabels').getActivityLabelText,
+ hasPendingApprovalInPart: jest.requireActual('~/utils/groupToolCalls').hasPendingApprovalInPart,
}));
jest.mock('../Parts', () => ({
diff --git a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx
index fc29f0391b..a0fef95cc4 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { Tools } from 'librechat-data-provider';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import type { TAttachment, SearchResultData, ValidSource } from 'librechat-data-provider';
import { SearchContext } from '~/Providers';
import WebSearch from '../WebSearch';
@@ -19,6 +19,7 @@ jest.mock('~/hooks', () => ({
};
return translations[key] || key;
},
+ useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default,
useExpandCollapse: (isExpanded: boolean) => ({
style: {
display: 'grid',
@@ -129,6 +130,7 @@ describe('WebSearch', () => {
const attachments = [makeAttachment(0, searchResults['0'])];
renderWebSearch({ searchResults, attachments });
+ fireEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
const links = screen.getAllByRole('link');
const hrefs = links.map((l) => l.getAttribute('href'));
@@ -143,6 +145,7 @@ describe('WebSearch', () => {
const attachments = [makeAttachment(1, searchResults['1'])];
renderWebSearch({ searchResults, attachments });
+ fireEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
const links = screen.getAllByRole('link');
const hrefs = links.map((l) => l.getAttribute('href'));
@@ -178,6 +181,9 @@ describe('WebSearch', () => {
,
);
+ fireEvent.click(container0.querySelector('button[aria-expanded]') as HTMLElement);
+ fireEvent.click(container1.querySelector('button[aria-expanded]') as HTMLElement);
+
const links0 = Array.from(container0.querySelectorAll('a[href]')).map((a) =>
a.getAttribute('href'),
);
@@ -195,6 +201,7 @@ describe('WebSearch', () => {
it('falls back to searchResults[ownTurn] when attachments is undefined', () => {
renderWebSearch({ searchResults });
+ fireEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
const links = screen.getAllByRole('link');
const hrefs = links.map((l) => l.getAttribute('href'));
diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx
index 37bf2b3dc2..ed76987ccd 100644
--- a/client/src/components/Chat/Messages/MessagesView.tsx
+++ b/client/src/components/Chat/Messages/MessagesView.tsx
@@ -5,9 +5,10 @@ import { Constants } from 'librechat-data-provider';
import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useScrollbarGutter, useLocalize } from '~/hooks';
+import { RowMountProvider, useProgressiveRowMount } from '~/hooks/Messages';
+import { MessagesViewProvider, useChatContext } from '~/Providers';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { steerOverlayHeightFamily } from '~/store/steer';
-import { MessagesViewProvider } from '~/Providers';
import { fontSizeAtom } from '~/store/fontSize';
import MultiMessage from './MultiMessage';
import MessageNav from './MessageNav';
@@ -114,6 +115,22 @@ function MessagesViewContent({
const { conversationId } = conversation ?? {};
+ const { index, latestMessageDepth } = useChatContext();
+ const isSubmitting = useRecoilValue(store.isSubmittingFamily(index));
+ const autoScroll = useRecoilValue(store.autoScroll);
+ /** Re-arm from the conversation that owns the RENDERED tree: the Recoil
+ * conversation id lags the route during warm-cache navigation, and keying
+ * off it would first mount the new tree unwindowed, then narrow it after
+ * the fact — visibly unmounting rows the user is already reading. */
+ const treeConversationId = _messagesTree?.[0]?.conversationId ?? conversationId;
+ const mountWindow = useProgressiveRowMount({
+ tailDepth: latestMessageDepth,
+ anchorBottom: autoScroll || isSubmitting,
+ isSubmitting,
+ conversationId: treeConversationId,
+ scrollableRef,
+ });
+
/** The in-flight steer overlay floats above the composer over the bottom of
* the thread (see `InFlightSteers`); reserve an equal band here so the
* newest message rests above it and older ones scroll behind. */
@@ -133,6 +150,10 @@ function MessagesViewContent({
height: '100%',
overflowY: 'auto',
width: '100%',
+ /** The mount hook pins the anchor row itself (document-space
+ * measurement); native scroll anchoring reacting to the same
+ * insertions would double-correct. */
+ overflowAnchor: mountWindow != null ? 'none' : undefined,
}}
>
-
+
+
+
>
)}
diff --git a/client/src/components/Chat/Messages/MultiMessage.tsx b/client/src/components/Chat/Messages/MultiMessage.tsx
index dc54587aea..5b8a658e58 100644
--- a/client/src/components/Chat/Messages/MultiMessage.tsx
+++ b/client/src/components/Chat/Messages/MultiMessage.tsx
@@ -5,6 +5,7 @@ import type { TMessage } from 'librechat-data-provider';
import type { ReactElement } from 'react';
import type { TMessageProps } from '~/common';
import MessageContent from '~/components/Messages/MessageContent';
+import { useRowMountWindow } from '~/hooks/Messages';
import MessageParts from './MessageParts';
import Message from './Message';
import store from '~/store';
@@ -21,6 +22,7 @@ function MultiMessage({
setCurrentEditId,
}: TMessageProps) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
+ const mountWindow = useRowMountWindow();
const setSiblingIdxRev = useCallback(
(value: number) => {
@@ -165,8 +167,17 @@ function MultiMessage({
setSiblingIdx: setSiblingIdxRev,
};
- let row: ReactElement;
- if (isAssistantsEndpoint(message.endpoint) && message.content) {
+ /** A row outside the progressive mount window renders nothing while the
+ * recursion continues, so descendants keep their atoms, effects, and
+ * streaming spine; the window only ever widens, so rows never unmount. */
+ const rowMounted =
+ mountWindow == null ||
+ ((message.depth ?? 0) >= mountWindow.start && (message.depth ?? 0) <= mountWindow.end);
+
+ let row: ReactElement | null = null;
+ if (!rowMounted) {
+ row = null;
+ } else if (isAssistantsEndpoint(message.endpoint) && message.content) {
row =
;
} else if (message.content) {
row =
;
diff --git a/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx b/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx
index b999d27000..586c1f2231 100644
--- a/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx
+++ b/client/src/components/Chat/Messages/__tests__/MultiMessage.spec.tsx
@@ -193,3 +193,46 @@ describe('MultiMessage sibling selection', () => {
expect(displayed()).toBe('a1');
});
});
+
+describe('MultiMessage row mount window', () => {
+ const { RowMountProvider } =
+ jest.requireActual
('~/hooks/Messages');
+
+ const chain = (): TMessage => {
+ const leaf = { ...msg('m2'), parentMessageId: 'm1', depth: 2 } as TMessage;
+ const mid = { ...msg('m1'), parentMessageId: 'm0', depth: 1, children: [leaf] } as TMessage;
+ return { ...msg('m0'), depth: 0, children: [mid] } as TMessage;
+ };
+
+ const windowedTree = (mountWindow: { start: number; end: number } | null) => (
+
+
+
+
+
+ );
+
+ it('renders every row without a window', () => {
+ render(windowedTree(null));
+ expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m0', 'm1', 'm2']);
+ });
+
+ it('gates rows outside the window while the recursion continues below them', () => {
+ render(windowedTree({ start: 2, end: 2 }));
+ expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m2']);
+ });
+
+ it('mounts newly windowed rows above without disturbing deeper rows', () => {
+ const view = render(windowedTree({ start: 2, end: 2 }));
+ view.rerender(windowedTree({ start: 1, end: 2 }));
+ expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m1', 'm2']);
+
+ view.rerender(windowedTree(null));
+ expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m0', 'm1', 'm2']);
+ });
+});
diff --git a/client/src/hooks/Messages/__tests__/useLazyCollapseBody.spec.tsx b/client/src/hooks/Messages/__tests__/useLazyCollapseBody.spec.tsx
new file mode 100644
index 0000000000..c51ea766c0
--- /dev/null
+++ b/client/src/hooks/Messages/__tests__/useLazyCollapseBody.spec.tsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import useLazyCollapseBody from '../useLazyCollapseBody';
+
+const TOGGLE_LABEL = 'toggle';
+
+function Disclosure({
+ initialExpanded,
+ retainBody = false,
+}: {
+ initialExpanded: boolean;
+ retainBody?: boolean;
+}) {
+ const [isExpanded, setIsExpanded] = React.useState(initialExpanded);
+ const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(
+ isExpanded,
+ retainBody,
+ );
+ return (
+
+
{
+ mountBody();
+ setIsExpanded((prev) => !prev);
+ }}
+ >
+ {TOGGLE_LABEL}
+
+
+ {shouldRenderBody &&
}
+
+
+ );
+}
+
+describe('useLazyCollapseBody', () => {
+ it('leaves a collapsed-by-default body unmounted', () => {
+ render( );
+ expect(screen.queryByTestId('body')).not.toBeInTheDocument();
+ });
+
+ it('mounts an expanded-by-default body immediately', () => {
+ render( );
+ expect(screen.getByTestId('body')).toBeInTheDocument();
+ });
+
+ it('mounts in the same commit as a user expand', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByTestId('body')).toBeInTheDocument();
+ });
+
+ it('keeps the body through the collapse transition, then releases it', () => {
+ render( );
+ const toggle = screen.getByRole('button');
+ fireEvent.click(toggle);
+ fireEvent.click(toggle);
+ expect(screen.getByTestId('body')).toBeInTheDocument();
+
+ fireEvent.transitionEnd(screen.getByTestId('panel'));
+ expect(screen.queryByTestId('body')).not.toBeInTheDocument();
+ });
+
+ it('ignores transition events bubbling from descendants', () => {
+ render( );
+ const toggle = screen.getByRole('button');
+ fireEvent.click(toggle);
+ fireEvent.click(toggle);
+
+ fireEvent.transitionEnd(screen.getByTestId('body'));
+ expect(screen.getByTestId('body')).toBeInTheDocument();
+ });
+
+ it('does not release the body when a transition ends while expanded', () => {
+ render( );
+ fireEvent.transitionEnd(screen.getByTestId('panel'));
+ expect(screen.getByTestId('body')).toBeInTheDocument();
+ });
+
+ it('retains the body across a collapse while retainBody is set', () => {
+ const view = render( );
+ const toggle = screen.getByRole('button');
+ fireEvent.click(toggle);
+ fireEvent.click(toggle);
+ fireEvent.transitionEnd(screen.getByTestId('panel'));
+ expect(screen.getByTestId('body')).toBeInTheDocument();
+
+ view.rerender( );
+ expect(screen.queryByTestId('body')).not.toBeInTheDocument();
+ });
+
+ it('keeps an expanded body mounted when retention clears', () => {
+ const view = render( );
+ fireEvent.click(screen.getByRole('button'));
+ view.rerender( );
+ expect(screen.getByTestId('body')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/hooks/Messages/__tests__/useProgressiveRowMount.spec.tsx b/client/src/hooks/Messages/__tests__/useProgressiveRowMount.spec.tsx
new file mode 100644
index 0000000000..911f051f7b
--- /dev/null
+++ b/client/src/hooks/Messages/__tests__/useProgressiveRowMount.spec.tsx
@@ -0,0 +1,148 @@
+import React from 'react';
+import { act, renderHook } from '@testing-library/react';
+import type { RowMountWindow } from '../useProgressiveRowMount';
+import { useProgressiveRowMount, completeProgressiveRowMounts } from '../useProgressiveRowMount';
+
+type HookProps = {
+ tailDepth: number | undefined;
+ anchorBottom: boolean;
+ isSubmitting: boolean;
+ conversationId: string | null | undefined;
+};
+
+describe('useProgressiveRowMount', () => {
+ let frames: Array;
+ const scrollableRef = { current: null } as React.RefObject;
+
+ /** Runs only the frames scheduled BEFORE this flush, so one call advances
+ * the expansion by exactly one step even though each step schedules the
+ * next frame during the act() flush. */
+ const flushFrames = () =>
+ act(() => {
+ const pending = frames.length;
+ for (let index = 0; index < pending; index += 1) {
+ const frame = frames[index];
+ frames[index] = undefined;
+ frame?.(index);
+ }
+ });
+
+ beforeEach(() => {
+ frames = [];
+ window.requestAnimationFrame = jest.fn((callback: FrameRequestCallback) => {
+ frames.push(callback);
+ return frames.length;
+ }) as unknown as typeof window.requestAnimationFrame;
+ window.cancelAnimationFrame = jest.fn((handle: number) => {
+ frames[handle - 1] = undefined;
+ }) as unknown as typeof window.cancelAnimationFrame;
+ });
+
+ const setup = (initial: Partial = {}) => {
+ const props: HookProps = {
+ tailDepth: 267,
+ anchorBottom: false,
+ isSubmitting: false,
+ conversationId: 'convo-a',
+ ...initial,
+ };
+ return renderHook(
+ (current: HookProps) => useProgressiveRowMount({ ...current, scrollableRef }),
+ { initialProps: props },
+ );
+ };
+
+ it('does not window short threads', () => {
+ const { result } = setup({ tailDepth: 20 });
+ expect(result.current).toBeNull();
+ });
+
+ it('does not window when a submission is already active', () => {
+ const { result } = setup({ isSubmitting: true });
+ expect(result.current).toBeNull();
+ });
+
+ it('anchors the first window at the top by default', () => {
+ const { result } = setup();
+ expect(result.current).toEqual({ start: 0, end: 15 });
+ });
+
+ it('anchors the first window at the tail for bottom anchoring', () => {
+ const { result } = setup({ anchorBottom: true });
+ expect(result.current).toEqual({ start: 252, end: Number.POSITIVE_INFINITY });
+ });
+
+ it('widens per frame until the whole path is covered, then lifts the restriction', () => {
+ const { result } = setup();
+ const seen: RowMountWindow[] = [result.current];
+
+ for (let i = 0; i < 20 && result.current != null; i += 1) {
+ flushFrames();
+ seen.push(result.current);
+ }
+
+ expect(result.current).toBeNull();
+ const ends = seen.filter((w): w is NonNullable => w != null).map((w) => w.end);
+ for (let i = 1; i < ends.length; i += 1) {
+ expect(ends[i]).toBeGreaterThan(ends[i - 1]);
+ }
+ /** The final widening and the covered-check that lifts the restriction
+ * land in the same flush, so the last observable window sits within one
+ * chunk of the tail. */
+ expect(ends[ends.length - 1]).toBeGreaterThanOrEqual(267 - 32);
+ });
+
+ it('completes immediately when a submission starts mid-expansion', () => {
+ const { result, rerender } = setup();
+ expect(result.current).not.toBeNull();
+
+ rerender({
+ tailDepth: 267,
+ anchorBottom: false,
+ isSubmitting: true,
+ conversationId: 'convo-a',
+ });
+ expect(result.current).toBeNull();
+ });
+
+ it('force-completes in-flight mounts for DOM consumers, resolving after paint', async () => {
+ const { result } = setup();
+ expect(result.current).not.toBeNull();
+
+ let resolved = false;
+ let completion: Promise = Promise.resolve();
+ act(() => {
+ completion = completeProgressiveRowMounts().then(() => {
+ resolved = true;
+ });
+ });
+ expect(result.current).toBeNull();
+
+ flushFrames();
+ flushFrames();
+ await act(async () => {
+ await completion;
+ });
+ expect(resolved).toBe(true);
+
+ /** With nothing in flight it resolves immediately, no frames needed. */
+ await expect(completeProgressiveRowMounts()).resolves.toBeUndefined();
+ });
+
+ it('re-arms a fresh window when the conversation changes', () => {
+ const { result, rerender } = setup();
+
+ while (result.current != null) {
+ flushFrames();
+ }
+ expect(result.current).toBeNull();
+
+ rerender({
+ tailDepth: 199,
+ anchorBottom: false,
+ isSubmitting: false,
+ conversationId: 'convo-b',
+ });
+ expect(result.current).toEqual({ start: 0, end: 15 });
+ });
+});
diff --git a/client/src/hooks/Messages/index.ts b/client/src/hooks/Messages/index.ts
index 1818bbc065..7a0eb4d833 100644
--- a/client/src/hooks/Messages/index.ts
+++ b/client/src/hooks/Messages/index.ts
@@ -11,6 +11,14 @@ export { default as useAttachments } from './useAttachments';
export { default as useSubmitMessage } from './useSubmitMessage';
export type { ContentMetadataResult } from './useContentMetadata';
export { default as useExpandCollapse } from './useExpandCollapse';
+export { default as useLazyCollapseBody } from './useLazyCollapseBody';
+export {
+ RowMountProvider,
+ useRowMountWindow,
+ useProgressiveRowMount,
+ completeProgressiveRowMounts,
+} from './useProgressiveRowMount';
+export type { RowMountWindow } from './useProgressiveRowMount';
export { default as useMessageActions } from './useMessageActions';
export { useLatestMessage, useLatestMessageId } from './useLatestMessage';
export { default as useMemoizedChatContext } from './useMemoizedChatContext';
diff --git a/client/src/hooks/Messages/useLazyCollapseBody.ts b/client/src/hooks/Messages/useLazyCollapseBody.ts
new file mode 100644
index 0000000000..ef00dae75d
--- /dev/null
+++ b/client/src/hooks/Messages/useLazyCollapseBody.ts
@@ -0,0 +1,59 @@
+import { useRef, useState, useEffect, useCallback } from 'react';
+import type { TransitionEvent } from 'react';
+
+/**
+ * Defers a disclosure panel's body: collapsed-by-default content stays
+ * unmounted until its first expansion and unmounts again after the collapse
+ * transition completes (`useExpandCollapse` keeps `transitionend` firing even
+ * under reduced motion, so the release always arrives). The expansion-flag
+ * effect mounts one commit after programmatic expands; toggle handlers should
+ * call `mountBody` so user-driven expands mount in the same commit the
+ * height transition measures.
+ *
+ * `retainBody` keeps an already-mounted body across collapses while true —
+ * for descendants that own unsent local form state (pending tool approvals) —
+ * and releases it once the flag clears while collapsed.
+ */
+export default function useLazyCollapseBody(
+ isExpanded: boolean,
+ retainBody = false,
+): {
+ shouldRenderBody: boolean;
+ mountBody: () => void;
+ handleTransitionEnd: (event: TransitionEvent) => void;
+} {
+ const [shouldRenderBody, setShouldRenderBody] = useState(isExpanded);
+ const retainedRef = useRef(false);
+ const mountBody = useCallback(() => setShouldRenderBody(true), []);
+
+ useEffect(() => {
+ if (isExpanded) {
+ retainedRef.current = false;
+ setShouldRenderBody(true);
+ }
+ }, [isExpanded]);
+
+ useEffect(() => {
+ if (!isExpanded && !retainBody && retainedRef.current) {
+ retainedRef.current = false;
+ setShouldRenderBody(false);
+ }
+ }, [isExpanded, retainBody]);
+
+ const handleTransitionEnd = useCallback(
+ (event: TransitionEvent) => {
+ if (event.target !== event.currentTarget || isExpanded) {
+ return;
+ }
+ if (retainBody) {
+ retainedRef.current = true;
+ return;
+ }
+ retainedRef.current = false;
+ setShouldRenderBody(false);
+ },
+ [isExpanded, retainBody],
+ );
+
+ return { shouldRenderBody, mountBody, handleTransitionEnd };
+}
diff --git a/client/src/hooks/Messages/useProgressiveRowMount.tsx b/client/src/hooks/Messages/useProgressiveRowMount.tsx
new file mode 100644
index 0000000000..57335b51b9
--- /dev/null
+++ b/client/src/hooks/Messages/useProgressiveRowMount.tsx
@@ -0,0 +1,202 @@
+import {
+ useRef,
+ useState,
+ useEffect,
+ useContext,
+ useCallback,
+ createContext,
+ useLayoutEffect,
+ startTransition,
+} from 'react';
+import type { ReactNode, RefObject } from 'react';
+
+/**
+ * Depth range (inclusive) of visible-path rows allowed to mount; `null` means
+ * no restriction. `MultiMessage` reads this to gate each row while always
+ * continuing its recursion, so the tree's structure, sibling state, and
+ * streaming spine are identical whether or not a window is active.
+ */
+export type RowMountWindow = { start: number; end: number } | null;
+
+const RowMountContext = createContext(null);
+
+export function RowMountProvider({
+ mountWindow,
+ children,
+}: {
+ mountWindow: RowMountWindow;
+ children: ReactNode;
+}) {
+ return {children} ;
+}
+
+export function useRowMountWindow(): RowMountWindow {
+ return useContext(RowMountContext);
+}
+
+/** Below this path length every row mounts in one commit, exactly as before. */
+const MIN_PROGRESSIVE_ROWS = 40;
+/** Rows in the first anchored commit — about one viewport plus overscan. */
+const INITIAL_ROWS = 16;
+/** Rows added per expansion step until the window covers the whole path. */
+const CHUNK_ROWS = 32;
+
+type ProgressiveRowMountOptions = {
+ /** Depth of the active branch tail (`latestMessageDepth` from ChatContext). */
+ tailDepth: number | undefined;
+ /** True anchors the first commit at the newest rows (auto-scroll lands
+ * there); false anchors at the conversation start, which is where a
+ * default-settings load rests. */
+ anchorBottom: boolean;
+ isSubmitting: boolean;
+ conversationId: string | null | undefined;
+ scrollableRef: RefObject;
+};
+
+function initialWindow(
+ tailDepth: number | undefined,
+ anchorBottom: boolean,
+ isSubmitting: boolean,
+): RowMountWindow {
+ if (isSubmitting || tailDepth == null || tailDepth + 1 <= MIN_PROGRESSIVE_ROWS) {
+ return null;
+ }
+ if (anchorBottom) {
+ return { start: Math.max(0, tailDepth - INITIAL_ROWS + 1), end: Number.POSITIVE_INFINITY };
+ }
+ return { start: 0, end: INITIAL_ROWS - 1 };
+}
+
+/**
+ * Windowed first commit for long threads: mount only the rows around the
+ * scroll anchor, then widen the window in transition-wrapped chunks until
+ * every row is mounted, then drop the restriction entirely. The DOM converges
+ * to the exact full structure — nothing ever unmounts — so message counts,
+ * screenshot export, and the nav rail see the same document they always have,
+ * just a few frames later.
+ *
+ * Bottom-anchored expansion inserts rows above the viewport; the layout
+ * effect re-pins the previously first-mounted row to its pre-commit viewport
+ * offset by measuring its actual shift, which also degrades to a no-op
+ * wherever native scroll anchoring already compensated.
+ */
+export function useProgressiveRowMount({
+ tailDepth,
+ anchorBottom,
+ isSubmitting,
+ conversationId,
+ scrollableRef,
+}: ProgressiveRowMountOptions): RowMountWindow {
+ const [mountWindow, setMountWindow] = useState(() =>
+ initialWindow(tailDepth, anchorBottom, isSubmitting),
+ );
+ const anchorRef = useRef<{ element: Element; documentOffset: number } | null>(null);
+
+ /** Re-arm per conversation so every navigation gets the anchored fast
+ * first commit (state adjustment during render, per React's guidance,
+ * so the old conversation's window never gates the new tree). */
+ const [prevConversationId, setPrevConversationId] = useState(conversationId);
+ if (prevConversationId !== conversationId) {
+ setPrevConversationId(conversationId);
+ setMountWindow(initialWindow(tailDepth, anchorBottom, isSubmitting));
+ anchorRef.current = null;
+ }
+
+ useEffect(() => {
+ if (isSubmitting && mountWindow != null) {
+ setMountWindow(null);
+ }
+ }, [isSubmitting, mountWindow]);
+
+ const captureAnchor = useCallback(() => {
+ const container = scrollableRef.current;
+ if (!container || !anchorBottom) {
+ anchorRef.current = null;
+ return;
+ }
+ const element = container.querySelector('.message-render');
+ /** Document-space offset (viewport top + scrollTop): the widening commit
+ * is transition-deferred, so the user may scroll between capture and
+ * commit. User scrolling moves viewport coordinates but not document
+ * ones, so measuring here isolates the inserted-row shift and never
+ * folds the user's own movement into the correction. */
+ anchorRef.current = element
+ ? { element, documentOffset: element.getBoundingClientRect().top + container.scrollTop }
+ : null;
+ }, [anchorBottom, scrollableRef]);
+
+ useEffect(() => {
+ if (mountWindow == null || tailDepth == null) {
+ return;
+ }
+ if (mountWindow.start <= 0 && mountWindow.end >= tailDepth) {
+ setMountWindow(null);
+ return;
+ }
+ const frameId = requestAnimationFrame(() => {
+ captureAnchor();
+ startTransition(() => {
+ setMountWindow((current) => {
+ if (current == null) {
+ return current;
+ }
+ return {
+ start: Math.max(0, current.start - CHUNK_ROWS),
+ end: current.end >= tailDepth ? current.end : current.end + CHUNK_ROWS,
+ };
+ });
+ });
+ });
+ return () => cancelAnimationFrame(frameId);
+ }, [mountWindow, tailDepth, captureAnchor]);
+
+ useLayoutEffect(() => {
+ const captured = anchorRef.current;
+ anchorRef.current = null;
+ const container = scrollableRef.current;
+ if (!captured || !container || !captured.element.isConnected) {
+ return;
+ }
+ const shift =
+ captured.element.getBoundingClientRect().top + container.scrollTop - captured.documentOffset;
+ if (shift !== 0) {
+ container.scrollTop += shift;
+ }
+ }, [mountWindow, scrollableRef]);
+
+ /** Registered while a window is active so `completeProgressiveRowMounts`
+ * (screenshot capture) can force the remaining rows in and wait for the
+ * commit to paint before cloning the DOM. */
+ const isWindowActive = mountWindow != null;
+ useEffect(() => {
+ if (!isWindowActive) {
+ return;
+ }
+ const complete = () =>
+ new Promise((resolve) => {
+ setMountWindow(null);
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ });
+ activeCompleters.add(complete);
+ return () => {
+ activeCompleters.delete(complete);
+ };
+ }, [isWindowActive]);
+
+ return mountWindow;
+}
+
+const activeCompleters = new Set<() => Promise>();
+
+/**
+ * Forces every in-flight progressive mount to completion and resolves after
+ * the resulting commit has painted. DOM consumers that clone the thread
+ * (screenshot export) call this so a capture taken mid-widening cannot
+ * silently truncate the rows still outside the window.
+ */
+export async function completeProgressiveRowMounts(): Promise {
+ if (activeCompleters.size === 0) {
+ return;
+ }
+ await Promise.all([...activeCompleters].map((complete) => complete()));
+}
diff --git a/client/src/hooks/ScreenshotContext.tsx b/client/src/hooks/ScreenshotContext.tsx
index 1e26398d28..5e4418e025 100644
--- a/client/src/hooks/ScreenshotContext.tsx
+++ b/client/src/hooks/ScreenshotContext.tsx
@@ -1,6 +1,7 @@
import { createContext, useRef, useContext, RefObject, ReactNode } from 'react';
import { toCanvas } from 'html-to-image';
import { ThemeContext, isDark } from '@librechat/client';
+import { completeProgressiveRowMounts } from '~/hooks/Messages/useProgressiveRowMount';
type ScreenshotContextType = {
ref?: RefObject;
@@ -76,6 +77,9 @@ export const useScreenshot = () => {
if (ref instanceof Function) {
throw new Error('Ref callback is not supported.');
}
+ /** A capture taken while a long thread is still progressively mounting
+ * would clone a truncated DOM; force the remaining rows in first. */
+ await completeProgressiveRowMounts();
if (ref?.current) {
return takeScreenShot(ref.current);
}
diff --git a/client/src/utils/groupToolCalls.ts b/client/src/utils/groupToolCalls.ts
index 2d6b72e087..f924a84a9e 100644
--- a/client/src/utils/groupToolCalls.ts
+++ b/client/src/utils/groupToolCalls.ts
@@ -7,6 +7,33 @@ export type GroupedPart =
| { type: 'single'; part: PartWithIndex }
| { type: 'tool-group'; parts: PartWithIndex[]; labelPart?: PartWithIndex };
+type ToolCallWithNestedContent = Agents.ToolCall & {
+ subagent_content?: TMessageContentParts[];
+};
+
+/**
+ * True when the part carries an unresolved tool approval — directly or nested
+ * in subagent content. Collapsed disclosure bodies retain instead of
+ * unmounting while this holds, because `ToolApproval` owns unsent local
+ * edit/respond/reason state that an unmount would discard.
+ */
+export function hasPendingApprovalInPart(part: TMessageContentParts): boolean {
+ if (part.type !== ContentTypes.TOOL_CALL) {
+ return false;
+ }
+ const toolCall = part[ContentTypes.TOOL_CALL] as ToolCallWithNestedContent | undefined;
+ if (!toolCall) {
+ return false;
+ }
+ if (toolCall.approval != null && (toolCall.output?.length ?? 0) === 0) {
+ return true;
+ }
+ return (
+ Array.isArray(toolCall.subagent_content) &&
+ toolCall.subagent_content.some(hasPendingApprovalInPart)
+ );
+}
+
function isGroupableToolCall(part: TMessageContentParts): boolean {
if (part.type !== ContentTypes.TOOL_CALL) {
return false;
diff --git a/e2e/specs/mock/export.spec.ts b/e2e/specs/mock/export.spec.ts
index 515f82d980..bb6482fa48 100644
--- a/e2e/specs/mock/export.spec.ts
+++ b/e2e/specs/mock/export.spec.ts
@@ -177,8 +177,15 @@ test.describe('conversation export', () => {
});
const target = page.getByTestId('screenshot-target');
- const area = await target.evaluate((node) => node.scrollWidth * node.scrollHeight);
- expect(area).toBeGreaterThan(ABORT_CSS_AREA * 1.15);
+ /** Long threads mount progressively from the scroll anchor, so the full
+ * area lands a few frames after first paint — poll until it converges.
+ * (The capture path itself force-completes the mount; this precondition
+ * samples the DOM directly and must wait on its own.) */
+ await expect
+ .poll(() => target.evaluate((node) => node.scrollWidth * node.scrollHeight), {
+ timeout: 60_000,
+ })
+ .toBeGreaterThan(ABORT_CSS_AREA * 1.15);
const dialog = await openExportModal(page);
await selectExportType(page, dialog, 'screenshot (.png)');
diff --git a/packages/data-provider/src/messages.spec.ts b/packages/data-provider/src/messages.spec.ts
index 1d3aed846b..f08da71286 100644
--- a/packages/data-provider/src/messages.spec.ts
+++ b/packages/data-provider/src/messages.spec.ts
@@ -144,4 +144,55 @@ describe('buildTree', () => {
expect(tree).toHaveLength(1);
expect(tree?.[0].files?.[0]).toBe(file);
});
+
+ describe('memoization', () => {
+ const chain = () => [
+ msg('u1', '00000000-0000-0000-0000-000000000000', { isCreatedByUser: true }),
+ msg('a1', 'u1'),
+ ];
+
+ it('returns the identical tree for the same messages array', () => {
+ const messages = chain();
+ expect(buildTree({ messages })).toBe(buildTree({ messages }));
+ });
+
+ it('keeps one cached tree per fileMap identity', () => {
+ const messages = chain();
+ const fileMap = { f1: { file_id: 'f1' } as TFile };
+
+ const bare = buildTree({ messages });
+ const hydrated = buildTree({ messages, fileMap });
+
+ expect(hydrated).not.toBe(bare);
+ expect(buildTree({ messages })).toBe(bare);
+ expect(buildTree({ messages, fileMap })).toBe(hydrated);
+ });
+
+ it('rebuilds for a new messages array identity', () => {
+ const first = chain();
+ const second = chain();
+ expect(buildTree({ messages: first })).not.toBe(buildTree({ messages: second }));
+ });
+
+ it('rebuilds when the fileMap identity changes', () => {
+ const messages = chain();
+ const treeA = buildTree({ messages, fileMap: {} });
+ const treeB = buildTree({ messages, fileMap: {} });
+ expect(treeB).not.toBe(treeA);
+ });
+
+ it('keeps only the latest hydrated tree, leaving the bare slot intact', () => {
+ const messages = chain();
+ const bare = buildTree({ messages });
+ const fileMapA = { f1: { file_id: 'f1' } as TFile };
+ const fileMapB = { f1: { file_id: 'f1' } as TFile };
+
+ const treeA = buildTree({ messages, fileMap: fileMapA });
+ const treeB = buildTree({ messages, fileMap: fileMapB });
+
+ expect(buildTree({ messages, fileMap: fileMapB })).toBe(treeB);
+ expect(buildTree({ messages, fileMap: fileMapA })).not.toBe(treeA);
+ expect(buildTree({ messages })).toBe(bare);
+ });
+ });
});
diff --git a/packages/data-provider/src/messages.ts b/packages/data-provider/src/messages.ts
index 518da77e7b..4b50e05952 100644
--- a/packages/data-provider/src/messages.ts
+++ b/packages/data-provider/src/messages.ts
@@ -23,6 +23,22 @@ export function stripReasoningLabelMetadata(part: TMessageContentParts): TMessag
}
export type ParentMessage = TMessage & { children: TMessage[]; depth: number };
+
+/**
+ * Memoizes built trees per messages-array identity. The same query data feeds
+ * several independent `select`s (ChatView plus the branch-tail helpers), which
+ * used to rebuild the full tree five times per cache write. Exactly two slots
+ * per array — the bare tree and the tree for the LATEST fileMap identity — so
+ * a long-lived cached conversation cannot accumulate a tree per historical
+ * file-map; entries die with the messages array itself.
+ */
+type TreeCacheEntry = {
+ bare?: TMessage[];
+ fileMap?: Record;
+ hydrated?: TMessage[];
+};
+const treeCache = new WeakMap<(TMessage | undefined)[], TreeCacheEntry>();
+
/**
* Builds the render tree from the flat messages array. Order-robust: live
* stream/steer/preempt cache writes can momentarily place a child before its
@@ -42,6 +58,16 @@ export function buildTree({
return null;
}
+ const cached = treeCache.get(messages);
+ if (cached) {
+ if (fileMap == null && cached.bare) {
+ return cached.bare;
+ }
+ if (fileMap != null && cached.fileMap === fileMap && cached.hydrated) {
+ return cached.hydrated;
+ }
+ }
+
const messageMap: Record = {};
const orderedMessages: ParentMessage[] = [];
const rootMessages: ParentMessage[] = [];
@@ -116,5 +142,16 @@ export function buildTree({
}
}
- return rootMessages as TMessage[];
+ const tree = rootMessages as TMessage[];
+ const entry = cached ?? {};
+ if (fileMap == null) {
+ entry.bare = tree;
+ } else {
+ entry.fileMap = fileMap;
+ entry.hydrated = tree;
+ }
+ if (!cached) {
+ treeCache.set(messages, entry);
+ }
+ return tree;
}
diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts
index a860a1b682..fdb7fe28d9 100644
--- a/packages/data-schemas/src/index.ts
+++ b/packages/data-schemas/src/index.ts
@@ -7,6 +7,7 @@ export * from './utils';
export { createModels } from './models';
export {
createMethods,
+ CLIENT_MESSAGE_SELECT,
RoleConflictError,
DEFAULT_REFRESH_TOKEN_EXPIRY,
DEFAULT_SESSION_EXPIRY,
diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts
index d83ccb0c8a..b6f42bed2a 100644
--- a/packages/data-schemas/src/methods/conversation.spec.ts
+++ b/packages/data-schemas/src/methods/conversation.spec.ts
@@ -1422,6 +1422,41 @@ describe('Conversation Operations', () => {
});
});
+ describe('getConvoOwnership', () => {
+ it('resolves only the owning user id, without the preset or message list', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ title: 'Test Conversation',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ const result = await methods.getConvoOwnership(
+ 'user123',
+ mockConversationData.conversationId,
+ );
+
+ expect(result?.user).toBe('user123');
+ expect(result).not.toHaveProperty('title');
+ expect(result).not.toHaveProperty('messages');
+ expect(result).not.toHaveProperty('endpoint');
+ });
+
+ it('returns null for another user or a missing conversation', async () => {
+ await Conversation.create({
+ conversationId: mockConversationData.conversationId,
+ user: 'user123',
+ title: 'Test Conversation',
+ endpoint: EModelEndpoint.openAI,
+ });
+
+ expect(
+ await methods.getConvoOwnership('someone-else', mockConversationData.conversationId),
+ ).toBeNull();
+ expect(await methods.getConvoOwnership('user123', 'non-existent-id')).toBeNull();
+ });
+ });
+
describe('getConvoRetention', () => {
it('should retrieve only retention fields for a user conversation', async () => {
await Conversation.create({
diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts
index 0cc7093d99..c4a85c2d55 100644
--- a/packages/data-schemas/src/methods/conversation.ts
+++ b/packages/data-schemas/src/methods/conversation.ts
@@ -80,6 +80,10 @@ export interface ConversationMethods {
convoMap: Record;
}>;
getConvo(user: string, conversationId: string): Promise;
+ getConvoOwnership(
+ user: string,
+ conversationId: string,
+ ): Promise | null>;
getConvoRetention(
user: string,
conversationId: string,
@@ -135,6 +139,23 @@ export function createConversationMethods(
}
}
+ /**
+ * Ownership probe for request validation: resolves only the owning user id
+ * instead of materializing the full conversation document (preset spread +
+ * message ObjectId array).
+ */
+ async function getConvoOwnership(user: string, conversationId: string) {
+ try {
+ const Conversation = mongoose.models.Conversation as Model;
+ return await Conversation.findOne({ user, conversationId }, 'user').lean<
+ Pick
+ >();
+ } catch (error) {
+ logger.error('[getConvoOwnership] Error checking conversation ownership', error);
+ throw new Error('Error checking conversation ownership');
+ }
+ }
+
/**
* Retrieves only the retention deadline for a conversation.
*/
@@ -1083,6 +1104,7 @@ export function createConversationMethods(
getConvosByCursor,
getConvosQueried,
getConvo,
+ getConvoOwnership,
getConvoRetention,
getConvoTitle,
deleteConvos,
diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts
index 1683489a8d..b5b3347bed 100644
--- a/packages/data-schemas/src/methods/index.ts
+++ b/packages/data-schemas/src/methods/index.ts
@@ -44,7 +44,7 @@ import { createCategoriesMethods, type CategoriesMethods } from './categories';
import { createPresetMethods, type PresetMethods } from './preset';
/* Tier 2 — Moderate (service deps injected) */
import { createConversationTagMethods, type ConversationTagMethods } from './conversationTag';
-import { createMessageMethods, type MessageMethods } from './message';
+import { createMessageMethods, CLIENT_MESSAGE_SELECT, type MessageMethods } from './message';
import { createConversationMethods, type ConversationMethods } from './conversation';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
export type {
@@ -132,6 +132,7 @@ export {
};
export { tokenValues, cacheTokenValues, premiumTokenValues, defaultRate, createTxMethods };
export { permissionBitSupersets };
+export { CLIENT_MESSAGE_SELECT };
export {
partitionIssues,
validateSkillName,
diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts
index 4b1d5ccd50..dd0922d886 100644
--- a/packages/data-schemas/src/methods/message.spec.ts
+++ b/packages/data-schemas/src/methods/message.spec.ts
@@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
import { RetentionMode } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { IMessage } from '..';
+import { createMessageMethods, CLIENT_MESSAGE_SELECT } from './message';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
-import { createMessageMethods } from './message';
import { createModels } from '../models';
import logger from '~/config/winston';
@@ -487,6 +487,134 @@ describe('Message Operations', () => {
});
});
+ describe('CLIENT_MESSAGE_SELECT projection', () => {
+ it('strips server-internal fields and dead SERP verticals, keeping rendered data', async () => {
+ const conversationId = uuidv4();
+ await Message.create({
+ messageId: 'projected-msg',
+ conversationId,
+ user: 'user123',
+ isCreatedByUser: false,
+ sender: 'Agent',
+ text: 'visible text',
+ content: [{ type: 'text', text: 'part text' }],
+ tokenCount: 42,
+ conversationSignature: 'sig',
+ clientId: 'client-1',
+ invocationId: 7,
+ summary: 'legacy summary',
+ summaryTokenCount: 11,
+ contextMeta: { anything: true },
+ langfuseSampled: true,
+ langfuseDestinationIds: ['lf-1'],
+ metadata: {
+ usage: { input: 10, output: 20 },
+ thoughtSignatures: { tool_1: 'opaque' },
+ },
+ attachments: [
+ {
+ type: 'web_search',
+ toolCallId: 'tool_1',
+ web_search: {
+ turn: 0,
+ organic: [
+ {
+ title: 'Result',
+ link: 'https://example.com',
+ snippet: 'snippet',
+ sitelinks: [{ title: 'sub', link: 'https://example.com/sub' }],
+ highlights: ['raw scrape'],
+ },
+ ],
+ topStories: [{ title: 'Story', link: 'https://example.com/s', highlights: ['x'] }],
+ references: [{ link: 'https://example.com', title: 'Result', type: 'link' }],
+ images: [{ imageUrl: 'https://example.com/i.png' }],
+ answerBox: { answer: '42' },
+ knowledgeGraph: { title: 'KG' },
+ peopleAlsoAsk: [{ question: 'q' }],
+ relatedSearches: ['related'],
+ news: [{ title: 'n' }],
+ videos: [{ title: 'v' }],
+ places: [{ title: 'p' }],
+ shopping: [{ title: 's' }],
+ },
+ },
+ ],
+ });
+
+ const [message] = await getMessages(
+ { conversationId, user: 'user123' },
+ CLIENT_MESSAGE_SELECT,
+ );
+
+ expect(message.text).toBe('visible text');
+ expect(message.content).toHaveLength(1);
+ expect(message.tokenCount).toBe(42);
+ const metadata = message.metadata as Record;
+ expect(metadata.usage).toBeDefined();
+ expect(metadata.thoughtSignatures).toBeUndefined();
+
+ const hidden = message as unknown as Record;
+ for (const field of [
+ '_id',
+ 'user',
+ 'conversationSignature',
+ 'clientId',
+ 'invocationId',
+ 'summary',
+ 'summaryTokenCount',
+ 'contextMeta',
+ 'langfuseSampled',
+ 'langfuseDestinationIds',
+ ]) {
+ expect(hidden[field]).toBeUndefined();
+ }
+
+ type ProjectedWebSearch = {
+ turn: number;
+ organic: Array>;
+ topStories: Array>;
+ references: unknown[];
+ images: unknown[];
+ } & Record;
+ const webSearch = (message.attachments?.[0] as { web_search: ProjectedWebSearch }).web_search;
+ expect(webSearch.turn).toBe(0);
+ expect(webSearch.organic[0].title).toBe('Result');
+ expect(webSearch.organic[0].link).toBe('https://example.com');
+ expect(webSearch.organic[0].snippet).toBe('snippet');
+ expect(webSearch.organic[0].sitelinks).toBeUndefined();
+ expect(webSearch.organic[0].highlights).toBeUndefined();
+ expect(webSearch.topStories[0].title).toBe('Story');
+ expect(webSearch.topStories[0].highlights).toBeUndefined();
+ expect(webSearch.references).toHaveLength(1);
+ expect(webSearch.images).toHaveLength(1);
+ /** `videos` stays: `turn…video…` citation markers resolve against it
+ * (the clipboard refTypeMap addresses it explicitly). */
+ expect(webSearch.videos).toHaveLength(1);
+ expect(webSearch.answerBox).toBeDefined();
+ for (const vertical of [
+ 'knowledgeGraph',
+ 'peopleAlsoAsk',
+ 'relatedSearches',
+ 'news',
+ 'places',
+ 'shopping',
+ ]) {
+ expect(webSearch[vertical]).toBeUndefined();
+ }
+ });
+ });
+
+ describe('conversation fetch index', () => {
+ it('declares the compound index that serves the conversation fetch and its sort', () => {
+ const indexes = Message.schema.indexes() as Array<[Record, unknown]>;
+ expect(indexes).toContainEqual([
+ { conversationId: 1, user: 1, createdAt: 1 },
+ expect.anything(),
+ ]);
+ });
+ });
+
describe('getMessages', () => {
it('should retrieve messages with the correct filter', async () => {
const conversationId = uuidv4();
diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts
index ca1928a907..a864acc576 100644
--- a/packages/data-schemas/src/methods/message.ts
+++ b/packages/data-schemas/src/methods/message.ts
@@ -9,6 +9,40 @@ import logger from '~/config/winston';
/** Simple UUID v4 regex to replace zod validation */
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+/**
+ * Exclusion projection for message reads that feed the chat client (the
+ * conversation GET and shared-link reads). Every excluded field is either
+ * server-internal (ids, replay signatures, legacy summarization state) or a
+ * web_search SERP vertical no citation marker or UI can address: markers
+ * resolve `search|image|news|video|ref|file` through organic/images/
+ * topStories/videos/references (all kept — `news` markers read topStories,
+ * never the `news` collection). The JSON export mirrors this cache, so
+ * fields removed here also leave user exports.
+ */
+export const CLIENT_MESSAGE_SELECT: string = [
+ '-_id',
+ '-__v',
+ '-user',
+ '-clientId',
+ '-invocationId',
+ '-conversationSignature',
+ '-summary',
+ '-summaryTokenCount',
+ '-contextMeta',
+ '-langfuseSampled',
+ '-langfuseDestinationIds',
+ '-metadata.thoughtSignatures',
+ '-attachments.web_search.knowledgeGraph',
+ '-attachments.web_search.peopleAlsoAsk',
+ '-attachments.web_search.relatedSearches',
+ '-attachments.web_search.shopping',
+ '-attachments.web_search.places',
+ '-attachments.web_search.news',
+ '-attachments.web_search.organic.sitelinks',
+ '-attachments.web_search.organic.highlights',
+ '-attachments.web_search.topStories.highlights',
+].join(' ');
+
interface MessageQueryOptions {
limit?: number;
sort?: Record | false;
diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts
index 88356114a6..87d470efc1 100644
--- a/packages/data-schemas/src/methods/share.ts
+++ b/packages/data-schemas/src/methods/share.ts
@@ -10,6 +10,7 @@ import {
} from '~/utils/stripUIResourceMarkers';
import { activeExpirationFilter } from '~/utils/retention';
import { isValidObjectIdString } from '~/utils/objectId';
+import { CLIENT_MESSAGE_SELECT } from './message';
import logger from '~/config/winston';
class ShareServiceError extends Error {
@@ -782,7 +783,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): {
const share = (await query
.populate({
path: 'messages',
- select: '-_id -__v -user',
+ select: CLIENT_MESSAGE_SELECT,
})
.select('-__v')
.lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null;
diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts
index 99415cb561..2d74d6b71c 100644
--- a/packages/data-schemas/src/schema/message.ts
+++ b/packages/data-schemas/src/schema/message.ts
@@ -199,6 +199,15 @@ messageSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
messageSchema.index({ createdAt: 1 });
messageSchema.index({ messageId: 1, user: 1, tenantId: 1 }, { unique: true });
+/**
+ * Serves the conversation fetch ({conversationId, user} filter + createdAt
+ * sort) from the index alone; without it Mongo fetches every full document in
+ * the conversation and sorts them in memory. tenantId is deliberately not in
+ * the middle: untenanted deployments issue no tenantId predicate, and a gap in
+ * the prefix would push the sort back into memory for them.
+ */
+messageSchema.index({ conversationId: 1, user: 1, createdAt: 1 });
+
// index for MeiliSearch sync operations
messageSchema.index({ _meiliIndex: 1, isTemporary: 1, expiredAt: 1 });