mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🪝 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:
parent
a7cfbccc50
commit
58662283af
21 changed files with 949 additions and 35 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
42
client/src/hooks/Messages/__tests__/messageLayout.spec.ts
Normal file
42
client/src/hooks/Messages/__tests__/messageLayout.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
290
client/src/hooks/Messages/__tests__/useMessageScrolling.spec.tsx
Normal file
290
client/src/hooks/Messages/__tests__/useMessageScrolling.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
87
client/src/hooks/Messages/messageLayout.ts
Normal file
87
client/src/hooks/Messages/messageLayout.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue