diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx
index 3b73a84f..b5e4ffa7 100644
--- a/packages/ui/src/components/chat/MessageList.tsx
+++ b/packages/ui/src/components/chat/MessageList.tsx
@@ -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 (
{entries.map((entry) => (
@@ -1269,57 +1269,82 @@ const MessageList = React.forwardRef
(({
const historyEntries = staticRenderEntries;
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
- const [historyWidthPx, setHistoryWidthPx] = React.useState(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) {
diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
index 5a8a6b28..b95f4a2d 100644
--- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
+++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
@@ -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(
- `[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 => {
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
return false;
diff --git a/packages/ui/src/components/chat/lib/turns/constants.ts b/packages/ui/src/components/chat/lib/turns/constants.ts
index 3489c4ae..dddc737d 100644
--- a/packages/ui/src/components/chat/lib/turns/constants.ts
+++ b/packages/ui/src/components/chat/lib/turns/constants.ts
@@ -3,8 +3,8 @@ export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']);
export const HIDDEN_INTERNAL_TOOL_NAMES = new Set(['todowrite', 'todoread']);
export const TURN_WINDOW_DEFAULTS = {
- initialTurns: 10,
- batchTurns: 8,
+ initialTurns: 7,
+ batchTurns: 7,
prefetchBuffer: 16,
} as const;
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx
index 6ed35d25..d3b4ed2b 100644
--- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx
+++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx
@@ -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): { 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 = ({
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 = ({
/>
) : null}
- {!isTaskTool && isExpanded ? (
+ {!isTaskTool && shouldRenderExpandedContent ? (