/** * Chat Message Component * Renders user / assistant / system / toolresult messages * with markdown, thinking sections, images, and tool cards. */ import { useState, useCallback, useEffect, memo } from 'react'; import { User, Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { createPortal } from 'react-dom'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { RawMessage, AttachedFileMeta } from '@/stores/chat'; import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils'; interface ChatMessageProps { message: RawMessage; showThinking: boolean; isStreaming?: boolean; streamingTools?: Array<{ id?: string; toolCallId?: string; name: string; status: 'running' | 'completed' | 'error'; durationMs?: number; summary?: string; }>; } interface ExtractedImage { url?: string; data?: string; mimeType: string; } /** 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, showThinking, isStreaming = false, streamingTools = [], }: ChatMessageProps) { const isUser = message.role === 'user'; const role = typeof message.role === 'string' ? message.role.toLowerCase() : ''; const isToolResult = role === 'toolresult' || role === 'tool_result'; const text = extractText(message); const hasText = text.trim().length > 0; const thinking = extractThinking(message); const images = extractImages(message); const tools = extractToolUse(message); const visibleThinking = showThinking ? thinking : null; const visibleTools = tools; const attachedFiles = message._attachedFiles || []; 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 && !visibleThinking && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null; return (
{/* Avatar */}
{isUser ? : }
{/* Content */}
{isStreaming && !isUser && streamingTools.length > 0 && ( )} {/* Thinking section */} {visibleThinking && ( )} {/* Tool use cards */} {visibleTools.length > 0 && (
{visibleTools.map((tool, i) => ( ))}
)} {/* Images — rendered ABOVE text bubble for user messages */} {/* Images from content blocks (Gateway session data / channel push photos) */} {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) { return file.preview ? ( 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 })} /> ); } if (isImage && !file.preview) { return (
); } return ; })}
)} {/* Hover row for user messages — timestamp only */} {isUser && 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 && {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, }: { text: string; isUser: boolean; isStreaming: boolean; }) { return (
{isUser ? (

{text}

) : (
{children} ); } return (
                    
                      {children}
                    
                  
); }, a({ href, children }) { return ( {children} ); }, }} > {text}
{isStreaming && ( )}
)}
); } // ── Thinking Block ────────────────────────────────────────────── function ThinkingBlock({ content }: { content: string }) { const [expanded, setExpanded] = useState(false); return (
{expanded && (
{content}
)}
); } // ── 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.startsWith('video/')) 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 }: { file: AttachedFileMeta }) { return (

{file.fileName}

{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) { window.electron.ipcRenderer.invoke('shell:showItemInFolder', filePath); } }, [filePath]); return createPortal(
{/* Image + buttons stacked */}
e.stopPropagation()} > {fileName} {/* Action buttons below image */}
{filePath && ( )}
, document.body, ); } // ── Tool Card ─────────────────────────────────────────────────── function ToolCard({ name, input }: { name: string; input: unknown }) { const [expanded, setExpanded] = useState(false); return (
{expanded && input != null && (
          {typeof input === 'string' ? input : JSON.stringify(input, null, 2) as string}
        
)}
); }