/** * Chat Message Component * Renders user / assistant / system / toolresult messages * with markdown, images, and tool cards. Thinking output is * surfaced via ExecutionGraphCard, not inside message bubbles. */ import { useState, useCallback, useEffect, memo } from 'react'; import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle, Clock3 } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import { createPortal } from 'react-dom'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { invokeIpc, statFile } from '@/lib/api-client'; import type { RawMessage, AttachedFileMeta } from '@/types/chat'; import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils'; import { getToolVisibilityId } from './chat-transcript'; interface ChatMessageProps { message: RawMessage; messageKey?: string; showTimestamps?: boolean; expandedToolIds?: ReadonlySet; onToolExpandedChange?: (toolId: string, expanded: boolean) => void; assistantAvatarSrc?: string; assistantAvatarAlt?: string; /** * Keep the assistant column aligned without painting a second avatar when * the message belongs to an execution trace that already owns the avatar. */ assistantAvatarMode?: 'visible' | 'reserved' | 'reserved-trace'; /** Reduce the top whitespace when the reply follows its own execution trace. */ compactAssistantReply?: boolean; textOverride?: string; suppressToolCards?: boolean; suppressProcessAttachments?: boolean; /** * When true, hides the assistant text bubble (and any thinking block that * would be shown above it). Used when the message's text is being folded * into an ExecutionGraphCard as a narration step, to prevent the same text * from appearing both inside the graph and as an orphan bubble in the chat * stream. */ suppressAssistantText?: boolean; isStreaming?: boolean; streamingTools?: Array<{ id?: string; toolCallId?: string; name: string; status: 'running' | 'completed' | 'error'; durationMs?: number; summary?: string; }>; /** * Optional callback invoked when a non-image file card is clicked. * When provided, the file opens in the in-app preview panel instead of * the system default editor. */ onOpenFile?: (file: AttachedFileMeta) => void; } interface ExtractedImage { url?: string; data?: string; mimeType: string; } const DIRECTORY_MIME_TYPE = 'application/x-directory'; const EMPTY_TOOL_IDS: ReadonlySet = new Set(); function isChatPreviewDocument(file: AttachedFileMeta): boolean { const name = file.fileName.toLowerCase(); const mime = file.mimeType.toLowerCase(); return ( mime === 'application/pdf' || mime === 'application/vnd.ms-excel' || mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || name.endsWith('.pdf') || name.endsWith('.xls') || name.endsWith('.xlsx') ); } function isDirectoryAttachment(file: AttachedFileMeta): boolean { return file.mimeType === DIRECTORY_MIME_TYPE; } function validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | null { if (!file.filePath) return null; // User-selected uploads and already enriched attachments are trusted enough // for immediate display. Regex-derived message refs start at size 0/null and // are validated through main-process stat before becoming clickable cards. if (file.source !== 'message-ref' && file.source !== 'tool-result') return null; if (file.fileSize > 0 || file.preview) return null; return isDirectoryAttachment(file) ? 'dir' : 'file'; } function attachmentIdentity(file: AttachedFileMeta): string { return file.filePath?.trim() || file.runtimeUrl?.trim() || `${file.mimeType}:${file.fileName}:${file.fileSize}`; } function normalizeRenderableAttachments(files: AttachedFileMeta[]): AttachedFileMeta[] { const normalized: AttachedFileMeta[] = []; const indexes = new Map(); for (const file of files) { const key = attachmentIdentity(file); const existingIndex = indexes.get(key); if (existingIndex === undefined) { indexes.set(key, normalized.length); normalized.push(file); continue; } const existing = normalized[existingIndex]; if (!existing.preview && file.preview) { normalized[existingIndex] = file; } } // Images need actual preview bytes/URLs to be meaningful in the transcript. // A metadata-only image record otherwise becomes a misleading blank file tile. return normalized.filter((file) => !file.mimeType.startsWith('image/') || Boolean(file.preview)); } function previewMimeFromPath(filePath: string): string | null { const lower = filePath.toLowerCase(); if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown'; if (lower.endsWith('.pdf')) return 'application/pdf'; if (lower.endsWith('.xls')) return 'application/vnd.ms-excel'; if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; return null; } function fileNameFromPath(filePath: string): string { return filePath.split(/[\\/]/).pop() || 'file'; } function trimPathTerminators(filePath: string): string { return filePath.replace(/[,。;;,.!?]+$/u, ''); } function extractPreviewDocumentPaths(text: string): AttachedFileMeta[] { if (!text) return []; const refs: AttachedFileMeta[] = []; const seen = new Set(); const pushRef = (filePath: string, mimeType: string) => { const normalizedPath = trimPathTerminators(filePath); if (!normalizedPath || seen.has(normalizedPath)) return; seen.add(normalizedPath); refs.push({ fileName: fileNameFromPath(normalizedPath), mimeType, fileSize: 0, preview: null, filePath: normalizedPath, source: 'message-ref', }); }; // Deliberately narrow this render-layer fallback to user-facing artifacts. const exts = 'pdf|xlsx?|PDF|XLSX?'; const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'g'); const unixRegex = new RegExp('(?]*?\\.(?:' + exts + '))', 'g'); const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>,。;;,.!?])'; const skillPathPart = '[^\\\\/\\s\\n"\'`()\\x5b\\x5d,<>]+'; const skillPathTail = '[^\\s\\n"\'`()\\x5b\\x5d,<>]*?'; const skillDirRegex = new RegExp( `(? `\n$$\n${body.trim()}\n$$\n`); next = next.replace(/\\\(([\s\S]+?)\\\)/g, (_m, body: string) => `$${body}$`); parts[i] = next; } return parts.join(''); } /** Resolve an ExtractedImage to a displayable src string, or null if not possible. */ function imageSrc(img: ExtractedImage): string | null { if (img.url) return img.url; if (img.data) return `data:${img.mimeType};base64,${img.data}`; return null; } export const ChatMessage = memo(function ChatMessage({ message, messageKey, showTimestamps = true, expandedToolIds = EMPTY_TOOL_IDS, onToolExpandedChange, assistantAvatarSrc, assistantAvatarAlt = '', assistantAvatarMode = 'visible', compactAssistantReply = false, textOverride, suppressToolCards = false, suppressProcessAttachments = false, suppressAssistantText = false, isStreaming = false, streamingTools = [], onOpenFile, }: ChatMessageProps) { const isUser = message.role === 'user'; const deliveryStatus = isUser ? message._deliveryStatus : undefined; const role = typeof message.role === 'string' ? message.role.toLowerCase() : ''; const isToolResult = role === 'toolresult' || role === 'tool_result'; const shouldRenderAssistantAvatar = !isUser && assistantAvatarMode === 'visible'; const shouldReserveAssistantAvatar = !isUser && (assistantAvatarMode === 'reserved' || assistantAvatarMode === 'reserved-trace'); const shouldAlignTraceContent = !isUser && assistantAvatarMode === 'reserved-trace'; const text = textOverride ?? extractText(message); // When text is folded into an ExecutionGraphCard, treat the message as // having no text for rendering purposes. Keeping this behind a flag (vs // blanking `text` outright) lets future hover affordances still read the // original content without surfacing the bubble. const hideAssistantText = suppressAssistantText && !isUser; const hasText = !hideAssistantText && text.trim().length > 0; const images = extractImages(message); const tools = extractToolUse(message); const visibleTools = suppressToolCards ? [] : tools; const stableMessageKey = messageKey?.trim() ? messageKey : (message.id?.trim() ? message.id : undefined); const [validatedPaths, setValidatedPaths] = useState>({}); const rawAttachedFiles = message._attachedFiles || []; const textPreviewFiles = isUser ? [] : extractPreviewDocumentPaths(text); const rawAttachedPaths = new Set(rawAttachedFiles.map((file) => file.filePath).filter(Boolean)); const derivedAttachedFiles = [ ...rawAttachedFiles, ...textPreviewFiles.filter((file) => !file.filePath || !rawAttachedPaths.has(file.filePath)), ]; const validationTargets = derivedAttachedFiles .map((file) => { const kind = validationKindForAttachment(file); return kind && file.filePath ? { filePath: file.filePath, kind } : null; }) .filter((target): target is { filePath: string; kind: 'file' | 'dir' } => !!target); const validationKey = validationTargets .map((target) => `${target.kind}:${target.filePath}`) .sort() .join('\n'); useEffect(() => { if (!validationKey) return; const pendingTargets = validationTargets.filter((target) => validatedPaths[target.filePath] === undefined); if (pendingTargets.length === 0) return; let cancelled = false; void Promise.all( pendingTargets.map(async (target) => { try { const stat = await statFile(target.filePath); return { filePath: target.filePath, exists: !!stat.ok && (target.kind === 'dir' ? !!stat.isDir : !!stat.isFile), }; } catch { return { filePath: target.filePath, exists: false }; } }), ).then((results) => { if (cancelled) return; setValidatedPaths((current) => { const next = { ...current }; for (const result of results) next[result.filePath] = result.exists; return next; }); }); return () => { cancelled = true; }; }, [validationKey, validationTargets, validatedPaths]); const existingDerivedAttachedFiles = derivedAttachedFiles.filter((file) => { const kind = validationKindForAttachment(file); if (!kind || !file.filePath) return true; return validatedPaths[file.filePath] === true; }); const filteredProcessAttachments = derivedAttachedFiles.filter((file) => { if (file.source !== 'tool-result' && file.source !== 'message-ref') return true; // Runtime-produced user-facing artifacts (images, PDFs, spreadsheets, // skill directories, ...) must remain visible in the reply bubble even // when generic process attachments are folded into the execution graph. // The graph card itself does not render `_attachedFiles`, so dropping // images here would leave the user with no way to see them at all. if (file.mimeType.startsWith('image/')) return true; return isChatPreviewDocument(file) || isDirectoryAttachment(file); }); // When a message is attachment-only, keep those attachments visible even if // process attachments are generally suppressed for this run segment — // otherwise the reply disappears entirely. const processVisibleAttachments = filteredProcessAttachments.filter((file) => { const kind = validationKindForAttachment(file); if (!kind || !file.filePath) return true; return validatedPaths[file.filePath] === true; }); const attachedFiles = normalizeRenderableAttachments(suppressProcessAttachments && (hasText || images.length > 0 || visibleTools.length > 0) ? processVisibleAttachments : existingDerivedAttachedFiles); const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null); // Never render tool result messages in chat UI if (isToolResult) return null; const hasStreamingToolStatus = isStreaming && streamingTools.length > 0; if (!hasText && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null; return (
{/* Avatar */} {(shouldRenderAssistantAvatar || shouldReserveAssistantAvatar) && (
{shouldRenderAssistantAvatar && ( assistantAvatarSrc ? ( {assistantAvatarAlt} ) : ( ) )}
)} {/* Content */}
{isStreaming && !isUser && streamingTools.length > 0 && ( )} {/* Tool use cards */} {visibleTools.length > 0 && (
{visibleTools.map((tool, i) => ( ))}
)} {/* Images — rendered ABOVE text bubble for user messages */} {/* Images from content blocks */} {isUser && images.length > 0 && (
{images.map((img, i) => { const src = imageSrc(img); if (!src) return null; return ( setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })} /> ); })}
)} {/* File attachments — images above text for user, file cards below */} {isUser && attachedFiles.length > 0 && (
{attachedFiles.map((file, i) => { const isImage = file.mimeType.startsWith('image/'); // Skip image attachments if we already have images from content blocks if (isImage && images.length > 0) return null; if (isImage && file.preview) { return ( setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })} /> ); } // Non-image files → file card return ; })}
)} {/* Main text bubble */} {hasText && ( )} {/* Images from content blocks — assistant messages (below text) */} {!isUser && images.length > 0 && (
{images.map((img, i) => { const src = imageSrc(img); if (!src) return null; return ( setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })} /> ); })}
)} {/* File attachments — assistant messages (below text) */} {!isUser && attachedFiles.length > 0 && (
{attachedFiles.map((file, i) => { const isImage = file.mimeType.startsWith('image/'); if (isImage && images.length > 0) return null; if (isImage && file.preview) { return ( setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })} /> ); } return ; })}
)} {/* Hover row for user messages — timestamp only */} {isUser && showTimestamps && message.timestamp && ( {formatTimestamp(message.timestamp)} )} {/* Hover row for assistant messages — only when there is real text content */} {!isUser && hasText && ( )}
{/* Image lightbox portal */} {lightboxImg && ( setLightboxImg(null)} /> )}
); }); function formatDuration(durationMs?: number): string | null { if (!durationMs || !Number.isFinite(durationMs)) return null; if (durationMs < 1000) return `${Math.round(durationMs)}ms`; return `${(durationMs / 1000).toFixed(1)}s`; } function ToolStatusBar({ tools, }: { tools: Array<{ id?: string; toolCallId?: string; name: string; status: 'running' | 'completed' | 'error'; durationMs?: number; summary?: string; }>; }) { return (
{tools.map((tool) => { const duration = formatDuration(tool.durationMs); const isRunning = tool.status === 'running'; const isError = tool.status === 'error'; return (
{isRunning && } {!isRunning && !isError && } {isError && } {tool.name} {duration && {tool.summary ? `(${duration})` : duration}} {tool.summary && ( {tool.summary} )}
); })}
); } // ── Assistant hover bar (timestamp + copy, shown on group hover) ─ function AssistantHoverBar({ text, timestamp }: { text: string; timestamp?: number }) { const [copied, setCopied] = useState(false); const copyContent = useCallback(() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [text]); return (
{timestamp ? formatTimestamp(timestamp) : ''}
); } // ── Message Bubble ────────────────────────────────────────────── function MessageBubble({ text, isUser, isStreaming, deliveryStatus, compactAssistantReply, }: { text: string; isUser: boolean; isStreaming: boolean; deliveryStatus?: RawMessage['_deliveryStatus']; compactAssistantReply: boolean; }) { const deliveryLabel = deliveryStatus === 'queued' ? 'Queued' : deliveryStatus === 'sending' ? 'Sending' : null; return (
{isUser ? ( <> {deliveryLabel && (
{deliveryStatus === 'sending' ? ( ) : ( )} {deliveryLabel}
)}

{text}

) : (
{children} ); } return (
                    
                      {children}
                    
                  
); }, a({ href, children }) { return ( {children} ); }, }} > {normalizeLatexDelimiters(text)}
{isStreaming && ( )}
)}
); } // ── File Card (for user-uploaded non-image files) ─────────────── function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } function FileIcon({ mimeType, className }: { mimeType: string; className?: string }) { if (mimeType === DIRECTORY_MIME_TYPE) return ; if (mimeType.startsWith('audio/')) return ; if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml') return ; if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar') || mimeType.includes('7z')) return ; if (mimeType === 'application/pdf') return ; return ; } function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: AttachedFileMeta) => void }) { const handleOpen = useCallback(() => { if (!file.filePath) return; if (onOpen) { onOpen(file); } else { invokeIpc('shell:openPath', file.filePath); } }, [file, onOpen]); return (

