perf: Optimize First Load of Large Conversations (#14901)

*  perf: Index the Conversation Fetch and Trim the Client Message Projection

*  perf: Memoize the Message Tree per Cache Write

*  perf: Serve Message Reads via the Trimmed Projection and an Ownership Probe

*  perf: Defer Collapsed Disclosure Bodies Until First Expansion

*  perf: Progressively Mount Long Threads from the Scroll Anchor

* 🩹 fix: Address Codex Findings on Retention, Anchoring, and Cache Bounds

* 🩹 fix: Poll the Oversized Export Precondition Through the Progressive Mount

* 🩹 fix: Keep Video Results in the Client Message Projection
This commit is contained in:
Danny Avila 2026-08-16 19:45:21 -04:00 committed by GitHub
parent df5abbb377
commit 1b7e2a4e6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1192 additions and 138 deletions

View file

@ -6,6 +6,7 @@ import type { TMessageContentParts } from 'librechat-data-provider';
import type { CSSProperties, ReactNode } from 'react';
import {
useExpandCollapse,
useLazyCollapseBody,
scheduleMessageContentLayoutReconcile,
EXPAND_TRANSITION,
} from '~/hooks';
@ -54,12 +55,14 @@ export default function ActivityPhaseGroup({
hasContent,
showCursor = false,
animateEntrance = false,
hasPendingApproval = false,
}: {
labelPart: ActivityPhasePart;
children: ReactNode;
hasContent: boolean;
showCursor?: boolean;
animateEntrance?: boolean;
hasPendingApproval?: boolean;
}) {
const label = getActivityLabelText(labelPart);
const hasFailure = labelPart.status === 'failed' || labelPart.status === 'partial';
@ -85,6 +88,14 @@ export default function ActivityPhaseGroup({
const previousIsExpandedRef = useRef(isExpanded);
const userOverrideRef = useRef(false);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
/** A phase label can resolve while an approval card inside it is still
* pending (see ApprovalContext), and ToolApproval owns unsent local
* edit/respond/reason state so a collapsed phase retains its body until
* every nested approval resolves, exactly like ToolCallGroup. */
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(
isExpanded,
hasPendingApproval,
);
useEffect(() => {
if (!foldsIn || userOverrideRef.current) {
@ -124,9 +135,10 @@ export default function ActivityPhaseGroup({
userOverrideRef.current = true;
cancelEntranceRef.current?.();
cancelEntranceRef.current = null;
mountBody();
setIsSettled(true);
setIsExpanded((expanded) => !expanded);
}, []);
}, [mountBody]);
/** Only the folding entrance drives the header off its natural height.
* History and reduced-motion render the plain, unstyled row. */
@ -211,24 +223,27 @@ export default function ActivityPhaseGroup({
<div
id={panelId}
style={expandStyle}
onTransitionEnd={handleTransitionEnd}
aria-hidden={!isExpanded}
data-testid="activity-phase-panel"
>
<div className="overflow-hidden" ref={expandRef}>
{/** Padding and the divider ride the same curve as the fold: the
* children occupy the exact position they held before the marker
* arrived and settle into the card as it materializes, instead of
* stepping sideways by the card's inset on the first frame. */}
<div
className={cn(
'border-t transition-[border-color,padding]',
FOLD_EASING,
isSettled ? 'border-border-light px-3 py-2' : 'border-transparent px-0 py-0',
)}
>
{children}
{shouldRenderBody && (
<div className="overflow-hidden" ref={expandRef}>
{/** Padding and the divider ride the same curve as the fold: the
* children occupy the exact position they held before the marker
* arrived and settle into the card as it materializes, instead of
* stepping sideways by the card's inset on the first frame. */}
<div
className={cn(
'border-t transition-[border-color,padding]',
FOLD_EASING,
isSettled ? 'border-border-light px-3 py-2' : 'border-transparent px-0 py-0',
)}
>
{children}
</div>
</div>
</div>
)}
</div>
</div>
);

View file

@ -15,6 +15,7 @@ import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts';
import { MessageContext, SearchContext } from '~/Providers';
import PendingSkillCall from './Parts/PendingSkillCall';
import ActivityPhaseGroup from './ActivityPhaseGroup';
import { hasPendingApprovalInPart } from '~/utils';
import EditContentParts from './EditContentParts';
import { EmptyText, AgentUpdate } from './Parts';
import ApprovalProvider from './ApprovalContext';
@ -530,6 +531,9 @@ const ContentParts = memo(function ContentParts({
key={`activity-phase-${messageId}-${segment.labelIndex}`}
labelPart={segment.labelPart}
hasContent={segment.hasContent}
hasPendingApproval={segment.content.some(
(part) => part != null && hasPendingApprovalInPart(part),
)}
animateEntrance={
previousPhaseIndices != null && !previousPhaseIndices.has(segment.labelIndex)
}

View file

@ -3,8 +3,8 @@ import { useAtomValue } from 'jotai';
import { ContentTypes } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent } from 'react';
import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking';
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { showThinkingAtom } from '~/store/showThinking';
import { useMessageContext } from '~/Providers';
import { cn } from '~/utils';
@ -47,6 +47,7 @@ const Reasoning = memo((props: ReasoningProps) => {
const [isBarVisible, setIsBarVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(isExpanded);
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
// Strip <think> tags from the reasoning content (modern format)
@ -57,10 +58,14 @@ const Reasoning = memo((props: ReasoningProps) => {
.trim();
}, [reasoning]);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsExpanded((prev) => !prev);
}, []);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
mountBody();
setIsExpanded((prev) => !prev);
},
[mountBody],
);
const handleFocus = useCallback(() => {
setIsBarVisible(true);
@ -127,20 +132,25 @@ const Reasoning = memo((props: ReasoningProps) => {
aria-hidden={!isExpanded || undefined}
className={cn(nextType !== ContentTypes.THINK && isExpanded && 'mb-4')}
style={expandStyle}
onTransitionEnd={handleTransitionEnd}
>
<div className="relative overflow-hidden" ref={expandRef}>
<ThinkingContent
animate={smoothStreaming && effectiveIsSubmitting && isLast && isExpanded}
>
{reasoningText}
</ThinkingContent>
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}
isExpanded={isExpanded}
onClick={handleClick}
content={reasoningText}
contentId={contentId}
/>
{shouldRenderBody && (
<>
<ThinkingContent
animate={smoothStreaming && effectiveIsSubmitting && isLast && isExpanded}
>
{reasoningText}
</ThinkingContent>
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}
isExpanded={isExpanded}
onClick={handleClick}
content={reasoningText}
contentId={contentId}
/>
</>
)}
</div>
</div>
</div>

View file

@ -10,7 +10,7 @@ import {
splitToolCallName,
} from 'librechat-data-provider';
import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
import { useLocalize, useProgress, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import { ToolIcon, getToolIconType, isError } from './ToolOutput';
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
import { useToolCallIntent } from './Parts/intent';
@ -54,6 +54,7 @@ export default function ToolCall({
const hasOutput = (output?.length ?? 0) > 0;
const [showInfo, setShowInfo] = useState(() => autoExpand && hasOutput);
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(showInfo);
useEffect(() => {
if (autoExpand && hasOutput) {
@ -194,6 +195,7 @@ export default function ToolCall({
const showCancelled = cancelled || (errorState && !output);
const handleToggleInfo = useCallback(() => {
mountBody();
setShowInfo((prev) => {
const next = !prev;
if (next) {
@ -201,7 +203,7 @@ export default function ToolCall({
}
return next;
});
}, [onExpand]);
}, [mountBody, onExpand]);
const subtitle = useMemo(() => {
if (isMCPToolCall && mcpServerName) {
@ -297,9 +299,13 @@ export default function ToolCall({
error={showCancelled}
/>
</div>
<div style={expandStyle} data-tool-call-output-id={toolCallId}>
<div
style={expandStyle}
onTransitionEnd={handleTransitionEnd}
data-tool-call-output-id={toolCallId}
>
<div className="overflow-hidden" ref={expandRef}>
{hasInfo && (
{hasInfo && shouldRenderBody && (
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
<ToolCallInfo input={args ?? ''} output={output} attachments={attachments} />
</div>

View file

@ -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;

View file

@ -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({
)}
</button>
{hasSourceData && (
<div style={sourceExpandStyle}>
<div style={sourceExpandStyle} onTransitionEnd={handleTransitionEnd}>
<div className="overflow-hidden" ref={sourceExpandRef}>
<div className="my-2 max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
return (
<a
key={source.link}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
i > 0 && 'border-t border-border-light',
)}
>
<FaviconImage domain={domain} className="size-4 shrink-0 rounded-sm" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
{source.title || domain}
</span>
<span className="shrink-0 text-[11px] text-text-secondary">{domain}</span>
</a>
);
})}
</div>
{shouldRenderBody && (
<div className="my-2 max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
return (
<a
key={source.link}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
i > 0 && 'border-t border-border-light',
)}
>
<FaviconImage domain={domain} className="size-4 shrink-0 rounded-sm" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
{source.title || domain}
</span>
<span className="shrink-0 text-[11px] text-text-secondary">{domain}</span>
</a>
);
})}
</div>
)}
</div>
</div>
)}

View file

@ -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(
<ActivityPhaseGroup labelPart={labelPart} hasContent>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
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(
<ActivityPhaseGroup labelPart={labelPart} hasContent>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
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(
<ActivityPhaseGroup labelPart={labelPart} hasContent hasPendingApproval>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
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(
<ActivityPhaseGroup labelPart={labelPart} hasContent hasPendingApproval={false}>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
});
test('the entrance fold keeps the body mounted, then releases it after settling', () => {
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent animateEntrance>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
expect(screen.getByTestId('phase-content')).toBeInTheDocument();
flushFrames();
flushFrames();
fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
});
});

View file

@ -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()),
}));

View file

@ -9,6 +9,7 @@ jest.mock('~/utils', () => ({
mapAttachments: () => ({}),
filterAttachmentsForPart: (attachments: unknown) => attachments,
groupSequentialToolCalls: jest.fn(),
hasPendingApprovalInPart: jest.requireActual('~/utils/groupToolCalls').hasPendingApprovalInPart,
}));
jest.mock('~/Providers', () => {

View file

@ -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(<ToolCall {...mockProps} />);
// 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(<ToolCall {...mockProps} />);
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(<ToolCall {...mockProps} args={undefined as any} />);
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(<ToolCall {...mockProps} output={null} />);
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(<ToolCall {...mockProps} name="simpleName" />);
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', () => {

View file

@ -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', () => ({

View file

@ -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', () => {
</RecoilRoot>,
);
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'));

View file

@ -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,
}}
>
<div
@ -156,12 +177,14 @@ function MessagesViewContent({
) : (
<>
<div ref={screenshotTargetRef} data-testid="screenshot-target">
<MultiMessage
messagesTree={_messagesTree}
messageId={conversationId ?? null}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
/>
<RowMountProvider mountWindow={mountWindow}>
<MultiMessage
messagesTree={_messagesTree}
messageId={conversationId ?? null}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
/>
</RowMountProvider>
</div>
</>
)}

View file

@ -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 = <MessageParts {...sharedProps} />;
} else if (message.content) {
row = <MessageContent {...sharedProps} />;

View file

@ -193,3 +193,46 @@ describe('MultiMessage sibling selection', () => {
expect(displayed()).toBe('a1');
});
});
describe('MultiMessage row mount window', () => {
const { RowMountProvider } =
jest.requireActual<typeof import('~/hooks/Messages')>('~/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) => (
<RecoilRoot>
<RowMountProvider mountWindow={mountWindow}>
<MultiMessage
messageId="parent-1"
messagesTree={[chain()]}
currentEditId={null}
setCurrentEditId={jest.fn()}
/>
</RowMountProvider>
</RecoilRoot>
);
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']);
});
});

View file

@ -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 (
<div>
<button
type="button"
onClick={() => {
mountBody();
setIsExpanded((prev) => !prev);
}}
>
{TOGGLE_LABEL}
</button>
<div data-testid="panel" onTransitionEnd={handleTransitionEnd}>
{shouldRenderBody && <div data-testid="body" />}
</div>
</div>
);
}
describe('useLazyCollapseBody', () => {
it('leaves a collapsed-by-default body unmounted', () => {
render(<Disclosure initialExpanded={false} />);
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
});
it('mounts an expanded-by-default body immediately', () => {
render(<Disclosure initialExpanded={true} />);
expect(screen.getByTestId('body')).toBeInTheDocument();
});
it('mounts in the same commit as a user expand', () => {
render(<Disclosure initialExpanded={false} />);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('body')).toBeInTheDocument();
});
it('keeps the body through the collapse transition, then releases it', () => {
render(<Disclosure initialExpanded={false} />);
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(<Disclosure initialExpanded={false} />);
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(<Disclosure initialExpanded={true} />);
fireEvent.transitionEnd(screen.getByTestId('panel'));
expect(screen.getByTestId('body')).toBeInTheDocument();
});
it('retains the body across a collapse while retainBody is set', () => {
const view = render(<Disclosure initialExpanded={false} retainBody />);
const toggle = screen.getByRole('button');
fireEvent.click(toggle);
fireEvent.click(toggle);
fireEvent.transitionEnd(screen.getByTestId('panel'));
expect(screen.getByTestId('body')).toBeInTheDocument();
view.rerender(<Disclosure initialExpanded={false} retainBody={false} />);
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
});
it('keeps an expanded body mounted when retention clears', () => {
const view = render(<Disclosure initialExpanded={false} retainBody />);
fireEvent.click(screen.getByRole('button'));
view.rerender(<Disclosure initialExpanded={false} retainBody={false} />);
expect(screen.getByTestId('body')).toBeInTheDocument();
});
});

View file

@ -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<FrameRequestCallback | undefined>;
const scrollableRef = { current: null } as React.RefObject<HTMLDivElement | null>;
/** 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<HookProps> = {}) => {
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<RowMountWindow> => 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<void> = 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 });
});
});

View file

@ -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';

View file

@ -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<HTMLElement>) => 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<HTMLElement>) => {
if (event.target !== event.currentTarget || isExpanded) {
return;
}
if (retainBody) {
retainedRef.current = true;
return;
}
retainedRef.current = false;
setShouldRenderBody(false);
},
[isExpanded, retainBody],
);
return { shouldRenderBody, mountBody, handleTransitionEnd };
}

View file

@ -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<RowMountWindow>(null);
export function RowMountProvider({
mountWindow,
children,
}: {
mountWindow: RowMountWindow;
children: ReactNode;
}) {
return <RowMountContext.Provider value={mountWindow}>{children}</RowMountContext.Provider>;
}
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<HTMLDivElement | null>;
};
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<RowMountWindow>(() =>
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<void>((resolve) => {
setMountWindow(null);
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
activeCompleters.add(complete);
return () => {
activeCompleters.delete(complete);
};
}, [isWindowActive]);
return mountWindow;
}
const activeCompleters = new Set<() => Promise<void>>();
/**
* 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<void> {
if (activeCompleters.size === 0) {
return;
}
await Promise.all([...activeCompleters].map((complete) => complete()));
}

View file

@ -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<HTMLDivElement>;
@ -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);
}

View file

@ -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;