import { memo, type ReactElement } from 'react'; import type { ACPToolCall, Message, PermissionRequest, TodoItem, } from '../adapters/types'; import type { WebShellAssistantTurnFooterRenderInfo } from '../customization'; import { useI18n } from '../i18n'; import { ErrorBoundary } from './ErrorBoundary'; import { MessageTimestamp } from './MessageTimestamp'; import { UserMessage } from './messages/UserMessage'; import { AssistantMessage, ThinkingMessage, type SessionContentGenerator, } from './messages/AssistantMessage'; import { SystemMessage } from './messages/SystemMessage'; import { ToolGroup } from './messages/ToolGroup'; import { PlanMessage } from './messages/PlanMessage'; import { BtwMessage } from './messages/BtwMessage'; import { UserShellMessage } from './messages/UserShellMessage'; import { InsightProgress } from './InsightProgress'; import { InsightReady } from './InsightReady'; interface MessageItemProps { message: Message; pendingApproval?: PermissionRequest | null; /** Run /context detail, exactly like typing it (context-usage panels). */ onShowContextDetail?: () => void; workspaceCwd?: string; isLatest?: boolean; showRetryHint?: boolean; onRetryClick?: () => void; onBranchSession?: () => void; showAssistantActions?: boolean; showAssistantBranch?: boolean; isLocateFlashing?: boolean; assistantTurnFooterInfo?: WebShellAssistantTurnFooterRenderInfo; generateContent?: SessionContentGenerator; } export const MessageItem = memo(function MessageItem({ message, pendingApproval, onShowContextDetail, workspaceCwd, isLatest = false, showRetryHint = false, onRetryClick, onBranchSession, showAssistantActions = false, showAssistantBranch = false, isLocateFlashing = false, assistantTurnFooterInfo, generateContent, }: MessageItemProps) { const { t } = useI18n(); const body = ((): ReactElement | null => { switch (message.role) { case 'user': return ( ); case 'assistant': return ( ); case 'thinking': return ( ); case 'tool_group': return ( ); case 'plan': return ( ); case 'system': return ( ); case 'user_shell': return ( ); case 'btw': return ( ); case 'insight_progress': return ( ); case 'insight_ready': return ; case 'insight_error': return (
{message.error}
); default: return null; } })(); if (body === null) return null; // Isolate each message's render: a throw in Markdown/KaTeX/Mermaid/a tool // panel degrades to an inline notice rather than white-screening the whole // (embeddable) transcript. `resetKeys={[message]}` lets a streamed/edited/ // retried update recover on its own; a stable broken message stays on the // fallback without looping. const safeBody = ( } > {body} ); // Re-enable text selection on every message row so users can long-press / // drag-select reply text. The blanket `html * { user-select: none }` in // standalone.css disables selection on UI chrome (native-app feel); this // attribute opts the message subtree back in, including descendants // (Markdown body, code blocks, tool panels, sub-messages). // // `display: contents` keeps this wrapper out of layout: several parents // (e.g. MessageTimestamp's chat row) are flex containers whose items used // to be the message body itself. A plain div here becomes the flex item // instead and shrinks to its content width, squeezing user chat bubbles // (whose max-width: 80% then resolves against the shrunken wrapper) so // even short messages wrap mid-word. The user-select re-enable rule // matches `[data-user-selectable] *`, so the boxless wrapper does not // affect it. const selectableSafeBody = (
{safeBody}
); if (message.role === 'assistant') { if (showAssistantActions) { return selectableSafeBody; } return ( {selectableSafeBody} ); } // The cancellation marker is a right-aligned, full-width turn-terminal row; // a hover timestamp would overlap its text, so skip the MessageTimestamp // wrapper. The data-user-selectable div is still applied for consistency. if (message.role === 'system' && message.source === 'prompt_cancelled') { return selectableSafeBody; } return ( {selectableSafeBody} ); }, areMessageItemPropsEqual); // Aligns with the message it replaces: user messages are right-aligned bubbles, // so the notice sits on the right too and still reads as that user turn's prompt // (a left-aligned notice would look like it belongs to the previous turn's // output). Assistant and other rows are left-aligned, matching their layout. function MessageRenderError({ align }: { align: 'start' | 'end' }) { const { t } = useI18n(); return (
{t('message.renderError')}
); } function areMessageItemPropsEqual( prev: MessageItemProps, next: MessageItemProps, ): boolean { if (prev.pendingApproval?.id !== next.pendingApproval?.id) return false; if (prev.onShowContextDetail !== next.onShowContextDetail) return false; if (prev.workspaceCwd !== next.workspaceCwd) return false; if (prev.isLatest !== next.isLatest) return false; if (prev.showRetryHint !== next.showRetryHint) return false; if (prev.onRetryClick !== next.onRetryClick) return false; if (prev.onBranchSession !== next.onBranchSession) return false; if (prev.showAssistantActions !== next.showAssistantActions) return false; if (prev.showAssistantBranch !== next.showAssistantBranch) return false; if (prev.isLocateFlashing !== next.isLocateFlashing) return false; if (prev.generateContent !== next.generateContent) return false; if ( !areAssistantTurnFooterInfosEqual( prev.assistantTurnFooterInfo, next.assistantTurnFooterInfo, ) ) { return false; } return areMessagesEqual(prev.message, next.message); } function areAssistantTurnFooterInfosEqual( prev?: WebShellAssistantTurnFooterRenderInfo, next?: WebShellAssistantTurnFooterRenderInfo, ): boolean { if (prev === next) return true; if (!prev || !next) return false; return ( prev.turnId === next.turnId && prev.message.id === next.message.id && prev.message.content === next.message.content && prev.message.isStreaming === next.message.isStreaming && prev.message.timestamp === next.message.timestamp ); } function areMessagesEqual(prev: Message, next: Message): boolean { if (prev === next) return true; if (prev.id !== next.id || prev.role !== next.role) return false; if (prev.timestamp !== next.timestamp) return false; switch (prev.role) { case 'user': return ( next.role === 'user' && prev.content === next.content && stableImagesEqual(prev.images, next.images) ); case 'assistant': return ( next.role === 'assistant' && prev.content === next.content && prev.isStreaming === next.isStreaming ); case 'thinking': return ( next.role === 'thinking' && prev.content === next.content && prev.isStreaming === next.isStreaming ); case 'system': return ( next.role === 'system' && prev.content === next.content && prev.variant === next.variant && prev.retryable === next.retryable && prev.source === next.source && prev.data === next.data ); case 'user_shell': return ( next.role === 'user_shell' && prev.command === next.command && prev.output === next.output && prev.cwd === next.cwd ); case 'btw': return ( next.role === 'btw' && prev.question === next.question && prev.answer === next.answer && prev.isPending === next.isPending ); case 'insight_progress': return ( next.role === 'insight_progress' && prev.stage === next.stage && prev.progress === next.progress && prev.detail === next.detail ); case 'insight_ready': return next.role === 'insight_ready' && prev.path === next.path; case 'insight_error': return next.role === 'insight_error' && prev.error === next.error; case 'plan': return next.role === 'plan' && areTodosEqual(prev.todos, next.todos); case 'tool_group': return ( next.role === 'tool_group' && prev.tools.length === next.tools.length && prev.tools.every((tool, index) => areToolCallsEqual(tool, next.tools[index]), ) ); default: return false; } } function areTodosEqual(prev: TodoItem[], next: TodoItem[]): boolean { return ( prev.length === next.length && prev.every((todo, index) => { const other = next[index]; return ( other && todo.id === other.id && todo.content === other.content && todo.status === other.status && todo.priority === other.priority ); }) ); } function areToolCallsEqual( prev: ACPToolCall, next: ACPToolCall | undefined, ): boolean { if (!next) return false; return ( prev.callId === next.callId && prev.toolName === next.toolName && prev.status === next.status && prev.title === next.title && prev.kind === next.kind && prev.startTime === next.startTime && prev.endTime === next.endTime && prev.subContent === next.subContent && stableJson(prev.args) === stableJson(next.args) && stableJson(prev.rawOutput) === stableJson(next.rawOutput) && stableJson(prev.locations) === stableJson(next.locations) && stableJson(prev.content) === stableJson(next.content) && areToolListsEqual(prev.subTools, next.subTools) ); } function areToolListsEqual( prev: ACPToolCall[] | undefined, next: ACPToolCall[] | undefined, ): boolean { if (!prev && !next) return true; if (!prev || !next || prev.length !== next.length) return false; return prev.every((tool, index) => areToolCallsEqual(tool, next[index])); } const jsonCache = new WeakMap(); function stableImagesEqual( a: Array<{ data: string; mimeType: string }> | undefined, b: Array<{ data: string; mimeType: string }> | undefined, ): boolean { if (a === b) return true; if (!a || !b || a.length !== b.length) return false; return a.every( (img, i) => img.data === b[i].data && img.mimeType === b[i].mimeType, ); } function stableJson(value: unknown): string { if (value === undefined) return ''; if (value !== null && typeof value === 'object') { let cached = jsonCache.get(value); if (cached !== undefined) return cached; try { cached = JSON.stringify(value); } catch { cached = String(value); } jsonCache.set(value, cached); return cached; } try { return JSON.stringify(value); } catch { return String(value); } }