mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
⚡ perf: Optimize First Load of Large Conversations (#14901)
* ⚡ perf: Index the Conversation Fetch and Trim the Client Message Projection * ⚡ perf: Memoize the Message Tree per Cache Write * ⚡ perf: Serve Message Reads via the Trimmed Projection and an Ownership Probe * ⚡ perf: Defer Collapsed Disclosure Bodies Until First Expansion * ⚡ perf: Progressively Mount Long Threads from the Scroll Anchor * 🩹 fix: Address Codex Findings on Retention, Anchoring, and Cache Bounds * 🩹 fix: Poll the Oversized Export Precondition Through the Progressive Mount * 🩹 fix: Keep Video Results in the Client Message Projection
This commit is contained in:
parent
df5abbb377
commit
1b7e2a4e6a
38 changed files with 1192 additions and 138 deletions
|
|
@ -6,6 +6,7 @@ import type { TMessageContentParts } from 'librechat-data-provider';
|
|||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import {
|
||||
useExpandCollapse,
|
||||
useLazyCollapseBody,
|
||||
scheduleMessageContentLayoutReconcile,
|
||||
EXPAND_TRANSITION,
|
||||
} from '~/hooks';
|
||||
|
|
@ -54,12 +55,14 @@ export default function ActivityPhaseGroup({
|
|||
hasContent,
|
||||
showCursor = false,
|
||||
animateEntrance = false,
|
||||
hasPendingApproval = false,
|
||||
}: {
|
||||
labelPart: ActivityPhasePart;
|
||||
children: ReactNode;
|
||||
hasContent: boolean;
|
||||
showCursor?: boolean;
|
||||
animateEntrance?: boolean;
|
||||
hasPendingApproval?: boolean;
|
||||
}) {
|
||||
const label = getActivityLabelText(labelPart);
|
||||
const hasFailure = labelPart.status === 'failed' || labelPart.status === 'partial';
|
||||
|
|
@ -85,6 +88,14 @@ export default function ActivityPhaseGroup({
|
|||
const previousIsExpandedRef = useRef(isExpanded);
|
||||
const userOverrideRef = useRef(false);
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
|
||||
/** A phase label can resolve while an approval card inside it is still
|
||||
* pending (see ApprovalContext), and ToolApproval owns unsent local
|
||||
* edit/respond/reason state — so a collapsed phase retains its body until
|
||||
* every nested approval resolves, exactly like ToolCallGroup. */
|
||||
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(
|
||||
isExpanded,
|
||||
hasPendingApproval,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!foldsIn || userOverrideRef.current) {
|
||||
|
|
@ -124,9 +135,10 @@ export default function ActivityPhaseGroup({
|
|||
userOverrideRef.current = true;
|
||||
cancelEntranceRef.current?.();
|
||||
cancelEntranceRef.current = null;
|
||||
mountBody();
|
||||
setIsSettled(true);
|
||||
setIsExpanded((expanded) => !expanded);
|
||||
}, []);
|
||||
}, [mountBody]);
|
||||
|
||||
/** Only the folding entrance drives the header off its natural height.
|
||||
* History and reduced-motion render the plain, unstyled row. */
|
||||
|
|
@ -211,24 +223,27 @@ export default function ActivityPhaseGroup({
|
|||
<div
|
||||
id={panelId}
|
||||
style={expandStyle}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
aria-hidden={!isExpanded}
|
||||
data-testid="activity-phase-panel"
|
||||
>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{/** Padding and the divider ride the same curve as the fold: the
|
||||
* children occupy the exact position they held before the marker
|
||||
* arrived and settle into the card as it materializes, instead of
|
||||
* stepping sideways by the card's inset on the first frame. */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t transition-[border-color,padding]',
|
||||
FOLD_EASING,
|
||||
isSettled ? 'border-border-light px-3 py-2' : 'border-transparent px-0 py-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{shouldRenderBody && (
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{/** Padding and the divider ride the same curve as the fold: the
|
||||
* children occupy the exact position they held before the marker
|
||||
* arrived and settle into the card as it materializes, instead of
|
||||
* stepping sideways by the card's inset on the first frame. */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t transition-[border-color,padding]',
|
||||
FOLD_EASING,
|
||||
isSettled ? 'border-border-light px-3 py-2' : 'border-transparent px-0 py-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts';
|
|||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import PendingSkillCall from './Parts/PendingSkillCall';
|
||||
import ActivityPhaseGroup from './ActivityPhaseGroup';
|
||||
import { hasPendingApprovalInPart } from '~/utils';
|
||||
import EditContentParts from './EditContentParts';
|
||||
import { EmptyText, AgentUpdate } from './Parts';
|
||||
import ApprovalProvider from './ApprovalContext';
|
||||
|
|
@ -530,6 +531,9 @@ const ContentParts = memo(function ContentParts({
|
|||
key={`activity-phase-${messageId}-${segment.labelIndex}`}
|
||||
labelPart={segment.labelPart}
|
||||
hasContent={segment.hasContent}
|
||||
hasPendingApproval={segment.content.some(
|
||||
(part) => part != null && hasPendingApprovalInPart(part),
|
||||
)}
|
||||
animateEntrance={
|
||||
previousPhaseIndices != null && !previousPhaseIndices.has(segment.labelIndex)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { useAtomValue } from 'jotai';
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { MouseEvent, FocusEvent } from 'react';
|
||||
import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking';
|
||||
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
|
||||
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -47,6 +47,7 @@ const Reasoning = memo((props: ReasoningProps) => {
|
|||
const [isBarVisible, setIsBarVisible] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
|
||||
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(isExpanded);
|
||||
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
|
|
@ -57,10 +58,14 @@ const Reasoning = memo((props: ReasoningProps) => {
|
|||
.trim();
|
||||
}, [reasoning]);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
mountBody();
|
||||
setIsExpanded((prev) => !prev);
|
||||
},
|
||||
[mountBody],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsBarVisible(true);
|
||||
|
|
@ -127,20 +132,25 @@ const Reasoning = memo((props: ReasoningProps) => {
|
|||
aria-hidden={!isExpanded || undefined}
|
||||
className={cn(nextType !== ContentTypes.THINK && isExpanded && 'mb-4')}
|
||||
style={expandStyle}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
<div className="relative overflow-hidden" ref={expandRef}>
|
||||
<ThinkingContent
|
||||
animate={smoothStreaming && effectiveIsSubmitting && isLast && isExpanded}
|
||||
>
|
||||
{reasoningText}
|
||||
</ThinkingContent>
|
||||
<FloatingThinkingBar
|
||||
isVisible={isBarVisible && isExpanded}
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
content={reasoningText}
|
||||
contentId={contentId}
|
||||
/>
|
||||
{shouldRenderBody && (
|
||||
<>
|
||||
<ThinkingContent
|
||||
animate={smoothStreaming && effectiveIsSubmitting && isLast && isExpanded}
|
||||
>
|
||||
{reasoningText}
|
||||
</ThinkingContent>
|
||||
<FloatingThinkingBar
|
||||
isVisible={isBarVisible && isExpanded}
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
content={reasoningText}
|
||||
contentId={contentId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
splitToolCallName,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TAttachment, PartMetadata } from 'librechat-data-provider';
|
||||
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
|
||||
import { useLocalize, useProgress, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
|
||||
import { ToolIcon, getToolIconType, isError } from './ToolOutput';
|
||||
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
|
||||
import { useToolCallIntent } from './Parts/intent';
|
||||
|
|
@ -54,6 +54,7 @@ export default function ToolCall({
|
|||
const hasOutput = (output?.length ?? 0) > 0;
|
||||
const [showInfo, setShowInfo] = useState(() => autoExpand && hasOutput);
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
|
||||
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(showInfo);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoExpand && hasOutput) {
|
||||
|
|
@ -194,6 +195,7 @@ export default function ToolCall({
|
|||
const showCancelled = cancelled || (errorState && !output);
|
||||
|
||||
const handleToggleInfo = useCallback(() => {
|
||||
mountBody();
|
||||
setShowInfo((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
|
|
@ -201,7 +203,7 @@ export default function ToolCall({
|
|||
}
|
||||
return next;
|
||||
});
|
||||
}, [onExpand]);
|
||||
}, [mountBody, onExpand]);
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (isMCPToolCall && mcpServerName) {
|
||||
|
|
@ -297,9 +299,13 @@ export default function ToolCall({
|
|||
error={showCancelled}
|
||||
/>
|
||||
</div>
|
||||
<div style={expandStyle} data-tool-call-output-id={toolCallId}>
|
||||
<div
|
||||
style={expandStyle}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
data-tool-call-output-id={toolCallId}
|
||||
>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{hasInfo && (
|
||||
{hasInfo && shouldRenderBody && (
|
||||
<div className="my-2 overflow-hidden rounded-lg border border-border-light bg-surface-secondary">
|
||||
<ToolCallInfo input={args ?? ''} output={output} attachments={attachments} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import type {
|
|||
FunctionToolCall,
|
||||
} from 'librechat-data-provider';
|
||||
import type { PartWithIndex } from './ParallelContent';
|
||||
import { cn, getToolDisplayLabel, getBatchActivityLabelPart, getActivityLabelText } from '~/utils';
|
||||
import {
|
||||
cn,
|
||||
getToolDisplayLabel,
|
||||
hasPendingApprovalInPart,
|
||||
getBatchActivityLabelPart,
|
||||
getActivityLabelText,
|
||||
} from '~/utils';
|
||||
import { useLocalize, useExpandCollapse, scheduleMessageContentLayoutReconcile } from '~/hooks';
|
||||
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
|
||||
import { isBashProgrammaticToolCall } from './routing';
|
||||
|
|
@ -25,27 +31,6 @@ interface ToolMeta {
|
|||
hasOutput: boolean;
|
||||
}
|
||||
|
||||
type ToolCallWithNestedContent = Agents.ToolCall & {
|
||||
subagent_content?: TMessageContentParts[];
|
||||
};
|
||||
|
||||
function hasPendingApprovalInPart(part: TMessageContentParts): boolean {
|
||||
if (part.type !== ContentTypes.TOOL_CALL) {
|
||||
return false;
|
||||
}
|
||||
const toolCall = part[ContentTypes.TOOL_CALL] as ToolCallWithNestedContent | undefined;
|
||||
if (!toolCall) {
|
||||
return false;
|
||||
}
|
||||
if (toolCall.approval != null && (toolCall.output?.length ?? 0) === 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Array.isArray(toolCall.subagent_content) &&
|
||||
toolCall.subagent_content.some(hasPendingApprovalInPart)
|
||||
);
|
||||
}
|
||||
|
||||
function getToolMeta(part: TMessageContentParts): ToolMeta | null {
|
||||
if (part.type !== ContentTypes.TOOL_CALL) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import type {
|
|||
PartMetadata,
|
||||
} from 'librechat-data-provider';
|
||||
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
|
||||
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
|
||||
import { StackedFavicons } from '~/components/Web/Sources';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import { useToolCallIntent } from './Parts/intent';
|
||||
import { useSearchContext } from '~/Providers';
|
||||
import cn from '~/utils/cn';
|
||||
|
|
@ -208,6 +208,7 @@ export default function WebSearch({
|
|||
const sourceCount = allSources.length;
|
||||
const [showSourceList, setShowSourceList] = useState(() => autoExpand && sourceCount > 0);
|
||||
const { style: sourceExpandStyle, ref: sourceExpandRef } = useExpandCollapse(showSourceList);
|
||||
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(showSourceList);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoExpand && sourceCount > 0) {
|
||||
|
|
@ -216,6 +217,7 @@ export default function WebSearch({
|
|||
}, [autoExpand, sourceCount]);
|
||||
|
||||
const handleToggleSources = () => {
|
||||
mountBody();
|
||||
setShowSourceList((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
|
|
@ -272,31 +274,33 @@ export default function WebSearch({
|
|||
)}
|
||||
</button>
|
||||
{hasSourceData && (
|
||||
<div style={sourceExpandStyle}>
|
||||
<div style={sourceExpandStyle} onTransitionEnd={handleTransitionEnd}>
|
||||
<div className="overflow-hidden" ref={sourceExpandRef}>
|
||||
<div className="my-2 max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
|
||||
{allSources.map((source, i) => {
|
||||
const domain = getCleanDomain(source.link);
|
||||
return (
|
||||
<a
|
||||
key={source.link}
|
||||
href={source.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
|
||||
i > 0 && 'border-t border-border-light',
|
||||
)}
|
||||
>
|
||||
<FaviconImage domain={domain} className="size-4 shrink-0 rounded-sm" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
|
||||
{source.title || domain}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-text-secondary">{domain}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{shouldRenderBody && (
|
||||
<div className="my-2 max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
|
||||
{allSources.map((source, i) => {
|
||||
const domain = getCleanDomain(source.link);
|
||||
return (
|
||||
<a
|
||||
key={source.link}
|
||||
href={source.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
|
||||
i > 0 && 'border-t border-border-light',
|
||||
)}
|
||||
>
|
||||
<FaviconImage domain={domain} className="size-4 shrink-0 rounded-sm" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
|
||||
{source.title || domain}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-text-secondary">{domain}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ jest.mock('~/hooks/Messages/useSmoothStreaming', () => ({
|
|||
|
||||
jest.mock('~/hooks', () => {
|
||||
const expandCollapse = jest.requireActual('~/hooks/Messages/useExpandCollapse');
|
||||
const lazyCollapseBody = jest.requireActual('~/hooks/Messages/useLazyCollapseBody');
|
||||
return {
|
||||
useExpandCollapse: expandCollapse.default,
|
||||
useLazyCollapseBody: lazyCollapseBody.default,
|
||||
EXPAND_TRANSITION: expandCollapse.EXPAND_TRANSITION,
|
||||
scheduleMessageContentLayoutReconcile: (target: HTMLElement | null) =>
|
||||
mockScheduleLayoutReconcile(target),
|
||||
|
|
@ -192,4 +194,72 @@ describe('ActivityPhaseGroup', () => {
|
|||
expect(screen.getByText(LABEL)).toHaveClass('text-left');
|
||||
expect(pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps a collapsed history phase body unmounted until expanded', () => {
|
||||
render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: LABEL }));
|
||||
expect(screen.getByTestId('phase-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('releases the body only after the collapse transition completes', () => {
|
||||
render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: LABEL });
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByTestId('phase-content')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByTestId('phase-content')).toBeInTheDocument();
|
||||
|
||||
fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
|
||||
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a pending approval retains the collapsed body until it resolves', () => {
|
||||
const { rerender } = render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent hasPendingApproval>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: LABEL });
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
|
||||
expect(screen.getByTestId('phase-content')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent hasPendingApproval={false}>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the entrance fold keeps the body mounted, then releases it after settling', () => {
|
||||
render(
|
||||
<ActivityPhaseGroup labelPart={labelPart} hasContent animateEntrance>
|
||||
<div data-testid="phase-content" />
|
||||
</ActivityPhaseGroup>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('phase-content')).toBeInTheDocument();
|
||||
|
||||
flushFrames();
|
||||
flushFrames();
|
||||
|
||||
fireEvent.transitionEnd(screen.getByTestId('activity-phase-panel'));
|
||||
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ jest.mock('~/hooks', () => ({
|
|||
style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' },
|
||||
ref: { current: null },
|
||||
}),
|
||||
useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default,
|
||||
useProgress: (initial: number) => (initial >= 1 ? 1 : initial),
|
||||
scheduleMessageContentLayoutReconcile: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ jest.mock('~/utils', () => ({
|
|||
mapAttachments: () => ({}),
|
||||
filterAttachmentsForPart: (attachments: unknown) => attachments,
|
||||
groupSequentialToolCalls: jest.fn(),
|
||||
hasPendingApprovalInPart: jest.requireActual('~/utils/groupToolCalls').hasPendingApprovalInPart,
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ jest.mock('~/hooks', () => ({
|
|||
},
|
||||
ref: { current: null },
|
||||
}),
|
||||
useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/MCP', () => {
|
||||
|
|
@ -291,24 +292,19 @@ describe('ToolCall', () => {
|
|||
});
|
||||
|
||||
describe('tool call info visibility', () => {
|
||||
it('should toggle tool call info expand/collapse when clicking header', () => {
|
||||
it('should mount tool call info only after expanding via the header', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||
|
||||
// ToolCallInfo is always in the DOM (CSS expand/collapse), but initially collapsed
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
expect(toolCallInfo).toBeInTheDocument();
|
||||
// Collapsed info stays unmounted until the first expansion
|
||||
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
|
||||
|
||||
// The expand wrapper starts collapsed (showInfo=false, autoExpand=false)
|
||||
const expandWrapper = toolCallInfo.closest('[style]')?.parentElement;
|
||||
expect(expandWrapper).toBeDefined();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(screen.getByTestId('progress-text'));
|
||||
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass input and output props to ToolCallInfo', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||
fireEvent.click(screen.getByTestId('progress-text'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
|
|
@ -375,6 +371,7 @@ describe('ToolCall', () => {
|
|||
describe('edge cases', () => {
|
||||
it('should handle undefined args', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} args={undefined as any} />);
|
||||
fireEvent.click(screen.getByTestId('progress-text'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
|
|
@ -383,6 +380,7 @@ describe('ToolCall', () => {
|
|||
|
||||
it('should handle null output', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
|
||||
fireEvent.click(screen.getByTestId('progress-text'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
|
|
@ -391,9 +389,9 @@ describe('ToolCall', () => {
|
|||
|
||||
it('should handle simple function name without domain', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} name="simpleName" />);
|
||||
fireEvent.click(screen.getByTestId('progress-text'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
expect(toolCallInfo).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle complex nested attachments', () => {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ jest.mock('~/utils', () => ({
|
|||
* so stubbing them out would hide the header logic under test. */
|
||||
getBatchActivityLabelPart: jest.requireActual('~/utils/activityLabels').getBatchActivityLabelPart,
|
||||
getActivityLabelText: jest.requireActual('~/utils/activityLabels').getActivityLabelText,
|
||||
hasPendingApprovalInPart: jest.requireActual('~/utils/groupToolCalls').hasPendingApprovalInPart,
|
||||
}));
|
||||
|
||||
jest.mock('../Parts', () => ({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TAttachment, SearchResultData, ValidSource } from 'librechat-data-provider';
|
||||
import { SearchContext } from '~/Providers';
|
||||
import WebSearch from '../WebSearch';
|
||||
|
|
@ -19,6 +19,7 @@ jest.mock('~/hooks', () => ({
|
|||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default,
|
||||
useExpandCollapse: (isExpanded: boolean) => ({
|
||||
style: {
|
||||
display: 'grid',
|
||||
|
|
@ -129,6 +130,7 @@ describe('WebSearch', () => {
|
|||
const attachments = [makeAttachment(0, searchResults['0'])];
|
||||
|
||||
renderWebSearch({ searchResults, attachments });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
const hrefs = links.map((l) => l.getAttribute('href'));
|
||||
|
|
@ -143,6 +145,7 @@ describe('WebSearch', () => {
|
|||
const attachments = [makeAttachment(1, searchResults['1'])];
|
||||
|
||||
renderWebSearch({ searchResults, attachments });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
const hrefs = links.map((l) => l.getAttribute('href'));
|
||||
|
|
@ -178,6 +181,9 @@ describe('WebSearch', () => {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(container0.querySelector('button[aria-expanded]') as HTMLElement);
|
||||
fireEvent.click(container1.querySelector('button[aria-expanded]') as HTMLElement);
|
||||
|
||||
const links0 = Array.from(container0.querySelectorAll('a[href]')).map((a) =>
|
||||
a.getAttribute('href'),
|
||||
);
|
||||
|
|
@ -195,6 +201,7 @@ describe('WebSearch', () => {
|
|||
|
||||
it('falls back to searchResults[ownTurn] when attachments is undefined', () => {
|
||||
renderWebSearch({ searchResults });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
const hrefs = links.map((l) => l.getAttribute('href'));
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import { Constants } from 'librechat-data-provider';
|
|||
import { CSSTransition } from 'react-transition-group';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useScreenshot, useMessageScrolling, useScrollbarGutter, useLocalize } from '~/hooks';
|
||||
import { RowMountProvider, useProgressiveRowMount } from '~/hooks/Messages';
|
||||
import { MessagesViewProvider, useChatContext } from '~/Providers';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
import { steerOverlayHeightFamily } from '~/store/steer';
|
||||
import { MessagesViewProvider } from '~/Providers';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import MessageNav from './MessageNav';
|
||||
|
|
@ -114,6 +115,22 @@ function MessagesViewContent({
|
|||
|
||||
const { conversationId } = conversation ?? {};
|
||||
|
||||
const { index, latestMessageDepth } = useChatContext();
|
||||
const isSubmitting = useRecoilValue(store.isSubmittingFamily(index));
|
||||
const autoScroll = useRecoilValue(store.autoScroll);
|
||||
/** Re-arm from the conversation that owns the RENDERED tree: the Recoil
|
||||
* conversation id lags the route during warm-cache navigation, and keying
|
||||
* off it would first mount the new tree unwindowed, then narrow it after
|
||||
* the fact — visibly unmounting rows the user is already reading. */
|
||||
const treeConversationId = _messagesTree?.[0]?.conversationId ?? conversationId;
|
||||
const mountWindow = useProgressiveRowMount({
|
||||
tailDepth: latestMessageDepth,
|
||||
anchorBottom: autoScroll || isSubmitting,
|
||||
isSubmitting,
|
||||
conversationId: treeConversationId,
|
||||
scrollableRef,
|
||||
});
|
||||
|
||||
/** The in-flight steer overlay floats above the composer over the bottom of
|
||||
* the thread (see `InFlightSteers`); reserve an equal band here so the
|
||||
* newest message rests above it and older ones scroll behind. */
|
||||
|
|
@ -133,6 +150,10 @@ function MessagesViewContent({
|
|||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
/** The mount hook pins the anchor row itself (document-space
|
||||
* measurement); native scroll anchoring reacting to the same
|
||||
* insertions would double-correct. */
|
||||
overflowAnchor: mountWindow != null ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -156,12 +177,14 @@ function MessagesViewContent({
|
|||
) : (
|
||||
<>
|
||||
<div ref={screenshotTargetRef} data-testid="screenshot-target">
|
||||
<MultiMessage
|
||||
messagesTree={_messagesTree}
|
||||
messageId={conversationId ?? null}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
<RowMountProvider mountWindow={mountWindow}>
|
||||
<MultiMessage
|
||||
messagesTree={_messagesTree}
|
||||
messageId={conversationId ?? null}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
</RowMountProvider>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { TMessage } from 'librechat-data-provider';
|
|||
import type { ReactElement } from 'react';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageContent from '~/components/Messages/MessageContent';
|
||||
import { useRowMountWindow } from '~/hooks/Messages';
|
||||
import MessageParts from './MessageParts';
|
||||
import Message from './Message';
|
||||
import store from '~/store';
|
||||
|
|
@ -21,6 +22,7 @@ function MultiMessage({
|
|||
setCurrentEditId,
|
||||
}: TMessageProps) {
|
||||
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
|
||||
const mountWindow = useRowMountWindow();
|
||||
|
||||
const setSiblingIdxRev = useCallback(
|
||||
(value: number) => {
|
||||
|
|
@ -165,8 +167,17 @@ function MultiMessage({
|
|||
setSiblingIdx: setSiblingIdxRev,
|
||||
};
|
||||
|
||||
let row: ReactElement;
|
||||
if (isAssistantsEndpoint(message.endpoint) && message.content) {
|
||||
/** A row outside the progressive mount window renders nothing while the
|
||||
* recursion continues, so descendants keep their atoms, effects, and
|
||||
* streaming spine; the window only ever widens, so rows never unmount. */
|
||||
const rowMounted =
|
||||
mountWindow == null ||
|
||||
((message.depth ?? 0) >= mountWindow.start && (message.depth ?? 0) <= mountWindow.end);
|
||||
|
||||
let row: ReactElement | null = null;
|
||||
if (!rowMounted) {
|
||||
row = null;
|
||||
} else if (isAssistantsEndpoint(message.endpoint) && message.content) {
|
||||
row = <MessageParts {...sharedProps} />;
|
||||
} else if (message.content) {
|
||||
row = <MessageContent {...sharedProps} />;
|
||||
|
|
|
|||
|
|
@ -193,3 +193,46 @@ describe('MultiMessage sibling selection', () => {
|
|||
expect(displayed()).toBe('a1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MultiMessage row mount window', () => {
|
||||
const { RowMountProvider } =
|
||||
jest.requireActual<typeof import('~/hooks/Messages')>('~/hooks/Messages');
|
||||
|
||||
const chain = (): TMessage => {
|
||||
const leaf = { ...msg('m2'), parentMessageId: 'm1', depth: 2 } as TMessage;
|
||||
const mid = { ...msg('m1'), parentMessageId: 'm0', depth: 1, children: [leaf] } as TMessage;
|
||||
return { ...msg('m0'), depth: 0, children: [mid] } as TMessage;
|
||||
};
|
||||
|
||||
const windowedTree = (mountWindow: { start: number; end: number } | null) => (
|
||||
<RecoilRoot>
|
||||
<RowMountProvider mountWindow={mountWindow}>
|
||||
<MultiMessage
|
||||
messageId="parent-1"
|
||||
messagesTree={[chain()]}
|
||||
currentEditId={null}
|
||||
setCurrentEditId={jest.fn()}
|
||||
/>
|
||||
</RowMountProvider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
it('renders every row without a window', () => {
|
||||
render(windowedTree(null));
|
||||
expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m0', 'm1', 'm2']);
|
||||
});
|
||||
|
||||
it('gates rows outside the window while the recursion continues below them', () => {
|
||||
render(windowedTree({ start: 2, end: 2 }));
|
||||
expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m2']);
|
||||
});
|
||||
|
||||
it('mounts newly windowed rows above without disturbing deeper rows', () => {
|
||||
const view = render(windowedTree({ start: 2, end: 2 }));
|
||||
view.rerender(windowedTree({ start: 1, end: 2 }));
|
||||
expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m1', 'm2']);
|
||||
|
||||
view.rerender(windowedTree(null));
|
||||
expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['m0', 'm1', 'm2']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import useLazyCollapseBody from '../useLazyCollapseBody';
|
||||
|
||||
const TOGGLE_LABEL = 'toggle';
|
||||
|
||||
function Disclosure({
|
||||
initialExpanded,
|
||||
retainBody = false,
|
||||
}: {
|
||||
initialExpanded: boolean;
|
||||
retainBody?: boolean;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(initialExpanded);
|
||||
const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(
|
||||
isExpanded,
|
||||
retainBody,
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
mountBody();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{TOGGLE_LABEL}
|
||||
</button>
|
||||
<div data-testid="panel" onTransitionEnd={handleTransitionEnd}>
|
||||
{shouldRenderBody && <div data-testid="body" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useLazyCollapseBody', () => {
|
||||
it('leaves a collapsed-by-default body unmounted', () => {
|
||||
render(<Disclosure initialExpanded={false} />);
|
||||
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mounts an expanded-by-default body immediately', () => {
|
||||
render(<Disclosure initialExpanded={true} />);
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mounts in the same commit as a user expand', () => {
|
||||
render(<Disclosure initialExpanded={false} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the body through the collapse transition, then releases it', () => {
|
||||
render(<Disclosure initialExpanded={false} />);
|
||||
const toggle = screen.getByRole('button');
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
|
||||
fireEvent.transitionEnd(screen.getByTestId('panel'));
|
||||
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores transition events bubbling from descendants', () => {
|
||||
render(<Disclosure initialExpanded={false} />);
|
||||
const toggle = screen.getByRole('button');
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(toggle);
|
||||
|
||||
fireEvent.transitionEnd(screen.getByTestId('body'));
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not release the body when a transition ends while expanded', () => {
|
||||
render(<Disclosure initialExpanded={true} />);
|
||||
fireEvent.transitionEnd(screen.getByTestId('panel'));
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retains the body across a collapse while retainBody is set', () => {
|
||||
const view = render(<Disclosure initialExpanded={false} retainBody />);
|
||||
const toggle = screen.getByRole('button');
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.transitionEnd(screen.getByTestId('panel'));
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
|
||||
view.rerender(<Disclosure initialExpanded={false} retainBody={false} />);
|
||||
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps an expanded body mounted when retention clears', () => {
|
||||
const view = render(<Disclosure initialExpanded={false} retainBody />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
view.rerender(<Disclosure initialExpanded={false} retainBody={false} />);
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { RowMountWindow } from '../useProgressiveRowMount';
|
||||
import { useProgressiveRowMount, completeProgressiveRowMounts } from '../useProgressiveRowMount';
|
||||
|
||||
type HookProps = {
|
||||
tailDepth: number | undefined;
|
||||
anchorBottom: boolean;
|
||||
isSubmitting: boolean;
|
||||
conversationId: string | null | undefined;
|
||||
};
|
||||
|
||||
describe('useProgressiveRowMount', () => {
|
||||
let frames: Array<FrameRequestCallback | undefined>;
|
||||
const scrollableRef = { current: null } as React.RefObject<HTMLDivElement | null>;
|
||||
|
||||
/** Runs only the frames scheduled BEFORE this flush, so one call advances
|
||||
* the expansion by exactly one step even though each step schedules the
|
||||
* next frame during the act() flush. */
|
||||
const flushFrames = () =>
|
||||
act(() => {
|
||||
const pending = frames.length;
|
||||
for (let index = 0; index < pending; index += 1) {
|
||||
const frame = frames[index];
|
||||
frames[index] = undefined;
|
||||
frame?.(index);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
frames = [];
|
||||
window.requestAnimationFrame = jest.fn((callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
}) as unknown as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = jest.fn((handle: number) => {
|
||||
frames[handle - 1] = undefined;
|
||||
}) as unknown as typeof window.cancelAnimationFrame;
|
||||
});
|
||||
|
||||
const setup = (initial: Partial<HookProps> = {}) => {
|
||||
const props: HookProps = {
|
||||
tailDepth: 267,
|
||||
anchorBottom: false,
|
||||
isSubmitting: false,
|
||||
conversationId: 'convo-a',
|
||||
...initial,
|
||||
};
|
||||
return renderHook(
|
||||
(current: HookProps) => useProgressiveRowMount({ ...current, scrollableRef }),
|
||||
{ initialProps: props },
|
||||
);
|
||||
};
|
||||
|
||||
it('does not window short threads', () => {
|
||||
const { result } = setup({ tailDepth: 20 });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('does not window when a submission is already active', () => {
|
||||
const { result } = setup({ isSubmitting: true });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('anchors the first window at the top by default', () => {
|
||||
const { result } = setup();
|
||||
expect(result.current).toEqual({ start: 0, end: 15 });
|
||||
});
|
||||
|
||||
it('anchors the first window at the tail for bottom anchoring', () => {
|
||||
const { result } = setup({ anchorBottom: true });
|
||||
expect(result.current).toEqual({ start: 252, end: Number.POSITIVE_INFINITY });
|
||||
});
|
||||
|
||||
it('widens per frame until the whole path is covered, then lifts the restriction', () => {
|
||||
const { result } = setup();
|
||||
const seen: RowMountWindow[] = [result.current];
|
||||
|
||||
for (let i = 0; i < 20 && result.current != null; i += 1) {
|
||||
flushFrames();
|
||||
seen.push(result.current);
|
||||
}
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
const ends = seen.filter((w): w is NonNullable<RowMountWindow> => w != null).map((w) => w.end);
|
||||
for (let i = 1; i < ends.length; i += 1) {
|
||||
expect(ends[i]).toBeGreaterThan(ends[i - 1]);
|
||||
}
|
||||
/** The final widening and the covered-check that lifts the restriction
|
||||
* land in the same flush, so the last observable window sits within one
|
||||
* chunk of the tail. */
|
||||
expect(ends[ends.length - 1]).toBeGreaterThanOrEqual(267 - 32);
|
||||
});
|
||||
|
||||
it('completes immediately when a submission starts mid-expansion', () => {
|
||||
const { result, rerender } = setup();
|
||||
expect(result.current).not.toBeNull();
|
||||
|
||||
rerender({
|
||||
tailDepth: 267,
|
||||
anchorBottom: false,
|
||||
isSubmitting: true,
|
||||
conversationId: 'convo-a',
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('force-completes in-flight mounts for DOM consumers, resolving after paint', async () => {
|
||||
const { result } = setup();
|
||||
expect(result.current).not.toBeNull();
|
||||
|
||||
let resolved = false;
|
||||
let completion: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
completion = completeProgressiveRowMounts().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
flushFrames();
|
||||
flushFrames();
|
||||
await act(async () => {
|
||||
await completion;
|
||||
});
|
||||
expect(resolved).toBe(true);
|
||||
|
||||
/** With nothing in flight it resolves immediately, no frames needed. */
|
||||
await expect(completeProgressiveRowMounts()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-arms a fresh window when the conversation changes', () => {
|
||||
const { result, rerender } = setup();
|
||||
|
||||
while (result.current != null) {
|
||||
flushFrames();
|
||||
}
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
rerender({
|
||||
tailDepth: 199,
|
||||
anchorBottom: false,
|
||||
isSubmitting: false,
|
||||
conversationId: 'convo-b',
|
||||
});
|
||||
expect(result.current).toEqual({ start: 0, end: 15 });
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,14 @@ export { default as useAttachments } from './useAttachments';
|
|||
export { default as useSubmitMessage } from './useSubmitMessage';
|
||||
export type { ContentMetadataResult } from './useContentMetadata';
|
||||
export { default as useExpandCollapse } from './useExpandCollapse';
|
||||
export { default as useLazyCollapseBody } from './useLazyCollapseBody';
|
||||
export {
|
||||
RowMountProvider,
|
||||
useRowMountWindow,
|
||||
useProgressiveRowMount,
|
||||
completeProgressiveRowMounts,
|
||||
} from './useProgressiveRowMount';
|
||||
export type { RowMountWindow } from './useProgressiveRowMount';
|
||||
export { default as useMessageActions } from './useMessageActions';
|
||||
export { useLatestMessage, useLatestMessageId } from './useLatestMessage';
|
||||
export { default as useMemoizedChatContext } from './useMemoizedChatContext';
|
||||
|
|
|
|||
59
client/src/hooks/Messages/useLazyCollapseBody.ts
Normal file
59
client/src/hooks/Messages/useLazyCollapseBody.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import type { TransitionEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Defers a disclosure panel's body: collapsed-by-default content stays
|
||||
* unmounted until its first expansion and unmounts again after the collapse
|
||||
* transition completes (`useExpandCollapse` keeps `transitionend` firing even
|
||||
* under reduced motion, so the release always arrives). The expansion-flag
|
||||
* effect mounts one commit after programmatic expands; toggle handlers should
|
||||
* call `mountBody` so user-driven expands mount in the same commit the
|
||||
* height transition measures.
|
||||
*
|
||||
* `retainBody` keeps an already-mounted body across collapses while true —
|
||||
* for descendants that own unsent local form state (pending tool approvals) —
|
||||
* and releases it once the flag clears while collapsed.
|
||||
*/
|
||||
export default function useLazyCollapseBody(
|
||||
isExpanded: boolean,
|
||||
retainBody = false,
|
||||
): {
|
||||
shouldRenderBody: boolean;
|
||||
mountBody: () => void;
|
||||
handleTransitionEnd: (event: TransitionEvent<HTMLElement>) => void;
|
||||
} {
|
||||
const [shouldRenderBody, setShouldRenderBody] = useState(isExpanded);
|
||||
const retainedRef = useRef(false);
|
||||
const mountBody = useCallback(() => setShouldRenderBody(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
retainedRef.current = false;
|
||||
setShouldRenderBody(true);
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded && !retainBody && retainedRef.current) {
|
||||
retainedRef.current = false;
|
||||
setShouldRenderBody(false);
|
||||
}
|
||||
}, [isExpanded, retainBody]);
|
||||
|
||||
const handleTransitionEnd = useCallback(
|
||||
(event: TransitionEvent<HTMLElement>) => {
|
||||
if (event.target !== event.currentTarget || isExpanded) {
|
||||
return;
|
||||
}
|
||||
if (retainBody) {
|
||||
retainedRef.current = true;
|
||||
return;
|
||||
}
|
||||
retainedRef.current = false;
|
||||
setShouldRenderBody(false);
|
||||
},
|
||||
[isExpanded, retainBody],
|
||||
);
|
||||
|
||||
return { shouldRenderBody, mountBody, handleTransitionEnd };
|
||||
}
|
||||
202
client/src/hooks/Messages/useProgressiveRowMount.tsx
Normal file
202
client/src/hooks/Messages/useProgressiveRowMount.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useContext,
|
||||
useCallback,
|
||||
createContext,
|
||||
useLayoutEffect,
|
||||
startTransition,
|
||||
} from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Depth range (inclusive) of visible-path rows allowed to mount; `null` means
|
||||
* no restriction. `MultiMessage` reads this to gate each row while always
|
||||
* continuing its recursion, so the tree's structure, sibling state, and
|
||||
* streaming spine are identical whether or not a window is active.
|
||||
*/
|
||||
export type RowMountWindow = { start: number; end: number } | null;
|
||||
|
||||
const RowMountContext = createContext<RowMountWindow>(null);
|
||||
|
||||
export function RowMountProvider({
|
||||
mountWindow,
|
||||
children,
|
||||
}: {
|
||||
mountWindow: RowMountWindow;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <RowMountContext.Provider value={mountWindow}>{children}</RowMountContext.Provider>;
|
||||
}
|
||||
|
||||
export function useRowMountWindow(): RowMountWindow {
|
||||
return useContext(RowMountContext);
|
||||
}
|
||||
|
||||
/** Below this path length every row mounts in one commit, exactly as before. */
|
||||
const MIN_PROGRESSIVE_ROWS = 40;
|
||||
/** Rows in the first anchored commit — about one viewport plus overscan. */
|
||||
const INITIAL_ROWS = 16;
|
||||
/** Rows added per expansion step until the window covers the whole path. */
|
||||
const CHUNK_ROWS = 32;
|
||||
|
||||
type ProgressiveRowMountOptions = {
|
||||
/** Depth of the active branch tail (`latestMessageDepth` from ChatContext). */
|
||||
tailDepth: number | undefined;
|
||||
/** True anchors the first commit at the newest rows (auto-scroll lands
|
||||
* there); false anchors at the conversation start, which is where a
|
||||
* default-settings load rests. */
|
||||
anchorBottom: boolean;
|
||||
isSubmitting: boolean;
|
||||
conversationId: string | null | undefined;
|
||||
scrollableRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function initialWindow(
|
||||
tailDepth: number | undefined,
|
||||
anchorBottom: boolean,
|
||||
isSubmitting: boolean,
|
||||
): RowMountWindow {
|
||||
if (isSubmitting || tailDepth == null || tailDepth + 1 <= MIN_PROGRESSIVE_ROWS) {
|
||||
return null;
|
||||
}
|
||||
if (anchorBottom) {
|
||||
return { start: Math.max(0, tailDepth - INITIAL_ROWS + 1), end: Number.POSITIVE_INFINITY };
|
||||
}
|
||||
return { start: 0, end: INITIAL_ROWS - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed first commit for long threads: mount only the rows around the
|
||||
* scroll anchor, then widen the window in transition-wrapped chunks until
|
||||
* every row is mounted, then drop the restriction entirely. The DOM converges
|
||||
* to the exact full structure — nothing ever unmounts — so message counts,
|
||||
* screenshot export, and the nav rail see the same document they always have,
|
||||
* just a few frames later.
|
||||
*
|
||||
* Bottom-anchored expansion inserts rows above the viewport; the layout
|
||||
* effect re-pins the previously first-mounted row to its pre-commit viewport
|
||||
* offset by measuring its actual shift, which also degrades to a no-op
|
||||
* wherever native scroll anchoring already compensated.
|
||||
*/
|
||||
export function useProgressiveRowMount({
|
||||
tailDepth,
|
||||
anchorBottom,
|
||||
isSubmitting,
|
||||
conversationId,
|
||||
scrollableRef,
|
||||
}: ProgressiveRowMountOptions): RowMountWindow {
|
||||
const [mountWindow, setMountWindow] = useState<RowMountWindow>(() =>
|
||||
initialWindow(tailDepth, anchorBottom, isSubmitting),
|
||||
);
|
||||
const anchorRef = useRef<{ element: Element; documentOffset: number } | null>(null);
|
||||
|
||||
/** Re-arm per conversation so every navigation gets the anchored fast
|
||||
* first commit (state adjustment during render, per React's guidance,
|
||||
* so the old conversation's window never gates the new tree). */
|
||||
const [prevConversationId, setPrevConversationId] = useState(conversationId);
|
||||
if (prevConversationId !== conversationId) {
|
||||
setPrevConversationId(conversationId);
|
||||
setMountWindow(initialWindow(tailDepth, anchorBottom, isSubmitting));
|
||||
anchorRef.current = null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting && mountWindow != null) {
|
||||
setMountWindow(null);
|
||||
}
|
||||
}, [isSubmitting, mountWindow]);
|
||||
|
||||
const captureAnchor = useCallback(() => {
|
||||
const container = scrollableRef.current;
|
||||
if (!container || !anchorBottom) {
|
||||
anchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
const element = container.querySelector('.message-render');
|
||||
/** Document-space offset (viewport top + scrollTop): the widening commit
|
||||
* is transition-deferred, so the user may scroll between capture and
|
||||
* commit. User scrolling moves viewport coordinates but not document
|
||||
* ones, so measuring here isolates the inserted-row shift and never
|
||||
* folds the user's own movement into the correction. */
|
||||
anchorRef.current = element
|
||||
? { element, documentOffset: element.getBoundingClientRect().top + container.scrollTop }
|
||||
: null;
|
||||
}, [anchorBottom, scrollableRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountWindow == null || tailDepth == null) {
|
||||
return;
|
||||
}
|
||||
if (mountWindow.start <= 0 && mountWindow.end >= tailDepth) {
|
||||
setMountWindow(null);
|
||||
return;
|
||||
}
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
captureAnchor();
|
||||
startTransition(() => {
|
||||
setMountWindow((current) => {
|
||||
if (current == null) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
start: Math.max(0, current.start - CHUNK_ROWS),
|
||||
end: current.end >= tailDepth ? current.end : current.end + CHUNK_ROWS,
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [mountWindow, tailDepth, captureAnchor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const captured = anchorRef.current;
|
||||
anchorRef.current = null;
|
||||
const container = scrollableRef.current;
|
||||
if (!captured || !container || !captured.element.isConnected) {
|
||||
return;
|
||||
}
|
||||
const shift =
|
||||
captured.element.getBoundingClientRect().top + container.scrollTop - captured.documentOffset;
|
||||
if (shift !== 0) {
|
||||
container.scrollTop += shift;
|
||||
}
|
||||
}, [mountWindow, scrollableRef]);
|
||||
|
||||
/** Registered while a window is active so `completeProgressiveRowMounts`
|
||||
* (screenshot capture) can force the remaining rows in and wait for the
|
||||
* commit to paint before cloning the DOM. */
|
||||
const isWindowActive = mountWindow != null;
|
||||
useEffect(() => {
|
||||
if (!isWindowActive) {
|
||||
return;
|
||||
}
|
||||
const complete = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
setMountWindow(null);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
activeCompleters.add(complete);
|
||||
return () => {
|
||||
activeCompleters.delete(complete);
|
||||
};
|
||||
}, [isWindowActive]);
|
||||
|
||||
return mountWindow;
|
||||
}
|
||||
|
||||
const activeCompleters = new Set<() => Promise<void>>();
|
||||
|
||||
/**
|
||||
* Forces every in-flight progressive mount to completion and resolves after
|
||||
* the resulting commit has painted. DOM consumers that clone the thread
|
||||
* (screenshot export) call this so a capture taken mid-widening cannot
|
||||
* silently truncate the rows still outside the window.
|
||||
*/
|
||||
export async function completeProgressiveRowMounts(): Promise<void> {
|
||||
if (activeCompleters.size === 0) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([...activeCompleters].map((complete) => complete()));
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext, useRef, useContext, RefObject, ReactNode } from 'react';
|
||||
import { toCanvas } from 'html-to-image';
|
||||
import { ThemeContext, isDark } from '@librechat/client';
|
||||
import { completeProgressiveRowMounts } from '~/hooks/Messages/useProgressiveRowMount';
|
||||
|
||||
type ScreenshotContextType = {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
|
|
@ -76,6 +77,9 @@ export const useScreenshot = () => {
|
|||
if (ref instanceof Function) {
|
||||
throw new Error('Ref callback is not supported.');
|
||||
}
|
||||
/** A capture taken while a long thread is still progressively mounting
|
||||
* would clone a truncated DOM; force the remaining rows in first. */
|
||||
await completeProgressiveRowMounts();
|
||||
if (ref?.current) {
|
||||
return takeScreenShot(ref.current);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,33 @@ export type GroupedPart =
|
|||
| { type: 'single'; part: PartWithIndex }
|
||||
| { type: 'tool-group'; parts: PartWithIndex[]; labelPart?: PartWithIndex };
|
||||
|
||||
type ToolCallWithNestedContent = Agents.ToolCall & {
|
||||
subagent_content?: TMessageContentParts[];
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the part carries an unresolved tool approval — directly or nested
|
||||
* in subagent content. Collapsed disclosure bodies retain instead of
|
||||
* unmounting while this holds, because `ToolApproval` owns unsent local
|
||||
* edit/respond/reason state that an unmount would discard.
|
||||
*/
|
||||
export function hasPendingApprovalInPart(part: TMessageContentParts): boolean {
|
||||
if (part.type !== ContentTypes.TOOL_CALL) {
|
||||
return false;
|
||||
}
|
||||
const toolCall = part[ContentTypes.TOOL_CALL] as ToolCallWithNestedContent | undefined;
|
||||
if (!toolCall) {
|
||||
return false;
|
||||
}
|
||||
if (toolCall.approval != null && (toolCall.output?.length ?? 0) === 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Array.isArray(toolCall.subagent_content) &&
|
||||
toolCall.subagent_content.some(hasPendingApprovalInPart)
|
||||
);
|
||||
}
|
||||
|
||||
function isGroupableToolCall(part: TMessageContentParts): boolean {
|
||||
if (part.type !== ContentTypes.TOOL_CALL) {
|
||||
return false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue