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 ? (
+
+ ) : null}
- return (
+ {virtualTranscriptWindow.items.map(({ item, index }) => {
+ const itemKey = getRenderItemKey(item)
+ const content = renderTranscriptItem(item, index)
+
+ return virtualTranscriptWindow.enabled ? (
+
+ {content}
+
+ ) : (
- {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)
- }}
- />
- ))}
+ {content}
)
})}
+ {virtualTranscriptWindow.enabled && virtualTranscriptWindow.afterHeight > 0 ? (
+
+ ) : null}
+
{streamingText.trim() && (
)}
diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx
index 79a82a1b..cbda8982 100644
--- a/desktop/src/components/markdown/MarkdownRenderer.tsx
+++ b/desktop/src/components/markdown/MarkdownRenderer.tsx
@@ -12,6 +12,7 @@ type Props = {
content: string
variant?: 'default' | 'document' | 'compact'
className?: string
+ cache?: boolean
onLinkClick?: (href: string, event: ReactMouseEvent
) => boolean | void
}
@@ -304,6 +305,38 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]
return { html, codeBlocks, mathBlocks }
}
+type MarkdownParseResult = ReturnType
+
+const MARKDOWN_PARSE_CACHE_MAX_ENTRIES = 48
+const MARKDOWN_PARSE_CACHE_MAX_CHARS = 1_800_000
+const markdownParseCache = new Map()
+let markdownParseCacheChars = 0
+
+function getCachedMarkdownParse(content: string): MarkdownParseResult {
+ const cached = markdownParseCache.get(content)
+ if (cached) {
+ markdownParseCache.delete(content)
+ markdownParseCache.set(content, cached)
+ return cached
+ }
+
+ const parsed = parseMarkdown(content)
+ markdownParseCache.set(content, parsed)
+ markdownParseCacheChars += content.length
+
+ while (
+ markdownParseCache.size > MARKDOWN_PARSE_CACHE_MAX_ENTRIES ||
+ markdownParseCacheChars > MARKDOWN_PARSE_CACHE_MAX_CHARS
+ ) {
+ const oldestContent = markdownParseCache.keys().next().value
+ if (typeof oldestContent !== 'string') break
+ markdownParseCache.delete(oldestContent)
+ markdownParseCacheChars -= oldestContent.length
+ }
+
+ return parsed
+}
+
const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
@@ -363,8 +396,11 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className?
.join(' ')
}
-export function MarkdownRenderer({ content, variant = 'default', className, onLinkClick }: Props) {
- const { html, codeBlocks, mathBlocks } = useMemo(() => parseMarkdown(content), [content])
+export function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) {
+ const { html, codeBlocks, mathBlocks } = useMemo(
+ () => cache ? getCachedMarkdownParse(content) : parseMarkdown(content),
+ [cache, content],
+ )
const proseClasses = useMemo(
() => getProseClasses(variant, className),
[variant, className],