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