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 (
{
event.preventDefault();
event.stopPropagation();
onOpen(file.file);
}}
>
);
});
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 ? (
setExpanded((value) => !value)}
>
{expanded ? t('chat.messageBody.subtask.hidePrompt') : t('chat.messageBody.subtask.showPrompt')}
{expanded ? (
{prompt}
) : null}
) : null}
{taskSessionID ? (
{
if (!effectiveDirectory) return;
// In contexts with no ContextPanel (embedded
// session-chat iframe) or single-surface layouts
// (mobile, VS Code), navigate in place. Otherwise
// open a new side-panel tab.
if (isEmbeddedSessionChat() || isMobile || isVSCodeRuntime()) {
setCurrentSession(taskSessionID, effectiveDirectory);
return;
}
openContextPanelTab(effectiveDirectory, {
mode: 'chat',
dedupeKey: `session:${taskSessionID}`,
label: description || agent || t('contextPanel.mode.chat'),
readOnly: true,
});
}}
>
{t('chat.messageBody.subtask.openSession')}
) : 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 ? (
setExpanded((value) => !value)}
>
{expanded ? t('chat.messageBody.shellCommand.hideOutput') : t('chat.messageBody.shellCommand.showOutput')}
{
void copyOutputToClipboard();
}}
aria-label={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
title={copiedOutput ? t('chat.messageBody.shellCommand.copied') : t('chat.messageBody.shellCommand.copyOutput')}
>
{copiedOutput ? : }
{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 && (
event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRevert();
}}
>
{t('chat.messageBody.actions.revert')}
)}
{effectiveOnFork && (
event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
effectiveOnFork();
}}
>
{t('chat.messageBody.actions.fork')}
)}
{onToggleContextPin && hasCopyableText && (
event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
)}
{canCopyMessage && hasCopyableText && (
event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
) : (
)}
{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 && (
event.stopPropagation()}
onClick={(event) => {
void handleCopyButtonClick(event);
}}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
}
}}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
) : (
)}
{t('chat.messageBody.actions.copyAnswer')}
)}
{reviewTransferAction && chatSurfaceMode !== 'mini-chat' ? (
event.stopPropagation()}
onClick={(event) => {
void handleReviewTransferClick(event);
}}
>
{isTransferringReview ? (
) : (
)}
{reviewTransferAction.tooltip}
) : null}
{chatSurfaceMode !== 'mini-chat' ?
event.stopPropagation()}
onClick={(event) => {
void handleShareImageClick(event);
}}
>
{isSharing ? (
) : (
)}
{isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')}
: null}
{chatSurfaceMode !== 'mini-chat' && showMessageTTSButtons && hasCopyableText && (
event.stopPropagation()}
onClick={handleTTSClick}
>
{isTTSPlaying ? (
) : (
)}
{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(
);
}
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 ? (
event.stopPropagation()}
onClick={() => {
const directory = effectiveDirectory
?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null);
if (!directory) {
return;
}
openContextPreview(directory, messagePreviewUrl);
}}
>
{t('chat.messageBody.actions.openPreview')}
) : null}
{canUseProjectPlanActions && !isReviewSessionView ? (
event.stopPropagation()}
onClick={handleSaveAsPlanClick}
>
{t('chat.messageBody.actions.saveAsPlan')}
) : null}
{onToggleContextPin && hasCopyableText ? (
event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
>
{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
) : null}
{!isMiniChatSurface && !isReviewSessionView ?
event.stopPropagation()}
onClick={handleForkClick}
>
{t('chat.messageBody.actions.startNewSession')}
: null}
{canShowMultiRunAction && !isReviewSessionView ? (
event.stopPropagation()}
onClick={handleForkMultiRunClick}
>
{t('chat.messageBody.actions.startNewMultiRun')}
) : null}
>
);
return (
{canUseProjectPlanActions ? (
) : null}
{isForkDialogOpen ? (
) : null}
{renderedParts}
{showErrorMessage && (
)}
{shouldRenderStandaloneActionsAfterContent && (
)}
{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;