import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import UserTextPart from './parts/UserTextPart'; import ToolPart from './parts/ToolPart'; import AssistantTextPart from './parts/AssistantTextPart'; 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 '../lib/turns/types'; 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, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line, RiErrorWarningLine } from '@remixicon/react'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { useSessionUIStore } from '@/sync/session-ui-store'; 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'; import { isVSCodeRuntime } from '@/lib/desktop'; import { toPng } from 'html-to-image'; import { toast } from '@/components/ui'; import { formatTimestampForDisplay } from './timeFormat'; import { ToolRevealOnMount } from './parts/ToolRevealOnMount'; import { StaticToolRow } from './parts/ProgressiveGroup'; import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; import TurnActivity from '../components/TurnActivity'; 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 = useSessionUIStore((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`; }; interface MessageBodyProps { sessionId?: string; messageId: string; parts: Part[]; isUser: boolean; isMessageCompleted: boolean; messageFinish?: string; messageCompletedAt?: number; messageCreatedAt?: 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; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; } const TOOL_REVEAL_CACHE_MAX = 200; const revealedToolIdsByMessage = new Map>(); const readRevealedToolIds = (messageId: string): Set => { const cached = revealedToolIdsByMessage.get(messageId); return cached ? new Set(cached) : new Set(); }; const writeRevealedToolIds = (messageId: string, value: Set): void => { if (revealedToolIdsByMessage.size >= TOOL_REVEAL_CACHE_MAX && !revealedToolIdsByMessage.has(messageId)) { const oldest = revealedToolIdsByMessage.keys().next().value; if (oldest) { revealedToolIdsByMessage.delete(oldest); } } revealedToolIdsByMessage.set(messageId, new Set(value)); }; 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; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; }> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => { 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 showUserContent = userActionsMode !== 'external-actions'; const showUserActions = userActionsMode !== 'external-content'; const useStickyScrollableUserContent = stickyUserHeaderEnabled && userActionsMode === 'inline'; 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 actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || onFork) && showUserActions ? (
{onRevert && ( Revert from here )} {onFork && ( Fork from here )} {canCopyMessage && hasCopyableText && ( Copy message )}
) : null; if (!showUserContent) { return <>{actionsBlock}; } 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 ( ); })}
{actionsBlock}
); }; const AssistantMessageBody: React.FC> = ({ sessionId, messageId, parts, isMessageCompleted, messageFinish, messageCompletedAt, messageCreatedAt, syntaxTheme, isMobile, hasTouchInput, expandedTools, onToggleTool, onShowPopup, streamPhase: _streamPhase, allowAnimation: _allowAnimation, onContentChange, hasTextContent = false, onCopyMessage, copiedMessage = false, onAuxiliaryContentComplete, showReasoningTraces = false, turnGroupingContext, errorMessage, }) => { const streamPhase = _streamPhase; void _allowAnimation; const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); const messageContentRef = React.useRef(null); const toolRevealReadyRef = React.useRef(false); React.useEffect(() => { toolRevealReadyRef.current = true; }, []); const canCopyMessage = Boolean(onCopyMessage); const isMessageCopied = Boolean(copiedMessage); const isTouchContext = Boolean(hasTouchInput ?? isMobile); const awaitingMessageCompletion = !isMessageCompleted; const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking); 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 toolRevealStateRef = React.useRef<{ messageId: string; hasCommitted: boolean; persistedToolIds: Set; animatedToolIds: Set; }>({ messageId, hasCommitted: false, persistedToolIds: readRevealedToolIds(messageId), animatedToolIds: new Set(), }); if (toolRevealStateRef.current.messageId !== messageId) { toolRevealStateRef.current = { messageId, hasCommitted: false, persistedToolIds: readRevealedToolIds(messageId), animatedToolIds: new Set(), }; } const currentToolIds = React.useMemo(() => { const ids = new Set(); for (const toolPart of toolParts) { ids.add(toolPart.id); } const activitySegments = turnGroupingContext?.activityGroupSegments; if (Array.isArray(activitySegments)) { for (const segment of activitySegments) { if (segment.anchorMessageId !== messageId) { continue; } for (const activity of segment.parts) { if (activity.kind !== 'tool') { continue; } const toolId = (activity.part as { id?: unknown }).id; if (typeof toolId === 'string' && toolId.length > 0) { ids.add(toolId); } } } } return Array.from(ids); }, [messageId, toolParts, turnGroupingContext?.activityGroupSegments]); const shouldAnimateNewToolMount = Boolean(turnGroupingContext?.isWorking && toolRevealReadyRef.current); const persistedToolIds = toolRevealStateRef.current.persistedToolIds; const animatedToolIds = toolRevealStateRef.current.animatedToolIds; if (shouldAnimateNewToolMount && toolRevealStateRef.current.hasCommitted) { for (const toolId of currentToolIds) { if (!persistedToolIds.has(toolId)) { animatedToolIds.add(toolId); } } } const animatedToolIdsKey = Array.from(animatedToolIds).join('\u0000'); const animatedToolIdsLookup = React.useMemo( () => new Set(animatedToolIdsKey ? animatedToolIdsKey.split('\u0000') : []), [animatedToolIdsKey] ); React.useEffect(() => { const nextPersistedToolIds = new Set(toolRevealStateRef.current.persistedToolIds); for (const toolId of currentToolIds) { nextPersistedToolIds.add(toolId); } toolRevealStateRef.current.persistedToolIds = nextPersistedToolIds; toolRevealStateRef.current.hasCommitted = true; writeRevealedToolIds(messageId, nextPersistedToolIds); }, [currentToolIds, messageId]); const assistantTextParts = React.useMemo(() => { return visibleParts.filter((part) => part.type === 'text'); }, [visibleParts]); const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const isSortedRenderMode = chatRenderMode === 'sorted'; const collapsedPreviewCount = 7; 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' : voiceProvider === 'openai-compatible' ? 'Custom' : '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 isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => { const state = (toolPart as Record).state as Record | undefined ?? {}; const status = state?.status; return status === 'pending' || status === 'running' || status === 'started'; }, []); 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 shouldShowTool = React.useCallback((toolPart: ToolPartType): boolean => { return isActiveTool(toolPart) || isToolFinalized(toolPart); }, [isActiveTool, isToolFinalized]); 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] ); const [isSharing, setIsSharing] = React.useState(false); const handleShareImage = React.useCallback( async (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (!messageContentRef.current || isSharing) return; setIsSharing(true); let wrapper: HTMLDivElement | null = null; try { const originalElement = messageContentRef.current; const computedStyle = window.getComputedStyle(originalElement); const rootStyle = window.getComputedStyle(document.documentElement); const resolvedBackgroundColor = rootStyle.getPropertyValue('--surface-background').trim() || computedStyle.backgroundColor || window.getComputedStyle(document.body).backgroundColor; const paddingSize = 24; wrapper = document.createElement('div'); wrapper.style.cssText = ` padding: ${paddingSize}px; background-color: ${resolvedBackgroundColor}; display: inline-block; `; const clone = originalElement.cloneNode(true) as HTMLElement; clone.style.cssText = ` ${computedStyle.cssText} transform: none; contain: none; `; const timestampElements = clone.querySelectorAll('[aria-label^="Message time:"]'); const footerRowsAdjusted = new Set(); timestampElements.forEach((element) => { const label = element.getAttribute('aria-label'); const timestamp = label?.replace('Message time:', '').trim(); if (!timestamp || element.textContent?.includes(timestamp)) { return; } const timestampText = document.createElement('span'); timestampText.style.marginLeft = '4px'; timestampText.textContent = timestamp; element.appendChild(timestampText); const metaGroup = element.parentElement; const footerRow = metaGroup?.parentElement as HTMLElement | null; const actionsGroup = footerRow?.firstElementChild as HTMLElement | null; if (!footerRow || !actionsGroup || actionsGroup === metaGroup || footerRowsAdjusted.has(footerRow)) { return; } actionsGroup.style.display = 'none'; footerRow.style.justifyContent = 'flex-start'; footerRowsAdjusted.add(footerRow); }); wrapper.appendChild(clone); document.body.appendChild(wrapper); const dataUrl = await toPng(wrapper, { quality: 1, pixelRatio: 2, backgroundColor: resolvedBackgroundColor, }); const fileName = `message-${messageId}.png`; if (isVSCodeRuntime()) { const response = await fetch('/api/vscode/save-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, dataUrl }), }); if (!response.ok) { throw new Error('Failed to save image in VS Code'); } const payload = await response.json() as { saved?: boolean; canceled?: boolean; error?: string }; if (payload.saved !== true) { if (payload.canceled) { return; } throw new Error(payload.error || 'Failed to save image in VS Code'); } } else { const link = document.createElement('a'); link.download = fileName; link.href = dataUrl; document.body.appendChild(link); link.click(); document.body.removeChild(link); } toast.success('Image saved'); } catch (error) { console.error('Failed to generate image:', error); toast.error('Failed to generate image'); } finally { if (wrapper && wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper); } setIsSharing(false); } }, [messageId, isSharing] ); React.useEffect(() => { return () => { clearCopyHintTimeout(); }; }, [clearCopyHintTimeout]); const activityPartsForTurn = React.useMemo(() => { const all = turnGroupingContext?.activityParts; if (!isSortedRenderMode || !all) { return []; } return all; }, [isSortedRenderMode, turnGroupingContext?.activityParts]); const activityGroupSegmentsForMessage = React.useMemo(() => { const all = turnGroupingContext?.activityGroupSegments; if (!isSortedRenderMode || !all) { return []; } return all.filter((segment) => segment.anchorMessageId === messageId); }, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]); const hasAnchoredActivitySegments = activityGroupSegmentsForMessage.length > 0; const activityByPart = React.useMemo(() => { const byRef = new Map(); const byId = new Map(); activityPartsForTurn.forEach((activity) => { byRef.set(activity.part, activity); const partId = (activity.part as { id?: unknown }).id; if (typeof partId === 'string' && partId.length > 0) { byId.set(partId, activity); } }); return { get: (part: Part) => { const direct = byRef.get(part); if (direct) { return direct; } const partId = (part as { id?: unknown }).id; if (typeof partId === 'string' && partId.length > 0) { return byId.get(partId); } return undefined; }, }; }, [activityPartsForTurn]); const toggleActivityGroup = turnGroupingContext?.toggleGroup; const isActivityOwnerMessage = !isSortedRenderMode || !turnGroupingContext?.activityOwnerMessageId || turnGroupingContext.activityOwnerMessageId === messageId || hasAnchoredActivitySegments; const shouldRenderActivityGroup = isSortedRenderMode && isActivityOwnerMessage && hasAnchoredActivitySegments && Boolean(toggleActivityGroup); const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; const renderedParts = React.useMemo(() => { const rendered: React.ReactNode[] = []; if (shouldRenderActivityGroup && toggleActivityGroup) { activityGroupSegmentsForMessage.forEach((segment) => { const visibleSegmentParts = showReasoningTraces ? segment.parts : segment.parts.filter((activity) => activity.kind !== 'reasoning'); if (visibleSegmentParts.length === 0) { return; } rendered.push(
); }); } // Flat rendering: iterate parts in natural order. // Group consecutive static tools (read, grep, glob, etc.) into compact rows. // Expandable tools (bash, edit, task) get individual rows. // Text and reasoning render inline at their natural position. let i = 0; while (i < visibleParts.length) { const part = visibleParts[i]; if (part.type === 'text') { const activity = activityByPart.get(part); if (shouldDeferSortedInlineText) { i += 1; continue; } if (activity?.kind === 'justification') { i += 1; continue; } rendered.push( ); i++; continue; } if (part.type === 'reasoning') { const activity = activityByPart.get(part); if (activity?.kind === 'reasoning') { i += 1; continue; } if (showReasoningTraces) { if (isSortedRenderMode) { rendered.push( ); } else { rendered.push( ); } } i++; continue; } if (part.type === 'tool') { const toolPart = part as ToolPartType; const toolName = toolPart.tool?.toLowerCase() ?? ''; if (isSortedRenderMode && !isActivityOwnerMessage) { i += 1; continue; } const activity = activityByPart.get(part); if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) { i += 1; continue; } if (!shouldShowTool(toolPart)) { i++; continue; } // Expandable tools: bash, edit, write, task, question — individual rows if (isExpandableTool(toolName)) { rendered.push( ); i++; continue; } // Static tools: one row per tool call (no grouping) rendered.push( ); i++; continue; } // Unknown part type — skip i++; } return rendered; }, [ activityByPart, activityGroupSegmentsForMessage, animatedToolIdsLookup, animateActivityRows, chatRenderMode, collapsedPreviewCount, expandedTools, isMobile, isActivityOwnerMessage, isSortedRenderMode, messageId, sessionId, onContentChange, onShowPopup, onToggleTool, shouldRenderActivityGroup, shouldShowTool, streamPhase, showReasoningTraces, shouldDeferSortedInlineText, syntaxTheme, toggleActivityGroup, turnGroupingContext, visibleParts, ]); // With flat rendering, no collapsed summary is needed — text renders inline. 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 footerTimestamp = React.useMemo(() => { const timestamp = typeof messageCompletedAt === 'number' && messageCompletedAt > 0 ? messageCompletedAt : (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null); if (timestamp === null) return null; const formatted = formatTimestampForDisplay(timestamp); return formatted.length > 0 ? formatted : null; }, [messageCompletedAt, messageCreatedAt]); const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1'; const footerButtons = ( <> {onCopyMessage && ( Copy answer )} {isSharing ? 'Saving image...' : 'Save as image'} Start new session from this answer Start new multi-run from this answer {showMessageTTSButtons && hasCopyableText && ( {readAloudTooltip} )} ); return (
{renderedParts} {showErrorMessage && (
)}
{shouldShowFooter && (
{footerButtons}
{turnDurationText ? ( {turnDurationText} ) : null} {footerTimestamp ? ( {footerTimestamp} ) : null}
)}
); }; const MessageBody: React.FC = ({ isUser, ...props }) => { if (isUser) { return ( ); } return ; }; export default MessageBody;