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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user