fix: virtualize long desktop transcripts

Large desktop sessions were still paying too much render and paint cost on tab switch, while the previous content-visibility approach could defer WebView painting enough to show white gaps during scroll. This moves long transcripts to a measured dynamic-height window, keeps small transcripts fully mounted, and caches completed Markdown parses so remounted assistant messages do not pay repeated parse cost.

Constraint: Tauri/WebView must not depend on content-visibility for transcript correctness because deferred offscreen paint can surface as blank regions

Rejected: Keep content-visibility:auto on every chat item | it improves synthetic DOM cost but matches the user's white-gap failure mode

Rejected: Fixed-height transcript virtualization | variable-height chat rows and tool groups can create spacer gaps and broken scroll positions

Confidence: high

Scope-risk: moderate

Directive: Do not reintroduce deferred-paint transcript optimization without testing the real Tauri/WebView scroll path on large sessions

Tested: bun run check:desktop

Tested: Chrome DevTools real-session smoke for 055015e4-6093-418e-9944-19bba25ecb69, switch trace max RunTask about 158ms and scroll blankSamples=0 across 34 sampled positions

Not-tested: Packaged Tauri binary smoke on WebKit/WebView2
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 19:03:51 +08:00
parent 385435a04d
commit d116067d9c
4 changed files with 627 additions and 79 deletions

View File

@ -27,7 +27,7 @@ export function AssistantMessage({ content, isStreaming, branchAction }: Props)
<div className={`rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm ${
documentLayout ? 'w-full' : 'max-w-full'
}`}>
<MarkdownRenderer content={content} variant={documentLayout ? 'document' : 'default'} />
<MarkdownRenderer content={content} variant={documentLayout ? 'document' : 'default'} cache={!isStreaming} />
{!isStreaming && <InlineImageGallery text={content} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />

View File

@ -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(<MessageList />)
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(<MessageList />)
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', () => {

View File

@ -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<string, SessionScrollSnapshot>()
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<string, unknown> {
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<string, number>,
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<number>(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<HTMLDivElement>(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 (
<div
ref={itemRef}
data-virtual-message-item={itemKey}
className={CHAT_RENDER_ITEM_CLASS}
>
{children}
</div>
)
})
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<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const virtualItemHeightsRef = useRef(new Map<string, number>())
const virtualItemMetricCacheRef = useRef(new Map<string, VirtualRenderItemMetric>())
const virtualScrollIdleTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null)
const shouldAutoScrollRef = useRef(true)
const isProgrammaticScrollingRef = useRef(false)
const ignoreProgrammaticScrollUntilRef = useRef(0)
const ignoreProgrammaticScrollTopRef = useRef<number | null>(null)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
const t = useTranslation()
@ -903,6 +1211,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
const [virtualViewport, setVirtualViewport] = useState<VirtualViewport>({
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' ? (
<ToolCallGroup
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
) : (
<MessageBlock
sessionId={resolvedSessionId}
message={item.message}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? (() => {
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) => (
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
checkpoint={card.checkpoint}
workDir={card.workDir}
error={turnActionErrors[card.target.messageId] ?? null}
isUndoing={rewindingTurnId === card.target.messageId}
isLatest={card.isLatest}
onUndo={() => {
setTurnUndoConfirmTargetId(card.target.messageId)
}}
/>
))}
</>
)
}
return (
<div className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
onWheel={markVirtualScrollActive}
onTouchMove={markVirtualScrollActive}
onPointerDown={markVirtualScrollActive}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
>
{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 ? (
<div
data-virtual-spacer="top"
aria-hidden="true"
style={{ height: virtualTranscriptWindow.beforeHeight }}
/>
) : null}
return (
{virtualTranscriptWindow.items.map(({ item, index }) => {
const itemKey = getRenderItemKey(item)
const content = renderTranscriptItem(item, index)
return virtualTranscriptWindow.enabled ? (
<MeasuredRenderItem
key={itemKey}
itemKey={itemKey}
onHeightChange={handleVirtualItemHeightChange}
>
{content}
</MeasuredRenderItem>
) : (
<div key={itemKey} className={CHAT_RENDER_ITEM_CLASS}>
{item.kind === 'tool_group' ? (
<ToolCallGroup
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
) : (
<MessageBlock
sessionId={resolvedSessionId}
message={item.message}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? (() => {
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) => (
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
checkpoint={card.checkpoint}
workDir={card.workDir}
error={turnActionErrors[card.target.messageId] ?? null}
isUndoing={rewindingTurnId === card.target.messageId}
isLatest={card.isLatest}
onUndo={() => {
setTurnUndoConfirmTargetId(card.target.messageId)
}}
/>
))}
{content}
</div>
)
})}
{virtualTranscriptWindow.enabled && virtualTranscriptWindow.afterHeight > 0 ? (
<div
data-virtual-spacer="bottom"
aria-hidden="true"
style={{ height: virtualTranscriptWindow.afterHeight }}
/>
) : null}
{streamingText.trim() && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}

View File

@ -12,6 +12,7 @@ type Props = {
content: string
variant?: 'default' | 'document' | 'compact'
className?: string
cache?: boolean
onLinkClick?: (href: string, event: ReactMouseEvent<HTMLDivElement>) => boolean | void
}
@ -304,6 +305,38 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]
return { html, codeBlocks, mathBlocks }
}
type MarkdownParseResult = ReturnType<typeof parseMarkdown>
const MARKDOWN_PARSE_CACHE_MAX_ENTRIES = 48
const MARKDOWN_PARSE_CACHE_MAX_CHARS = 1_800_000
const markdownParseCache = new Map<string, MarkdownParseResult>()
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],