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 ReasoningPart from './parts/ReasoningPart'; 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, RiVolumeUpLine, RiStopLine } 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'; import { useMessageTTS } from '@/hooks/useMessageTTS'; import { useConfigStore } from '@/stores/useConfigStore'; import { TextSelectionMenu } from './TextSelectionMenu'; import { copyTextToClipboard } from '@/lib/clipboard'; type SubtaskPartLike = Part & { type: 'subtask'; description?: unknown; command?: unknown; agent?: unknown; prompt?: unknown; taskSessionID?: unknown; model?: { providerID?: unknown; modelID?: unknown; }; }; type ShellActionPartLike = Part & { type: 'text'; shellAction?: { command?: unknown; output?: unknown; status?: unknown; }; }; const isSubtaskPart = (part: Part): part is SubtaskPartLike => { return part.type === 'subtask'; }; const isShellActionPart = (part: Part): part is ShellActionPartLike => { const textPart = part as unknown as { type?: unknown; shellAction?: unknown }; return textPart.type === 'text' && typeof textPart.shellAction === 'object' && textPart.shellAction !== null; }; const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null => { if (!model || typeof model !== 'object') return null; const providerID = typeof model.providerID === 'string' ? model.providerID.trim() : ''; const modelID = typeof model.modelID === 'string' ? model.modelID.trim() : ''; if (!providerID || !modelID) return null; return `${providerID}/${modelID}`; }; const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => { const [expanded, setExpanded] = React.useState(false); const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const description = typeof part.description === 'string' ? part.description.trim() : ''; const command = typeof part.command === 'string' ? part.command.trim() : ''; const agent = typeof part.agent === 'string' ? part.agent.trim() : ''; const prompt = typeof part.prompt === 'string' ? part.prompt.trim() : ''; const taskSessionID = typeof part.taskSessionID === 'string' ? part.taskSessionID.trim() : ''; const model = normalizeSubtaskModel(part.model); return (
Delegated task {command ? ( /{command} ) : null} {agent ? ( @{agent} ) : null} {model ? ( {model} ) : null}
{description ? (
{description}
) : null} {prompt ? (
{expanded ? (
                            {prompt}
                        
) : null}
) : null} {taskSessionID ? (
) : null}
); }; const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => { const [expanded, setExpanded] = React.useState(false); const [copiedOutput, setCopiedOutput] = React.useState(false); const copiedResetTimeoutRef = React.useRef(null); const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : ''; const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : ''; const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : ''; const hasOutput = output.trim().length > 0; const clearCopiedResetTimeout = React.useCallback(() => { if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(copiedResetTimeoutRef.current); copiedResetTimeoutRef.current = null; } }, []); React.useEffect(() => { return () => { clearCopiedResetTimeout(); }; }, [clearCopiedResetTimeout]); const copyOutputToClipboard = React.useCallback(async () => { if (!hasOutput) return; const result = await copyTextToClipboard(output); if (!result.ok) return; clearCopiedResetTimeout(); setCopiedOutput(true); if (typeof window !== 'undefined') { copiedResetTimeoutRef.current = window.setTimeout(() => { setCopiedOutput(false); copiedResetTimeoutRef.current = null; }, 2000); } }, [clearCopiedResetTimeout, hasOutput, output]); return (
Shell command {status ? ( {status} ) : null}
{command ? (
                    {command}
                
) : null} {hasOutput ? (
{expanded ? (
                            {output}
                        
) : null}
) : null}
); }; 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 userContentParts = React.useMemo(() => { return parts.filter((part) => { if (part.type === 'text') { return !isEmptyTextPart(part); } if (isSubtaskPart(part)) { return true; } if (isShellActionPart(part)) { return true; } return false; }); }, [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 (
{userContentParts.map((part, index) => { if (isSubtaskPart(part)) { return ( ); } if (isShellActionPart(part)) { return ( ); } 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 _streamPhase; void _allowAnimation; const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); const messageContentRef = 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'; // TTS for message playback const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const voiceProvider = useConfigStore((state) => state.voiceProvider); const readAloudTooltip = React.useMemo(() => { if (isTTSPlaying) { return 'Stop speaking'; } const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : 'Say'; return `Read aloud (${providerLabel} voice)`; }, [isTTSPlaying, voiceProvider]); 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] ); const handleTTSClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (isTTSPlaying) { stopTTS(); return; } const messageText = flattenAssistantTextParts(assistantTextParts); if (messageText.trim()) { void playTTS(messageText); } }, [assistantTextParts, isTTSPlaying, playTTS, stopTTS] ); 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 []; // Filter out reasoning if showReasoningTraces is off. // Justification parts are already filtered at the source (useTurnGrouping) // based on showTextJustificationActivity, so we keep them here. const base = !showReasoningTraces ? activityPartsForTurn.filter((activity) => activity.kind !== 'reasoning') : 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 ); const hasReasoningActivity = visibleActivityPartsForTurn.some( (activity) => activity.kind === 'reasoning' ); if ( visibleActivityPartsForTurn.length > 1 || (hasTaskSplitSegments && visibleActivityPartsForTurn.length > 0) || hasReasoningActivity ) { setHasEverHadMultipleVisibleActivities(true); } }, [turnGroupingContext, visibleActivityPartsForTurn]); 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) => { // Filter out reasoning if showReasoningTraces is off. // Justification parts are already filtered at the source (useTurnGrouping) // based on showTextJustificationActivity, so we keep them here. const visibleSegmentParts = !showReasoningTraces ? segment.parts.filter((activity) => activity.kind !== 'reasoning') : segment.parts; if (visibleSegmentParts.length === 0) { return; } rendered.push( ); }); }; // Activity groups and standalone tasks are interleaved in message order. // Note: Reasoning parts are rendered in the visibleParts.forEach loop below // when Activity group isn't showing, to maintain proper ordering with tools. 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; } else if (activity.kind === 'reasoning' && showReasoningTraces) { // Fallback rendering for reasoning when Activity group isn't shown const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time; const partEndTime = typeof time?.end === 'number' ? time.end : null; const reasoningElement = ( ); element = reasoningElement; endTime = partEndTime; } 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, messageId, 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 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 = ( <> {onCopyMessage && ( Copy answer )} Start new session from this answer Start new multi-run from this answer {showMessageTTSButtons && hasCopyableText && ( {readAloudTooltip} )} ); return (
{renderedParts} {showErrorMessage && (
)} {showSummaryBody && (
{shouldShowFooter && (
{footerButtons}
{turnDurationText ? ( {turnDurationText} ) : null}
)}
)}
{!showSummaryBody && shouldShowFooter && (
{footerButtons}
{turnDurationText ? ( {turnDurationText} ) : null}
)}
); }; const MessageBody: React.FC = ({ isUser, ...props }) => { if (isUser) { return ( ); } return ; }; export default React.memo(MessageBody);