import React from 'react'; import type { Message, Part } from '@opencode-ai/sdk/v2'; import { useShallow } from 'zustand/react/shallow'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useContextStore } from '@/stores/contextStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useDeviceInfo } from '@/lib/device'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { cn } from '@/lib/utils'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow'; import MessageHeader from './message/MessageHeader'; import MessageBody from './message/MessageBody'; import type { AgentMentionInfo } from './message/types'; import type { StreamPhase, ToolPopupContent } from './message/types'; import { deriveMessageRole } from './message/messageRole'; import { filterVisibleParts, normalizeParts } from './message/partUtils'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError'; import { getProviderModelDisplayName } from '@/lib/modelDisplay'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { TurnGroupingContext } from './lib/turns/types'; import { copyTextToClipboard } from '@/lib/clipboard'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare'; import type { ReviewTransferDirection } from '@/lib/reviewFlow'; const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); const EXPANDED_TOOLS_CACHE_MAX = 4000; const expandedToolsStateCache = new Map>(); const collapsedToolsStateCache = new Map>(); const BASH_TOOL_NAMES = new Set(['bash', 'shell', 'cmd', 'terminal']); const EDIT_TOOL_NAMES = new Set([ 'apply_patch', 'edit', 'write', 'multiedit', 'str_replace', 'str_replace_based_edit_tool', 'create', 'file_write', ]); const normalizeToolName = (toolName: unknown): string => { if (typeof toolName !== 'string') return ''; const trimmed = toolName.trim().toLowerCase(); if (!trimmed) return ''; const withoutIndex = trimmed.replace(/:\d+$/, ''); if (!withoutIndex.includes('.')) { return withoutIndex; } const parts = withoutIndex.split('.').filter(Boolean); return parts[parts.length - 1] ?? withoutIndex; }; const readExpandedToolsCache = (messageId: string): Set => { const cached = expandedToolsStateCache.get(messageId); return cached ? new Set(cached) : new Set(); }; const writeExpandedToolsCache = (messageId: string, value: Set): void => { if (expandedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !expandedToolsStateCache.has(messageId)) { const oldest = expandedToolsStateCache.keys().next().value; if (typeof oldest === 'string') { expandedToolsStateCache.delete(oldest); } } expandedToolsStateCache.set(messageId, new Set(value)); }; const readCollapsedToolsCache = (messageId: string): Set => { const cached = collapsedToolsStateCache.get(messageId); return cached ? new Set(cached) : new Set(); }; const writeCollapsedToolsCache = (messageId: string, value: Set): void => { if (collapsedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !collapsedToolsStateCache.has(messageId)) { const oldest = collapsedToolsStateCache.keys().next().value; if (typeof oldest === 'string') { collapsedToolsStateCache.delete(oldest); } } collapsedToolsStateCache.set(messageId, new Set(value)); }; function useStickyDisplayValue(value: T | null | undefined): T | null | undefined { const [stickyValue, setStickyValue] = React.useState(value); React.useEffect(() => { if (value !== undefined && value !== null) { setStickyValue(value); } }, [value]); return value ?? stickyValue; } const getMessageInfoProp = (info: unknown, key: string): unknown => { if (typeof info === 'object' && info !== null) { return (info as Record)[key]; } return undefined; }; interface ChatMessageProps { message: { info: Message; parts: Part[]; }; previousMessage?: { info: Message; parts: Part[]; }; nextMessage?: { info: Message; parts: Part[]; }; onContentChange?: (reason?: ContentChangeReason) => void; animationHandlers?: AnimationHandlers; scrollToBottom?: () => void; turnGroupingContext?: TurnGroupingContext; assistantHeaderMessageId?: string; isInActiveTurn?: boolean; activeStreamingPhase?: StreamPhase | null; animateUserOnMount?: boolean; onUserAnimationConsumed?: (messageId: string) => void; reviewTransferDirection?: ReviewTransferDirection | null; } const ChatMessage: React.FC = ({ message, previousMessage, nextMessage, onContentChange, animationHandlers, turnGroupingContext, assistantHeaderMessageId, isInActiveTurn = false, activeStreamingPhase = null, animateUserOnMount = false, onUserAnimationConsumed, reviewTransferDirection = null, }) => { const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const alwaysShowMessageActions = isMobile || isTablet; const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession); const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection); const revertToMessage = useSessionUIStore((s) => s.revertToMessage); const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage); streamPerfCount('ui.chat_message.render'); if (isInActiveTurn) { streamPerfCount('ui.chat_message.render.streaming'); } const providers = useConfigStore((state) => state.providers); const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore( useShallow((state) => ({ showReasoningTraces: state.showReasoningTraces, stickyUserHeader: state.stickyUserHeader, chatRenderMode: state.chatRenderMode, showExpandedBashTools: state.showExpandedBashTools, showExpandedEditTools: state.showExpandedEditTools, })) ); React.useEffect(() => { if (currentSessionId) { MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); } }, [currentSessionId]); const [copiedCode, setCopiedCode] = React.useState(null); const [copiedMessage, setCopiedMessage] = React.useState(false); const [expandedTools, setExpandedTools] = React.useState>(() => readExpandedToolsCache(message.info.id)); const [collapsedTools, setCollapsedTools] = React.useState>(() => readCollapsedToolsCache(message.info.id)); const [popupContent, setPopupContent] = React.useState({ open: false, title: '', content: '', }); React.useEffect(() => { setExpandedTools(readExpandedToolsCache(message.info.id)); setCollapsedTools(readCollapsedToolsCache(message.info.id)); }, [message.info.id]); const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]); const isUser = messageRole.isUser; const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader); const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow; const sessionId = message.info.sessionID; const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); // Keep non-active-turn rows detached from context-store churn. const { currentContextAgent, savedSessionAgentSelection } = useContextStore( useShallow((state) => ({ currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined, savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined, })) ); const normalizedParts = React.useMemo(() => { const safeParts = normalizeParts(message.parts); if (!isUser) { return safeParts; } return normalizeUserDisplayParts(safeParts, { planModeEnabled }); }, [isUser, message.parts, planModeEnabled]); const previousUserMetadata = React.useMemo(() => { if (isUser || !previousMessage) { return null; } const clientRole = getMessageInfoProp(previousMessage.info, 'clientRole'); const role = getMessageInfoProp(previousMessage.info, 'role'); const previousRole = typeof clientRole === 'string' ? clientRole : (typeof role === 'string' ? role : undefined); if (previousRole !== 'user') { return null; } const mode = getMessageInfoProp(previousMessage.info, 'mode'); const agent = getMessageInfoProp(previousMessage.info, 'agent'); const providerID = getMessageInfoProp(previousMessage.info, 'providerID'); const modelID = getMessageInfoProp(previousMessage.info, 'modelID'); const variant = getMessageInfoProp(previousMessage.info, 'variant'); const resolvedAgent = typeof mode === 'string' && mode.trim().length > 0 ? mode : (typeof agent === 'string' && agent.trim().length > 0 ? agent : undefined); const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined; const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined; const resolvedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined; if (!resolvedAgent && !resolvedProvider && !resolvedModel && !resolvedVariant) { return null; } return { agentName: resolvedAgent, providerId: resolvedProvider, modelId: resolvedModel, variant: resolvedVariant, }; }, [isUser, previousMessage]); const previousIsModeSwitchMessage = React.useMemo(() => { if (!planModeEnabled) return false; if (isUser || !previousMessage) return false; const parts = Array.isArray(previousMessage.parts) ? previousMessage.parts : []; for (let i = 0; i < parts.length; i++) { const part = parts[i] as unknown as { type?: string; text?: string; synthetic?: boolean }; if (part?.type !== 'text') continue; if (part?.synthetic !== true) continue; const text = typeof part.text === 'string' ? part.text.trim() : ''; if (text.startsWith('User has requested to enter plan mode') || text.startsWith('The plan at ')) { return true; } } return false; }, [isUser, planModeEnabled, previousMessage]); const agentName = React.useMemo(() => { if (isUser) return undefined; // While the assistant message is streaming, if the immediately previous user message is a // synthetic mode switch, trust that mode for the badge. const timeInfo = message.info.time as { completed?: number } | undefined; const isCompleted = typeof timeInfo?.completed === 'number' && timeInfo.completed > 0; if (!isCompleted && previousIsModeSwitchMessage && previousUserMetadata?.agentName) { return previousUserMetadata.agentName; } const messageMode = getMessageInfoProp(message.info, 'mode'); if (typeof messageMode === 'string' && messageMode.trim().length > 0) { return messageMode; } const messageAgent = getMessageInfoProp(message.info, 'agent'); if (typeof messageAgent === 'string' && messageAgent.trim().length > 0) { return messageAgent; } if (previousUserMetadata?.agentName) { return previousUserMetadata.agentName; } if (!sessionId) { return undefined; } if (currentContextAgent) { return currentContextAgent; } return savedSessionAgentSelection ?? undefined; }, [isUser, message.info, previousIsModeSwitchMessage, previousUserMetadata, sessionId, currentContextAgent, savedSessionAgentSelection]); const messageProviderID = !isUser ? getMessageInfoProp(message.info, 'providerID') : null; const messageModelID = !isUser ? getMessageInfoProp(message.info, 'modelID') : null; const contextModelSelection = React.useMemo(() => { if (isUser || !sessionId) return null; if (previousUserMetadata?.providerId && previousUserMetadata?.modelId) { return { providerId: previousUserMetadata.providerId, modelId: previousUserMetadata.modelId, }; } if (agentName) { const agentSelection = getAgentModelForSession(sessionId, agentName); if (agentSelection?.providerId && agentSelection?.modelId) { return agentSelection; } } const sessionSelection = getSessionModelSelection(sessionId); if (sessionSelection?.providerId && sessionSelection?.modelId) { return sessionSelection; } return null; }, [isUser, sessionId, agentName, previousUserMetadata, getAgentModelForSession, getSessionModelSelection]); const providerID = React.useMemo(() => { if (isUser) return null; if (typeof messageProviderID === 'string' && messageProviderID.trim().length > 0) { return messageProviderID; } return contextModelSelection?.providerId ?? null; }, [isUser, messageProviderID, contextModelSelection]); const modelID = React.useMemo(() => { if (isUser) return null; if (typeof messageModelID === 'string' && messageModelID.trim().length > 0) { return messageModelID; } return contextModelSelection?.modelId ?? null; }, [isUser, messageModelID, contextModelSelection]); const modelName = React.useMemo(() => { if (isUser) return undefined; const provider = providerID && providers.length > 0 ? providers.find((p) => p.id === providerID) : undefined; return getProviderModelDisplayName(provider, modelID) || undefined; }, [isUser, providerID, modelID, providers]); const modelHasVariants = React.useMemo(() => { if (isUser) return false; if (!providerID || !modelID) return false; const provider = providers.find((p) => p.id === providerID); if (!provider?.models || !Array.isArray(provider.models)) { return false; } const model = provider.models.find((m: Record) => (m as Record).id === modelID) as | { variants?: Record } | undefined; const variants = model?.variants; return Boolean(variants && Object.keys(variants).length > 0); }, [isUser, modelID, providerID, providers]); const displayAgentName = useStickyDisplayValue(agentName); const displayProviderIDValue = useStickyDisplayValue(providerID ?? undefined); const displayModelName = useStickyDisplayValue(modelName); const headerAgentName = displayAgentName ?? undefined; const headerProviderID = displayProviderIDValue ?? null; const headerModelName = displayModelName ?? undefined; const messageCompletedAt = React.useMemo(() => { const timeInfo = message.info.time as { completed?: number } | undefined; return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null; }, [message.info.time]); const messageCreatedAt = React.useMemo(() => { const timeInfo = message.info.time as { created?: number } | undefined; return typeof timeInfo?.created === 'number' ? timeInfo.created : null; }, [message.info.time]); const isMessageCompleted = React.useMemo(() => { if (isUser) return true; return Boolean(messageCompletedAt && messageCompletedAt > 0); }, [isUser, messageCompletedAt]); const messageFinish = React.useMemo(() => { const finish = (message.info as { finish?: string }).finish; return typeof finish === 'string' ? finish : undefined; }, [message.info]); const visibleParts = React.useMemo( () => filterVisibleParts(normalizedParts, { includeReasoning: showReasoningTraces, }), [normalizedParts, showReasoningTraces] ); const displayParts = React.useMemo(() => { if (isUser) { return visibleParts; } if (!isMessageCompleted && chatRenderMode === 'sorted') { return []; } return visibleParts; }, [chatRenderMode, isMessageCompleted, isUser, visibleParts]); const assistantTextParts = React.useMemo(() => { if (isUser) { return []; } return visibleParts.filter((part) => part.type === 'text'); }, [isUser, visibleParts]); const toolParts = React.useMemo(() => { if (isUser) { return []; } const filtered = visibleParts.filter((part) => part.type === 'tool'); return filtered; }, [isUser, visibleParts]); const turnActivityToolParts = React.useMemo(() => { if (isUser) { return [] as Part[]; } const records = turnGroupingContext?.activityParts ?? []; return records .filter((record) => record.kind === 'tool') .map((record) => record.part) .filter((part): part is Part => part.type === 'tool'); }, [isUser, turnGroupingContext?.activityParts]); const defaultOpenToolIds = React.useMemo(() => { if (!showExpandedBashTools && !showExpandedEditTools) { return new Set(); } const next = new Set(); for (const part of [...toolParts, ...turnActivityToolParts]) { const toolId = typeof part?.id === 'string' ? part.id : ''; if (!toolId) continue; const toolName = normalizeToolName((part as { tool?: string }).tool); if (!toolName) continue; if (showExpandedBashTools && BASH_TOOL_NAMES.has(toolName)) { next.add(toolId); continue; } if (showExpandedEditTools && EDIT_TOOL_NAMES.has(toolName)) { next.add(toolId); } } return next; }, [showExpandedBashTools, showExpandedEditTools, toolParts, turnActivityToolParts]); const effectiveExpandedTools = React.useMemo(() => { if (defaultOpenToolIds.size === 0 && collapsedTools.size === 0) { return expandedTools; } const next = new Set(expandedTools); defaultOpenToolIds.forEach((toolId) => { if (!collapsedTools.has(toolId)) { next.add(toolId); } }); collapsedTools.forEach((toolId) => { next.delete(toolId); }); return next; }, [collapsedTools, defaultOpenToolIds, expandedTools]); const agentMention = React.useMemo(() => { if (!isUser) { return undefined; } const mentionPart = normalizedParts.find((part) => part.type === 'agent'); if (!mentionPart) { return undefined; } const partWithName = mentionPart as { name?: string; source?: { value?: string } }; const name = typeof partWithName.name === 'string' ? partWithName.name : undefined; if (!name) { return undefined; } const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0 ? partWithName.source.value : `@${name}`; return { name, token: rawValue } satisfies AgentMentionInfo; }, [isUser, normalizedParts]); const shouldHideUserMessage = isUser && displayParts.length === 0; // Message is considered to have an "open step" if info.finish is not yet present const hasOpenStep = typeof messageFinish !== 'string'; const shouldCoordinateRendering = React.useMemo(() => { if (isUser) { return false; } if (assistantTextParts.length === 0 || toolParts.length === 0) { return hasOpenStep; } return true; }, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]); const themeVariant = currentTheme?.metadata.variant; const isDarkTheme = React.useMemo(() => { if (themeVariant) { return themeVariant === 'dark'; } if (typeof document !== 'undefined') { return document.documentElement.classList.contains('dark'); } return false; }, [themeVariant]); const shouldAnimateMessage = React.useMemo(() => { if (isUser) return false; const freshnessDetector = MessageFreshnessDetector.getInstance(); return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID); }, [message.info, currentSessionId, isUser]); const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false); const nextRole = React.useMemo(() => { if (!nextMessage) return null; return deriveMessageRole(nextMessage.info); }, [nextMessage]); const hasTurnGrouping = Boolean(turnGroupingContext); const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const isFollowedByAssistant = React.useMemo(() => { if (isUser) return false; if (hasTurnGrouping) { return !isLastAssistantInTurn; } if (!nextRole) return false; return !nextRole.isUser && nextRole.role === 'assistant'; }, [hasTurnGrouping, isLastAssistantInTurn, isUser, nextRole]); const streamPhase: StreamPhase = React.useMemo(() => { if (isMessageCompleted) { return 'completed'; } if (isInActiveTurn) { return activeStreamingPhase ?? 'streaming'; } return 'completed'; }, [activeStreamingPhase, isInActiveTurn, isMessageCompleted]); React.useEffect(() => { if (!isUser || !animateUserOnMount) { return; } onUserAnimationConsumed?.(message.info.id); }, [animateUserOnMount, isUser, message.info.id, onUserAnimationConsumed]); React.useEffect(() => { setHasStartedStreamingHeader(false); }, [message.info.id]); React.useEffect(() => { const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId; if (isUser || !headerMessageId || headerMessageId !== message.info.id) { return; } const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown'; if (isCurrentlyStreaming) { setHasStartedStreamingHeader(true); } }, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]); const shouldShowHeader = React.useMemo(() => { if (isUser) return true; // Use turn grouping context if available for more precise control const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId; if (headerMessageId) { // For turn grouping: only show header for the first assistant message in the turn const isFirstAssistantInTurn = message.info.id === headerMessageId; if (isFirstAssistantInTurn) { // For completed messages, always show header (historical messages) if (streamPhase === 'completed') { return true; } // For streaming messages: show header when streaming starts and keep it visible const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown'; return hasStartedStreamingHeader || isCurrentlyStreaming; } // For non-first assistant messages, don't show header return false; } // Ungrouped fallback path: always show assistant header. return true; }, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]); const handleCopyCode = React.useCallback((code: string) => { void copyTextToClipboard(code).then((result) => { if (!result.ok) { return; } setCopiedCode(code); setTimeout(() => setCopiedCode(null), 2000); }); }, []); const headerVariantRaw = !isUser ? (turnGroupingContext?.userMessageVariant ?? previousUserMetadata?.variant) : undefined; const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined; // Summary body removed — flat rendering means text is always inline. const assistantError = React.useMemo(() => { if (isUser) { return undefined; } const errorInfo = (message.info as { error?: unknown } | undefined)?.error as | { data?: { message?: unknown }; message?: unknown; name?: unknown } | undefined; if (!errorInfo) { return undefined; } const dataMessage = typeof errorInfo.data?.message === 'string' ? errorInfo.data.message : undefined; const errorMessage = typeof errorInfo.message === 'string' ? errorInfo.message : undefined; const errorName = typeof errorInfo.name === 'string' ? errorInfo.name : undefined; const detail = dataMessage || errorMessage || errorName; if (!detail) { return undefined; } if (errorName === 'SessionRetry') { return { text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``, variant: 'info' as const, }; } if (isLikelyProviderAuthFailure(detail)) { return { text: PROVIDER_AUTH_FAILURE_MESSAGE, variant: 'error' as const, }; } if (detail.trim().toLowerCase() === 'aborted') { return { text: 'The running turn was stopped before OpenCode could send the next message.', variant: 'info' as const, }; } return { text: `Opencode failed to send message with error:\n\`${detail}\``, variant: 'error' as const, }; }, [isUser, message.info]); const assistantErrorText = assistantError?.text; const assistantErrorVariant = assistantError?.variant; const messageTextContent = React.useMemo(() => { if (isUser) { const shellOutputs = displayParts .filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text') .map((part) => { const output = part.shellAction?.output; return typeof output === 'string' ? output.trim() : ''; }) .filter((output) => output.length > 0); if (shellOutputs.length > 0) { return shellOutputs.join('\n\n'); } const shellCommands = displayParts .filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text') .map((part) => { const command = part.shellAction?.command; return typeof command === 'string' ? command.trim() : ''; }) .filter((command) => command.length > 0); if (shellCommands.length > 0) { return shellCommands.join('\n'); } const textParts = displayParts .filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text') .map((part) => { const text = part.text || part.content || ''; return text.trim(); }) .filter((text) => text.length > 0); const combined = textParts.join('\n'); return combined.replace(/\n\s*\n+/g, '\n'); } if (assistantErrorText && assistantErrorText.trim().length > 0) { return assistantErrorText; } return flattenAssistantTextParts(displayParts); }, [assistantErrorText, displayParts, isUser]); const hasTextContent = messageTextContent.length > 0; const handleCopyMessage = React.useCallback(async () => { const result = await copyTextToClipboard(messageTextContent); if (!result.ok) { return false; } if (isUser) { setCopiedMessage(true); setTimeout(() => setCopiedMessage(false), 2000); } return true; }, [isUser, messageTextContent]); const handleRevert = React.useCallback(() => { if (!sessionId || !message.info.id) return; revertToMessage(sessionId, message.info.id); }, [sessionId, message.info.id, revertToMessage]); // NEW: Fork handler const handleFork = React.useCallback(() => { if (!sessionId || !message.info.id) return; forkFromMessage(sessionId, message.info.id); }, [sessionId, message.info.id, forkFromMessage]); const handleToggleTool = React.useCallback((toolId: string) => { const isDefaultOpen = defaultOpenToolIds.has(toolId); const isCurrentlyExpanded = effectiveExpandedTools.has(toolId); if (isDefaultOpen) { setCollapsedTools((prev) => { const next = new Set(prev); if (isCurrentlyExpanded) { next.add(toolId); } else { next.delete(toolId); } writeCollapsedToolsCache(message.info.id, next); return next; }); if (!isCurrentlyExpanded) { setExpandedTools((prev) => { const next = new Set(prev); next.delete(toolId); writeExpandedToolsCache(message.info.id, next); return next; }); } return; } setExpandedTools((prev) => { const next = new Set(prev); if (next.has(toolId)) { next.delete(toolId); } else { next.add(toolId); } writeExpandedToolsCache(message.info.id, next); return next; }); setCollapsedTools((prev) => { if (!prev.has(toolId)) { return prev; } const next = new Set(prev); next.delete(toolId); writeCollapsedToolsCache(message.info.id, next); return next; }); }, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]); const resolvedAnimationHandlers = animationHandlers ?? null; const hasAnnouncedAuxiliaryScrollRef = React.useRef(false); const animationCompletedRef = React.useRef(false); const hasRequestedReservationRef = React.useRef(false); const animationStartNotifiedRef = React.useRef(false); const hasTriggeredReservationOnceRef = React.useRef(false); const hasEverStreamedRef = React.useRef(false); React.useEffect(() => { animationCompletedRef.current = false; hasRequestedReservationRef.current = false; animationStartNotifiedRef.current = false; hasTriggeredReservationOnceRef.current = false; hasAnnouncedAuxiliaryScrollRef.current = false; hasEverStreamedRef.current = false; }, [message.info.id]); const handleAuxiliaryContentComplete = React.useCallback(() => { if (isUser) { return; } if (hasAnnouncedAuxiliaryScrollRef.current) { return; } hasAnnouncedAuxiliaryScrollRef.current = true; onContentChange?.('structural'); }, [isUser, onContentChange]); const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const handleShowPopup = React.useCallback((content: ToolPopupContent) => { if (content.image || content.mermaid) { setPopupContent(content); setImagePreviewOpen(true); } }, [setImagePreviewOpen]); const handlePopupChange = React.useCallback((open: boolean) => { setPopupContent((prev) => ({ ...prev, open })); setImagePreviewOpen(open); }, [setImagePreviewOpen]); const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled')); const isStreamingPhase = streamPhase === 'streaming' || streamPhase === 'cooldown'; if (isStreamingPhase) { hasEverStreamedRef.current = true; } const hasReasoningParts = React.useMemo(() => { if (isUser) { return false; } return visibleParts.some((part) => part.type === 'reasoning'); }, [isUser, visibleParts]); const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current; const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering; React.useEffect(() => { if (!resolvedAnimationHandlers?.onStreamingCandidate) { return; } if (!shouldReserveAnimationSpace) { if (hasRequestedReservationRef.current) { if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) { resolvedAnimationHandlers.onReasoningBlock(); } else if (resolvedAnimationHandlers?.onReservationCancelled) { resolvedAnimationHandlers.onReservationCancelled(); } hasRequestedReservationRef.current = false; } return; } if (hasTriggeredReservationOnceRef.current) { return; } hasTriggeredReservationOnceRef.current = true; resolvedAnimationHandlers.onStreamingCandidate(); hasRequestedReservationRef.current = true; }, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]); React.useEffect(() => { if (!resolvedAnimationHandlers?.onAnimationStart) { return; } if (!allowAnimation) { return; } if (animationStartNotifiedRef.current) { return; } resolvedAnimationHandlers.onAnimationStart(); animationStartNotifiedRef.current = true; }, [resolvedAnimationHandlers, allowAnimation]); React.useEffect(() => { if (isUser) { return; } const handler = resolvedAnimationHandlers?.onAnimatedHeightChange; if (!handler) { return; } const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace; if (!shouldTrackHeight) { return; } const element = messageContainerRef.current; if (!element) { return; } if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') { handler(element.getBoundingClientRect().height); return; } let rafId: number | null = null; const notifyHeight = (height: number) => { if (typeof window === 'undefined') { handler(height); return; } if (rafId !== null) { window.cancelAnimationFrame(rafId); } rafId = window.requestAnimationFrame(() => { handler(height); }); }; const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) { return; } notifyHeight(entry.contentRect.height); }); observer.observe(element); notifyHeight(element.getBoundingClientRect().height); return () => { if (rafId !== null) { window.cancelAnimationFrame(rafId); rafId = null; } observer.disconnect(); }; }, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]); if (shouldHideUserMessage) { return null; } const assistantTopPaddingClass = !isUser && shouldShowHeader ? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0') : 'pt-0'; const userMessageRadius = 'var(--radius-xl)'; return ( <>
{isUser ? ( displayParts.length === 0 ? null : (
{useExternalUserActionsRow ? ( ) : null}
) ) : (
{shouldShowHeader && ( )}
)}
); }; export default React.memo(ChatMessage, (prev, next) => { return areRenderRelevantMessagesEqual( { info: prev.message.info, parts: prev.message.parts }, { info: next.message.info, parts: next.message.parts } ) && areOptionalRenderRelevantMessagesEqual( prev.previousMessage ? { info: prev.previousMessage.info, parts: prev.previousMessage.parts } : undefined, next.previousMessage ? { info: next.previousMessage.info, parts: next.previousMessage.parts } : undefined ) && areOptionalRenderRelevantMessagesEqual( prev.nextMessage ? { info: prev.nextMessage.info, parts: prev.nextMessage.parts } : undefined, next.nextMessage ? { info: next.nextMessage.info, parts: next.nextMessage.parts } : undefined ) && prev.isInActiveTurn === next.isInActiveTurn && prev.activeStreamingPhase === next.activeStreamingPhase && prev.reviewTransferDirection === next.reviewTransferDirection && prev.assistantHeaderMessageId === next.assistantHeaderMessageId && prev.animateUserOnMount === next.animateUserOnMount && prev.onUserAnimationConsumed === next.onUserAnimationConsumed && areRelevantTurnGroupingContextsEqual( prev.turnGroupingContext, next.turnGroupingContext, prev.message.info.id, deriveMessageRole(prev.message.info).isUser ); });