import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer } from '@tanstack/react-virtual'; import ChatMessage from './ChatMessage'; import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import TurnItem from './components/TurnItem'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import { useTurnRecords } from './hooks/useTurnRecords'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; import { useUIStore } from '@/stores/useUIStore'; import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; import type { StreamPhase } from './message/types'; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = Number.POSITIVE_INFINITY; const MESSAGE_LIST_OVERSCAN = 6; const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => { if (!entry) { return 160; } if (entry.kind === 'turn') { return 180 + Math.min(entry.turn.assistantMessages.length, 4) * 100; } return 140; }; const useStableEvent = (handler: (...args: TArgs) => TResult) => { const handlerRef = React.useRef(handler); React.useEffect(() => { handlerRef.current = handler; }, [handler]); return React.useCallback((...args: TArgs) => handlerRef.current(...args), []); }; const USER_SHELL_MARKER = 'The following tool was executed by the user'; const resolveMessageRole = (message: ChatMessageEntry): string | null => { const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined }; return (typeof info.clientRole === 'string' ? info.clientRole : null) ?? (typeof info.role === 'string' ? info.role : null) ?? null; }; const hasCompactionPart = (message: ChatMessageEntry): boolean => { return message.parts.some((part) => { const type = (part as { type?: unknown }).type; return type === 'compaction'; }); }; const getPartText = (part: Part): string => { const text = (part as { text?: unknown }).text; if (typeof text === 'string') { return text; } const content = (part as { content?: unknown }).content; if (typeof content === 'string') { return content; } return ''; }; const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => { if (!hasCompactionPart(message)) { return message; } let changedParts = false; const nextParts = message.parts.map((part) => { const type = (part as { type?: unknown }).type; if (type !== 'compaction') { return part; } changedParts = true; return { type: 'text', text: '/compact' } as Part; }); const info = message.info as unknown as { clientRole?: string | null | undefined }; const needsClientRole = info.clientRole !== 'user'; if (!changedParts && !needsClientRole) { return message; } return { ...message, info: needsClientRole ? ({ ...(message.info as unknown as Record), clientRole: 'user', } as unknown as typeof message.info) : message.info, parts: changedParts ? nextParts : message.parts, }; }; const normalizeCompactionSummaryMessage = ( message: ChatMessageEntry, compactionCommandIds: Set, ): ChatMessageEntry => { const role = resolveMessageRole(message); if (role !== 'system') { return message; } const parentID = getMessageParentId(message); if (!parentID || !compactionCommandIds.has(parentID)) { return message; } const info = message.info as unknown as { clientRole?: string | null | undefined }; if (info.clientRole === 'assistant') { return message; } return { ...message, info: ({ ...(message.info as unknown as Record), clientRole: 'assistant', } as unknown as typeof message.info), }; }; const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => { const info = message.info as { time?: { completed?: unknown }; status?: unknown }; const completed = info.time?.completed; const status = info.status; if (typeof completed !== 'number' || completed <= 0) { return false; } if (typeof status === 'string') { return status === 'completed'; } return true; }; const isUserSubtaskMessage = (message: ChatMessageEntry | undefined): boolean => { if (!message) return false; if (resolveMessageRole(message) !== 'user') return false; return message.parts.some((part) => part?.type === 'subtask'); }; const getMessageId = (message: ChatMessageEntry | undefined): string | null => { if (!message) return null; const id = (message.info as unknown as { id?: unknown }).id; return typeof id === 'string' && id.trim().length > 0 ? id : null; }; const getMessageParentId = (message: ChatMessageEntry): string | null => { const parentID = (message.info as unknown as { parentID?: unknown }).parentID; return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null; }; const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => { if (!message) return false; if (resolveMessageRole(message) !== 'user') return false; return message.parts.some((part) => { if (part?.type !== 'text') return false; const text = (part as unknown as { text?: unknown }).text; const synthetic = (part as unknown as { synthetic?: unknown }).synthetic; return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER); }); }; type ShellBridgeDetails = { command?: string; output?: string; status?: string; }; const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParentId: string | null): { hide: boolean; details: ShellBridgeDetails | null } => { if (resolveMessageRole(message) !== 'assistant') { return { hide: false, details: null }; } if (expectedParentId && getMessageParentId(message) !== expectedParentId) { return { hide: false, details: null }; } if (message.parts.length !== 1) { return { hide: false, details: null }; } const part = message.parts[0] as unknown as { type?: unknown; tool?: unknown; state?: { status?: unknown; input?: { command?: unknown }; output?: unknown; metadata?: { output?: unknown }; }; }; if (part.type !== 'tool') { return { hide: false, details: null }; } const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : ''; if (toolName !== 'bash') { return { hide: false, details: null }; } const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined; const output = (typeof part.state?.output === 'string' ? part.state.output : undefined) ?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined); const status = typeof part.state?.status === 'string' ? part.state.status : undefined; return { hide: true, details: { command, output, status, }, }; }; const readTaskSessionId = (toolPart: Part): string | null => { const partRecord = toolPart as unknown as { state?: { metadata?: { sessionId?: unknown; sessionID?: unknown; }; output?: unknown; }; }; const metadata = partRecord.state?.metadata; const fromMetadata = (typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0 ? metadata.sessionID.trim() : null) ?? (typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0 ? metadata.sessionId.trim() : null); if (fromMetadata) return fromMetadata; const output = partRecord.state?.output; if (typeof output === 'string') { const match = output.match(/task_id\s*:\s*([^\s<"']+)/i); if (match?.[1]) { return match[1]; } } return null; }; const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: boolean; taskSessionId: string | null } => { if (resolveMessageRole(message) !== 'assistant') { return { hide: false, taskSessionId: null }; } if (message.parts.length !== 1) { return { hide: false, taskSessionId: null }; } const onlyPart = message.parts[0] as unknown as { type?: unknown; tool?: unknown; }; if (onlyPart.type !== 'tool') { return { hide: false, taskSessionId: null }; } const toolName = typeof onlyPart.tool === 'string' ? onlyPart.tool.toLowerCase() : ''; if (toolName !== 'task') { return { hide: false, taskSessionId: null }; } return { hide: true, taskSessionId: readTaskSessionId(message.parts[0]), }; }; const withSubtaskSessionId = (message: ChatMessageEntry, taskSessionId: string | null): ChatMessageEntry => { if (!taskSessionId) return message; const nextParts = message.parts.map((part) => { if (part?.type !== 'subtask') return part; const existing = (part as unknown as { taskSessionID?: unknown }).taskSessionID; if (typeof existing === 'string' && existing.trim().length > 0) return part; return { ...part, taskSessionID: taskSessionId, } as Part; }); return { ...message, parts: nextParts, }; }; const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeDetails | null): ChatMessageEntry => { const command = typeof details?.command === 'string' ? details.command.trim() : ''; const output = typeof details?.output === 'string' ? details.output : ''; const status = typeof details?.status === 'string' ? details.status.trim() : ''; const nextParts: Part[] = []; let injected = false; for (const part of message.parts) { if (!injected && part?.type === 'text') { const text = (part as unknown as { text?: unknown }).text; const synthetic = (part as unknown as { synthetic?: unknown }).synthetic; if (synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER)) { nextParts.push({ type: 'text', text: '/shell', shellAction: { ...(command ? { command } : {}), ...(output ? { output } : {}), ...(status ? { status } : {}), }, } as unknown as Part); injected = true; continue; } } nextParts.push(part); } if (!injected) { nextParts.push({ type: 'text', text: '/shell', shellAction: { ...(command ? { command } : {}), ...(output ? { output } : {}), ...(status ? { status } : {}), }, } as unknown as Part); } return { ...message, parts: nextParts, }; }; const normalizedMessageBySource = new WeakMap(); const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => { const cached = normalizedMessageBySource.get(message); if (cached) { return cached; } const normalizedCompactionMessage = normalizeCompactionCommandMessage(message); const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts); const normalized = filteredParts === normalizedCompactionMessage.parts ? normalizedCompactionMessage : { ...normalizedCompactionMessage, parts: filteredParts, }; normalizedMessageBySource.set(message, normalized); return normalized; }; interface MessageListProps { sessionKey: string; turnStart: number; disableStaging?: boolean; messages: ChatMessageEntry[]; sessionIsWorking?: boolean; activeStreamingMessageId?: string | null; activeStreamingPhase?: StreamPhase | null; retryOverlay?: { sessionId: string; message: string; confirmedAt?: number; fallbackTimestamp?: number; } | null; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; hasMoreAbove: boolean; isLoadingOlder: boolean; onLoadOlder: () => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; scrollRef?: React.RefObject; } export interface MessageListHandle { scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior }) => boolean; scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; } type RenderEntry = | { kind: 'ungrouped'; key: string; message: ChatMessageEntry; previousMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry; } | { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean }; type TurnUiState = { isExpanded: boolean }; interface MessageRowProps { message: ChatMessageEntry; previousMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry; turnGroupingContext?: TurnGroupingContext; assistantHeaderMessageId?: string; isInActiveTurn?: boolean; activeStreamingPhase?: StreamPhase | null; animateUserOnMount?: boolean; onUserAnimationConsumed?: (messageId: string) => void; onContentChange: (reason?: ContentChangeReason) => void; animationHandlers: AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } const MessageRow = React.memo(({ message, previousMessage, nextMessage, turnGroupingContext, assistantHeaderMessageId, isInActiveTurn, activeStreamingPhase, animateUserOnMount, onUserAnimationConsumed, onContentChange, animationHandlers, scrollToBottom, }) => { return ( ); }, (prev, next) => { const prevTurn = prev.turnGroupingContext; const nextTurn = next.turnGroupingContext; return areRenderRelevantMessagesEqual(prev.message, next.message) && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) && prev.animateUserOnMount === next.animateUserOnMount && prev.onUserAnimationConsumed === next.onUserAnimationConsumed && prev.onContentChange === next.onContentChange && prev.scrollToBottom === next.scrollToBottom && areRelevantTurnGroupingContextsEqual(prevTurn, nextTurn, prev.message.info.id, resolveMessageRole(prev.message) === 'user') && prev.assistantHeaderMessageId === next.assistantHeaderMessageId && prev.isInActiveTurn === next.isInActiveTurn && prev.activeStreamingPhase === next.activeStreamingPhase && prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk && prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete && prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate && prev.animationHandlers?.onAnimationStart === next.animationHandlers?.onAnimationStart && prev.animationHandlers?.onReservationCancelled === next.animationHandlers?.onReservationCancelled && prev.animationHandlers?.onReasoningBlock === next.animationHandlers?.onReasoningBlock && prev.animationHandlers?.onAnimatedHeightChange === next.animationHandlers?.onAnimatedHeightChange; }); MessageRow.displayName = 'MessageRow'; interface TurnBlockProps { turn: TurnRecord; isLastTurn: boolean; sessionIsWorking: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; onToggleTurnGroup: (turnId: string) => void; chatRenderMode: 'sorted' | 'live'; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; stickyUserHeader?: boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; activeStreamingMessageId?: string | null; activeStreamingPhase?: StreamPhase | null; } const TurnBlock = React.memo(({ turn, isLastTurn, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader = true, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId, activeStreamingPhase, }: TurnBlockProps) => { const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded }; const handleToggleTurnGroup = React.useCallback(() => { onToggleTurnGroup(turn.turnId); }, [onToggleTurnGroup, turn.turnId]); const messageOrder = React.useMemo(() => { const ordered = [turn.userMessage, ...turn.assistantMessages]; const lookup = new Map(); ordered.forEach((message, index) => { lookup.set(message.info.id, index); }); return { ordered, lookup }; }, [turn.assistantMessages, turn.userMessage]); const streamingAssistantMessageId = React.useMemo(() => { if (activeStreamingMessageId && turn.assistantMessages.some((assistant) => assistant.info.id === activeStreamingMessageId)) { return activeStreamingMessageId; } for (let index = turn.assistantMessages.length - 1; index >= 0; index -= 1) { const assistant = turn.assistantMessages[index]; if (!isAssistantMessageCompleted(assistant)) { return assistant.info.id; } } return null; }, [activeStreamingMessageId, turn.assistantMessages]); const visibleAssistantMessages = React.useMemo(() => { if (chatRenderMode === 'live') { return turn.assistantMessages; } const completed = turn.assistantMessages.filter(isAssistantMessageCompleted); if (completed.length === turn.assistantMessages.length) { return turn.assistantMessages; } if (streamingAssistantMessageId) { const completedIds = new Set(completed.map((assistant) => assistant.info.id)); return turn.assistantMessages.filter((assistant) => ( completedIds.has(assistant.info.id) || assistant.info.id === streamingAssistantMessageId )); } if (completed.length > 0) { return completed; } const firstAssistant = turn.assistantMessages[0]; return firstAssistant ? [firstAssistant] : []; }, [chatRenderMode, streamingAssistantMessageId, turn.assistantMessages]); const completedAssistantMessages = React.useMemo(() => { if (chatRenderMode !== 'sorted') { return turn.assistantMessages; } return turn.assistantMessages.filter(isAssistantMessageCompleted); }, [chatRenderMode, turn.assistantMessages]); const visibleAssistantIds = React.useMemo(() => { const ids = new Map(); visibleAssistantMessages.forEach((assistant, index) => { ids.set(assistant.info.id, index); }); return ids; }, [visibleAssistantMessages]); const completedAssistantIdSet = React.useMemo(() => { return new Set(completedAssistantMessages.map((assistant) => assistant.info.id)); }, [completedAssistantMessages]); const visibleActivityMessageIdSet = React.useMemo(() => { const ids = new Set(completedAssistantIdSet); if (streamingAssistantMessageId) { ids.add(streamingAssistantMessageId); } return ids; }, [completedAssistantIdSet, streamingAssistantMessageId]); const turnIsInActiveStream = React.useMemo(() => { return turnContainsMessageId(turn, streamingAssistantMessageId); }, [turn, streamingAssistantMessageId]); const activityOwnerMessageId = React.useMemo(() => { if (turnIsInActiveStream && streamingAssistantMessageId) { return streamingAssistantMessageId; } return visibleAssistantMessages[0]?.info.id; }, [streamingAssistantMessageId, turnIsInActiveStream, visibleAssistantMessages]); const visibleActivityParts = React.useMemo(() => { if (chatRenderMode !== 'sorted') { return turn.activityParts; } if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) { return turn.activityParts; } return turn.activityParts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId)); }, [chatRenderMode, visibleActivityMessageIdSet, turn.activityParts, turn.assistantMessages.length]); const visibleActivitySegments = React.useMemo(() => { if (chatRenderMode !== 'sorted') { return turn.activitySegments; } if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) { return turn.activitySegments; } return turn.activitySegments .map((segment) => { const parts = segment.parts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId)); if (parts.length === 0) { return null; } const anchorMessageId = visibleActivityMessageIdSet.has(segment.anchorMessageId) ? segment.anchorMessageId : parts[0]?.messageId; if (!anchorMessageId) { return null; } return { ...segment, anchorMessageId, parts, }; }) .filter((segment): segment is NonNullable => segment !== null); }, [chatRenderMode, visibleActivityMessageIdSet, turn.activitySegments, turn.assistantMessages.length]); const turnGroupingContextBase = React.useMemo(() => { const userCreatedAt = (turn.userMessage.info.time as { created?: number } | undefined)?.created; // OpenCode 1.4.0 moved variant from top-level to model.variant on UserMessage. // Prefer the new location, fall back to the legacy one for older servers. const info = turn.userMessage.info as { variant?: unknown; model?: { variant?: unknown } } | undefined; const rawVariant = info?.model?.variant ?? info?.variant; const userMessageVariant = typeof rawVariant === 'string' && rawVariant.trim().length > 0 ? rawVariant : undefined; return { turnId: turn.turnId, summaryBody: turn.summaryText, activityParts: visibleActivityParts, activityGroupSegments: visibleActivitySegments, headerMessageId: turn.headerMessageId, hasTools: turn.hasTools, hasReasoning: turn.hasReasoning, diffStats: turn.diffStats, userMessageCreatedAt: typeof userCreatedAt === 'number' ? userCreatedAt : undefined, userMessageVariant, }; }, [turn.diffStats, turn.hasReasoning, turn.hasTools, turn.headerMessageId, turn.summaryText, turn.turnId, turn.userMessage.info, visibleActivityParts, visibleActivitySegments]); const renderMessage = React.useCallback( (message: ChatMessageEntry) => { const messageRole = resolveMessageRole(message); const isUserMessage = messageRole === 'user'; const messageIndex = messageOrder.lookup.get(message.info.id); const assistantIndex = visibleAssistantIds.get(message.info.id) ?? -1; const isAssistantMessage = assistantIndex >= 0; const isFirstAssistant = assistantIndex === 0; const isLastAssistant = assistantIndex === visibleAssistantMessages.length - 1; const isActivityOwner = Boolean(activityOwnerMessageId) && message.info.id === activityOwnerMessageId; const shouldAttachFullTurnContext = chatRenderMode === 'sorted' ? isAssistantMessage : (isActivityOwner || isFirstAssistant || isLastAssistant); const assistantHeaderMessageId = visibleAssistantMessages[0]?.info.id ?? turn.headerMessageId; const previousMessage = isUserMessage ? undefined : (isAssistantMessage ? (isFirstAssistant ? turn.userMessage : undefined) : (typeof messageIndex === 'number' && messageIndex > 0 ? messageOrder.ordered[messageIndex - 1] : undefined)); const nextMessage = undefined; const turnGroupingContext = isAssistantMessage ? { turnId: turn.turnId, activityOwnerMessageId, isFirstAssistantInTurn: isFirstAssistant, isLastAssistantInTurn: isLastAssistant, isWorking: isLastTurn && sessionIsWorking && message.info.id === streamingAssistantMessageId, hasTools: turn.hasTools, hasReasoning: turn.hasReasoning, ...(shouldAttachFullTurnContext ? { summaryBody: turnGroupingContextBase.summaryBody, activityParts: turnGroupingContextBase.activityParts, activityGroupSegments: turnGroupingContextBase.activityGroupSegments, headerMessageId: turnGroupingContextBase.headerMessageId, diffStats: turnGroupingContextBase.diffStats, userMessageCreatedAt: turnGroupingContextBase.userMessageCreatedAt, userMessageVariant: turnGroupingContextBase.userMessageVariant, isGroupExpanded: turnUiState.isExpanded, toggleGroup: handleToggleTurnGroup, } : {}), } satisfies TurnGroupingContext : undefined; return ( ); }, [ getAnimationHandlers, isLastTurn, messageOrder.lookup, messageOrder.ordered, onMessageContentChange, scrollToBottom, sessionIsWorking, chatRenderMode, turn.headerMessageId, turn.hasReasoning, turn.hasTools, turn.turnId, turn.userMessage, turnUiState.isExpanded, turnGroupingContextBase, streamingAssistantMessageId, activeStreamingPhase, visibleAssistantMessages, visibleAssistantIds, activityOwnerMessageId, shouldAnimateUserMessage, onUserAnimationConsumed, handleToggleTurnGroup, ] ); const renderableTurn = React.useMemo(() => { if (visibleAssistantMessages === turn.assistantMessages) { return turn; } return { ...turn, assistantMessages: visibleAssistantMessages, }; }, [turn, visibleAssistantMessages]); return ( ); }); TurnBlock.displayName = 'TurnBlock'; interface UngroupedMessageRowProps { message: ChatMessageEntry; previousMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; activeStreamingMessageId?: string | null; activeStreamingPhase?: StreamPhase | null; } const UngroupedMessageRow = React.memo(({ message, previousMessage, nextMessage, onMessageContentChange, getAnimationHandlers, scrollToBottom, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId, activeStreamingPhase, }: UngroupedMessageRowProps) => { return ( ); }); UngroupedMessageRow.displayName = 'UngroupedMessageRow'; interface MessageListEntryProps { entry: RenderEntry; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; stickyUserHeader?: boolean; sessionIsWorking: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; onToggleTurnGroup: (turnId: string) => void; chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; activeStreamingMessageId?: string | null; activeStreamingPhase?: StreamPhase | null; } const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | undefined): boolean => { if (!messageId) { return false; } if (turn.userMessage.info.id === messageId) { return true; } return turn.assistantMessages.some((assistant) => assistant.info.id === messageId); }; const MessageListEntry = React.memo(({ entry, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId, activeStreamingPhase, }: MessageListEntryProps) => { if (entry.kind === 'ungrouped') { return ( ); } return ( ); }); MessageListEntry.displayName = 'MessageListEntry'; // Inner component that renders staged turn entries. const StaticHistoryList: React.FC<{ entries: RenderEntry[]; shouldVirtualize: boolean; virtualRows: VirtualItem[]; totalSize: number; measureElement: (element: HTMLDivElement | null) => void; contentRef: React.RefObject; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; stickyUserHeader: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; onToggleTurnGroup: (turnId: string) => void; chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; activeStreamingPhase?: StreamPhase | null; }> = ({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase }) => { const renderEntry = React.useCallback((entry: RenderEntry) => { return ( ); }, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]); const paddingTop = shouldVirtualize && virtualRows.length > 0 ? virtualRows[0]?.start ?? 0 : 0; const paddingBottom = shouldVirtualize && virtualRows.length > 0 ? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0)) : 0; if (!shouldVirtualize) { return (
{entries.map((entry) => (
{renderEntry(entry)}
))}
); } return (
{paddingTop > 0 ?