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 type { StreamPhase } from './message/types';
|
||||||
import { normalizeParts } from './message/partUtils';
|
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 MESSAGE_LIST_OVERSCAN = 6;
|
||||||
|
|
||||||
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
|
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
|
||||||
@@ -977,7 +977,7 @@ const StaticHistoryList: React.FC<{
|
|||||||
? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
if (!shouldVirtualize) {
|
if (!shouldVirtualize || (virtualRows.length === 0 && entries.length > 0)) {
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="relative w-full">
|
<div ref={contentRef} className="relative w-full">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
@@ -1269,57 +1269,82 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
|
|
||||||
const historyEntries = staticRenderEntries;
|
const historyEntries = staticRenderEntries;
|
||||||
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
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(() => {
|
React.useLayoutEffect(() => {
|
||||||
const historyContent = historyContentRef.current;
|
const previousLen = previousHistoryLenRef.current;
|
||||||
if (!historyContent || !shouldVirtualizeHistory) {
|
const currentLen = historyEntries.length;
|
||||||
setHistoryWidthPx((previous) => (previous === null ? previous : null));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateWidth = (nextWidth: number) => {
|
const prependedCount = currentLen - previousLen;
|
||||||
setHistoryWidthPx((previous) => {
|
const shiftedOldFirst = historyEntries[prependedCount]?.key;
|
||||||
if (previous !== null && Math.abs(previous - nextWidth) < 0.5) {
|
if (shiftedOldFirst !== previousFirstKey) {
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
return nextWidth;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updateWidth(historyContent.getBoundingClientRect().width);
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
// Prepend detected: new entries added at the beginning of the list.
|
||||||
updateWidth(historyContent.getBoundingClientRect().width);
|
// The virtualizer renders based on the current scroll offset which
|
||||||
});
|
// now maps to different items. Compensate so the user sees the
|
||||||
observer.observe(historyContent);
|
// prepended content (scroll to top) or stays on the same content.
|
||||||
return () => {
|
let prependedHeight = 0;
|
||||||
observer.disconnect();
|
for (let i = 0; i < prependedCount; i++) {
|
||||||
};
|
prependedHeight += estimateHistoryEntryHeight(historyEntries[i]);
|
||||||
}, [historyEntries.length, shouldVirtualizeHistory]);
|
}
|
||||||
|
|
||||||
|
const scrollEl = resolveScrollContainer();
|
||||||
|
if (!scrollEl || prependedHeight <= 0) return;
|
||||||
|
|
||||||
|
scrollEl.scrollTop += prependedHeight;
|
||||||
|
});
|
||||||
|
|
||||||
const historyVirtualizer = useVirtualizer({
|
const historyVirtualizer = useVirtualizer({
|
||||||
count: historyEntries.length,
|
count: historyEntries.length,
|
||||||
getScrollElement: resolveScrollContainer,
|
getScrollElement: resolveScrollContainer,
|
||||||
estimateSize: (index) => estimateHistoryEntryHeight(historyEntries[index]),
|
estimateSize: (index) => estimateHistoryEntryHeight(historyEntries[index]),
|
||||||
getItemKey: (index) => `${historyMeasurementScopeKey}:${historyEntries[index]?.key ?? index}`,
|
getItemKey: (index) => historyEntries[index]?.key ?? String(index),
|
||||||
measureElement: measureVirtualElement,
|
measureElement: measureVirtualElement,
|
||||||
useAnimationFrameWithResizeObserver: true,
|
useAnimationFrameWithResizeObserver: true,
|
||||||
overscan: MESSAGE_LIST_OVERSCAN,
|
overscan: MESSAGE_LIST_OVERSCAN,
|
||||||
enabled: shouldVirtualizeHistory,
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!shouldVirtualizeHistory || historyWidthPx === null) {
|
if (!shouldVirtualizeHistory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
historyVirtualizer.measure();
|
historyVirtualizer.measure();
|
||||||
}, [historyVirtualizer, historyWidthPx, shouldVirtualizeHistory]);
|
}, [historyVirtualizer, shouldVirtualizeHistory]);
|
||||||
|
|
||||||
const scheduleVirtualMeasure = React.useCallback(() => {
|
const scheduleVirtualMeasure = React.useCallback(() => {
|
||||||
if (!shouldVirtualizeHistory) {
|
if (!shouldVirtualizeHistory) {
|
||||||
|
|||||||
@@ -247,32 +247,6 @@ export const useChatTimelineController = ({
|
|||||||
anchor: ViewportAnchor | null;
|
anchor: ViewportAnchor | null;
|
||||||
} | null>(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 => {
|
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
|
||||||
return messageListRef.current?.captureViewportAnchor() ?? null;
|
return messageListRef.current?.captureViewportAnchor() ?? null;
|
||||||
}, [messageListRef]);
|
}, [messageListRef]);
|
||||||
@@ -281,6 +255,26 @@ export const useChatTimelineController = ({
|
|||||||
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
|
||||||
}, [messageListRef]);
|
}, [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> => {
|
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
|
||||||
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
|
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
|
||||||
return false;
|
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 HIDDEN_INTERNAL_TOOL_NAMES = new Set<string>(['todowrite', 'todoread']);
|
||||||
|
|
||||||
export const TURN_WINDOW_DEFAULTS = {
|
export const TURN_WINDOW_DEFAULTS = {
|
||||||
initialTurns: 10,
|
initialTurns: 7,
|
||||||
batchTurns: 8,
|
batchTurns: 7,
|
||||||
prefetchBuffer: 16,
|
prefetchBuffer: 16,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,32 @@ const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> =
|
|||||||
return <>{formatDuration(start, end, now)}</>;
|
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 parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => {
|
||||||
const diffText = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
|
const diffText = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
|
||||||
?? getPatchText(metadata?.diff);
|
?? 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 iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
|
||||||
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
|
||||||
|
const shouldRenderExpandedContent = useDeferredExpandedContent(isExpanded);
|
||||||
|
|
||||||
if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
|
if (!shouldTreatAsFinalized && !isActive && !isTaskTool) {
|
||||||
return null;
|
return null;
|
||||||
@@ -2638,7 +2665,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isTaskTool && isExpanded ? (
|
{!isTaskTool && shouldRenderExpandedContent ? (
|
||||||
<div className="relative ml-2 pl-3">
|
<div className="relative ml-2 pl-3">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
Reference in New Issue
Block a user