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