diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index 06c2ac86..bb38fd55 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -27,7 +27,7 @@ export function AssistantMessage({ content, isStreaming, branchAction }: Props)
- + {!isStreaming && } {isStreaming && ( diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 874bab71..eef2fe32 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -123,7 +123,7 @@ describe('MessageList nested tool calls', () => { }) }) - it('keeps full long transcripts mounted so variable-height messages cannot leave spacer gaps', () => { + it('windows long transcripts instead of mounting every historical message at once', () => { useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ @@ -147,13 +147,14 @@ describe('MessageList nested tool calls', () => { const { container } = render() - expect(screen.getByText('assistant transcript line 0')).toBeTruthy() expect(screen.getByText('assistant transcript line 219')).toBeTruthy() - expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220) - expect(container.querySelector('[data-virtual-message-item]')).toBeNull() + expect(screen.queryByText('assistant transcript line 0')).toBeNull() + expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(220) + expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull() + expect(container.querySelector('[data-virtual-spacer="top"]')).not.toBeNull() }) - it('marks transcript items as offscreen-renderable without virtualizing variable-height rows', () => { + it('keeps small transcripts fully mounted without deferred browser painting', () => { useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ @@ -180,12 +181,40 @@ describe('MessageList nested tool calls', () => { expect(renderItems).toHaveLength(2) for (const item of renderItems) { - expect(item.className).toContain('[content-visibility:auto]') - expect(item.className).toContain('[contain-intrinsic-size:auto_180px]') + expect(item.className).not.toContain('content-visibility') + expect(item.className).not.toContain('contain-intrinsic-size') } expect(container.querySelector('[data-virtual-message-item]')).toBeNull() }) + it('virtualizes short message lists when their content is very large', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-huge', + type: 'user_text', + content: '超长设计内容 '.repeat(24_000), + timestamp: 1, + }, + { + id: 'assistant-tail', + type: 'assistant_text', + content: 'latest assistant reply', + timestamp: 2, + }, + ], + }), + }, + }) + + const { container } = render() + + expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull() + expect(screen.getByText('latest assistant reply')).toBeTruthy() + }) + it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => { const messages: UIMessage[] = [ { @@ -533,7 +562,7 @@ describe('MessageList nested tool calls', () => { expect(screen.queryByText('local_agent')).toBeNull() }) - it('restores the full transcript when scrolling away from latest', async () => { + it('renders the historical window when scrolling away from latest', async () => { useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ @@ -559,11 +588,11 @@ describe('MessageList nested tool calls', () => { }) expect(screen.getByText('assistant transcript line 0')).toBeTruthy() - expect(screen.getByText('assistant transcript line 219')).toBeTruthy() - expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220) + expect(screen.queryByText('assistant transcript line 219')).toBeNull() + expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(220) }) - it('keeps long histories with tool-call groups mounted while scrolling history', async () => { + it('keeps tool-call groups reachable while scrolling virtualized history', async () => { useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ @@ -601,7 +630,7 @@ describe('MessageList nested tool calls', () => { Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 }) await waitForProgrammaticScrollReset() - expect(screen.getByText('Read')).toBeTruthy() + expect(screen.queryByText('Read')).toBeNull() expect(screen.getByText('assistant transcript line 219')).toBeTruthy() scrollArea.scrollTop = 0 @@ -610,8 +639,8 @@ describe('MessageList nested tool calls', () => { }) expect(screen.getByText('Read')).toBeTruthy() - expect(screen.getByText('assistant transcript line 219')).toBeTruthy() - expect(container.querySelector('[data-virtual-message-item]')).toBeNull() + expect(screen.queryByText('assistant transcript line 219')).toBeNull() + expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull() }) it('renders sub-agent tool calls inline beneath the parent agent tool call', () => { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 237c3cc1..8f8156b0 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,4 +1,5 @@ import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react' +import { flushSync } from 'react-dom' import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' @@ -784,6 +785,14 @@ type MessageListProps = { const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 const SCROLL_BOTTOM_SENTINEL = 1_000_000_000 const MAX_SCROLL_SNAPSHOTS = 100 +const VIRTUALIZE_MIN_RENDER_ITEMS = 120 +const VIRTUALIZE_MIN_CONTENT_CHARS = 120_000 +const VIRTUAL_IDLE_OVERSCAN_PX = 360 +const VIRTUAL_SCROLL_OVERSCAN_PX = 7200 +const VIRTUAL_SCROLL_IDLE_DELAY_MS = 180 +const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720 +const VIRTUAL_MIN_ITEM_HEIGHT = 48 +const VIRTUAL_MAX_ITEM_HEIGHT = 24_000 const EMPTY_MESSAGES: UIMessage[] = [] const CHAT_SCROLL_AREA_CLASS = [ 'chat-scroll-area', @@ -801,8 +810,6 @@ const CHAT_SCROLL_AREA_CLASS = [ ].join(' ') const CHAT_RENDER_ITEM_CLASS = [ 'chat-render-item', - '[content-visibility:auto]', - '[contain-intrinsic-size:auto_180px]', ].join(' ') type SessionScrollSnapshot = { @@ -810,6 +817,29 @@ type SessionScrollSnapshot = { wasAtBottom: boolean } +type VirtualViewport = { + scrollTop: number + viewportHeight: number +} + +type VirtualTranscriptItem = { + item: RenderItem + index: number +} + +type VirtualTranscriptWindow = { + enabled: boolean + beforeHeight: number + afterHeight: number + items: VirtualTranscriptItem[] +} + +type VirtualRenderItemMetric = { + signature: string + contentWeight: number + estimatedHeight: number +} + const sessionScrollSnapshots = new Map() function isNearScrollBottom(element: HTMLElement) { @@ -859,6 +889,279 @@ function setScrollToBottomWithoutLayoutRead(element: HTMLElement, behavior: Scro } } +function clampNumber(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function getRenderItemKey(item: RenderItem) { + return item.kind === 'tool_group' ? item.id : item.message.id +} + +function isRecordValue(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function getShallowStringWeight(value: unknown, depth = 0): number { + if (typeof value === 'string') return value.length + if (!value || depth > 1) return 0 + if (Array.isArray(value)) { + return value.slice(0, 12).reduce((total, item) => total + getShallowStringWeight(item, depth + 1), 0) + } + if (!isRecordValue(value)) return 0 + + let total = 0 + for (const item of Object.values(value).slice(0, 24)) { + total += getShallowStringWeight(item, depth + 1) + if (total >= VIRTUALIZE_MIN_CONTENT_CHARS) return total + } + return total +} + +function getMessageContentWeight(message: UIMessage): number { + switch (message.type) { + case 'user_text': + case 'assistant_text': + case 'thinking': + case 'system': + return message.content.length + case 'tool_use': + return getShallowStringWeight(message.input) + (message.partialInput?.length ?? 0) + case 'tool_result': + return getShallowStringWeight(message.content) + case 'permission_request': + return getShallowStringWeight(message.input) + (message.description?.length ?? 0) + case 'error': + return message.message.length + case 'compact_summary': + return message.title.length + (message.summary?.length ?? 0) + case 'goal_event': + return (message.objective?.length ?? 0) + (message.message?.length ?? 0) + case 'memory_event': + return (message.message?.length ?? 0) + message.files.reduce((total, file) => total + file.path.length + (file.summary?.length ?? 0), 0) + case 'background_task': + return getShallowStringWeight(message.task) + case 'task_summary': + return message.tasks.reduce((total, task) => total + task.subject.length + (task.activeForm?.length ?? 0), 0) + } +} + +function getRenderItemContentWeight(item: RenderItem): number { + if (item.kind === 'message') return getMessageContentWeight(item.message) + return item.toolCalls.reduce((total, toolCall) => total + getMessageContentWeight(toolCall), 0) +} + +function shouldVirtualizeRenderItems(metrics: VirtualRenderItemMetric[]) { + if (metrics.length >= VIRTUALIZE_MIN_RENDER_ITEMS) return true + + let totalWeight = 0 + for (const metric of metrics) { + totalWeight += metric.contentWeight + if (totalWeight >= VIRTUALIZE_MIN_CONTENT_CHARS) return true + } + return false +} + +function countLineBreaksCapped(content: string, maxLines: number) { + let lineBreaks = 0 + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) === 10) { + lineBreaks += 1 + if (lineBreaks >= maxLines) return lineBreaks + } + } + return lineBreaks +} + +function estimateTextHeight(content: string, baseHeight: number) { + const sample = content.length > 12_000 ? content.slice(0, 12_000) : content + const sampledLineBreaks = countLineBreaksCapped(sample, 900) + const explicitLines = content.length > sample.length + ? Math.ceil((sampledLineBreaks + 1) * (content.length / sample.length)) + : sampledLineBreaks + 1 + const wrappedLines = Math.ceil(content.length / 76) + const estimated = baseHeight + Math.max(explicitLines, wrappedLines) * 22 + return clampNumber(estimated, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT) +} + +function estimateMessageHeight(message: UIMessage): number { + switch (message.type) { + case 'user_text': + return estimateTextHeight(message.content, message.attachments?.length ? 140 : 74) + case 'assistant_text': + return estimateTextHeight(message.content, 96) + case 'thinking': + return estimateTextHeight(message.content, 88) + case 'tool_use': + return clampNumber(92 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 72, 2200) + case 'tool_result': + return clampNumber(88 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 64, 2200) + case 'background_task': + case 'goal_event': + case 'memory_event': + case 'permission_request': + case 'task_summary': + return 110 + case 'compact_summary': + return message.summary ? clampNumber(92 + Math.ceil(message.summary.length / 90) * 20, 80, 1800) : 70 + case 'error': + case 'system': + return 64 + } +} + +function estimateRenderItemHeight(item: RenderItem): number { + if (item.kind === 'message') return estimateMessageHeight(item.message) + const textWeight = getRenderItemContentWeight(item) + return clampNumber(92 + item.toolCalls.length * 78 + Math.ceil(textWeight / 140) * 16, 88, 2600) +} + +function getMessageMetricSignature(message: UIMessage): string { + switch (message.type) { + case 'user_text': + return `${message.type}:${message.content.length}:${message.attachments?.length ?? 0}:${message.pending ? 1 : 0}` + case 'assistant_text': + case 'thinking': + case 'system': + return `${message.type}:${message.content.length}` + case 'tool_use': + return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}` + case 'tool_result': + return `${message.type}:${message.toolUseId}:${message.isError ? 1 : 0}` + case 'compact_summary': + return `${message.type}:${message.phase ?? ''}:${message.title.length}:${message.summary?.length ?? 0}` + case 'goal_event': + return `${message.type}:${message.action}:${message.status ?? ''}:${message.objective?.length ?? 0}:${message.message?.length ?? 0}` + case 'memory_event': + return `${message.type}:${message.event}:${message.files.length}:${message.message?.length ?? 0}` + case 'background_task': + return `${message.type}:${message.task.taskId}:${message.task.status}:${message.task.updatedAt}` + case 'permission_request': + return `${message.type}:${message.requestId}:${message.toolUseId ?? ''}:${message.description?.length ?? 0}` + case 'error': + return `${message.type}:${message.code}:${message.message.length}` + case 'task_summary': + return `${message.type}:${message.tasks.length}:${message.tasks.map((task) => task.id).join(',')}` + } +} + +function getRenderItemMetricSignature(item: RenderItem): string { + if (item.kind === 'message') return getMessageMetricSignature(item.message) + return item.toolCalls.map(getMessageMetricSignature).join('|') +} + +function findVirtualStartIndex(offsets: number[], target: number) { + let low = 0 + let high = offsets.length - 1 + while (low < high) { + const mid = Math.floor((low + high) / 2) + if ((offsets[mid + 1] ?? offsets[mid] ?? 0) < target) { + low = mid + 1 + } else { + high = mid + } + } + return Math.max(0, low) +} + +function findVirtualEndIndex(offsets: number[], target: number) { + let low = 0 + let high = offsets.length - 1 + while (low < high) { + const mid = Math.floor((low + high) / 2) + if ((offsets[mid] ?? 0) <= target) { + low = mid + 1 + } else { + high = mid + } + } + return clampNumber(low + 1, 0, offsets.length - 1) +} + +function buildVirtualTranscriptWindow( + renderItems: RenderItem[], + itemKeys: string[], + metrics: VirtualRenderItemMetric[], + measuredHeights: Map, + viewport: VirtualViewport, + overscanPx: number, +): VirtualTranscriptWindow { + if (!shouldVirtualizeRenderItems(metrics)) { + return { + enabled: false, + beforeHeight: 0, + afterHeight: 0, + items: renderItems.map((item, index) => ({ item, index })), + } + } + + const offsets = new Array(renderItems.length + 1) + offsets[0] = 0 + for (let index = 0; index < renderItems.length; index += 1) { + const item = renderItems[index]! + const measuredHeight = measuredHeights.get(itemKeys[index]!) + const height = measuredHeight && measuredHeight > 0 + ? measuredHeight + : metrics[index]?.estimatedHeight ?? estimateRenderItemHeight(item) + offsets[index + 1] = offsets[index]! + height + } + + const totalHeight = offsets[renderItems.length] ?? 0 + const viewportHeight = viewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT + const maxScrollTop = Math.max(0, totalHeight - viewportHeight) + const scrollTop = clampNumber(viewport.scrollTop, 0, maxScrollTop) + const windowTop = Math.max(0, scrollTop - overscanPx) + const windowBottom = Math.min(totalHeight, scrollTop + viewportHeight + overscanPx) + const startIndex = findVirtualStartIndex(offsets, windowTop) + const endIndex = Math.min(renderItems.length, findVirtualEndIndex(offsets, windowBottom)) + + return { + enabled: true, + beforeHeight: offsets[startIndex] ?? 0, + afterHeight: totalHeight - (offsets[endIndex] ?? totalHeight), + items: renderItems.slice(startIndex, endIndex).map((item, offset) => ({ + item, + index: startIndex + offset, + })), + } +} + +const MeasuredRenderItem = memo(function MeasuredRenderItem({ + itemKey, + onHeightChange, + children, +}: { + itemKey: string + onHeightChange: (itemKey: string, height: number) => void + children: ReactNode +}) { + const itemRef = useRef(null) + + useLayoutEffect(() => { + const node = itemRef.current + if (!node) return undefined + + if (typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (entry && Number.isFinite(entry.contentRect.height) && entry.contentRect.height > 0) { + onHeightChange(itemKey, Math.ceil(entry.contentRect.height)) + } + }) + observer.observe(node) + return () => observer.disconnect() + }, [itemKey, onHeightChange]) + + return ( +
+ {children} +
+ ) +}) + export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId @@ -890,8 +1193,13 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { (chatState === 'thinking' && Boolean(activeThinkingId)) const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) + const virtualItemHeightsRef = useRef(new Map()) + const virtualItemMetricCacheRef = useRef(new Map()) + const virtualScrollIdleTimerRef = useRef | null>(null) const shouldAutoScrollRef = useRef(true) const isProgrammaticScrollingRef = useRef(false) + const ignoreProgrammaticScrollUntilRef = useRef(0) + const ignoreProgrammaticScrollTopRef = useRef(null) const lastSessionIdRef = useRef(resolvedSessionId) const lastTailMessageIdBySessionRef = useRef(new Map()) const t = useTranslation() @@ -903,6 +1211,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const [rewindingTurnId, setRewindingTurnId] = useState(null) const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState(null) const [showJumpToLatest, setShowJumpToLatest] = useState(false) + const [virtualViewport, setVirtualViewport] = useState({ + scrollTop: SCROLL_BOTTOM_SENTINEL, + viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, + }) + const [isVirtualScrollActive, setIsVirtualScrollActive] = useState(false) + const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0) const branchActionsDisabled = isMemberSession || chatState !== 'idle' || @@ -913,15 +1227,60 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const hasCompactingDivider = messages.some((message) => message.type === 'compact_summary' && message.phase === 'compacting') + useEffect(() => () => { + if (virtualScrollIdleTimerRef.current) { + window.clearTimeout(virtualScrollIdleTimerRef.current) + } + }, []) + + const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => { + const nextScrollTop = container.scrollTop + const nextViewportHeight = container.clientHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT + setVirtualViewport((current) => { + if ( + Math.abs(current.scrollTop - nextScrollTop) < 1 && + Math.abs(current.viewportHeight - nextViewportHeight) < 1 + ) { + return current + } + return { + scrollTop: nextScrollTop, + viewportHeight: nextViewportHeight, + } + }) + }, []) + + const markVirtualScrollActive = useCallback(() => { + setIsVirtualScrollActive(true) + if (virtualScrollIdleTimerRef.current) { + window.clearTimeout(virtualScrollIdleTimerRef.current) + } + virtualScrollIdleTimerRef.current = window.setTimeout(() => { + setIsVirtualScrollActive(false) + virtualScrollIdleTimerRef.current = null + }, VIRTUAL_SCROLL_IDLE_DELAY_MS) + }, []) + const scrollToBottom = useCallback((behavior: ScrollBehavior) => { shouldAutoScrollRef.current = true isProgrammaticScrollingRef.current = true + ignoreProgrammaticScrollUntilRef.current = performance.now() + 250 + setIsVirtualScrollActive(false) + if (virtualScrollIdleTimerRef.current) { + window.clearTimeout(virtualScrollIdleTimerRef.current) + virtualScrollIdleTimerRef.current = null + } const container = scrollContainerRef.current let requestedScrollTop: number | null = null if (container) { setScrollToBottomWithoutLayoutRead(container, behavior) requestedScrollTop = container.scrollTop + ignoreProgrammaticScrollTopRef.current = requestedScrollTop } + setVirtualViewport((current) => ({ + scrollTop: SCROLL_BOTTOM_SENTINEL, + viewportHeight: current.viewportHeight, + })) if (container && resolvedSessionId) { sessionScrollSnapshots.set(resolvedSessionId, { scrollTop: container.scrollTop, @@ -952,12 +1311,37 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { }) }, [resolvedSessionId]) + const handleVirtualItemHeightChange = useCallback((itemKey: string, height: number) => { + const measuredHeight = clampNumber(height, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT) + const previousHeight = virtualItemHeightsRef.current.get(itemKey) + if (previousHeight !== undefined && Math.abs(previousHeight - measuredHeight) < 1) return + + virtualItemHeightsRef.current.set(itemKey, measuredHeight) + setMeasuredItemsVersion((version) => version + 1) + + if (shouldAutoScrollRef.current) { + requestAnimationFrame(() => { + if (shouldAutoScrollRef.current) scrollToBottom('auto') + }) + } + }, [scrollToBottom]) + const updateAutoScrollState = useCallback(() => { // Ignore scroll events triggered by our own programmatic scrolling to // prevent the jump-to-latest button from flickering during auto-scroll. - if (isProgrammaticScrollingRef.current) return const container = scrollContainerRef.current if (!container) return + const shouldIgnoreRecentProgrammaticScroll = + performance.now() < ignoreProgrammaticScrollUntilRef.current && + ignoreProgrammaticScrollTopRef.current !== null && + Math.abs(container.scrollTop - ignoreProgrammaticScrollTopRef.current) < 1 + if (isProgrammaticScrollingRef.current || shouldIgnoreRecentProgrammaticScroll) { + syncVirtualViewportFromContainer(container) + return + } + flushSync(() => { + syncVirtualViewportFromContainer(container) + }) const isAtBottom = isNearScrollBottom(container) shouldAutoScrollRef.current = isAtBottom setShowJumpToLatest(!isAtBottom) @@ -965,17 +1349,27 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { if (resolvedSessionId) { rememberSessionScroll(resolvedSessionId, container) } - }, [resolvedSessionId]) + }, [resolvedSessionId, syncVirtualViewportFromContainer]) useLayoutEffect(() => { if (lastSessionIdRef.current !== resolvedSessionId) { const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true lastSessionIdRef.current = resolvedSessionId + virtualItemHeightsRef.current.clear() + virtualItemMetricCacheRef.current.clear() + setIsVirtualScrollActive(false) + setMeasuredItemsVersion((version) => version + 1) const container = scrollContainerRef.current if (container && snapshot && !snapshot.wasAtBottom) { + ignoreProgrammaticScrollUntilRef.current = performance.now() + 250 + ignoreProgrammaticScrollTopRef.current = snapshot.scrollTop setScrollTopWithoutLayoutRead(container, snapshot.scrollTop) + setVirtualViewport((current) => ({ + scrollTop: snapshot.scrollTop, + viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, + })) setShowJumpToLatest(true) } else { scrollToBottom('auto') @@ -1043,11 +1437,63 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { () => buildTurnCardInsertionMap(renderItems, turnChangeCards), [renderItems, turnChangeCards], ) + const renderItemKeys = useMemo( + () => renderItems.map(getRenderItemKey), + [renderItems], + ) + const renderItemMetrics = useMemo( + () => renderItems.map((item, index) => { + const key = renderItemKeys[index]! + const signature = getRenderItemMetricSignature(item) + const cached = virtualItemMetricCacheRef.current.get(key) + if (cached?.signature === signature) return cached + + const metric = { + signature, + contentWeight: getRenderItemContentWeight(item), + estimatedHeight: estimateRenderItemHeight(item), + } + virtualItemMetricCacheRef.current.set(key, metric) + return metric + }), + [renderItemKeys, renderItems], + ) + const virtualOverscanPx = isVirtualScrollActive + ? VIRTUAL_SCROLL_OVERSCAN_PX + : VIRTUAL_IDLE_OVERSCAN_PX + const virtualTranscriptWindow = useMemo( + () => buildVirtualTranscriptWindow( + renderItems, + renderItemKeys, + renderItemMetrics, + virtualItemHeightsRef.current, + virtualViewport, + virtualOverscanPx, + ), + [measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualOverscanPx, virtualViewport], + ) const confirmTurnCard = useMemo( () => turnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null, [turnChangeCards, turnUndoConfirmTargetId], ) + useEffect(() => { + const liveKeys = new Set(renderItemKeys) + let removed = false + for (const key of virtualItemHeightsRef.current.keys()) { + if (!liveKeys.has(key)) { + virtualItemHeightsRef.current.delete(key) + removed = true + } + } + for (const key of virtualItemMetricCacheRef.current.keys()) { + if (!liveKeys.has(key)) { + virtualItemMetricCacheRef.current.delete(key) + } + } + if (removed) setMeasuredItemsVersion((version) => version + 1) + }, [renderItemKeys]) + useEffect(() => { if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) { setTurnChangeCards([]) @@ -1197,81 +1643,118 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { } }, [addToast, branchSession, branchingMessageId, resolvedSessionId, t]) + const renderTranscriptItem = (item: RenderItem, index: number) => { + const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] + + return ( + <> + {item.kind === 'tool_group' ? ( + !toolResultMap.has(tc.toolUseId)) + } + /> + ) : ( + { + const result = toolResultMap.get(item.message.toolUseId) + return result ? { content: result.content, isError: result.isError } : null + })() + : null + } + branchAction={ + (() => { + const branchTarget = branchableMessageTargets.get(item.message.id) + if (!branchTarget) return undefined + return { + label: t('chat.branchFromHere'), + loading: branchingMessageId === branchTarget.uiMessageId, + onBranch: () => { void handleBranchMessage(branchTarget) }, + } + })() + } + /> + )} + + {resolvedSessionId && cardsForItem.map((card) => ( + { + setTurnUndoConfirmTargetId(card.target.messageId) + }} + /> + ))} + + ) + } + return (
- {renderItems.map((item, index) => { - const itemKey = item.kind === 'tool_group' ? item.id : item.message.id - const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] + {virtualTranscriptWindow.enabled && virtualTranscriptWindow.beforeHeight > 0 ? ( +