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 { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types'; import type { TurnActivityGroup, TurnChangedFile, TurnGroupingContext } from '../lib/turns/types'; import { cn } from '@/lib/utils'; import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { isEmptyTextPart, extractTextContent } from './partUtils'; import { FadeInOnReveal } from './FadeInOnReveal'; import { Button } from '@/components/ui/button'; import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialog'; import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { flattenAssistantTextParts, suggestPlanTitleFromText } 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 { useProjectsStore } from '@/stores/useProjectsStore'; import { TextSelectionMenu } from './TextSelectionMenu'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; 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'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useI18n } from '@/lib/i18n'; import { extractLoopbackUrls } from '@/lib/url'; import { useDeviceInfo } from '@/lib/device'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { type ReviewTransferDirection, sendImplementationResponseToReviewer, sendReviewFeedbackToOriginal, } from '@/lib/reviewFlow'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { useProviderLogo } from '@/hooks/useProviderLogo'; import { getAgentColor } from '@/lib/agentColors'; import { isCapacitorMobileApp } from '@/apps/mobileNativeChrome'; const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' }; const MESSAGE_FOOTER_CONTAINER_STYLE = { containerType: 'inline-size' as const, containerName: 'message-footer' }; const INLINE_MESSAGE_ACTIONS_CLASS_NAME = 'mt-2 mb-1 flex items-center justify-start gap-1.5'; const getDisplayFileName = (file: string): string => { const normalized = file.replace(/\\/g, '/'); const segments = normalized.split('/').filter(Boolean); return segments.at(-1) ?? file; }; const TurnChangedFileChipContent = React.memo(({ file, interactive = false }: { file: TurnChangedFile; interactive?: boolean }) => ( {getDisplayFileName(file.file)} +{file.additions} / -{file.deletions} )); const TurnChangedFilePillButton = React.memo(({ file, onOpen, }: { file: TurnChangedFile; onOpen: (file: string) => void; }) => { const { t } = useI18n(); return ( ); }); const StaticTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => ( <> {files.map((file) => ( ))} )); const InteractiveTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => { const effectiveDirectory = useEffectiveDirectory(); const isMobile = useUIStore((state) => state.isMobile); const navigateToDiff = useUIStore((state) => state.navigateToDiff); const openContextDiff = useUIStore((state) => state.openContextDiff); const openLastTurnDiff = React.useCallback((file: string) => { if (!isMobile && effectiveDirectory) { openContextDiff(effectiveDirectory, file, false, 'turn'); return; } navigateToDiff(file, false, 'turn'); }, [effectiveDirectory, isMobile, navigateToDiff, openContextDiff]); return ( <> {files.map((file) => ( ))} ); }); const TurnChangedFilePills = React.memo(({ files, isInteractive }: { files?: TurnChangedFile[]; isInteractive: boolean }) => { if (!files || files.length === 0) return null; return isInteractive ? : ; }); 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 effectiveDirectory = useEffectiveDirectory(); const { isMobile } = useDeviceInfo(); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const { t } = useI18n(); 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 (
{t('chat.messageBody.subtask.title')} {command ? ( /{command} ) : null} {agent ? ( @{agent} ) : null} {model ? ( {model} ) : null}
{description ? (
{description}
) : null} {prompt ? (
{expanded ? (
                            {prompt}
                        
) : null}
) : null} {taskSessionID ? (
) : null}
); }; const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' }; const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => { const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : ''; const [expanded, setExpanded] = React.useState(true); const [copiedOutput, setCopiedOutput] = React.useState(false); const copiedResetTimeoutRef = React.useRef(null); const { t } = useI18n(); const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : ''; 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 (
{t('chat.messageBody.shellCommand.title')} {status ? ( {status} ) : null}
{command ? (
) : null} {hasOutput ? (
{expanded ? (
) : 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; isMobile: boolean; alwaysShowActions?: boolean; hasTouchInput?: boolean; copiedCode: string | null; onCopyCode: (code: string) => void; expandedTools: Set; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; streamPhase: StreamPhase; allowAnimation: boolean; shouldShowHeader?: boolean; hasTextContent?: boolean; onCopyMessage?: () => void | boolean | Promise; copiedMessage?: boolean; showReasoningTraces?: boolean; agentMention?: AgentMentionInfo; turnGroupingContext?: TurnGroupingContext; onRevert?: () => void; onFork?: () => void; errorMessage?: string; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; reviewTransferDirection?: ReviewTransferDirection | null; contextPinned?: boolean; contextPinPending?: boolean; onToggleContextPin?: () => void; footerProviderID?: string | null; footerModelName?: string; footerAgentName?: string; footerVariant?: string; isDarkTheme?: 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.memo(({ messageId, parts, messageCreatedAt, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, contextPinned, contextPinPending, onToggleContextPin, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: { messageId: string; parts: Part[]; messageCreatedAt?: number | null; isMobile: boolean; alwaysShowActions?: boolean; hasTouchInput?: boolean; hasTextContent?: boolean; onCopyMessage?: () => void; copiedMessage?: boolean; onShowPopup: (content: ToolPopupContent) => void; agentMention?: AgentMentionInfo; onRevert?: () => void; onFork?: () => void; contextPinned?: boolean; contextPinPending?: boolean; onToggleContextPin?: () => void; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; }) => { const { locale, t } = useI18n(); const chatSurfaceMode = useChatSurfaceMode(); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); // One expanded state for the whole message: text parts and context cards // collapse and expand together, with a single collapse control up here // instead of one per part. const collapsibleUserMessages = useUIStore((state) => state.collapsibleUserMessages); const [messageExpanded, setMessageExpanded] = React.useState(false); const expandMessage = React.useCallback(() => setMessageExpanded(true), []); const collapseMessage = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); setMessageExpanded(false); }, []); React.useEffect(() => { if (!collapsibleUserMessages) setMessageExpanded(false); }, [collapsibleUserMessages]); 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 effectiveOnFork = chatSurfaceMode === 'mini-chat' ? undefined : onFork; const timestamp = React.useMemo(() => { void locale; if (typeof messageCreatedAt !== 'number' || messageCreatedAt <= 0) return null; const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference); return formatted.length > 0 ? formatted : null; }, [locale, messageCreatedAt, timeFormatPreference]); const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
{timestamp ? ( {timestamp} {timestamp} ) : null} {onRevert && ( {t('chat.messageBody.actions.revert')} )} {effectiveOnFork && ( {t('chat.messageBody.actions.fork')} )} {onToggleContextPin && hasCopyableText && ( {t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')} )} {canCopyMessage && hasCopyableText && ( {t('chat.messageBody.actions.copyMessage')} )}
) : null; if (!showUserContent) { return <>{actionsBlock}; } return (
{collapsibleUserMessages && messageExpanded && ( )}
{/* Positional keys, not part ids: the server echo of a just-sent message swaps the optimistic part id, and id-based keys would remount the text subtree (blank frame + height jump). */} {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}
); }); interface AssistantMessageActionButtonsProps { hasCopyableText: boolean; isTouchContext: boolean; onCopyMessage?: () => void | boolean | Promise; reviewTransferAction?: { ariaLabel: string; tooltip: string; onClick: () => void | Promise; }; onShareImage: (sourceElement?: HTMLElement | null) => Promise; ttsText: string; } const AssistantMessageActionButtons = React.memo(({ hasCopyableText, isTouchContext, onCopyMessage, reviewTransferAction, onShareImage, ttsText, }: AssistantMessageActionButtonsProps) => { const { t } = useI18n(); const chatSurfaceMode = useChatSurfaceMode(); const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const voiceProvider = useConfigStore((state) => state.voiceProvider); const [copyHintVisible, setCopyHintVisible] = React.useState(false); const [isMessageCopied, setIsMessageCopied] = React.useState(false); const [isSharing, setIsSharing] = React.useState(false); const [isTransferringReview, setIsTransferringReview] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); const copiedResetTimeoutRef = React.useRef(null); const canCopyMessage = Boolean(onCopyMessage); const clearCopyHintTimeout = React.useCallback(() => { if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(copyHintTimeoutRef.current); copyHintTimeoutRef.current = null; } }, []); const clearCopiedResetTimeout = React.useCallback(() => { if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(copiedResetTimeoutRef.current); copiedResetTimeoutRef.current = null; } }, []); React.useEffect(() => { return () => { clearCopyHintTimeout(); clearCopiedResetTimeout(); }; }, [clearCopiedResetTimeout, clearCopyHintTimeout]); React.useEffect(() => { if (!hasCopyableText || !canCopyMessage) { setCopyHintVisible(false); setIsMessageCopied(false); clearCopyHintTimeout(); clearCopiedResetTimeout(); } }, [canCopyMessage, clearCopiedResetTimeout, clearCopyHintTimeout, hasCopyableText]); 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]); const handleCopyButtonClick = React.useCallback( async (event: React.MouseEvent) => { if (!onCopyMessage || !hasCopyableText) { return; } event.stopPropagation(); event.preventDefault(); const copied = await onCopyMessage(); if (copied === false) { return; } clearCopiedResetTimeout(); setIsMessageCopied(true); if (typeof window !== 'undefined') { copiedResetTimeoutRef.current = window.setTimeout(() => { setIsMessageCopied(false); copiedResetTimeoutRef.current = null; }, 2000); } if (isTouchContext) { revealCopyHint(); } }, [clearCopiedResetTimeout, hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint] ); const handleShareImageClick = React.useCallback( async (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (isSharing || !hasCopyableText) { return; } setIsSharing(true); try { const root = event.currentTarget.closest('[data-message-text-export-root]'); const sourceElement = root?.querySelector('[data-message-text-export-source]') ?? null; await onShareImage(sourceElement); } finally { setIsSharing(false); } }, [hasCopyableText, isSharing, onShareImage] ); const handleReviewTransferClick = React.useCallback( async (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (!reviewTransferAction || isTransferringReview || !hasCopyableText) return; setIsTransferringReview(true); try { await reviewTransferAction.onClick(); } finally { setIsTransferringReview(false); } }, [hasCopyableText, isTransferringReview, reviewTransferAction] ); const readAloudTooltip = React.useMemo(() => { if (isTTSPlaying) { return t('chat.messageBody.tts.stopSpeaking'); } const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say'; return t('chat.messageBody.tts.readAloudWithProvider', { provider: providerLabel }); }, [isTTSPlaying, t, voiceProvider]); const handleTTSClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (isTTSPlaying) { stopTTS(); return; } if (ttsText.trim()) { void playTTS(ttsText); } }, [isTTSPlaying, playTTS, stopTTS, ttsText] ); return ( <> {onCopyMessage && ( {t('chat.messageBody.actions.copyAnswer')} )} {reviewTransferAction && chatSurfaceMode !== 'mini-chat' ? ( {reviewTransferAction.tooltip} ) : null} {chatSurfaceMode !== 'mini-chat' ? {isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')} : null} {chatSurfaceMode !== 'mini-chat' && showMessageTTSButtons && hasCopyableText && ( {readAloudTooltip} )} ); }); const AssistantMessageBody = React.memo(({ sessionId, messageId, parts, isMessageCompleted, messageFinish, messageCompletedAt, messageCreatedAt, isMobile, alwaysShowActions, hasTouchInput, expandedTools, onToggleTool, onShowPopup, streamPhase: _streamPhase, allowAnimation: _allowAnimation, hasTextContent = false, onCopyMessage, showReasoningTraces = false, turnGroupingContext, errorMessage, reviewTransferDirection = null, contextPinned, contextPinPending, onToggleContextPin, footerProviderID, footerModelName, footerAgentName, footerVariant, isDarkTheme = false, }: Omit) => { const { t, locale } = useI18n(); const chatSurfaceMode = useChatSurfaceMode(); const streamPhase = _streamPhase; void _allowAnimation; const messageContentRef = React.useRef(null); const messageTextContentRef = React.useRef(null); const toolRevealReadyRef = React.useRef(false); React.useEffect(() => { toolRevealReadyRef.current = true; }, []); const isTouchContext = Boolean(hasTouchInput ?? isMobile); const alwaysShowMessageActions = Boolean(alwaysShowActions ?? isMobile); const { src: footerLogoSrc, onError: handleFooterLogoError, hasLogo: footerHasLogo } = useProviderLogo(footerProviderID ?? null); 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 finalizedAssistantMarkdownContents = React.useMemo(() => ( isMessageCompleted ? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0) : [] ), [assistantTextParts, isMessageCompleted]); const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]); const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]); const openContextPreview = useUIStore((state) => state.openContextPreview); const isVSCode = isVSCodeRuntime(); const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; const canUseProjectPlanActions = !isVSCode && !isMiniChatSurface && !isMobile; const canShowMultiRunAction = !isVSCode && !isMiniChatSurface && !isMobile; const messagePreviewUrl = React.useMemo(() => { if (isVSCode || isMobile || isMiniChatSurface) { return null; } for (const part of assistantTextParts) { const text = (part as { text?: unknown }).text; if (typeof text !== 'string' || text.length === 0) { continue; } const url = extractLoopbackUrls(text)[0]; if (!url) { continue; } return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url; } for (const part of toolParts) { const state = (part as unknown as { state?: unknown }).state as Record | undefined; const output = state && typeof state.output === 'string' ? state.output : null; if (!output) { continue; } // eslint-disable-next-line no-control-regex const url = extractLoopbackUrls(output.replace(/\x1b\[[0-9;]*m/g, ''))[0]; if (!url) { continue; } return url.includes('0.0.0.0') ? url.replace('0.0.0.0', '127.0.0.1') : url; } return null; }, [assistantTextParts, isMobile, isMiniChatSurface, isVSCode, toolParts]); const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const getDirectoryForSession = useSessionUIStore((state) => state.getDirectoryForSession); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const projects = useProjectsStore((state) => state.projects); const effectiveDirectory = useEffectiveDirectory(); const isReviewSessionView = reviewTransferDirection === 'review-to-original'; const effectiveReviewTransferDirection = (!isMobile && !isVSCode) ? reviewTransferDirection : null; const reviewTransferAction = React.useMemo(() => { const transferText = assistantPlanText.trim(); if (!sessionId || !effectiveDirectory || !transferText || !effectiveReviewTransferDirection) return undefined; if (effectiveReviewTransferDirection === 'review-to-original') { return { ariaLabel: t('chat.messageBody.actions.sendReviewFeedback'), tooltip: t('chat.messageBody.actions.sendReviewFeedback'), onClick: async () => { try { await sendReviewFeedbackToOriginal(sessionId, effectiveDirectory, transferText); } catch (error) { console.error('[review-flow] failed to send review feedback', error); } }, }; } return { ariaLabel: t('chat.messageBody.actions.sendImplementationResponse'), tooltip: t('chat.messageBody.actions.sendImplementationResponse'), onClick: async () => { try { await sendImplementationResponseToReviewer(sessionId, effectiveDirectory, transferText); } catch (error) { console.error('[review-flow] failed to send implementation response', error); } }, }; }, [assistantPlanText, effectiveDirectory, effectiveReviewTransferDirection, sessionId, t]); const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false); const [isSavingPlan, setIsSavingPlan] = React.useState(false); const [isForkDialogOpen, setIsForkDialogOpen] = React.useState(false); const [isForkSubmitting, setIsForkSubmitting] = React.useState(false); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks); const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const vscodeApi = useRuntimeAPIs().vscode; const isSortedRenderMode = chatRenderMode === 'sorted'; const collapsedPreviewCount = 7; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; const effectiveStreamPhase: StreamPhase = hasStopFinish ? 'completed' : streamPhase; const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const currentProjectRef = React.useMemo(() => { if (!canUseProjectPlanActions) { return null; } const directory = effectiveDirectory ?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null) ?? ''; const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); return resolved ? { id: resolved.id, path: resolved.path } : null; }, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]); 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 hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; const handleForkClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (!createSessionFromAssistantMessage || !assistantPlanText.trim()) { return; } setIsForkDialogOpen(true); }, [createSessionFromAssistantMessage, assistantPlanText] ); const handleConfirmFork = React.useCallback( async (execution: ForkSessionExecution) => { if (!createSessionFromAssistantMessage) { return; } setIsForkSubmitting(true); try { await createSessionFromAssistantMessage(messageId, execution); setIsForkDialogOpen(false); } finally { setIsForkSubmitting(false); } }, [createSessionFromAssistantMessage, messageId] ); const handleForkMultiRunClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (!assistantPlanText.trim()) { return; } const prefilledPrompt = `${MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT}\n\n${assistantPlanText}`; openMultiRunLauncherWithPrompt(prefilledPrompt); }, [assistantPlanText, openMultiRunLauncherWithPrompt] ); const handleSaveAsPlanClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); if (!assistantPlanText.trim()) { return; } setIsPlanDialogOpen(true); }, [assistantPlanText] ); const handleConfirmSaveAsPlan = React.useCallback( async (title: string) => { if (!assistantPlanText.trim()) { return; } if (!currentProjectRef) { toast.error(t('chat.messageBody.toast.noProject')); return; } setIsSavingPlan(true); try { const created = await useProjectContextStore.getState().createPlan(currentProjectRef, { title, body: assistantPlanText, }); if (!created) { toast.error(t('chat.messageBody.toast.savePlanFailed')); return; } setIsPlanDialogOpen(false); toast.success(t('chat.messageBody.toast.planSaved')); } finally { setIsSavingPlan(false); } }, [assistantPlanText, currentProjectRef, t] ); const shareMessageAsImage = React.useCallback( async (requestedSourceElement?: HTMLElement | null) => { const sourceElement = requestedSourceElement ?? messageTextContentRef.current ?? messageContentRef.current; if (!sourceElement) return; let wrapper: HTMLDivElement | null = null; try { // Load the exporter before attaching its temporary clone so a slow // chunk request cannot leave export-only content in the page layout. const { toPng } = await import('html-to-image'); const originalElement = sourceElement; 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.setAttribute('data-message-image-export', 'true'); 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 actionRows = clone.querySelectorAll('[data-message-actions="true"]'); actionRows.forEach((row) => { row.style.display = 'none'; }); const actionGroups = clone.querySelectorAll('[data-message-action-group="true"]'); actionGroups.forEach((group) => { group.style.display = '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; if (!footerRow || footerRowsAdjusted.has(footerRow)) { return; } 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 payload = await vscodeApi?.saveImage?.({ fileName, dataUrl }) as { saved?: boolean; canceled?: boolean; error?: string } | undefined; if (!payload) { throw new Error('Failed to save image in VS Code'); } if (payload.saved !== true) { if (payload.canceled) { return; } throw new Error(payload.error || 'Failed to save image in VS Code'); } } else if (isCapacitorMobileApp()) { const blob = await fetch(dataUrl).then((response) => response.blob()); const file = new File([blob], fileName, { type: blob.type || 'image/png' }); if (!navigator.canShare?.({ files: [file] })) { throw new Error('Image sharing is unavailable in this mobile runtime'); } await navigator.share({ files: [file] }); } else { const link = document.createElement('a'); link.download = fileName; link.href = dataUrl; document.body.appendChild(link); link.click(); document.body.removeChild(link); } toast.success(t('chat.messageBody.toast.imageSaved')); } catch (error) { console.error('Failed to generate image:', error); toast.error(t('chat.messageBody.toast.generateImageFailed')); } finally { if (wrapper && wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper); } } }, [messageId, t, vscodeApi] ); 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 showErrorMessage = Boolean(errorMessage); const isPeekSurface = chatSurfaceMode === 'peek'; const shouldShowMessageActions = hasCopyableText && !isPeekSurface; const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface; const shouldRenderActionsInActivity = isSortedRenderMode; const shouldShowStandaloneMessageActions = showSplitAssistantMessageActions && shouldShowMessageActions && !shouldShowTurnFooter && !shouldRenderActionsInActivity; const messageActionButtons = React.useMemo(() => ( ), [assistantPlanText, hasCopyableText, isTouchContext, onCopyMessage, reviewTransferAction, shareMessageAsImage]); const renderJustificationActions = React.useCallback((activity: NonNullable[number]) => { if (!showSplitAssistantMessageActions || !isSortedRenderMode) { return null; } const text = extractTextContent(activity.part).trim(); if (!text) { return null; } const copyJustificationText = async () => { const result = await copyTextToClipboard(text); return result.ok; }; return ( ); }, [isSortedRenderMode, isTouchContext, shareMessageAsImage, showSplitAssistantMessageActions]); const lastRenderableTextPartIndex = React.useMemo(() => { if (!shouldShowStandaloneMessageActions) { return -1; } let lastIndex = -1; for (let index = 0; index < visibleParts.length; index += 1) { const part = visibleParts[index]; if (!part || part.type !== 'text') { continue; } if (shouldDeferSortedInlineText) { continue; } const activity = activityByPart.get(part); if (activity?.kind === 'justification') { continue; } lastIndex = index; } return lastIndex; }, [activityByPart, shouldDeferSortedInlineText, shouldShowStandaloneMessageActions, visibleParts]); const shouldRenderStandaloneActionsAfterContent = shouldShowStandaloneMessageActions && lastRenderableTextPartIndex < 0; const renderedParts = React.useMemo(() => { const rendered: React.ReactNode[] = []; const renderSegmentBlock = (segment: TurnActivityGroup): React.ReactNode | null => { if (!shouldRenderActivityGroup || !toggleActivityGroup) { return null; } const visibleSegmentParts = showReasoningTraces ? segment.parts : segment.parts.filter((activity) => activity.kind !== 'reasoning'); if (visibleSegmentParts.length === 0) { return null; } return (
); }; // Segments that follow a standalone tool of THIS message render right // after that tool's row so e.g. an Agent Task sits chronologically // between the activity before it and the activity after it. const localToolPartIds = new Set(); visibleParts.forEach((part, partIndex) => { if (part.type === 'tool') { localToolPartIds.add(part.id ?? `${messageId}-part-${partIndex}-${part.type}`); } }); const segmentsAfterLocalTool = new Map(); if (shouldRenderActivityGroup && toggleActivityGroup) { activityGroupSegmentsForMessage.forEach((segment) => { if (segment.afterToolPartId && localToolPartIds.has(segment.afterToolPartId)) { const list = segmentsAfterLocalTool.get(segment.afterToolPartId) ?? []; list.push(segment); segmentsAfterLocalTool.set(segment.afterToolPartId, list); return; } const block = renderSegmentBlock(segment); if (block) { rendered.push(block); } }); } const flushSegmentsAfterTool = (toolPartId: string) => { const segments = segmentsAfterLocalTool.get(toolPartId); if (!segments) { return; } segmentsAfterLocalTool.delete(toolPartId); segments.forEach((segment) => { const block = renderSegmentBlock(segment); if (block) { rendered.push(block); } }); }; // 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 renders inline at its 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(
); if (shouldShowStandaloneMessageActions && i === lastRenderableTextPartIndex) { rendered.push(
{messageActionButtons}
); } i++; continue; } if (part.type === 'reasoning') { const activity = activityByPart.get(part); if (activity?.kind === 'reasoning') { i += 1; continue; } if (showReasoningTraces) { if (!collapsibleThinkingBlocks) { // Non-collapsible mode: render thinking blocks as plain text inline. rendered.push( ); } else { // Per-part mode: each reasoning block at its natural position. rendered.push( ); } } i++; continue; } if (part.type === 'tool') { const toolPart = part as ToolPartType; const toolName = toolPart.tool?.toLowerCase() ?? ''; const toolPartId = toolPart.id ?? `${messageId}-part-${i}-${part.type}`; if (isSortedRenderMode && !isActivityOwnerMessage) { flushSegmentsAfterTool(toolPartId); i += 1; continue; } const activity = activityByPart.get(part); if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) { flushSegmentsAfterTool(toolPartId); i += 1; continue; } if (!shouldShowTool(toolPart)) { flushSegmentsAfterTool(toolPartId); i++; continue; } // Expandable tools: bash, edit, write, task, question — individual rows if (isExpandableTool(toolName)) { rendered.push( ); flushSegmentsAfterTool(toolPartId); i++; continue; } // Static tools: one row per tool call (no grouping) rendered.push( ); flushSegmentsAfterTool(toolPartId); i++; continue; } // Unknown part type — skip i++; } // Any segments whose anchor tool never got flushed (filtered parts, // unexpected ordering) must still render rather than disappear. segmentsAfterLocalTool.forEach((segments) => { segments.forEach((segment) => { const block = renderSegmentBlock(segment); if (block) { rendered.push(block); } }); }); return rendered; }, [ activityByPart, activityGroupSegmentsForMessage, alwaysShowMessageActions, animatedToolIdsLookup, animateActivityRows, chatRenderMode, collapsibleThinkingBlocks, collapsedPreviewCount, expandedTools, isMobile, isActivityOwnerMessage, isSortedRenderMode, lastRenderableTextPartIndex, messageId, messageActionButtons, renderJustificationActions, sessionId, onShowPopup, onToggleTool, shouldRenderActivityGroup, shouldShowStandaloneMessageActions, shouldShowTool, effectiveStreamPhase, showReasoningTraces, shouldDeferSortedInlineText, toggleActivityGroup, turnGroupingContext, visibleParts, ]); 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(() => { void locale; const timestamp = typeof messageCompletedAt === 'number' && messageCompletedAt > 0 ? messageCompletedAt : (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null); if (timestamp === null) return null; const formatted = formatTimestampForDisplay(timestamp, timeFormatPreference); return formatted.length > 0 ? formatted : null; }, [messageCompletedAt, messageCreatedAt, timeFormatPreference, locale]); const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1'; const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode; const finalTurnActionButtons = ( <> {canOpenMessagePreview && messagePreviewUrl ? ( {t('chat.messageBody.actions.openPreview')} ) : null} {canUseProjectPlanActions && !isReviewSessionView ? ( {t('chat.messageBody.actions.saveAsPlan')} ) : null} {onToggleContextPin && hasCopyableText ? ( {t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')} ) : null} {!isMiniChatSurface && !isReviewSessionView ? {t('chat.messageBody.actions.startNewSession')} : null} {canShowMultiRunAction && !isReviewSessionView ? ( {t('chat.messageBody.actions.startNewMultiRun')} ) : null} ); return (
{canUseProjectPlanActions ? ( ) : null} {isForkDialogOpen ? ( ) : null}
{renderedParts} {showErrorMessage && (
)}
{shouldRenderStandaloneActionsAfterContent && (
{messageActionButtons}
)} {shouldShowTurnFooter && (
{footerModelName ? ( {footerHasLogo && footerLogoSrc ? ( ) : ( )} {footerModelName} ) : null} {footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? ( {footerVariant[0].toLowerCase() + footerVariant.slice(1)} ) : null} {footerAgentName ? ( {footerAgentName} ) : null} {turnDurationText ? ( {turnDurationText} {turnDurationText} ) : null} {footerTimestamp ? ( {footerTimestamp} {footerTimestamp} ) : null} {!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? ( ) : null} {!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? ( ) : null}
{messageActionButtons} {finalTurnActionButtons}
)}
); }); const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => { if (isUser) { return ( ); } return ; }); export default MessageBody;