import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type ReactNode } from 'react' import { createPortal } 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' import { useChatStore } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useTeamStore } from '../../stores/teamStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n/locales/en' import { UserMessage } from './UserMessage' import { AssistantMessage } from './AssistantMessage' import { ThinkingBlock } from './ThinkingBlock' import { ToolCallBlock } from './ToolCallBlock' import { ToolCallGroup } from './ToolCallGroup' import { ToolResultBlock } from './ToolResultBlock' import { PermissionDialog } from './PermissionDialog' import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { formatTokenCount } from '../../lib/formatTokenCount' import { isTouchH5Document } from '../../lib/touchH5' import { ConfirmDialog } from '../shared/ConfirmDialog' import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss' import { getHeightsForSession, getMetricsForSession, type VirtualRenderItemMetric, } from './virtualHeightCache' type ToolCall = Extract type ToolResult = Extract type MemoryEvent = Extract type GoalEvent = Extract type BackgroundTaskEvent = Extract type CompactSummaryEvent = Extract type RenderItem = | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } | { kind: 'message'; message: UIMessage } type RenderModel = { renderItems: RenderItem[] toolResultMap: Map childToolCallsByParent: Map } type RewindTurnTarget = { messageId: string userMessageIndex: number content: string expectedContent: string attachments?: Extract['attachments'] } type BranchableMessageTarget = { uiMessageId: string transcriptMessageId: string } type TurnChangeCardModel = { target: RewindTurnTarget checkpoint: SessionTurnCheckpoint workDir: string | null isLatest: boolean } type ChatMessageRole = 'user' | 'assistant' type ChatSelectionState = { text: string x: number y: number } type SelectionPointer = { clientX: number clientY: number } const CHAT_SELECTION_MENU_OFFSET = 10 const CHAT_SELECTION_MENU_WIDTH = 158 const CHAT_SELECTION_MENU_HEIGHT = 44 function getElementForNode(node: Node | null): Element | null { if (!node) return null return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement } function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) { return getSelectionPopoverPosition(range, root, { menuWidth: CHAT_SELECTION_MENU_WIDTH, menuHeight: CHAT_SELECTION_MENU_HEIGHT, offset: CHAT_SELECTION_MENU_OFFSET, fallbackPointer: pointer, }) } function getChatSelectionFromContainer( root: HTMLElement | null, pointer: SelectionPointer, ): ChatSelectionState | null { if (!root) return null const selection = window.getSelection() if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null const range = selection.getRangeAt(0) const startElement = getElementForNode(range.startContainer) const endElement = getElementForNode(range.endContainer) if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) { return null } const text = selection.toString().trim() if (!text) return null return { ...getChatSelectionPosition(range, root, pointer), text, } } function getSelectionPointer(event: SelectionPointer): SelectionPointer { return { clientX: event.clientX, clientY: event.clientY, } } function ChatSelectionMenu({ selection, onAdd, popoverRef, }: { selection: ChatSelectionState | null onAdd: () => void popoverRef: { current: HTMLButtonElement | null } }) { const t = useTranslation() if (!selection) return null return createPortal( , document.body, ) } function getCompactSummaryTitle(message: CompactSummaryEvent, t: ReturnType) { if (message.trigger === 'auto') return t('chat.compactSummary.autoTitle') if (message.trigger === 'manual') return t('chat.compactSummary.manualTitle') if (!message.title || message.title === 'Context compacted' || message.title === 'Conversation compacted') { return t('chat.compactSummary.title') } return message.title } function CompactStatusDivider({ message, state }: { message?: CompactSummaryEvent; state: 'compacting' | 'complete' }) { const t = useTranslation() const [expanded, setExpanded] = useState(false) const hasSummary = Boolean(message?.summary?.trim()) const meta = [ message?.trigger ? t(`chat.compactSummary.trigger.${message.trigger}` as TranslationKey) : null, typeof message?.preTokens === 'number' ? t('chat.compactSummary.tokens', { count: formatTokenCount(message.preTokens) }) : null, typeof message?.messagesSummarized === 'number' ? t('chat.compactSummary.messages', { count: String(message.messagesSummarized) }) : null, ].filter((item): item is string => Boolean(item)) const hasDetails = hasSummary || meta.length > 0 const title = state === 'compacting' ? t('chat.compactSummary.compacting') : message ? getCompactSummaryTitle(message, t) : t('chat.compactSummary.title') return (
) } function GoalEventCard({ message }: { message: GoalEvent }) { const t = useTranslation() const [expanded, setExpanded] = useState(true) const titleKey = `chat.goalEvent.${message.action === 'status' ? 'statusTitle' : message.action}` as TranslationKey const title = t(titleKey) === titleKey ? t('chat.goalEvent.message') : t(titleKey) const metaDetails = [ message.status ? t('chat.goalEvent.statusValue', { value: message.status }) : null, message.budget ? t('chat.goalEvent.budget', { value: message.budget }) : null, message.continuations ? t('chat.goalEvent.continuations', { value: message.continuations }) : null, ].filter((detail): detail is string => detail !== null) return (
{expanded ? (
{message.objective ? (
{t('chat.goalEvent.objective', { value: message.objective })}
) : message.message ? (
{message.message}
) : null} {metaDetails.length > 0 && (
{metaDetails.map((detail) => ( {detail} ))}
)}
) : null}
) } function formatBackgroundTaskDuration(durationMs?: number) { if (typeof durationMs !== 'number' || durationMs < 0) return null const seconds = Math.round(durationMs / 1000) if (seconds < 60) return `${seconds}s` const minutes = Math.floor(seconds / 60) return `${minutes}m ${seconds % 60}s` } function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent }) { const t = useTranslation() const { task } = message const isRunning = task.status === 'running' const isFailed = task.status === 'failed' const isStopped = task.status === 'stopped' const duration = formatBackgroundTaskDuration(task.usage?.durationMs) const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId const label = getBackgroundTaskLabel(task.taskType, t) return (
{isRunning ? (
{detail}
) } function isAgentBackgroundTaskMessage(message: UIMessage): boolean { if (message.type !== 'background_task') return false if (message.task.taskType === 'local_agent' || message.task.taskType === 'remote_agent') { return true } return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test( message.task.summary ?? '', ) } function getBackgroundTaskLabel( taskType: string | undefined, t: (key: TranslationKey, params?: Record) => string, ): string { if (taskType === 'local_bash') return t('chat.backgroundTasks.command') if (taskType === 'local_workflow') return t('chat.backgroundTasks.workflow') return t('chat.backgroundTasks.task') } function SelectableChatMessage({ sessionId, messageId, role, content, children, }: { sessionId?: string | null messageId: string role: ChatMessageRole content: string children: ReactNode }) { const rootRef = useRef(null) const selectionMenuRef = useRef(null) const lastSelectionPointerRef = useRef(null) const selectionUpdateFrameRef = useRef(null) const addReference = useWorkspaceChatContextStore((state) => state.addReference) const [selectionMenu, setSelectionMenu] = useState(null) const t = useTranslation() const sourceName = role === 'assistant' ? t('chat.assistantMessageReference') : t('chat.userMessageReference') useEffect(() => { setSelectionMenu(null) lastSelectionPointerRef.current = null }, [content, messageId]) const dismissSelectionMenu = useCallback(() => { setSelectionMenu(null) }, []) const queueSelectionMenuUpdate = useCallback((pointer?: SelectionPointer) => { if (pointer) lastSelectionPointerRef.current = pointer if (selectionUpdateFrameRef.current !== null) { window.cancelAnimationFrame(selectionUpdateFrameRef.current) } selectionUpdateFrameRef.current = window.requestAnimationFrame(() => { selectionUpdateFrameRef.current = window.requestAnimationFrame(() => { selectionUpdateFrameRef.current = null const root = rootRef.current const rootRect = root?.getBoundingClientRect() const fallbackPointer = lastSelectionPointerRef.current ?? { clientX: (rootRect?.left ?? 0) + 24, clientY: (rootRect?.top ?? 0) + 24, } setSelectionMenu(getChatSelectionFromContainer(root, fallbackPointer)) }) }) }, []) useEffect(() => { return () => { if (selectionUpdateFrameRef.current !== null) { window.cancelAnimationFrame(selectionUpdateFrameRef.current) } } }, []) useEffect(() => { const handlePointerDown = (event: PointerEvent) => { lastSelectionPointerRef.current = getSelectionPointer(event) } const handlePointerUp = (event: PointerEvent) => { queueSelectionMenuUpdate(getSelectionPointer(event)) } const handleMouseUp = (event: MouseEvent) => { queueSelectionMenuUpdate(getSelectionPointer(event)) } const handleSelectionChange = () => { queueSelectionMenuUpdate() } const handleKeyUp = () => { queueSelectionMenuUpdate() } document.addEventListener('pointerdown', handlePointerDown, true) document.addEventListener('pointerup', handlePointerUp, true) document.addEventListener('mouseup', handleMouseUp, true) document.addEventListener('selectionchange', handleSelectionChange) document.addEventListener('keyup', handleKeyUp, true) return () => { document.removeEventListener('pointerdown', handlePointerDown, true) document.removeEventListener('pointerup', handlePointerUp, true) document.removeEventListener('mouseup', handleMouseUp, true) document.removeEventListener('selectionchange', handleSelectionChange) document.removeEventListener('keyup', handleKeyUp, true) } }, [queueSelectionMenuUpdate]) useSelectionPopoverDismiss({ active: Boolean(selectionMenu), popoverRef: selectionMenuRef, onDismiss: dismissSelectionMenu, }) const addCurrentSelectionToChat = useCallback(() => { if (!sessionId || !selectionMenu) return addReference(sessionId, { kind: 'chat-selection', path: `chat://${role}/${messageId}`, name: sourceName, quote: selectionMenu.text, sourceRole: role, messageId, }) setSelectionMenu(null) clearWindowSelection() }, [addReference, messageId, role, selectionMenu, sessionId, sourceName]) return (
{ if (event.pointerType === 'mouse' && event.button !== 0) return lastSelectionPointerRef.current = getSelectionPointer(event) }} onMouseUp={(event) => { queueSelectionMenuUpdate(getSelectionPointer(event)) }} onKeyDown={(event) => { if (event.key === 'Escape') setSelectionMenu(null) }} > {children}
) } function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, toolCall: ToolCall, ) { const siblings = childToolCallsByParent.get(parentToolUseId) if (siblings) { siblings.push(toolCall) } else { childToolCallsByParent.set(parentToolUseId, [toolCall]) } } export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToolUseId?: string | null): RenderModel { const items: RenderItem[] = [] const toolResultMap = new Map() const childToolCallsByParent = new Map() const toolUseIds = new Set() const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map() let lastUnresolvedAskUserQuestionIndex: number | null = null let pendingToolCalls: ToolCall[] = [] const flushGroup = () => { if (pendingToolCalls.length > 0) { items.push({ kind: 'tool_group', toolCalls: [...pendingToolCalls], id: `group-${pendingToolCalls[0]!.id}`, }) pendingToolCalls = [] } } const appendRootToolCall = (toolCall: ToolCall) => { const nextIsAgent = toolCall.toolName === 'Agent' const pendingIsAgentGroup = pendingToolCalls.every((pendingToolCall) => pendingToolCall.toolName === 'Agent') if (pendingToolCalls.length > 0 && pendingIsAgentGroup !== nextIsAgent) { flushGroup() } pendingToolCalls.push(toolCall) } for (const msg of messages) { if (msg.type === 'tool_use') { toolUseIds.add(msg.toolUseId) } if (msg.type === 'tool_result') { toolResultMap.set(msg.toolUseId, msg) } } messages.forEach((msg, index) => { if ( msg.type === 'tool_use' && msg.toolName === 'AskUserQuestion' && !toolResultMap.has(msg.toolUseId) ) { lastUnresolvedAskUserQuestionIndexByToolUseId.set(msg.toolUseId, index) lastUnresolvedAskUserQuestionIndex = index } }) for (const msg of messages) { if (msg.type === 'assistant_text' && !msg.content.trim()) { continue } if (isAgentBackgroundTaskMessage(msg)) { continue } if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) { continue } if (msg.type === 'tool_result' && msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { continue } if (msg.type === 'tool_use') { if (msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { flushGroup() appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg) continue } if (msg.toolName === 'AskUserQuestion') { const isResolved = toolResultMap.has(msg.toolUseId) const lastUnresolvedIndex = lastUnresolvedAskUserQuestionIndexByToolUseId.get(msg.toolUseId) if (!isResolved && lastUnresolvedIndex !== undefined && messages[lastUnresolvedIndex] !== msg) { continue } if ( !isResolved && activeAskUserQuestionToolUseId && msg.toolUseId !== activeAskUserQuestionToolUseId ) { continue } if ( !isResolved && !activeAskUserQuestionToolUseId && lastUnresolvedAskUserQuestionIndex !== null && messages[lastUnresolvedAskUserQuestionIndex] !== msg ) { continue } flushGroup() items.push({ kind: 'message', message: msg }) } else { appendRootToolCall(msg) } } else { flushGroup() items.push({ kind: 'message', message: msg }) } } flushGroup() return { renderItems: items, toolResultMap, childToolCallsByParent } } function isTurnResponseMessage(message: UIMessage) { return ( message.type === 'assistant_text' || message.type === 'tool_use' || message.type === 'tool_result' || (message.type === 'background_task' && !isAgentBackgroundTaskMessage(message)) || message.type === 'error' || message.type === 'task_summary' ) } function getBranchableMessageTargets(messages: UIMessage[]): Map { const branchableTargets = new Map() let currentTurnCandidates: Array> = [] let hasResponseForCurrentTurn = false const markCurrentTurnBranchable = () => { if (!hasResponseForCurrentTurn) return for (const candidate of currentTurnCandidates) { if (!candidate.transcriptMessageId) continue branchableTargets.set(candidate.id, { uiMessageId: candidate.id, transcriptMessageId: candidate.transcriptMessageId, }) } } for (const message of messages) { if (message.type === 'user_text') { markCurrentTurnBranchable() currentTurnCandidates = [] hasResponseForCurrentTurn = false if (!message.pending && message.transcriptMessageId) { currentTurnCandidates = [message] } continue } if (currentTurnCandidates.length === 0) continue if (isTurnResponseMessage(message)) { hasResponseForCurrentTurn = true } if (message.type === 'assistant_text' && message.transcriptMessageId) { currentTurnCandidates.push(message) } } markCurrentTurnBranchable() return branchableTargets } export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] { let userMessageIndex = -1 const completedTurns: RewindTurnTarget[] = [] let currentTarget: RewindTurnTarget | null = null let hasResponseForCurrentTarget = false for (const message of messages) { if (message.type === 'user_text' && !message.pending) { if (currentTarget && hasResponseForCurrentTarget) { completedTurns.push(currentTarget) } userMessageIndex += 1 currentTarget = { messageId: message.id, userMessageIndex, content: message.content, expectedContent: message.modelContent ?? message.content, attachments: message.attachments, } hasResponseForCurrentTarget = false continue } if (currentTarget && isTurnResponseMessage(message)) { hasResponseForCurrentTarget = true } } if (currentTarget && hasResponseForCurrentTarget) { completedTurns.push(currentTarget) } return completedTurns } export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null { const completedTurns = getCompletedTurnTargets(messages) return completedTurns.length > 0 ? completedTurns[completedTurns.length - 1] ?? null : null } function buildTurnCardInsertionMap( renderItems: RenderItem[], turnChangeCards: TurnChangeCardModel[], ) { const lastResponseIndexByTurnId = new Map() const userIndexByTurnId = new Map() let activeTurnId: string | null = null renderItems.forEach((item, index) => { if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) { activeTurnId = item.message.id userIndexByTurnId.set(activeTurnId, index) return } if (activeTurnId) { lastResponseIndexByTurnId.set(activeTurnId, index) } }) const cardsByRenderIndex = new Map() turnChangeCards.forEach((card) => { const renderIndex = lastResponseIndexByTurnId.get(card.target.messageId) ?? userIndexByTurnId.get(card.target.messageId) if (renderIndex === undefined) return const existing = cardsByRenderIndex.get(renderIndex) if (existing) { existing.push(card) } else { cardsByRenderIndex.set(renderIndex, [card]) } }) return cardsByRenderIndex } function getApiErrorMessage(error: unknown) { return error instanceof ApiError ? typeof error.body === 'object' && error.body && 'message' in error.body ? String((error.body as { message: unknown }).message) : error.message : error instanceof Error ? error.message : String(error) } function isSessionTurnCheckpoint(value: unknown): value is SessionTurnCheckpoint { if (!value || typeof value !== 'object') return false const checkpoint = value as Partial return ( Boolean(checkpoint.target) && typeof checkpoint.target?.targetUserMessageId === 'string' && typeof checkpoint.target?.userMessageIndex === 'number' && Boolean(checkpoint.code) && typeof checkpoint.code?.available === 'boolean' && Array.isArray(checkpoint.code?.filesChanged) ) } function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] { if (!response || typeof response !== 'object') return [] const checkpoints = (response as { checkpoints?: unknown }).checkpoints if (!Array.isArray(checkpoints)) return [] return checkpoints.filter(isSessionTurnCheckpoint) } function memoryFileLabel(path: string) { const normalized = path.replace(/\\/g, '/') return normalized.split('/').pop() || normalized } function openMemorySettings(path?: string) { const ui = useUIStore.getState() if (path) ui.setPendingMemoryPath(path) ui.setPendingSettingsTab('memory') useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') } function MemoryEventCard({ message }: { message: MemoryEvent }) { const t = useTranslation() const visibleFiles = message.files.slice(0, 3) const hiddenCount = Math.max(0, message.files.length - visibleFiles.length) return (
{t('chat.memorySavedTitle', { count: message.files.length })}
{message.message ? (
{message.message}
) : null}
{visibleFiles.map((file) => ( {memoryFileLabel(file.path)} ))} {hiddenCount > 0 ? ( {t('chat.memoryMoreFiles', { count: hiddenCount })} ) : null}
) } type MessageListProps = { sessionId?: string | null compact?: boolean } 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 // Touch-H5 disables content-visibility paint skipping for selection // correctness (globals.css), which makes virtualization the only paint bound // for long transcripts there — so it kicks in at half the desktop thresholds. const TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS = 60 const TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS = 60_000 const VIRTUAL_OVERSCAN_PX = 1200 const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720 const VIRTUAL_MIN_ITEM_HEIGHT = 48 const VIRTUAL_MAX_ITEM_HEIGHT = 24_000 // Windows WebView2 can report 1px oscillations for live chat content; don't // convert those into bottom-scroll corrections. const CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX = 2 const EMPTY_MESSAGES: UIMessage[] = [] const EMPTY_AGENT_TASK_NOTIFICATIONS: Record = {} const CHAT_SCROLL_AREA_CLASS = [ 'chat-scroll-area', '[scrollbar-width:auto]', '[scrollbar-color:color-mix(in_srgb,var(--color-outline)_72%,transparent)_transparent]', '[&::-webkit-scrollbar]:w-2.5', '[&::-webkit-scrollbar-track]:bg-transparent', '[&::-webkit-scrollbar-thumb]:rounded-full', '[&::-webkit-scrollbar-thumb]:border-[3px]', '[&::-webkit-scrollbar-thumb]:border-transparent', '[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--color-outline)_74%,transparent)]', '[&::-webkit-scrollbar-thumb]:bg-clip-content', '[&::-webkit-scrollbar-thumb:hover]:border-2', '[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_90%,transparent)]', ].join(' ') const CHAT_RENDER_ITEM_CLASS = [ 'chat-render-item', ].join(' ') type SessionScrollSnapshot = { scrollTop: number wasAtBottom: boolean } type VirtualViewport = { scrollTop: number viewportHeight: number } type VirtualTranscriptItem = { item: RenderItem index: number } type VirtualTranscriptWindow = { enabled: boolean beforeHeight: number afterHeight: number items: VirtualTranscriptItem[] } const sessionScrollSnapshots = new Map() function isNearScrollBottom(element: HTMLElement) { return ( element.scrollHeight - element.scrollTop - element.clientHeight <= AUTO_SCROLL_BOTTOM_THRESHOLD_PX ) } function rememberSessionScroll(sessionId: string, element: HTMLElement) { if (sessionScrollSnapshots.size >= MAX_SCROLL_SNAPSHOTS && !sessionScrollSnapshots.has(sessionId)) { const oldestSessionId = sessionScrollSnapshots.keys().next().value if (oldestSessionId) { sessionScrollSnapshots.delete(oldestSessionId) } } sessionScrollSnapshots.set(sessionId, { scrollTop: element.scrollTop, wasAtBottom: isNearScrollBottom(element), }) } function getBottomScrollTop(element: HTMLElement) { return Math.max(0, element.scrollHeight - element.clientHeight) } function setScrollTopWithoutLayoutRead(element: HTMLElement, scrollTop: number) { element.scrollTop = Math.max(0, scrollTop) } function setScrollToBottomWithoutLayoutRead(element: HTMLElement, behavior: ScrollBehavior) { if (typeof element.scrollTo === 'function') { try { element.scrollTo({ top: SCROLL_BOTTOM_SENTINEL, behavior }) } catch { element.scrollTo(0, SCROLL_BOTTOM_SENTINEL) } } element.scrollTop = SCROLL_BOTTOM_SENTINEL // Browsers clamp the large value to the true bottom without needing us to // synchronously read layout metrics. JSDOM test doubles do not clamp, so keep // the old numeric behavior there as a fallback. if (element.scrollTop === SCROLL_BOTTOM_SENTINEL) { element.scrollTop = getBottomScrollTop(element) } } 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) } export function shouldVirtualizeRenderItems( metrics: VirtualRenderItemMetric[], touchH5 = isTouchH5Document(), ) { const minRenderItems = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS : VIRTUALIZE_MIN_RENDER_ITEMS const minContentChars = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS : VIRTUALIZE_MIN_CONTENT_CHARS if (metrics.length >= minRenderItems) return true let totalWeight = 0 for (const metric of metrics) { totalWeight += metric.contentWeight if (totalWeight >= minContentChars) 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 VIRTUAL_SPACER_CHUNK_PX = 800 function VirtualSpacer({ height, position }: { height: number; position: 'top' | 'bottom' }) { if (height <= 0) return null if (height <= VIRTUAL_SPACER_CHUNK_PX) { return (