import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import UserTextPart from './parts/UserTextPart'; import ToolPart from './parts/ToolPart'; import ProgressiveGroup from './parts/ProgressiveGroup'; import { MessageFilesDisplay } from '../FileAttachment'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types'; import type { TurnGroupingContext } from '../hooks/useTurnGrouping'; import { cn } from '@/lib/utils'; import { isEmptyTextPart, extractTextContent } from './partUtils'; import { FadeInOnReveal } from './FadeInOnReveal'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine } from '@remixicon/react'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { useMessageStore } from '@/stores/messageStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta'; const formatTurnDuration = (durationMs: number): string => { const totalSeconds = durationMs / 1000; if (totalSeconds < 60) { return `${totalSeconds.toFixed(1)}s`; } const minutes = Math.floor(totalSeconds / 60); const seconds = Math.round(totalSeconds % 60); return `${minutes}m ${seconds}s`; }; const ACTIVITY_STANDALONE_TOOL_NAMES = new Set(['task']); const isActivityStandaloneTool = (toolName: unknown): boolean => { return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase()); }; interface MessageBodyProps { messageId: string; parts: Part[]; isUser: boolean; isMessageCompleted: boolean; messageFinish?: string; messageCompletedAt?: number; syntaxTheme: { [key: string]: React.CSSProperties }; isMobile: boolean; hasTouchInput?: boolean; copiedCode: string | null; onCopyCode: (code: string) => void; expandedTools: Set; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; streamPhase: StreamPhase; allowAnimation: boolean; onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void; shouldShowHeader?: boolean; hasTextContent?: boolean; onCopyMessage?: () => void; copiedMessage?: boolean; onAuxiliaryContentComplete?: () => void; showReasoningTraces?: boolean; agentMention?: AgentMentionInfo; turnGroupingContext?: TurnGroupingContext; onRevert?: () => void; onFork?: () => void; errorMessage?: string; } const UserMessageBody: React.FC<{ messageId: string; parts: Part[]; isMobile: boolean; hasTouchInput?: boolean; hasTextContent?: boolean; onCopyMessage?: () => void; copiedMessage?: boolean; onShowPopup: (content: ToolPopupContent) => void; agentMention?: AgentMentionInfo; onRevert?: () => void; onFork?: () => void; }> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork }) => { const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); const textParts = React.useMemo(() => { return parts.filter((part) => { if (part.type !== 'text') return false; return !isEmptyTextPart(part); }); }, [parts]); const mentionToken = agentMention?.token; let mentionInjected = false; const canCopyMessage = Boolean(onCopyMessage); const isMessageCopied = Boolean(copiedMessage); const isTouchContext = Boolean(hasTouchInput ?? isMobile); const hasCopyableText = Boolean(hasTextContent); const clearCopyHintTimeout = React.useCallback(() => { if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(copyHintTimeoutRef.current); copyHintTimeoutRef.current = null; } }, []); const revealCopyHint = React.useCallback(() => { if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') { return; } clearCopyHintTimeout(); setCopyHintVisible(true); copyHintTimeoutRef.current = window.setTimeout(() => { setCopyHintVisible(false); copyHintTimeoutRef.current = null; }, 1800); }, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]); React.useEffect(() => { if (!hasCopyableText) { setCopyHintVisible(false); clearCopyHintTimeout(); } }, [clearCopyHintTimeout, hasCopyableText]); const handleCopyButtonClick = React.useCallback( (event: React.MouseEvent) => { if (!onCopyMessage || !hasCopyableText) { return; } event.stopPropagation(); event.preventDefault(); onCopyMessage(); if (isTouchContext) { revealCopyHint(); } }, [hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint] ); return (
{textParts.map((part, index) => { let mentionForPart: AgentMentionInfo | undefined; if (agentMention && mentionToken && !mentionInjected) { const candidateText = extractTextContent(part); if (candidateText.includes(mentionToken)) { mentionForPart = agentMention; mentionInjected = true; } } return ( ); })}
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
{onRevert && ( Revert from here )} {onFork && ( Fork from here )} {canCopyMessage && hasCopyableText && ( Copy message )}
) : null}
); }; const AssistantMessageBody: React.FC> = ({ messageId, parts, isMessageCompleted, messageFinish, messageCompletedAt, syntaxTheme, isMobile, hasTouchInput, expandedTools, onToggleTool, onShowPopup, streamPhase: _streamPhase, allowAnimation: _allowAnimation, onContentChange, hasTextContent = false, onCopyMessage, copiedMessage = false, onAuxiliaryContentComplete, showReasoningTraces = false, turnGroupingContext, errorMessage, }) => { void _allowAnimation; const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); const canCopyMessage = Boolean(onCopyMessage); const isMessageCopied = Boolean(copiedMessage); const isTouchContext = Boolean(hasTouchInput ?? isMobile); const awaitingMessageCompletion = !isMessageCompleted; const visibleParts = React.useMemo(() => { return parts .filter((part) => !isEmptyTextPart(part)) .filter((part) => { const rawPart = part as Record; return rawPart.type !== 'compaction'; }); }, [parts]); const toolParts = React.useMemo(() => { return visibleParts.filter((part): part is ToolPartType => part.type === 'tool'); }, [visibleParts]); const assistantTextParts = React.useMemo(() => { return visibleParts.filter((part) => part.type === 'text'); }, [visibleParts]); const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; const hasTools = toolParts.length > 0; const hasPendingTools = React.useMemo(() => { return toolParts.some((toolPart) => { const state = (toolPart as Record).state as Record | undefined ?? {}; const status = state?.status; return status === 'pending' || status === 'running' || status === 'started'; }); }, [toolParts]); const isToolFinalized = React.useCallback((toolPart: ToolPartType) => { const state = (toolPart as Record).state as Record | undefined ?? {}; const status = state?.status; if (status === 'pending' || status === 'running' || status === 'started') { return false; } const time = state?.time as Record | undefined ?? {}; const endTime = typeof time?.end === 'number' ? time.end : undefined; const startTime = typeof time?.start === 'number' ? time.start : undefined; if (typeof endTime !== 'number') { return false; } if (typeof startTime === 'number' && endTime < startTime) { return false; } return true; }, []); const allToolsFinalized = React.useMemo(() => { if (toolParts.length === 0) { return true; } if (hasPendingTools) { return false; } return toolParts.every((toolPart) => isToolFinalized(toolPart)); }, [toolParts, hasPendingTools, isToolFinalized]); const reasoningParts = React.useMemo(() => { return visibleParts.filter((part) => part.type === 'reasoning'); }, [visibleParts]); const reasoningComplete = React.useMemo(() => { if (reasoningParts.length === 0) { return true; } return reasoningParts.every((part) => { const time = (part as Record).time as { end?: number } | undefined; return typeof time?.end === 'number'; }); }, [reasoningParts]); // Message is considered to have an "open step" if info.finish is not yet present const hasOpenStep = typeof messageFinish !== 'string'; const shouldHoldForReasoning = reasoningParts.length > 0 && hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized); const shouldHoldTools = awaitingMessageCompletion || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; const hasAuxiliaryContent = hasTools || reasoningParts.length > 0; const isTextlessAssistantMessage = assistantTextParts.length === 0; const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete; const auxiliaryCompletionAnnouncedRef = React.useRef(false); const soloReasoningScrollTriggeredRef = React.useRef(false); React.useEffect(() => { soloReasoningScrollTriggeredRef.current = false; }, [messageId]); React.useEffect(() => { if (!auxiliaryContentComplete) { auxiliaryCompletionAnnouncedRef.current = false; return; } if (auxiliaryCompletionAnnouncedRef.current) { return; } auxiliaryCompletionAnnouncedRef.current = true; onAuxiliaryContentComplete?.(); }, [auxiliaryContentComplete, onAuxiliaryContentComplete]); React.useEffect(() => { if (awaitingMessageCompletion) { soloReasoningScrollTriggeredRef.current = false; return; } if (hasTools) { soloReasoningScrollTriggeredRef.current = false; return; } if (reasoningParts.length === 0) { return; } if (shouldHoldReasoning || !reasoningComplete) { return; } if (soloReasoningScrollTriggeredRef.current) { return; } soloReasoningScrollTriggeredRef.current = true; onContentChange?.('structural'); }, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]); const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; const clearCopyHintTimeout = React.useCallback(() => { if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(copyHintTimeoutRef.current); copyHintTimeoutRef.current = null; } }, []); const revealCopyHint = React.useCallback(() => { if (!isTouchContext || !canCopyMessage || !hasCopyableText || typeof window === 'undefined') { return; } clearCopyHintTimeout(); setCopyHintVisible(true); copyHintTimeoutRef.current = window.setTimeout(() => { setCopyHintVisible(false); copyHintTimeoutRef.current = null; }, 1800); }, [canCopyMessage, clearCopyHintTimeout, hasCopyableText, isTouchContext]); React.useEffect(() => { if (!hasCopyableText) { setCopyHintVisible(false); clearCopyHintTimeout(); } }, [clearCopyHintTimeout, hasCopyableText]); const handleCopyButtonClick = React.useCallback( (event: React.MouseEvent) => { if (!onCopyMessage || !hasCopyableText) { return; } event.stopPropagation(); event.preventDefault(); onCopyMessage(); if (isTouchContext) { revealCopyHint(); } }, [hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint] ); const handleForkClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (!createSessionFromAssistantMessage) { return; } void createSessionFromAssistantMessage(messageId); }, [createSessionFromAssistantMessage, messageId] ); const handleForkMultiRunClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); const assistantPlanText = flattenAssistantTextParts(assistantTextParts); if (!assistantPlanText.trim()) { return; } const prefilledPrompt = `${MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT}\n\n${assistantPlanText}`; openMultiRunLauncherWithPrompt(prefilledPrompt); }, [assistantTextParts, openMultiRunLauncherWithPrompt] ); React.useEffect(() => { return () => { clearCopyHintTimeout(); }; }, [clearCopyHintTimeout]); const toolConnections = React.useMemo(() => { const connections: Record = {}; const displayableTools = toolParts.filter((toolPart) => { if (isActivityStandaloneTool(toolPart.tool)) { return false; } if (shouldHoldTools) { return false; } return isToolFinalized(toolPart); }); displayableTools.forEach((toolPart, index) => { connections[toolPart.id] = { hasPrev: index > 0, hasNext: index < displayableTools.length - 1, }; }); return connections; }, [toolParts, shouldHoldTools, isToolFinalized]); const activityPartsForTurn = React.useMemo(() => { return turnGroupingContext?.activityParts ?? []; }, [turnGroupingContext]); const activityPartsForMessage = React.useMemo(() => { if (!turnGroupingContext) return []; return activityPartsForTurn.filter((activity) => activity.messageId === messageId); }, [activityPartsForTurn, messageId, turnGroupingContext]); const activityGroupSegmentsForMessage = React.useMemo(() => { if (!turnGroupingContext) return []; return turnGroupingContext.activityGroupSegments.filter((segment) => segment.anchorMessageId === messageId); }, [messageId, turnGroupingContext]); const activityPartsByPart = React.useMemo(() => { const map = new Map(); activityPartsForMessage.forEach((activity) => { map.set(activity.part, activity); }); return map; }, [activityPartsForMessage]); const visibleActivityPartsForTurn = React.useMemo(() => { if (!turnGroupingContext) return []; const base = !showReasoningTraces ? activityPartsForTurn.filter((activity) => activity.kind === 'tool') : activityPartsForTurn; // Tools rendered standalone are excluded from Activity group. return base.filter((activity) => { if (activity.kind !== 'tool') { return true; } const toolName = (activity.part as ToolPartType).tool; return !isActivityStandaloneTool(toolName); }); }, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]); const [hasEverHadMultipleVisibleActivities, setHasEverHadMultipleVisibleActivities] = React.useState(false); React.useEffect(() => { if (!turnGroupingContext) { return; } const hasTaskSplitSegments = turnGroupingContext.activityGroupSegments.some( (segment) => segment.afterToolPartId !== null ); if (visibleActivityPartsForTurn.length > 1 || (hasTaskSplitSegments && visibleActivityPartsForTurn.length > 0)) { setHasEverHadMultipleVisibleActivities(true); } }, [turnGroupingContext, visibleActivityPartsForTurn.length]); const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities); const shouldRenderActivityGroup = Boolean( turnGroupingContext && shouldShowActivityGroup && visibleActivityPartsForTurn.length > 0 && activityGroupSegmentsForMessage.length > 0 ); const standaloneToolParts = React.useMemo(() => { return toolParts.filter((toolPart) => isActivityStandaloneTool(toolPart.tool)); }, [toolParts]); const renderedParts = React.useMemo(() => { const rendered: React.ReactNode[] = []; const renderActivitySegments = (afterToolPartId: string | null) => { if (!turnGroupingContext || !shouldRenderActivityGroup) { return; } activityGroupSegmentsForMessage .filter((segment) => (segment.afterToolPartId ?? null) === afterToolPartId) .forEach((segment) => { const visibleSegmentParts = !showReasoningTraces ? segment.parts.filter((activity) => activity.kind === 'tool') : segment.parts; if (visibleSegmentParts.length === 0) { return; } rendered.push( ); }); }; // Activity groups and standalone tasks are interleaved in message order. renderActivitySegments(null); standaloneToolParts.forEach((standaloneToolPart) => { rendered.push( ); renderActivitySegments(standaloneToolPart.id); }); const partsWithTime: Array<{ part: Part; index: number; endTime: number | null; element: React.ReactNode; }> = []; visibleParts.forEach((part, index) => { const activity = activityPartsByPart.get(part); if (!activity) { return; } if (!turnGroupingContext) { return; } let endTime: number | null = null; let element: React.ReactNode | null = null; if (!shouldShowActivityGroup) { if (activity.kind === 'tool') { const toolPart = part as ToolPartType; if (isActivityStandaloneTool(toolPart.tool)) { return; } const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state; const time = toolState?.time; const isFinalized = isToolFinalized(toolPart); const shouldShowTool = !shouldHoldTools && isFinalized; if (!shouldShowTool) { return; } const connection = toolConnections[toolPart.id]; const toolElement = ( ); element = toolElement; endTime = isFinalized && typeof time?.end === 'number' ? time.end : null; } if (element) { partsWithTime.push({ part, index, endTime, element, }); } } }); partsWithTime.sort((a, b) => { if (a.endTime === null && b.endTime === null) { return a.index - b.index; } if (a.endTime === null) { return 1; } if (b.endTime === null) { return -1; } return a.endTime - b.endTime; }); partsWithTime.forEach(({ element }) => { rendered.push(element); }); return rendered; }, [ activityPartsByPart, activityGroupSegmentsForMessage, expandedTools, isMobile, isToolFinalized, onContentChange, onShowPopup, onToggleTool, shouldHoldTools, shouldShowActivityGroup, showReasoningTraces, syntaxTheme, toolConnections, turnGroupingContext, visibleParts, standaloneToolParts, shouldRenderActivityGroup, ]); const userMessageId = turnGroupingContext?.turnId; const currentSessionId = useSessionStore((state) => state.currentSessionId); const rawSummaryBodyFromStore = useMessageStore((state) => { if (!userMessageId || !currentSessionId) return undefined; const sessionMessages = state.messages.get(currentSessionId); if (!sessionMessages) return undefined; const userMsg = sessionMessages.find((m) => m.info?.id === userMessageId); if (!userMsg) return undefined; const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary; const body = summary?.body; return typeof body === 'string' && body.trim().length > 0 ? body : undefined; }); const summaryCandidate = typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0 ? turnGroupingContext.summaryBody : rawSummaryBodyFromStore; const summaryBodyRef = React.useRef(undefined); if (summaryCandidate && summaryCandidate.trim().length > 0) { summaryBodyRef.current = summaryCandidate; } const prevUserMessageId = React.useRef(userMessageId); if (prevUserMessageId.current !== userMessageId) { prevUserMessageId.current = userMessageId; summaryBodyRef.current = undefined; } const summaryBody = summaryBodyRef.current; const showSummaryBody = turnGroupingContext?.isLastAssistantInTurn && summaryBody && summaryBody.trim().length > 0; const showErrorMessage = Boolean(errorMessage); const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)); const [isSummaryHovered, setIsSummaryHovered] = React.useState(false); const turnDurationText = React.useMemo(() => { if (!isLastAssistantInTurn || !hasStopFinish) return undefined; const userCreatedAt = turnGroupingContext?.userMessageCreatedAt; if (typeof userCreatedAt !== 'number' || typeof messageCompletedAt !== 'number') return undefined; if (messageCompletedAt <= userCreatedAt) return undefined; return formatTurnDuration(messageCompletedAt - userCreatedAt); }, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]); const footerButtons = ( <> Start new session from this answer Start new multi-run from this answer {onCopyMessage && ( Copy answer )} ); return (
{renderedParts} {showErrorMessage && (
)} {showSummaryBody && (
setIsSummaryHovered(true)} onMouseLeave={() => setIsSummaryHovered(false)} > {shouldShowFooter && (
{turnDurationText ? ( {turnDurationText} ) : }
{footerButtons}
)}
)}
{!showSummaryBody && shouldShowFooter && (
{turnDurationText ? ( {turnDurationText} ) : }
{footerButtons}
)}
); }; const MessageBody: React.FC = ({ isUser, ...props }) => { if (isUser) { return ( ); } return ; }; export default React.memo(MessageBody);