{file.fileName}

{file.mimeType === DIRECTORY_MIME_TYPE ? 'Folder' : file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'}

); } // ── Image Thumbnail (user bubble — square crop with zoom hint) ── function ImageThumbnail({ src, fileName, filePath, base64, mimeType, onPreview, }: { src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string; onPreview: () => void; }) { void filePath; void base64; void mimeType; return (
{fileName}
); } // ── Image Preview Card (assistant bubble — natural size with overlay actions) ── function ImagePreviewCard({ src, fileName, filePath, base64, mimeType, onPreview, }: { src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string; onPreview: () => void; }) { void filePath; void base64; void mimeType; return (
{fileName}
); } // ── Image Lightbox ─────────────────────────────────────────────── function ImageLightbox({ src, fileName, filePath, base64, mimeType, onClose, }: { src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string; onClose: () => void; }) { void src; void base64; void mimeType; void fileName; useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); }, [onClose]); const handleShowInFolder = useCallback(() => { if (filePath) { invokeIpc('shell:showItemInFolder', filePath); } }, [filePath]); return createPortal(
{/* Image + buttons stacked */}
e.stopPropagation()} > {fileName} {/* Action buttons below image */}
{filePath && ( )}
, document.body, ); } // ── Tool Card ─────────────────────────────────────────────────── interface ToolCardProps { id: string; name: string; input: unknown; expanded: boolean; onExpandedChange?: (id: string, expanded: boolean) => void; } function ToolCard({ id, name, input, expanded, onExpandedChange, }: ToolCardProps) { return (
{expanded && input != null && (
          {typeof input === 'string' ? input : JSON.stringify(input, null, 2)}
        
)}
); }