import React from 'react'; import type { Message, Part } from '@opencode-ai/sdk/v2'; import { useShallow } from 'zustand/react/shallow'; import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; import { useSessionStore } from '@/stores/useSessionStore'; import { useMessageStore } from '@/stores/messageStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useContextStore } from '@/stores/contextStore'; import { useDeviceInfo } from '@/lib/device'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { cn } from '@/lib/utils'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; 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 } from './message/partUtils'; import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import type { TurnGroupingContext } from './hooks/useTurnGrouping'; const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'bash']); const isDetailedDefaultTool = (toolName: unknown): boolean => typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase()); function useStickyDisplayValue(value: T | null | undefined): T | null | undefined { const ref = React.useRef<{ hasValue: boolean; value: T | null | undefined }>({ hasValue: false, value: undefined as T | null | undefined }); if (value !== undefined && value !== null) { if (!ref.current.hasValue || ref.current.value !== value) { ref.current = { hasValue: true, value }; } } return ref.current.hasValue ? ref.current.value : value; } 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?: (options?: { instant?: boolean; force?: boolean }) => void; turnGroupingContext?: TurnGroupingContext; } const ChatMessage: React.FC = ({ message, previousMessage, nextMessage, onContentChange, animationHandlers, turnGroupingContext, }) => { const { isMobile, hasTouchInput } = useDeviceInfo(); const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); const sessionState = useSessionStore( useShallow((state) => ({ lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null, isStreamingMessage: (() => { const sessionId = (message.info as { sessionID?: string }).sessionID ?? state.currentSessionId ?? null; if (!sessionId) return false; return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id; })(), currentSessionId: state.currentSessionId, getAgentModelForSession: state.getAgentModelForSession, getSessionModelSelection: state.getSessionModelSelection, revertToMessage: state.revertToMessage, forkFromMessage: state.forkFromMessage, })) ); const { lifecyclePhase, isStreamingMessage, currentSessionId, getAgentModelForSession, getSessionModelSelection, revertToMessage, forkFromMessage, } = sessionState; const providers = useConfigStore((state) => state.providers); const { showReasoningTraces, toolCallExpansion } = useUIStore( useShallow((state) => ({ showReasoningTraces: state.showReasoningTraces, toolCallExpansion: state.toolCallExpansion, })) ); 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>(new Set()); const [popupContent, setPopupContent] = React.useState({ open: false, title: '', content: '', }); React.useEffect(() => { setExpandedTools(new Set()); }, [message.info.id, toolCallExpansion]); const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]); const isUser = messageRole.isUser; const sessionId = message.info.sessionID; // Subscribe to context changes so badges update immediately on mode switches. const { currentContextAgent, savedSessionAgentSelection } = useContextStore( useShallow((state) => ({ currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined, savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined, })) ); const normalizedParts = React.useMemo(() => { if (!isUser) { return message.parts; } const keepSyntheticUserText = (text: string): boolean => { const trimmed = text.trim(); if (trimmed.startsWith('User has requested to enter plan mode')) return true; if (trimmed.startsWith('The plan at ')) return true; return false; }; return message.parts .filter((part) => { const synthetic = (part as unknown as { synthetic?: boolean })?.synthetic === true; if (!synthetic) return true; if (part.type !== 'text') return false; const text = (part as unknown as { text?: unknown })?.text; return typeof text === 'string' ? keepSyntheticUserText(text) : false; }) .map((part) => { const rawPart = part as Record; if (rawPart.type === 'compaction') { return { type: 'text', text: '/compact' } as Part; } return part; }); }, [isUser, message.parts]); 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 (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, 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; if (providerID && modelID && providers.length > 0) { const provider = providers.find((p) => p.id === providerID); if (provider?.models && Array.isArray(provider.models)) { const model = provider.models.find((m: Record) => (m as Record).id === modelID); const modelObj = model as Record | undefined; const name = modelObj?.name; return typeof name === 'string' ? name : undefined; } } return 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 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; } return isMessageCompleted ? visibleParts : []; }, [isUser, isMessageCompleted, 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 effectiveExpandedTools = React.useMemo(() => { // 'collapsed': Activity and tools start collapsed // 'activity': Activity expanded, tools collapsed // 'detailed': Activity expanded, only key tools expanded if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') { // Tools default collapsed: expandedTools contains IDs of tools that ARE expanded return expandedTools; } // 'detailed': expand only allowlisted tools by default. // expandedTools acts as a "toggled" set (XOR with defaults). const defaultExpandedToolIds = new Set(); for (const part of toolParts) { const toolName = (part as { tool?: unknown }).tool; if (part.id && isDetailedDefaultTool(toolName)) { defaultExpandedToolIds.add(part.id); } } if (turnGroupingContext?.isFirstAssistantInTurn) { for (const activity of turnGroupingContext.activityParts) { if (activity.kind !== 'tool') { continue; } const toolPart = activity.part as unknown as { id?: string; tool?: unknown }; if (toolPart.id && isDetailedDefaultTool(toolPart.tool)) { defaultExpandedToolIds.add(toolPart.id); } } } const effective = new Set(defaultExpandedToolIds); for (const id of expandedTools) { if (effective.has(id)) { effective.delete(id); } else { effective.add(id); } } return effective; }, [expandedTools, toolCallExpansion, toolParts, turnGroupingContext]); const agentMention = React.useMemo(() => { if (!isUser) { return undefined; } const mentionPart = message.parts.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, message.parts]); // 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 syntaxTheme = React.useMemo(() => { if (currentTheme) { return generateSyntaxTheme(currentTheme); } return isDarkTheme ? defaultCodeDark : defaultCodeLight; }, [currentTheme, isDarkTheme]); 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]); // Track if this message should show header to prevent flickering const shouldShowHeaderRef = React.useRef(false); const previousRole = React.useMemo(() => { if (!previousMessage) return null; return deriveMessageRole(previousMessage.info); }, [previousMessage]); const nextRole = React.useMemo(() => { if (!nextMessage) return null; return deriveMessageRole(nextMessage.info); }, [nextMessage]); const isFollowedByAssistant = React.useMemo(() => { if (isUser) return false; if (!nextRole) return false; return !nextRole.isUser && nextRole.role === 'assistant'; }, [isUser, nextRole]); const streamPhase: StreamPhase = React.useMemo(() => { if (isMessageCompleted) { return 'completed'; } if (lifecyclePhase) { return lifecyclePhase; } return isStreamingMessage ? 'streaming' : 'completed'; }, [isMessageCompleted, lifecyclePhase, isStreamingMessage]); const shouldShowHeader = React.useMemo(() => { if (isUser) return true; // Use turn grouping context if available for more precise control const headerMessageId = 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'; const hasStartedStreaming = shouldShowHeaderRef.current; // Update the ref when streaming starts if (isCurrentlyStreaming && !hasStartedStreaming) { shouldShowHeaderRef.current = true; } // Show header if streaming has started or is currently active return hasStartedStreaming || isCurrentlyStreaming; } // For non-first assistant messages, don't show header return false; } // Fallback to original logic when turn grouping is not available if (!previousRole) return true; return previousRole.isUser; }, [isUser, previousRole, turnGroupingContext, streamPhase, message.info]); const handleCopyCode = React.useCallback((code: string) => { navigator.clipboard.writeText(code); setCopiedCode(code); setTimeout(() => setCopiedCode(null), 2000); }, []); const userMessageIdForTurn = turnGroupingContext?.turnId; const { assistantSummaryFromStore, variantFromTurnStore } = useMessageStore( useShallow((state) => { if (!userMessageIdForTurn || !message.info.sessionID) { return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; } const sessionMessages = state.messages.get(message.info.sessionID); if (!sessionMessages) { return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; } const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn); if (!userMsg) { return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; } const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary; const body = summary?.body; const variant = (userMsg.info as { variant?: unknown }).variant; return { assistantSummaryFromStore: typeof body === 'string' && body.trim().length > 0 ? body : undefined, variantFromTurnStore: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined, }; }) ); const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined; const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined; const assistantSummaryCandidate = typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0 ? turnGroupingContext.summaryBody : assistantSummaryFromStore; const assistantSummaryRef = React.useRef(undefined); if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) { assistantSummaryRef.current = assistantSummaryCandidate; } const prevUserMessageIdForCopy = React.useRef(userMessageIdForTurn); if (prevUserMessageIdForCopy.current !== userMessageIdForTurn) { prevUserMessageIdForCopy.current = userMessageIdForTurn; assistantSummaryRef.current = undefined; } const assistantSummaryForCopy = assistantSummaryRef.current; const assistantErrorText = 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; } return `Opencode failed to send message with error:\n\`${detail}\``; }, [isUser, message.info]); const messageTextContent = React.useMemo(() => { if (isUser) { 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; } if (assistantSummaryForCopy && assistantSummaryForCopy.trim().length > 0) { return assistantSummaryForCopy; } return flattenAssistantTextParts(displayParts); }, [assistantErrorText, assistantSummaryForCopy, displayParts, isUser]); const hasTextContent = messageTextContent.length > 0; const copyTextToClipboard = React.useCallback(async (text: string): Promise => { if (!text) { return false; } if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch (error) { void error; } } if (typeof document === 'undefined') { return false; } const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', ''); textarea.style.position = 'fixed'; textarea.style.top = '-1000px'; textarea.style.left = '-1000px'; document.body.appendChild(textarea); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); const succeeded = document.execCommand('copy'); document.body.removeChild(textarea); return succeeded; }, []); const handleCopyMessage = React.useCallback(async () => { const didCopy = await copyTextToClipboard(messageTextContent); if (!didCopy) { return; } setCopiedMessage(true); setTimeout(() => setCopiedMessage(false), 2000); }, [copyTextToClipboard, 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) => { setExpandedTools((prev) => { const next = new Set(prev); if (next.has(toolId)) { next.delete(toolId); } else { next.add(toolId); } return next; }); }, []); 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); React.useEffect(() => { animationCompletedRef.current = false; hasRequestedReservationRef.current = false; animationStartNotifiedRef.current = false; hasTriggeredReservationOnceRef.current = false; hasAnnouncedAuxiliaryScrollRef.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) { 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'; const hasReasoningParts = React.useMemo(() => { if (isUser) { return false; } return visibleParts.some((part) => part.type === 'reasoning'); }, [isUser, visibleParts]); const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase; 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]); return ( <>
{isUser ? ( displayParts.length === 0 ? null : (
) ) : (
{shouldShowHeader && ( )}
)}
); }; export default React.memo(ChatMessage);