perf(ui): enable session timeline virtualization and defer heavy tool content

Virtualize message timeline at 5+ turns. Stabilize virtualizer keys,
add prepend-aware scroll compensation for "Load older", and defer
expanded tool bodies by one animation frame. Tighten initial turn
window to 7 turns for faster session open.
This commit is contained in:
Bohdan Triapitsyn
2026-05-12 14:44:53 +03:00
parent 644050824e
commit d587ed7542
4 changed files with 105 additions and 59 deletions
+55 -30
View File
@@ -17,7 +17,7 @@ import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { normalizeParts } from './message/partUtils';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = Number.POSITIVE_INFINITY;
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const MESSAGE_LIST_OVERSCAN = 6;
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
@@ -977,7 +977,7 @@ const StaticHistoryList: React.FC<{
? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0))
: 0;
if (!shouldVirtualize) {
if (!shouldVirtualize || (virtualRows.length === 0 && entries.length > 0)) {
return (
<div ref={contentRef} className="relative w-full">
{entries.map((entry) => (
@@ -1269,57 +1269,82 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const historyEntries = staticRenderEntries;
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
const [historyWidthPx, setHistoryWidthPx] = React.useState<number | null>(null);
const historyMeasurementScopeKey = historyWidthPx === null ? 'width:unknown' : `width:${Math.round(historyWidthPx)}`;
const previousHistoryLenRef = React.useRef(historyEntries.length);
const previousFirstEntryKeyRef = React.useRef(historyEntries[0]?.key);
React.useLayoutEffect(() => {
const historyContent = historyContentRef.current;
if (!historyContent || !shouldVirtualizeHistory) {
setHistoryWidthPx((previous) => (previous === null ? previous : null));
const previousLen = previousHistoryLenRef.current;
const currentLen = historyEntries.length;
const previousFirstKey = previousFirstEntryKeyRef.current;
const currentFirstKey = historyEntries[0]?.key;
previousHistoryLenRef.current = currentLen;
previousFirstEntryKeyRef.current = currentFirstKey;
const grew = currentLen > previousLen;
const firstChanged = previousFirstKey !== currentFirstKey;
if (!shouldVirtualizeHistory || !grew || !firstChanged || previousLen === 0) {
return;
}
const updateWidth = (nextWidth: number) => {
setHistoryWidthPx((previous) => {
if (previous !== null && Math.abs(previous - nextWidth) < 0.5) {
return previous;
}
return nextWidth;
});
};
updateWidth(historyContent.getBoundingClientRect().width);
if (typeof ResizeObserver === 'undefined') {
const prependedCount = currentLen - previousLen;
const shiftedOldFirst = historyEntries[prependedCount]?.key;
if (shiftedOldFirst !== previousFirstKey) {
return;
}
const observer = new ResizeObserver(() => {
updateWidth(historyContent.getBoundingClientRect().width);
});
observer.observe(historyContent);
return () => {
observer.disconnect();
};
}, [historyEntries.length, shouldVirtualizeHistory]);
// Prepend detected: new entries added at the beginning of the list.
// The virtualizer renders based on the current scroll offset which
// now maps to different items. Compensate so the user sees the
// prepended content (scroll to top) or stays on the same content.
let prependedHeight = 0;
for (let i = 0; i < prependedCount; i++) {
prependedHeight += estimateHistoryEntryHeight(historyEntries[i]);
}
const scrollEl = resolveScrollContainer();
if (!scrollEl || prependedHeight <= 0) return;
scrollEl.scrollTop += prependedHeight;
});
const historyVirtualizer = useVirtualizer({
count: historyEntries.length,
getScrollElement: resolveScrollContainer,
estimateSize: (index) => estimateHistoryEntryHeight(historyEntries[index]),
getItemKey: (index) => `${historyMeasurementScopeKey}:${historyEntries[index]?.key ?? index}`,
getItemKey: (index) => historyEntries[index]?.key ?? String(index),
measureElement: measureVirtualElement,
useAnimationFrameWithResizeObserver: true,
overscan: MESSAGE_LIST_OVERSCAN,
enabled: shouldVirtualizeHistory,
});
React.useLayoutEffect(() => {
const historyContent = historyContentRef.current;
if (!historyContent || !shouldVirtualizeHistory) {
return;
}
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => {
historyVirtualizer.measure();
});
observer.observe(historyContent);
return () => {
observer.disconnect();
};
}, [historyEntries.length, shouldVirtualizeHistory, historyVirtualizer]);
React.useEffect(() => {
if (!shouldVirtualizeHistory || historyWidthPx === null) {
if (!shouldVirtualizeHistory) {
return;
}
historyVirtualizer.measure();
}, [historyVirtualizer, historyWidthPx, shouldVirtualizeHistory]);
}, [historyVirtualizer, shouldVirtualizeHistory]);
const scheduleVirtualMeasure = React.useCallback(() => {
if (!shouldVirtualizeHistory) {
@@ -247,32 +247,6 @@ export const useChatTimelineController = ({
anchor: ViewportAnchor | null;
} | null>(null);
React.useLayoutEffect(() => {
const snap = prePrependScrollRef.current;
const container = scrollRef.current;
if (!snap || !container) return;
prePrependScrollRef.current = null;
// Try anchor-based restoration first (pixel-perfect)
if (snap.anchor) {
const anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${snap.anchor.messageId}"]`,
);
if (anchorEl) {
const containerRect = container.getBoundingClientRect();
const anchorTop = anchorEl.getBoundingClientRect().top - containerRect.top;
container.scrollTop += anchorTop - snap.anchor.offsetTop;
return;
}
}
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
}, [renderedMessages, scrollRef]);
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
return messageListRef.current?.captureViewportAnchor() ?? null;
}, [messageListRef]);
@@ -281,6 +255,26 @@ export const useChatTimelineController = ({
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
}, [messageListRef]);
React.useLayoutEffect(() => {
const snap = prePrependScrollRef.current;
const container = scrollRef.current;
if (!snap || !container) return;
prePrependScrollRef.current = null;
// When a viewport anchor is available, delegate to MessageList
// restoreViewportAnchor which falls back to virtualizer-aware
// scrollHistoryIndexIntoView when the element is not in the DOM.
if (snap.anchor && restoreViewportAnchor(snap.anchor)) {
return;
}
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
}, [renderedMessages, scrollRef, restoreViewportAnchor]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
return false;
@@ -3,8 +3,8 @@ export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
export const HIDDEN_INTERNAL_TOOL_NAMES = new Set<string>(['todowrite', 'todoread']);
export const TURN_WINDOW_DEFAULTS = {
initialTurns: 10,
batchTurns: 8,
initialTurns: 7,
batchTurns: 7,
prefetchBuffer: 16,
} as const;
@@ -187,6 +187,32 @@ const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> =
return <>{formatDuration(start, end, now)}</>;
};
const useDeferredExpandedContent = (isExpanded: boolean) => {
const [shouldRender, setShouldRender] = React.useState(false);
React.useEffect(() => {
if (!isExpanded) {
setShouldRender(false);
return;
}
if (typeof window === 'undefined') {
setShouldRender(true);
return;
}
const frame = window.requestAnimationFrame(() => {
setShouldRender(true);
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [isExpanded]);
return shouldRender;
};
const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => {
const diffText = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
?? getPatchText(metadata?.diff);
@@ -2495,6 +2521,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
const shouldRenderExpandedContent = useDeferredExpandedContent(isExpanded);
if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
return null;
@@ -2638,7 +2665,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
/>
) : null}
{!isTaskTool && isExpanded ? (
{!isTaskTool && shouldRenderExpandedContent ? (
<div className="relative ml-2 pl-3">
<span
aria-hidden="true"