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 { useChatSurfaceMode } from './useChatSurfaceMode'; 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 { isHiddenUserMessage } from './message/hiddenUserMessage'; import { flattenAssistantTextParts, flattenUserTextParts } 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 { copyMarkdownToClipboard, 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'; import { toast } from 'sonner'; import { useI18n } from '@/lib/i18n'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages'; import { setContextObligatoryMessage } from '@/sync/session-actions'; import { isVSCodeRuntime } from '@/lib/desktop'; import { focusChatInput } from './composer/editor/dom'; 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[]; }; 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, turnGroupingContext, assistantHeaderMessageId, isInActiveTurn = false, activeStreamingPhase = null, animateUserOnMount = false, onUserAnimationConsumed, reviewTransferDirection = null, }) => { const { t } = useI18n(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const alwaysShowMessageActions = isMobile || isTablet; const canPinIntoContext = !isVSCodeRuntime(); const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession); const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection); 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, })) ); 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 chatSurfaceMode = useChatSurfaceMode(); 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 isPinnedIntoContext = useGlobalSessionsStore((state) => { const session = state.activeSessions.find((candidate) => candidate.id === sessionId) ?? state.archivedSessions.find((candidate) => candidate.id === sessionId); return getContextObligatoryMessages(session).some((entry) => entry.id === message.info.id); }); const [pinPending, setPinPending] = React.useState(false); const handleToggleContextPin = React.useCallback(async () => { if (!sessionId || !messageCreatedAt || pinPending) return; setPinPending(true); try { const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId); await setContextObligatoryMessage(sessionId, directory, { id: message.info.id, createdAt: messageCreatedAt, role: isUser ? 'user' : 'assistant', }, !isPinnedIntoContext); // Return focus to the composer so the user can keep typing right // after adding the message to context (matches the refocus pattern // used by the model/agent selectors). requestAnimationFrame(focusChatInput); } catch (error) { console.error('[chat-message] failed to update context pin', error); toast.error(t('chat.messageBody.actions.contextPinFailed')); } finally { setPinPending(false); } }, [isPinnedIntoContext, isUser, message.info.id, messageCreatedAt, pinPending, sessionId, t]); 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 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; 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, message.info.sessionID); }, [message.info, 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 previousIsHiddenUserMessage = React.useMemo( () => !isUser && isHiddenUserMessage(previousMessage, { planModeEnabled }), [isUser, planModeEnabled, previousMessage] ); const nextIsHiddenUserMessage = React.useMemo( () => !isUser && isHiddenUserMessage(nextMessage, { planModeEnabled }), [isUser, planModeEnabled, nextMessage] ); 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: ${detail}`, }; } if (isLikelyProviderAuthFailure(detail)) { return { text: PROVIDER_AUTH_FAILURE_MESSAGE, }; } if (detail.trim().toLowerCase() === 'aborted') { return { text: 'The running turn was stopped before OpenCode could send the next message.', }; } return { text: `Opencode failed to send message with error: ${detail}`, }; }, [isUser, message.info]); const assistantErrorText = assistantError?.text; const messageTextContent = React.useMemo(() => { if (isUser) { return flattenUserTextParts(displayParts); } if (assistantErrorText && assistantErrorText.trim().length > 0) { return assistantErrorText; } return flattenAssistantTextParts(displayParts); }, [assistantErrorText, displayParts, isUser]); const hasTextContent = messageTextContent.length > 0; const handleCopyMessage = React.useCallback(async () => { let result; if (isUser) { result = await copyTextToClipboard(messageTextContent); } else { const { renderMarkdownSync } = await import('./markdown/markdownCore'); result = await copyMarkdownToClipboard(messageTextContent, renderMarkdownSync(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; useSessionUIStore.getState().revertToMessage(sessionId, message.info.id); }, [sessionId, message.info.id]); // NEW: Fork handler const handleFork = React.useCallback(() => { if (!sessionId || !message.info.id) return; useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id); }, [sessionId, message.info.id]); 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 hasEverStreamedRef = React.useRef(false); React.useEffect(() => { hasEverStreamedRef.current = false; }, [message.info.id]); 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 allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current; if (shouldHideUserMessage) { return null; } const assistantTopPaddingClass = !isUser && shouldShowHeader && !previousIsHiddenUserMessage ? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0') : 'pt-0'; const userMessageRadius = 'var(--radius-xl)'; return ( <>
{isUser ? ( displayParts.length === 0 ? null : (
{/* peek: the action row under the bubble is suppressed, so reserve its gap to the next message here, OUTSIDE the bubble background. */}
{useExternalUserActionsRow ? ( ) : null}
) ) : (
)}
); }; 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 ); });