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 ? (