⚡ 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

@ -1,5 +1,5 @@
jest.mock('~/models', () => ({
getConvo: jest.fn(),
getConvoOwnership: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
@ -18,7 +18,7 @@ jest.mock('@librechat/data-schemas', () => ({
}));
const validateMessageReq = require('../validateMessageReq');
const { getConvo } = require('~/models');
const { getConvoOwnership } = require('~/models');
const { GenerationJobManager } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
@ -52,7 +52,7 @@ describe('validateMessageReq', () => {
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' });
expect(getConvo).not.toHaveBeenCalled();
expect(getConvoOwnership).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
@ -69,7 +69,7 @@ describe('validateMessageReq', () => {
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' });
expect(getConvo).not.toHaveBeenCalled();
expect(getConvoOwnership).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
@ -81,11 +81,11 @@ describe('validateMessageReq', () => {
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue({ conversationId: 'convo-owned', user: userId });
getConvoOwnership.mockResolvedValue({ conversationId: 'convo-owned', user: userId });
await validateMessageReq(req, res, next);
expect(getConvo).toHaveBeenCalledWith(userId, 'convo-owned');
expect(getConvoOwnership).toHaveBeenCalledWith(userId, 'convo-owned');
expect(next).toHaveBeenCalledTimes(1);
});
@ -98,7 +98,7 @@ describe('validateMessageReq', () => {
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
getConvoOwnership.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId, tenantId: 'tenant-a' },
@ -120,7 +120,7 @@ describe('validateMessageReq', () => {
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
getConvoOwnership.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId },
@ -141,7 +141,7 @@ describe('validateMessageReq', () => {
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
getConvoOwnership.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId: 'another-user' },
@ -163,7 +163,7 @@ describe('validateMessageReq', () => {
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
getConvoOwnership.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId, tenantId: 'tenant-b' },
@ -185,7 +185,7 @@ describe('validateMessageReq', () => {
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
getConvoOwnership.mockResolvedValue(null);
await validateMessageReq(req, res, next);
@ -205,7 +205,7 @@ describe('validateMessageReq', () => {
const res = createResponse();
const next = jest.fn();
const error = new Error('job store unavailable');
getConvo.mockResolvedValue(null);
getConvoOwnership.mockResolvedValue(null);
GenerationJobManager.getJob.mockRejectedValue(error);
await validateMessageReq(req, res, next);
@ -229,7 +229,7 @@ describe('validateMessageReq', () => {
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
getConvoOwnership.mockResolvedValue(null);
await validateMessageReq(req, res, next);

View file

@ -4,10 +4,10 @@ const {
isPendingActionStale,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getConvo } = require('~/models');
const { getConvoOwnership } = require('~/models');
module.exports = createMessageRequestMiddleware({
getConvo,
getConvo: getConvoOwnership,
getJob: (conversationId) => GenerationJobManager.getJob(conversationId),
isPendingActionStale,
logger,

View file

@ -1,3 +1,4 @@
const { CLIENT_MESSAGE_SELECT } = require('@librechat/data-schemas');
const express = require('express');
const request = require('supertest');
@ -49,7 +50,7 @@ jest.mock('librechat-data-provider', () => ({
jest.mock('~/models', () => ({
saveConvo: jest.fn(),
getConvo: jest.fn(),
getConvoOwnership: jest.fn(),
getMessage: jest.fn(),
saveMessage: jest.fn(),
getMessages: jest.fn(),
@ -90,7 +91,7 @@ jest.mock('~/db/models', () => ({
describe('GET /api/messages/:conversationId with real validation middleware', () => {
let app;
const { getConvo, getMessages } = require('~/models');
const { getConvoOwnership, getMessages } = require('~/models');
const authenticatedUserId = 'user-owner-123';
beforeAll(() => {
@ -114,7 +115,7 @@ describe('GET /api/messages/:conversationId with real validation middleware', ()
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
expect(getConvo).not.toHaveBeenCalled();
expect(getConvoOwnership).not.toHaveBeenCalled();
expect(getMessages).not.toHaveBeenCalled();
});
@ -125,7 +126,7 @@ describe('GET /api/messages/:conversationId with real validation middleware', ()
resolveConvo = resolve;
});
getConvo.mockImplementation(() => {
getConvoOwnership.mockImplementation(() => {
events.push('convo-started');
return convoPromise;
});
@ -156,10 +157,10 @@ describe('GET /api/messages/:conversationId with real validation middleware', ()
const response = await responsePromise;
expect(eventsBeforeValidation).toEqual(['convo-started', 'messages-started']);
expect(getConvo).toHaveBeenCalledWith(authenticatedUserId, 'convo-1');
expect(getConvoOwnership).toHaveBeenCalledWith(authenticatedUserId, 'convo-1');
expect(getMessages).toHaveBeenCalledWith(
{ conversationId: 'convo-1', user: authenticatedUserId },
'-_id -__v -user',
CLIENT_MESSAGE_SELECT,
);
expect(response.status).toBe(200);
expect(response.body).toEqual([{ messageId: 'message-1', conversationId: 'convo-1' }]);

View file

@ -1,3 +1,4 @@
const { CLIENT_MESSAGE_SELECT } = require('@librechat/data-schemas');
const express = require('express');
const request = require('supertest');
@ -171,7 +172,7 @@ describe('message route conversation ownership filters', () => {
expect(response.status).toBe(200);
expect(getMessages).toHaveBeenCalledWith(
{ conversationId: 'convo-1', user: authenticatedUserId },
'-_id -__v -user',
CLIENT_MESSAGE_SELECT,
);
});
@ -216,7 +217,7 @@ describe('message route conversation ownership filters', () => {
expect(eventsBeforeValidation).toEqual(['messages-started']);
expect(getMessages).toHaveBeenCalledWith(
{ conversationId: 'convo-1', user: authenticatedUserId },
'-_id -__v -user',
CLIENT_MESSAGE_SELECT,
);
expect(response.status).toBe(200);
@ -242,7 +243,7 @@ describe('message route conversation ownership filters', () => {
expect(getMessages).toHaveBeenCalledWith(
{ conversationId: 'convo-1', user: authenticatedUserId },
'-_id -__v -user',
CLIENT_MESSAGE_SELECT,
);
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Conversation not found' });
@ -256,7 +257,7 @@ describe('message route conversation ownership filters', () => {
expect(response.status).toBe(200);
expect(getMessages).toHaveBeenCalledWith(
{ conversationId: 'convo-1', messageId: 'message-1', user: authenticatedUserId },
'-_id -__v -user',
CLIENT_MESSAGE_SELECT,
);
});
});

View file

@ -1,6 +1,6 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { logger, CLIENT_MESSAGE_SELECT } = require('@librechat/data-schemas');
const {
ContentTypes,
feedbackSchema,
@ -289,7 +289,7 @@ router.get('/:conversationId', prepareMessageRequestValidation, async (req, res)
// This intentionally starts a user-scoped read before validation resolves;
// the response remains gated on validation success below.
const messagesPromise = validation.shouldFetchMessages
? db.getMessages({ conversationId, user: req.user.id }, '-_id -__v -user').then(
? db.getMessages({ conversationId, user: req.user.id }, CLIENT_MESSAGE_SELECT).then(
(messages) => ({ messages }),
(error) => ({ error }),
)
@ -350,7 +350,7 @@ router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) =
const { conversationId, messageId } = req.params;
const message = await db.getMessages(
{ conversationId, messageId, user: req.user.id },
'-_id -__v -user',
CLIENT_MESSAGE_SELECT,
);
if (!message) {
return res.status(404).json({ error: 'Message not found' });

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;

View file

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

View file

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

View file

@ -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<string, TFile>;
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<string, ParentMessage> = {};
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;
}

View file

@ -7,6 +7,7 @@ export * from './utils';
export { createModels } from './models';
export {
createMethods,
CLIENT_MESSAGE_SELECT,
RoleConflictError,
DEFAULT_REFRESH_TOKEN_EXPIRY,
DEFAULT_SESSION_EXPIRY,

View file

@ -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({

View file

@ -80,6 +80,10 @@ export interface ConversationMethods {
convoMap: Record<string, unknown>;
}>;
getConvo(user: string, conversationId: string): Promise<IConversation | null>;
getConvoOwnership(
user: string,
conversationId: string,
): Promise<Pick<IConversation, 'user'> | 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<IConversation>;
return await Conversation.findOne({ user, conversationId }, 'user').lean<
Pick<IConversation, 'user'>
>();
} 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,

View file

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

View file

@ -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<string, unknown>;
expect(metadata.usage).toBeDefined();
expect(metadata.thoughtSignatures).toBeUndefined();
const hidden = message as unknown as Record<string, unknown>;
for (const field of [
'_id',
'user',
'conversationSignature',
'clientId',
'invocationId',
'summary',
'summaryTokenCount',
'contextMeta',
'langfuseSampled',
'langfuseDestinationIds',
]) {
expect(hidden[field]).toBeUndefined();
}
type ProjectedWebSearch = {
turn: number;
organic: Array<Record<string, unknown>>;
topStories: Array<Record<string, unknown>>;
references: unknown[];
images: unknown[];
} & Record<string, unknown>;
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<string, number>, 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();

View file

@ -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<string, 1 | -1> | false;

View file

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

View file

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