🪝 fix: Preserve Grouped Tool Expansion During Streaming (#13462)

* 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
This commit is contained in:
Danny Avila 2026-06-01 22:34:59 -04:00 committed by GitHub
parent a7cfbccc50
commit 58662283af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 949 additions and 35 deletions

View file

@ -11,10 +11,12 @@ export default function CodeAnalyze({
initialProgress = 0.1,
code,
outputs = [],
onExpand,
}: {
initialProgress: number;
code: string;
outputs: Record<string, unknown>[];
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({
<div className="my-1 flex items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowCode((prev) => !prev)}
onClick={handleToggleCode}
inProgressText={localize('com_ui_analyzing')}
finishedText={localize('com_ui_analyzing_finished')}
hasInput={!!code.length}

View file

@ -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}
/>
</MessageContext.Provider>
);
@ -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<string, ToolCallGroupExpansionState>());
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 (
<PartWithContext
key={`provider-${messageId}-${idx}`}
@ -235,6 +272,7 @@ const ContentParts = memo(function ContentParts({
isSubmitting={effectiveIsSubmitting}
partAttachments={attachmentMap[getToolCallId(part)]}
hideAttachments
onToolExpand={onToolExpand}
/>
);
},
@ -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 (
<ToolCallGroup
key={`tool-group-${group.parts[0].idx}`}
key={`tool-group-${groupId}`}
parts={group.parts}
isSubmitting={effectiveIsSubmitting}
isLast={group.parts.some((p) => p.idx === lastContentIdx)}
renderPart={renderGroupedPart}
lastContentIdx={lastContentIdx}
groupAttachments={group.groupAttachments}
initialExpansionState={toolGroupExpansionRef.current.get(groupId)}
onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)}
/>
);
})}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<SubagentDialogPart
key={`${toolCallId}-part-${idx}`}
@ -301,6 +306,7 @@ export default function SubagentCall({
isSubmitting={running}
showCursor={running && isLastPart}
isLast={isLastPart}
onToolExpand={onToolExpand}
/>
);
},
@ -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}
/>
);
}

View file

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

View file

@ -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 (
<div className="my-1">
<span className="sr-only" aria-live="polite" aria-atomic="true">
@ -416,7 +428,7 @@ export default function RetrievalCall({
<div className="relative my-1 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={hasOutput ? () => 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}

View file

@ -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({
<div className="relative my-1.5 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowInfo((prev) => !prev)}
onClick={handleToggleInfo}
inProgressText={
function_name
? localize('com_assistants_running_var', { 0: function_name })

View file

@ -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<HTMLDivElement | null>(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<HTMLDivElement>) => {
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 (
<div className="mb-2 mt-1">
<div className="mb-2 mt-1" ref={rootRef}>
<button
type="button"
className="inline-flex w-full items-center gap-2 py-1 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
@ -216,12 +283,16 @@ export default function ToolCallGroup({
aria-hidden="true"
/>
</button>
<div style={expandStyle}>
<div className="overflow-hidden" ref={expandRef}>
<div className="py-0.5 pl-4">
{parts.map(({ part, idx }) => renderPart(part, idx, isLast && idx === lastContentIdx))}
<div style={expandStyle} onTransitionEnd={handleTransitionEnd} aria-hidden={!isExpanded}>
{shouldRenderBody && (
<div className="overflow-hidden" ref={expandRef}>
<div className="py-0.5 pl-4">
{parts.map(({ part, idx }) =>
renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand),
)}
</div>
</div>
</div>
)}
</div>
{groupAttachments && groupAttachments.length > 0 && (
<AttachmentGroup attachments={groupAttachments} />

View file

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

View file

@ -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(
<RecoilRoot>
<ContentParts {...baseProps} content={content} />
</RecoilRoot>,
);
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(
<RecoilRoot>
<ContentParts {...baseProps} content={nextContent} />
</RecoilRoot>,
);
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(
<RecoilRoot>
<ContentParts {...baseProps} isSubmitting isLatestMessage content={runningContent} />
</RecoilRoot>,
);
const toggle = screen.getByRole('button', { name: 'Used 2 tools' });
expect(toggle).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getAllByTestId('progress-text')[0]);
rerender(
<RecoilRoot>
<ContentParts
{...baseProps}
isSubmitting={false}
isLatestMessage
content={completedContent}
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<ContentParts {...baseProps} messageId="msg1" content={content} />
</RecoilRoot>,
);
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(
<RecoilRoot>
<ContentParts {...baseProps} messageId="msg2" content={content} />
</RecoilRoot>,
);
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(
<RecoilRoot>
<ContentParts {...baseProps} messageId="placeholder-msg" content={content} />
</RecoilRoot>,
);
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(
<RecoilRoot>
<ContentParts {...baseProps} messageId="server-msg" content={content} />
</RecoilRoot>,
);
expect(screen.getByRole('button', { name: 'Used 2 tools' })).toHaveAttribute(
'aria-expanded',
'true',
);
});
});

View file

@ -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<string | number, string>) => {
@ -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<typeof ToolCallGroup>) =>
</RecoilRoot>,
);
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<typeof ToolCallGroup>;
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) => (
<div data-testid={`inner-${idx}`} key={idx}>
{'inner'}
</div>
));
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,

View file

@ -26,6 +26,7 @@ function MessagesViewContent({
const {
conversation,
contentRef,
scrollableRef,
messagesEndRef,
showScrollButton,
@ -49,7 +50,7 @@ function MessagesViewContent({
width: '100%',
}}
>
<div className="flex flex-col pb-9 pt-14 dark:bg-transparent">
<div ref={contentRef} className="flex flex-col pb-9 pt-14 dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div
className={cn(

View file

@ -0,0 +1,42 @@
import { reconcileMessageContentLayout } from '../messageLayout';
function setRect(element: HTMLElement, rect: Partial<DOMRect>): 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);
});
});

View file

@ -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<DOMRect>): 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> = {},
): 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 (
<div ref={scrollableRef} onScroll={debouncedHandleScroll} data-testid="scrollable">
<div ref={contentRef} data-testid="content">
<div ref={messagesEndRef} data-testid="end" />
</div>
</div>
);
}
function renderScrolling({
contextOverrides,
messagesTree,
}: {
contextOverrides?: Partial<MessagesViewContextValue>;
messagesTree?: TMessage[] | null;
} = {}) {
return render(
<RecoilRoot>
<MessagesViewContext.Provider value={createContextValue(contextOverrides)}>
<ScrollingHarness messagesTree={messagesTree} />
</MessagesViewContext.Provider>
</RecoilRoot>,
);
}
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();
});
});

View file

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

View file

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

View file

@ -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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(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<NodeJS.Timeout>();
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,