From 58662283aff4c7a1a3a1d1469461d739843e5fb0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 1 Jun 2026 22:34:59 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9D=20fix:=20Preserve=20Grouped=20Tool?= =?UTF-8?q?=20Expansion=20During=20Streaming=20(#13462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix grouped tool output streaming state * fix codex review feedback for grouped tools * fix grouped tool review edge cases * fix grouped tool manual expansion cleanup * fix grouped tool scroll cleanup * fix grouped tool initial scroll cleanup * fix lazy mount collapsed tool groups --- .../Chat/Messages/Content/CodeAnalyze.tsx | 14 +- .../Chat/Messages/Content/ContentParts.tsx | 52 +++- .../components/Chat/Messages/Content/Part.tsx | 13 + .../Chat/Messages/Content/Parts/BashCall.tsx | 4 +- .../Messages/Content/Parts/ExecuteCode.tsx | 4 +- .../Messages/Content/Parts/ReadFileCall.tsx | 4 +- .../Chat/Messages/Content/Parts/SkillCall.tsx | 4 +- .../Messages/Content/Parts/SubagentCall.tsx | 11 +- .../Content/Parts/useToolCallState.ts | 11 +- .../Chat/Messages/Content/RetrievalCall.tsx | 14 +- .../Chat/Messages/Content/ToolCall.tsx | 14 +- .../Chat/Messages/Content/ToolCallGroup.tsx | 101 +++++- .../Chat/Messages/Content/WebSearch.tsx | 14 +- .../ContentParts.integration.test.tsx | 130 +++++++- .../Content/__tests__/ToolCallGroup.test.tsx | 75 ++++- .../components/Chat/Messages/MessagesView.tsx | 3 +- .../Messages/__tests__/messageLayout.spec.ts | 42 +++ .../__tests__/useMessageScrolling.spec.tsx | 290 ++++++++++++++++++ client/src/hooks/Messages/index.ts | 7 + client/src/hooks/Messages/messageLayout.ts | 87 ++++++ .../src/hooks/Messages/useMessageScrolling.ts | 90 +++++- 21 files changed, 949 insertions(+), 35 deletions(-) create mode 100644 client/src/hooks/Messages/__tests__/messageLayout.spec.ts create mode 100644 client/src/hooks/Messages/__tests__/useMessageScrolling.spec.tsx create mode 100644 client/src/hooks/Messages/messageLayout.ts diff --git a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx index 3d4fdee1c9..dd80e95d78 100644 --- a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx +++ b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx @@ -11,10 +11,12 @@ export default function CodeAnalyze({ initialProgress = 0.1, code, outputs = [], + onExpand, }: { initialProgress: number; code: string; outputs: Record[]; + onExpand?: () => void; }) { const localize = useLocalize(); const progress = useProgress(initialProgress); @@ -27,6 +29,16 @@ export default function CodeAnalyze({ } }, [autoExpand]); + const handleToggleCode = () => { + setShowCode((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }; + const logs = outputs.reduce((acc, output) => { if (output['logs']) { return acc + output['logs'] + '\n'; @@ -42,7 +54,7 @@ export default function CodeAnalyze({
setShowCode((prev) => !prev)} + onClick={handleToggleCode} inProgressText={localize('com_ui_analyzing')} finishedText={localize('com_ui_analyzing_finished')} hasInput={!!code.length} diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 6a8a544d43..84e8ce3db8 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useCallback } from 'react'; +import { memo, useRef, useMemo, useCallback } from 'react'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, @@ -13,12 +13,25 @@ import { EditTextPart, EmptyText } from './Parts'; import PendingSkillCall from './Parts/PendingSkillCall'; import MemoryArtifacts from './MemoryArtifacts'; import ToolCallGroup from './ToolCallGroup'; +import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import Container from './Container'; import Part from './Part'; const getToolCallId = (part: TMessageContentParts): string => (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; +const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string => { + const firstPart = parts[0]; + if (!firstPart) { + return 'empty'; + } + const toolCallId = getToolCallId(firstPart.part); + if (toolCallId) { + return `tool:${toolCallId}`; + } + return `fallback:${fallbackScope}:${firstPart.idx}`; +}; + type PartWithContextProps = { part: TMessageContentParts; idx: number; @@ -32,6 +45,7 @@ type PartWithContextProps = { isLast: boolean; partAttachments: TAttachment[] | undefined; hideAttachments?: boolean; + onToolExpand?: () => void; }; const PartWithContext = memo(function PartWithContext({ @@ -47,6 +61,7 @@ const PartWithContext = memo(function PartWithContext({ isLast, partAttachments, hideAttachments, + onToolExpand, }: PartWithContextProps) { const contextValue = useMemo( () => ({ @@ -72,6 +87,7 @@ const PartWithContext = memo(function PartWithContext({ isLast={isLastPart} showCursor={isLastPart && isLast} hideAttachments={hideAttachments} + onToolExpand={onToolExpand} /> ); @@ -129,6 +145,27 @@ const ContentParts = memo(function ContentParts({ }: ContentPartsProps) { const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + const toolGroupExpansionRef = useRef(new Map()); + const fallbackScopeRef = useRef({ messageId, scope: 0 }); + if (fallbackScopeRef.current.messageId !== messageId) { + if (!effectiveIsSubmitting) { + fallbackScopeRef.current.scope += 1; + toolGroupExpansionRef.current.clear(); + } + fallbackScopeRef.current.messageId = messageId; + } + const fallbackScope = fallbackScopeRef.current.scope; + + const handleGroupExpansionChange = useCallback( + (groupId: string, state: ToolCallGroupExpansionState) => { + if (!state.userOverride) { + toolGroupExpansionRef.current.delete(groupId); + return; + } + toolGroupExpansionRef.current.set(groupId, state); + }, + [], + ); /** * Interim skill cards — rendered in a separate slot ABOVE the Parts @@ -219,7 +256,7 @@ const ContentParts = memo(function ContentParts({ ); const renderGroupedPart = useCallback( - (part: TMessageContentParts, idx: number, isLastPart: boolean) => { + (part: TMessageContentParts, idx: number, isLastPart: boolean, onToolExpand?: () => void) => { return ( ); }, @@ -269,12 +307,13 @@ const ContentParts = memo(function ContentParts({ if (group.type === 'single') { return group; } + const groupId = getToolGroupId(group.parts, fallbackScope); const groupAttachments = group.parts.flatMap( ({ part }) => attachmentMap[getToolCallId(part)] ?? [], ); - return { ...group, groupAttachments }; + return { ...group, groupId, groupAttachments }; }), - [sequentialParts, attachmentMap], + [sequentialParts, attachmentMap, fallbackScope], ); // Early return: no content to render AND no pending skill cards @@ -361,15 +400,18 @@ const ContentParts = memo(function ContentParts({ const { part, idx } = group.part; return renderPart(part, idx, idx === lastContentIdx); } + const { groupId } = group; return ( p.idx === lastContentIdx)} renderPart={renderGroupedPart} lastContentIdx={lastContentIdx} groupAttachments={group.groupAttachments} + initialExpansionState={toolGroupExpansionRef.current.get(groupId)} + onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)} /> ); })} diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 888298cdb8..a0502816f6 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -40,6 +40,7 @@ type PartProps = { isCreatedByUser: boolean; attachments?: TAttachment[]; hideAttachments?: boolean; + onToolExpand?: () => void; }; const Part = memo(function Part({ @@ -50,6 +51,7 @@ const Part = memo(function Part({ showCursor, isCreatedByUser, hideAttachments, + onToolExpand, }: PartProps) { if (!part) { return null; @@ -143,6 +145,7 @@ const Part = memo(function Part({ attachments={attachments} commandField="code" hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if ( @@ -159,6 +162,7 @@ const Part = memo(function Part({ initialProgress={toolCall.progress ?? 0.1} args={toolCall.args} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if ( @@ -187,6 +191,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if (isToolCall && toolCall.name === Constants.SUBAGENT) { @@ -222,6 +227,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if (isToolCall && toolCall.name === Tools.bash_tool) { @@ -233,6 +239,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if (isToolCall && toolCall.name === Tools.web_search) { @@ -243,6 +250,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} isLast={isLast} + onExpand={onToolExpand} /> ); } else if (isToolCall && (toolCall.name === 'file_search' || toolCall.name === 'retrieval')) { @@ -252,6 +260,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} output={toolCall.output ?? undefined} attachments={attachments} + onExpand={onToolExpand} /> ); } else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) { @@ -268,6 +277,7 @@ const Part = memo(function Part({ auth={toolCall.auth} isLast={isLast} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { @@ -277,6 +287,7 @@ const Part = memo(function Part({ initialProgress={toolCall.progress ?? 0.1} code={code_interpreter.input} outputs={code_interpreter.outputs ?? []} + onExpand={onToolExpand} /> ); } else if ( @@ -289,6 +300,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} output={(toolCall as { output?: string }).output} attachments={attachments} + onExpand={onToolExpand} /> ); } else if ( @@ -326,6 +338,7 @@ const Part = memo(function Part({ output={toolCall.function.output} isLast={isLast} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index d9e5e92116..7221a0f7f0 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -20,6 +20,7 @@ export default function BashCall({ attachments, commandField = 'command', hideAttachments = false, + onExpand, }: { initialProgress: number; isSubmitting: boolean; @@ -28,13 +29,14 @@ export default function BashCall({ attachments?: TAttachment[]; commandField?: string; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const command = useMemo(() => parseJsonField(args, commandField), [args, commandField]); const isWritingCommand = !command || !areToolCallArgsComplete(args); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!command); + useToolCallState(initialProgress, isSubmitting, output, !!command, onExpand); const highlighted = useLazyHighlight(command || undefined, 'bash'); const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 018fe50ee7..c2ab2d575e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -57,6 +57,7 @@ export default function ExecuteCode({ output = '', attachments, hideAttachments = false, + onExpand, }: { initialProgress: number; isSubmitting: boolean; @@ -64,12 +65,13 @@ export default function ExecuteCode({ output?: string; attachments?: TAttachment[]; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const { lang = 'py', code } = useParseArgs(args) ?? ({} as ParsedArgs); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!code); + useToolCallState(initialProgress, isSubmitting, output, !!code, onExpand); const highlighted = useLazyHighlight(code, lang); const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); diff --git a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx index 98bd7dfd9f..da9d5e3806 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx @@ -68,6 +68,7 @@ export default function ReadFileCall({ output = '', attachments, hideAttachments = false, + onExpand, }: { initialProgress: number; isSubmitting: boolean; @@ -75,6 +76,7 @@ export default function ReadFileCall({ output?: string; attachments?: TAttachment[]; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]); @@ -82,7 +84,7 @@ export default function ReadFileCall({ const lang = useMemo(() => langFromPath(filePath), [filePath]); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!filePath); + useToolCallState(initialProgress, isSubmitting, output, !!filePath, onExpand); const highlighted = useLazyHighlight(hasOutput ? output : undefined, lang); diff --git a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx index 7f99ada76e..fae460a3d6 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx @@ -16,6 +16,7 @@ export default function SkillCall({ output = '', attachments, hideAttachments = false, + onExpand, }: { initialProgress: number; isSubmitting: boolean; @@ -23,12 +24,13 @@ export default function SkillCall({ output?: string; attachments?: TAttachment[]; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const skillName = useMemo(() => parseJsonField(args, 'skillName'), [args]); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!skillName); + useToolCallState(initialProgress, isSubmitting, output, !!skillName, onExpand); return ( <> diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index d36bca7fc8..23f5537e24 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -293,7 +293,12 @@ export default function SubagentCall({ * from routing through `Parts/index`. */ const renderDialogPart = useCallback( - (part: TMessageContentParts, idx: number, isLastPart: boolean): JSX.Element | null => { + ( + part: TMessageContentParts, + idx: number, + isLastPart: boolean, + onToolExpand?: () => void, + ): JSX.Element | null => { return ( ); }, @@ -811,11 +817,13 @@ function SubagentDialogPart({ isSubmitting, showCursor, isLast, + onToolExpand, }: { part: TMessageContentParts; isSubmitting: boolean; showCursor: boolean; isLast: boolean; + onToolExpand?: () => void; }): JSX.Element | null { if (part.type === ContentTypes.TEXT) { const text = (part as { text: string }).text; @@ -849,6 +857,7 @@ function SubagentDialogPart({ isSubmitting={isSubmitting} isLast={isLast} name={tc.name ?? ''} + onExpand={onToolExpand} /> ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts index 3599221444..7a487d4b7e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts +++ b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts @@ -21,6 +21,7 @@ export default function useToolCallState( isSubmitting: boolean, output: string, hasInput: boolean, + onExpand?: () => void, ): ToolCallState { const autoExpand = useRecoilValue(store.autoExpandTools); const hasOutput = output.length > 0; @@ -37,7 +38,15 @@ export default function useToolCallState( }, [autoExpand, hasContent]); const progress = useProgress(initialProgress); - const toggleCode = useCallback(() => setShowCode((prev) => !prev), []); + const toggleCode = useCallback(() => { + setShowCode((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }, [onExpand]); const cancelled = !isSubmitting && progress < 1 && !hasError; return { diff --git a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx index f367f07feb..97f86fdb75 100644 --- a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx +++ b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx @@ -326,11 +326,13 @@ export default function RetrievalCall({ isSubmitting, output, attachments, + onExpand, }: { initialProgress: number; isSubmitting: boolean; output?: string; attachments?: TAttachment[]; + onExpand?: () => void; }) { const progress = useProgress(initialProgress); const localize = useLocalize(); @@ -400,6 +402,16 @@ export default function RetrievalCall({ } }, [autoExpand, hasOutput]); + const handleToggleOutput = useCallback(() => { + setShowOutput((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }, [onExpand]); + return (
@@ -416,7 +428,7 @@ export default function RetrievalCall({
setShowOutput((prev) => !prev) : undefined} + onClick={hasOutput ? handleToggleOutput : undefined} inProgressText={localize('com_ui_searching_files')} finishedText={localize('com_ui_retrieved_files')} errorSuffix={errorState && !cancelled ? localize('com_ui_tool_failed') : undefined} diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index bd344bcc49..2efde33a20 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -28,6 +28,7 @@ export default function ToolCall({ attachments, auth, hideAttachments = false, + onExpand, }: { initialProgress: number; isLast?: boolean; @@ -38,6 +39,7 @@ export default function ToolCall({ attachments?: TAttachment[]; auth?: string; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const autoExpand = useRecoilValue(store.autoExpandTools); @@ -163,6 +165,16 @@ export default function ToolCall({ const progress = useProgress(initialProgress); const showCancelled = cancelled || (errorState && !output); + const handleToggleInfo = useCallback(() => { + setShowInfo((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }, [onExpand]); + const subtitle = useMemo(() => { if (isMCPToolCall && mcpServerName) { return localize('com_ui_via_server', { 0: mcpServerName }); @@ -205,7 +217,7 @@ export default function ToolCall({
setShowInfo((prev) => !prev)} + onClick={handleToggleInfo} inProgressText={ function_name ? localize('com_assistants_running_var', { 0: function_name }) diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index e7ef24151b..52b166b110 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useState, useRef, useMemo, useEffect, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; import { ChevronDown, Users } from 'lucide-react'; import { Tools, Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider'; @@ -9,7 +9,7 @@ import type { FunctionToolCall, } from 'librechat-data-provider'; import type { PartWithIndex } from './ParallelContent'; -import { useLocalize, useExpandCollapse } from '~/hooks'; +import { useLocalize, useExpandCollapse, scheduleMessageContentLayoutReconcile } from '~/hooks'; import { cn, getToolDisplayLabel } from '~/utils'; import { StackedToolIcons } from './ToolOutput'; import { useMCPIconMap } from '~/hooks/MCP'; @@ -75,11 +75,23 @@ interface ToolCallGroupProps { parts: PartWithIndex[]; isSubmitting: boolean; isLast: boolean; - renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode; + renderPart: ( + part: TMessageContentParts, + idx: number, + isLastPart: boolean, + onToolExpand?: () => void, + ) => React.ReactNode; lastContentIdx: number; groupAttachments?: TAttachment[]; + initialExpansionState?: ToolCallGroupExpansionState; + onExpansionChange?: (state: ToolCallGroupExpansionState) => void; } +export type ToolCallGroupExpansionState = { + isExpanded: boolean; + userOverride: boolean; +}; + export default function ToolCallGroup({ parts, isSubmitting, @@ -87,9 +99,13 @@ export default function ToolCallGroup({ renderPart, lastContentIdx, groupAttachments, + initialExpansionState, + onExpansionChange, }: ToolCallGroupProps) { const localize = useLocalize(); const mcpIconMap = useMCPIconMap(); + const rootRef = useRef(null); + const cancelLayoutReconcileRef = useRef<(() => void) | null>(null); const count = parts.length; const toolMetadata = useMemo(() => parts.map((p) => getToolMeta(p.part)), [parts]); @@ -136,9 +152,33 @@ export default function ToolCallGroup({ const autoExpand = useRecoilValue(store.autoExpandTools); const autoCollapse = !autoExpand && count >= 2 && allCompleted; - const [isExpanded, setIsExpanded] = useState(autoExpand || !autoCollapse); - const [userOverride, setUserOverride] = useState(false); + const initialState = initialExpansionState?.userOverride === true ? initialExpansionState : null; + const [isExpanded, setIsExpanded] = useState( + initialState?.isExpanded ?? (autoExpand || !autoCollapse), + ); + const [userOverride, setUserOverride] = useState(initialState != null); + const [shouldRenderBody, setShouldRenderBody] = useState(isExpanded); + const previousIsExpandedRef = useRef(isExpanded); const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded); + const notifyLayoutChange = useCallback(() => { + cancelLayoutReconcileRef.current?.(); + cancelLayoutReconcileRef.current = scheduleMessageContentLayoutReconcile(rootRef.current); + }, []); + + useEffect( + () => () => { + cancelLayoutReconcileRef.current?.(); + }, + [], + ); + + useEffect(() => { + const wasExpanded = previousIsExpandedRef.current; + previousIsExpandedRef.current = isExpanded; + if (wasExpanded && !isExpanded) { + notifyLayoutChange(); + } + }, [isExpanded, notifyLayoutChange]); useEffect(() => { if (autoCollapse && !userOverride) { @@ -147,9 +187,35 @@ export default function ToolCallGroup({ }, [autoCollapse, userOverride]); const handleToggle = useCallback(() => { + const nextExpanded = !isExpanded; setUserOverride(true); - setIsExpanded((prev) => !prev); - }, []); + if (nextExpanded) { + setShouldRenderBody(true); + } + setIsExpanded(nextExpanded); + onExpansionChange?.({ isExpanded: nextExpanded, userOverride: true }); + }, [isExpanded, onExpansionChange]); + + const handleToolExpand = useCallback(() => { + setUserOverride(true); + setShouldRenderBody(true); + setIsExpanded(true); + onExpansionChange?.({ isExpanded: true, userOverride: true }); + }, [onExpansionChange]); + + const handleTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + if (event.target !== event.currentTarget) { + return; + } + if (isExpanded) { + return; + } + setShouldRenderBody(false); + notifyLayoutChange(); + }, + [isExpanded, notifyLayoutChange], + ); const getSubagentLabel = () => subagentsDone @@ -165,13 +231,14 @@ export default function ToolCallGroup({ ); useEffect(() => { - if (hasActiveToolCall) { + if (hasActiveToolCall && !userOverride) { + setShouldRenderBody(true); setIsExpanded(true); } - }, [hasActiveToolCall]); + }, [hasActiveToolCall, userOverride]); return ( -
+
-
-
-
- {parts.map(({ part, idx }) => renderPart(part, idx, isLast && idx === lastContentIdx))} +
+ {shouldRenderBody && ( +
+
+ {parts.map(({ part, idx }) => + renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand), + )} +
-
+ )}
{groupAttachments && groupAttachments.length > 0 && ( diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index e4029193cc..7af0811fd7 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -82,12 +82,14 @@ export default function WebSearch({ isLast, output, attachments, + onExpand, }: { isLast?: boolean; isSubmitting: boolean; output?: string | null; initialProgress: number; attachments?: TAttachment[]; + onExpand?: () => void; }) { const localize = useLocalize(); const { searchResults } = useSearchContext(); @@ -182,6 +184,16 @@ export default function WebSearch({ } }, [autoExpand, sourceCount]); + const handleToggleSources = () => { + setShowSourceList((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }; + if (cancelled) { return null; } @@ -204,7 +216,7 @@ export default function WebSearch({ : 'pointer-events-none text-text-secondary', )} disabled={!hasSourceData} - onClick={hasSourceData ? () => setShowSourceList((prev) => !prev) : undefined} + onClick={hasSourceData ? handleToggleSources : undefined} aria-expanded={hasSourceData ? showSourceList : undefined} aria-label={ hasSourceData 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 9cfd848bff..555bf7c4f6 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 @@ -2,7 +2,7 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; import { ContentTypes } from 'librechat-data-provider'; import type { TAttachment, TMessageContentParts } from 'librechat-data-provider'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import ContentParts from '../ContentParts'; jest.mock('~/hooks', () => ({ @@ -17,6 +17,7 @@ jest.mock('~/hooks', () => ({ ref: { current: null }, }), useProgress: (initial: number) => (initial >= 1 ? 1 : initial), + scheduleMessageContentLayoutReconcile: jest.fn(() => jest.fn()), })); jest.mock('~/hooks/MCP', () => ({ @@ -126,6 +127,19 @@ const makeMcpToolCall = (id: string, hasOutput = true): TMessageContentParts => }, }) as unknown as TMessageContentParts; +const makeMcpToolCallWithoutId = (name: string, hasOutput = true): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { + name: `${name}${MCP_DELIMITER}Everything`, + args: '{}', + output: hasOutput ? 'image_returned' : '', + }, + }) as unknown as TMessageContentParts; + +const makeTextPart = (text: string): TMessageContentParts => + ({ type: ContentTypes.TEXT, text }) as unknown as TMessageContentParts; + const imageAttachment = (toolCallId: string, name = 'tiny.png'): TAttachment => ({ filename: name, @@ -222,4 +236,118 @@ describe('ContentParts integration: MCP image hoist and grouping', () => { expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument(); }); + + it('keeps a manually expanded completed tool group open when its content index shifts', () => { + const content = [makeMcpToolCall('t1'), makeMcpToolCall('t2')]; + const nextContent = [makeTextPart('streamed preface'), ...content]; + + const { rerender } = render( + + + , + ); + + const toggle = screen.getByRole('button', { name: 'Used 2 tools' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Used 2 tools' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('keeps a running tool group open when an individual tool is expanded before completion', () => { + const runningContent = [makeMcpToolCall('t1', false), makeMcpToolCall('t2', false)]; + const completedContent = [makeMcpToolCall('t1'), makeMcpToolCall('t2')]; + + const { rerender } = render( + + + , + ); + + const toggle = screen.getByRole('button', { name: 'Used 2 tools' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(screen.getAllByTestId('progress-text')[0]); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Used 2 tools' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('does not reuse fallback-index expansion state across message ids', () => { + const content = [makeMcpToolCallWithoutId('first'), makeMcpToolCallWithoutId('second')]; + + const { rerender } = render( + + + , + ); + + const toggle = screen.getByRole('button', { name: 'Used 2 tools' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Used 2 tools' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); + + it('keeps id-backed expansion state across transient message id changes', () => { + const content = [makeMcpToolCall('t1'), makeMcpToolCall('t2')]; + + const { rerender } = render( + + + , + ); + + const toggle = screen.getByRole('button', { name: 'Used 2 tools' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Used 2 tools' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); }); 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 706eaf3924..058e21f6d2 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; import { Tools, Constants, ContentTypes } from 'librechat-data-provider'; import type { TAttachment, TMessageContentParts } from 'librechat-data-provider'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import ToolCallGroup from '../ToolCallGroup'; +import { scheduleMessageContentLayoutReconcile } from '~/hooks'; jest.mock('~/hooks', () => ({ useLocalize: () => (key: string, values?: Record) => { @@ -22,6 +23,7 @@ jest.mock('~/hooks', () => ({ }, ref: { current: null }, }), + scheduleMessageContentLayoutReconcile: jest.fn(() => jest.fn()), })); jest.mock('~/hooks/MCP', () => ({ @@ -95,6 +97,9 @@ const renderGroup = (props: React.ComponentProps) => , ); +const mockScheduleMessageContentLayoutReconcile = + scheduleMessageContentLayoutReconcile as jest.Mock; + describe('ToolCallGroup image hoisting', () => { const parts = [ { part: makePart('t1'), idx: 0 }, @@ -113,6 +118,10 @@ describe('ToolCallGroup image hoisting', () => { ), } satisfies React.ComponentProps; + beforeEach(() => { + mockScheduleMessageContentLayoutReconcile.mockClear(); + }); + it('renders an AttachmentGroup outside the collapsible container with all attachments', () => { renderGroup({ ...baseProps, @@ -140,6 +149,70 @@ describe('ToolCallGroup image hoisting', () => { expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument(); }); + it('does not reconcile layout for an initially collapsed completed group', () => { + renderGroup(baseProps); + expect(mockScheduleMessageContentLayoutReconcile).not.toHaveBeenCalled(); + }); + + it('does not render tool bodies for an initially collapsed large completed group', () => { + const largeParts = Array.from({ length: 59 }, (_, idx) => ({ + part: makePart(`t${idx}`), + idx, + })); + const renderPart = jest.fn((_p: TMessageContentParts, idx: number) => ( +
+ {'inner'} +
+ )); + + renderGroup({ + ...baseProps, + parts: largeParts, + lastContentIdx: largeParts.length - 1, + renderPart, + }); + + expect(screen.getByRole('button', { name: 'Used 59 tools' })).toBeInTheDocument(); + expect(renderPart).not.toHaveBeenCalled(); + expect(screen.queryByTestId('inner-0')).not.toBeInTheDocument(); + }); + + it('mounts tool bodies when a collapsed group is expanded', () => { + renderGroup(baseProps); + + fireEvent.click(screen.getByRole('button', { name: 'Used 2 tools' })); + + expect(screen.getByTestId('inner-0')).toBeInTheDocument(); + expect(screen.getByTestId('inner-1')).toBeInTheDocument(); + }); + + it('unmounts tool bodies after a collapsed group finishes transitioning', () => { + renderGroup(baseProps); + + const button = screen.getByRole('button', { name: 'Used 2 tools' }); + const collapsible = button.nextElementSibling as HTMLElement; + fireEvent.click(button); + fireEvent.click(button); + expect(screen.getByTestId('inner-0')).toBeInTheDocument(); + + fireEvent.transitionEnd(collapsible); + + expect(screen.queryByTestId('inner-0')).not.toBeInTheDocument(); + }); + + it('reconciles layout after the group collapses from an expanded state', async () => { + renderGroup(baseProps); + + fireEvent.click(screen.getByRole('button', { name: 'Used 2 tools' })); + expect(mockScheduleMessageContentLayoutReconcile).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Used 2 tools' })); + + await waitFor(() => { + expect(mockScheduleMessageContentLayoutReconcile).toHaveBeenCalledTimes(1); + }); + }); + it('renders the image AttachmentGroup as a sibling of the collapsible panel, not a child', () => { const { container } = renderGroup({ ...baseProps, diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index 9c59cab292..0e2166b028 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -26,6 +26,7 @@ function MessagesViewContent({ const { conversation, + contentRef, scrollableRef, messagesEndRef, showScrollButton, @@ -49,7 +50,7 @@ function MessagesViewContent({ width: '100%', }} > -
+
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
): void { + element.getBoundingClientRect = jest.fn( + () => + ({ + x: rect.x ?? 0, + y: rect.y ?? 0, + top: rect.top ?? 0, + left: rect.left ?? 0, + right: rect.right ?? 0, + bottom: rect.bottom ?? 0, + width: rect.width ?? 0, + height: rect.height ?? 0, + toJSON: () => ({}), + }) as DOMRect, + ); +} + +describe('message layout reconciliation', () => { + it('clamps to the rendered content bottom when the scroll container is still oversized', () => { + const scrollable = document.createElement('div'); + const content = document.createElement('div'); + const target = document.createElement('button'); + + scrollable.className = 'scrollbar-gutter-stable'; + content.append(target); + scrollable.append(content); + document.body.append(scrollable); + + Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true }); + Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true }); + scrollable.scrollTop = 700; + setRect(scrollable, { top: 0, bottom: 200, height: 200 }); + setRect(content, { top: -700, bottom: -200, height: 500 }); + + expect(reconcileMessageContentLayout(target)).toBe(true); + expect(scrollable.scrollTop).toBe(300); + + document.body.removeChild(scrollable); + }); +}); diff --git a/client/src/hooks/Messages/__tests__/useMessageScrolling.spec.tsx b/client/src/hooks/Messages/__tests__/useMessageScrolling.spec.tsx new file mode 100644 index 0000000000..97f56e2943 --- /dev/null +++ b/client/src/hooks/Messages/__tests__/useMessageScrolling.spec.tsx @@ -0,0 +1,290 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { TConversation, TMessage } from 'librechat-data-provider'; +import { + MessagesViewContext, + type MessagesViewContextValue, +} from '~/Providers/MessagesViewContext'; + +type MockScrollToBottom = jest.Mock & { + cancel: jest.Mock; + flush: jest.Mock; +}; + +const mockScrollToBottom = jest.fn() as MockScrollToBottom; +mockScrollToBottom.cancel = jest.fn(); +mockScrollToBottom.flush = jest.fn(); +const mockHandleSmoothToRef = jest.fn(); +let mockScrollCallback: (() => void) | undefined; + +jest.mock('~/hooks/useScrollToRef', () => ({ + __esModule: true, + default: ({ callback }: { callback: () => void }) => { + mockScrollCallback = callback; + return { + scrollToRef: mockScrollToBottom, + handleSmoothToRef: mockHandleSmoothToRef, + }; + }, +})); + +jest.mock('../messageLayout', () => ({ + reconcileMessageContentLayout: jest.fn(), +})); + +import useMessageScrolling from '../useMessageScrolling'; +import { reconcileMessageContentLayout } from '../messageLayout'; + +const mockReconcileMessageContentLayout = reconcileMessageContentLayout as jest.Mock; + +class MockResizeObserver { + static instances: MockResizeObserver[] = []; + + static reset() { + MockResizeObserver.instances = []; + } + + static last(): MockResizeObserver | undefined { + return MockResizeObserver.instances[MockResizeObserver.instances.length - 1]; + } + + readonly callback: ResizeObserverCallback; + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + MockResizeObserver.instances.push(this); + } + + trigger() { + this.callback([], this as unknown as ResizeObserver); + } +} + +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + + static reset() { + MockIntersectionObserver.instances = []; + } + + readonly callback: IntersectionObserverCallback; + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + takeRecords = jest.fn(() => []); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + MockIntersectionObserver.instances.push(this); + } +} + +const originalResizeObserver = global.ResizeObserver; +const originalIntersectionObserver = global.IntersectionObserver; + +function setRect(element: HTMLElement, rect: Partial): void { + element.getBoundingClientRect = jest.fn( + () => + ({ + x: rect.x ?? 0, + y: rect.y ?? 0, + top: rect.top ?? 0, + left: rect.left ?? 0, + right: rect.right ?? 0, + bottom: rect.bottom ?? 0, + width: rect.width ?? 0, + height: rect.height ?? 0, + toJSON: () => ({}), + }) as DOMRect, + ); +} + +const conversation = { + conversationId: 'conversation-1', + endpoint: 'openAI', + model: 'gpt-4', +} as TConversation; + +const message = { + messageId: 'message-1', + conversationId: conversation.conversationId, + isCreatedByUser: false, +} as TMessage; + +function createContextValue( + overrides: Partial = {}, +): MessagesViewContextValue { + return { + conversation, + conversationId: conversation.conversationId, + isSubmitting: true, + abortScroll: false, + setAbortScroll: jest.fn(), + ask: jest.fn(), + regenerate: jest.fn(), + handleContinue: jest.fn(), + index: 0, + latestMessageId: message.messageId, + latestMessageDepth: 0, + getMessages: jest.fn(), + setMessages: jest.fn(), + ...overrides, + } as MessagesViewContextValue; +} + +function ScrollingHarness({ messagesTree }: { messagesTree?: TMessage[] | null }) { + const { contentRef, scrollableRef, messagesEndRef, debouncedHandleScroll } = + useMessageScrolling(messagesTree); + + return ( +
+
+
+
+
+ ); +} + +function renderScrolling({ + contextOverrides, + messagesTree, +}: { + contextOverrides?: Partial; + messagesTree?: TMessage[] | null; +} = {}) { + return render( + + + + + , + ); +} + +describe('useMessageScrolling resize reconciliation', () => { + beforeEach(() => { + MockResizeObserver.reset(); + MockIntersectionObserver.reset(); + mockScrollToBottom.mockClear(); + mockScrollToBottom.cancel.mockClear(); + mockScrollToBottom.flush.mockClear(); + mockHandleSmoothToRef.mockClear(); + mockReconcileMessageContentLayout.mockClear(); + mockScrollCallback = undefined; + (global as unknown as { ResizeObserver: typeof MockResizeObserver }).ResizeObserver = + MockResizeObserver; + ( + global as unknown as { IntersectionObserver: typeof MockIntersectionObserver } + ).IntersectionObserver = MockIntersectionObserver; + }); + + afterEach(() => { + (global as unknown as { ResizeObserver: typeof ResizeObserver | undefined }).ResizeObserver = + originalResizeObserver; + ( + global as unknown as { IntersectionObserver: typeof IntersectionObserver | undefined } + ).IntersectionObserver = originalIntersectionObserver; + }); + + it('scrolls to the bottom when streaming content resizes and auto-scroll is active', () => { + renderScrolling(); + + const observer = MockResizeObserver.last(); + expect(observer?.observe).toHaveBeenCalledWith(screen.getByTestId('content')); + + act(() => { + observer?.trigger(); + }); + + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + }); + + it('reconciles message layout after an explicit scroll to bottom', () => { + renderScrolling(); + + const scrollable = screen.getByTestId('scrollable'); + act(() => { + mockScrollCallback?.(); + }); + + expect(mockReconcileMessageContentLayout).toHaveBeenCalledWith(scrollable); + }); + + it('does not follow resizes after the user aborts streaming auto-scroll', () => { + renderScrolling({ contextOverrides: { abortScroll: true } }); + + act(() => { + MockResizeObserver.last()?.trigger(); + }); + + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not follow resizes after the user scrolls away from the bottom', () => { + renderScrolling(); + + const scrollable = screen.getByTestId('scrollable'); + Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true }); + Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true }); + scrollable.scrollTop = 100; + + fireEvent.scroll(scrollable); + + act(() => { + MockResizeObserver.last()?.trigger(); + }); + + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not follow the next resize after user interaction inside message content', () => { + renderScrolling(); + + fireEvent.pointerDown(screen.getByTestId('content')); + + act(() => { + MockResizeObserver.last()?.trigger(); + }); + + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); + + it('clamps the scroll position back to content after a resize shrink', () => { + renderScrolling({ contextOverrides: { abortScroll: true } }); + + const scrollable = screen.getByTestId('scrollable'); + Object.defineProperty(scrollable, 'scrollHeight', { value: 500, configurable: true }); + Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true }); + scrollable.scrollTop = 450; + + act(() => { + MockResizeObserver.last()?.trigger(); + }); + + expect(scrollable.scrollTop).toBe(300); + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not clamp to rendered content bottom during general resize reconciliation', () => { + renderScrolling({ contextOverrides: { abortScroll: true } }); + + const scrollable = screen.getByTestId('scrollable'); + const content = screen.getByTestId('content'); + Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true }); + Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true }); + scrollable.scrollTop = 700; + setRect(scrollable, { top: 0, bottom: 200, height: 200 }); + setRect(content, { top: -700, bottom: -200, height: 500 }); + + act(() => { + MockResizeObserver.last()?.trigger(); + }); + + expect(scrollable.scrollTop).toBe(700); + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/Messages/index.ts b/client/src/hooks/Messages/index.ts index f9c5ab834f..ad93d910a1 100644 --- a/client/src/hooks/Messages/index.ts +++ b/client/src/hooks/Messages/index.ts @@ -1,4 +1,11 @@ export { default as useProgress } from './useProgress'; +export { + MESSAGE_CONTENT_LAYOUT_CHANGE_EVENT, + dispatchMessageContentLayoutChange, + getRenderedContentMaxScrollTop, + reconcileMessageContentLayout, + scheduleMessageContentLayoutReconcile, +} from './messageLayout'; export { EXPAND_TRANSITION } from './useExpandCollapse'; export { default as useAttachments } from './useAttachments'; export { default as useSubmitMessage } from './useSubmitMessage'; diff --git a/client/src/hooks/Messages/messageLayout.ts b/client/src/hooks/Messages/messageLayout.ts new file mode 100644 index 0000000000..203e0c9e3f --- /dev/null +++ b/client/src/hooks/Messages/messageLayout.ts @@ -0,0 +1,87 @@ +export const MESSAGE_CONTENT_LAYOUT_CHANGE_EVENT = 'librechat:message-content-layout-change'; +const MESSAGE_SCROLL_CONTAINER_SELECTOR = '.scrollbar-gutter-stable'; +const MESSAGE_LAYOUT_RECONCILE_DURATION_MS = 350; + +function getNow(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); +} + +function getContentElement(scrollEl: HTMLElement): HTMLElement | null { + return scrollEl.firstElementChild instanceof HTMLElement ? scrollEl.firstElementChild : null; +} + +export function getRenderedContentMaxScrollTop(scrollEl: HTMLElement): number { + const scrollHeightMax = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight); + const contentEl = getContentElement(scrollEl); + if (!contentEl) { + return scrollHeightMax; + } + + const scrollRect = scrollEl.getBoundingClientRect(); + const contentRect = contentEl.getBoundingClientRect(); + if (scrollRect.height === 0 && contentRect.height === 0 && scrollEl.clientHeight > 0) { + return scrollHeightMax; + } + + const contentBottom = contentRect.bottom - scrollRect.top + scrollEl.scrollTop; + const renderedMax = Math.max(0, Math.ceil(contentBottom - scrollEl.clientHeight)); + + return Math.min(scrollHeightMax, renderedMax); +} + +export function reconcileMessageContentLayout(target: HTMLElement | null): boolean { + const scrollEl = target?.closest(MESSAGE_SCROLL_CONTAINER_SELECTOR) ?? null; + if (!scrollEl) { + return false; + } + + const maxScrollTop = getRenderedContentMaxScrollTop(scrollEl); + if (scrollEl.scrollTop <= maxScrollTop) { + return false; + } + + scrollEl.scrollTop = maxScrollTop; + return true; +} + +export function scheduleMessageContentLayoutReconcile(target: HTMLElement | null): () => void { + reconcileMessageContentLayout(target); + if ( + !target || + typeof window === 'undefined' || + typeof window.requestAnimationFrame !== 'function' + ) { + return () => {}; + } + + const startedAt = getNow(); + let animationFrameId: number | undefined; + const reconcile = () => { + reconcileMessageContentLayout(target); + if (getNow() - startedAt >= MESSAGE_LAYOUT_RECONCILE_DURATION_MS) { + animationFrameId = undefined; + return; + } + animationFrameId = window.requestAnimationFrame(reconcile); + }; + + animationFrameId = window.requestAnimationFrame(reconcile); + return () => { + if (animationFrameId !== undefined) { + window.cancelAnimationFrame(animationFrameId); + } + }; +} + +export function dispatchMessageContentLayoutChange(target: HTMLElement | null): void { + reconcileMessageContentLayout(target); + if (!target || typeof CustomEvent === 'undefined') { + return; + } + + target.dispatchEvent( + new CustomEvent(MESSAGE_CONTENT_LAYOUT_CHANGE_EVENT, { + bubbles: true, + }), + ); +} diff --git a/client/src/hooks/Messages/useMessageScrolling.ts b/client/src/hooks/Messages/useMessageScrolling.ts index 9fa51f0851..84e420f848 100644 --- a/client/src/hooks/Messages/useMessageScrolling.ts +++ b/client/src/hooks/Messages/useMessageScrolling.ts @@ -4,22 +4,36 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import type { TMessage } from 'librechat-data-provider'; import { useMessagesConversation, useMessagesSubmission } from '~/Providers'; import useScrollToRef from '~/hooks/useScrollToRef'; +import { reconcileMessageContentLayout } from './messageLayout'; import store from '~/store'; const threshold = 0.85; const debounceRate = 150; +const resizeFollowThreshold = 120; export default function useMessageScrolling(messagesTree?: TMessage[] | null) { const autoScroll = useRecoilValue(store.autoScroll); const scrollableRef = useRef(null); + const contentRef = useRef(null); const messagesEndRef = useRef(null); + const isNearBottomRef = useRef(true); + const suppressNextResizeFollowRef = useRef(false); const [showScrollButton, setShowScrollButton] = useState(false); const { conversation, conversationId } = useMessagesConversation(); const { setAbortScroll, isSubmitting, abortScroll } = useMessagesSubmission(); const timeoutIdRef = useRef(); + const getIsNearBottom = useCallback(() => { + const scrollEl = scrollableRef.current; + if (!scrollEl) { + return true; + } + const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight; + return distance <= resizeFollowThreshold; + }, []); + const debouncedSetShowScrollButton = useCallback((value: boolean) => { clearTimeout(timeoutIdRef.current); timeoutIdRef.current = setTimeout(() => { @@ -34,6 +48,7 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { const observer = new IntersectionObserver( ([entry]) => { + isNearBottomRef.current = entry.isIntersecting; debouncedSetShowScrollButton(!entry.isIntersecting); }, { root: scrollableRef.current, threshold }, @@ -48,9 +63,11 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { }, [messagesEndRef, scrollableRef, debouncedSetShowScrollButton]); const debouncedHandleScroll = useCallback(() => { + isNearBottomRef.current = getIsNearBottom(); if (messagesEndRef.current && scrollableRef.current) { const observer = new IntersectionObserver( ([entry]) => { + isNearBottomRef.current = entry.isIntersecting; debouncedSetShowScrollButton(!entry.isIntersecting); }, { root: scrollableRef.current, threshold }, @@ -58,9 +75,13 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { observer.observe(messagesEndRef.current); return () => observer.disconnect(); } - }, [debouncedSetShowScrollButton]); + }, [debouncedSetShowScrollButton, getIsNearBottom]); - const scrollCallback = () => debouncedSetShowScrollButton(false); + const scrollCallback = () => { + reconcileMessageContentLayout(scrollableRef.current); + isNearBottomRef.current = true; + debouncedSetShowScrollButton(false); + }; const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({ targetRef: messagesEndRef, @@ -71,6 +92,70 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { }, }); + const clampScrollToContent = useCallback(() => { + const scrollEl = scrollableRef.current; + if (!scrollEl) { + return false; + } + + const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight); + if (scrollEl.scrollTop <= maxScrollTop) { + return false; + } + + scrollEl.scrollTop = maxScrollTop; + isNearBottomRef.current = getIsNearBottom(); + return true; + }, [getIsNearBottom]); + + const reconcileContentResize = useCallback( + (shouldFollowResize = true) => { + if (clampScrollToContent()) { + return; + } + + if (suppressNextResizeFollowRef.current) { + suppressNextResizeFollowRef.current = false; + isNearBottomRef.current = getIsNearBottom(); + return; + } + + if (shouldFollowResize && isSubmitting && abortScroll !== true && isNearBottomRef.current) { + scrollToBottom?.(); + } + }, + [abortScroll, clampScrollToContent, getIsNearBottom, isSubmitting, scrollToBottom], + ); + + useEffect(() => { + const contentEl = contentRef.current; + if (!contentEl || typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(() => reconcileContentResize()); + observer.observe(contentEl); + return () => observer.disconnect(); + }, [reconcileContentResize]); + + useEffect(() => { + const contentEl = contentRef.current; + if (!contentEl) { + return; + } + + const suppressNextResizeFollow = () => { + suppressNextResizeFollowRef.current = true; + }; + + contentEl.addEventListener('pointerdown', suppressNextResizeFollow, true); + contentEl.addEventListener('keydown', suppressNextResizeFollow, true); + return () => { + contentEl.removeEventListener('pointerdown', suppressNextResizeFollow, true); + contentEl.removeEventListener('keydown', suppressNextResizeFollow, true); + }; + }, []); + useEffect(() => { if (!messagesTree || messagesTree.length === 0) { return; @@ -103,6 +188,7 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) { return { conversation, + contentRef, scrollableRef, messagesEndRef, scrollToBottom,