/**
* 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 { 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 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 } from '@/lib/api-client';
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
import assistantLogo from '@/assets/logo.svg';
interface ChatMessageProps {
message: RawMessage;
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;
}>;
}
interface ExtractedImage { url?: string; data?: string; mimeType: string; }
/**
* Normalize LaTeX delimiters so `remark-math` can detect them.
*
* Many LLMs emit LaTeX using `\(` / `\)` for inline math and `\[` / `\]`
* for block math (OpenAI style), which are NOT recognized by remark-math.
* remark-math only parses `$...$` and `$$...$$`.
*
* We convert the backslash-paren/bracket forms to dollar-sign forms so the
* math is rendered regardless of which convention the model uses.
*
* Transformations are skipped inside fenced/inline code spans to avoid
* clobbering code samples that legitimately contain `\(` etc.
*/
function normalizeLatexDelimiters(input: string): string {
if (!input || (input.indexOf('\\(') === -1 && input.indexOf('\\[') === -1)) {
return input;
}
const parts = input.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (part.startsWith('```') || part.startsWith('`')) continue;
let next = part.replace(/\\\[([\s\S]+?)\\\]/g, (_m, body: string) => `\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,
textOverride,
suppressToolCards = false,
suppressProcessAttachments = false,
suppressAssistantText = false,
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 = 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 shouldHideProcessAttachments = suppressProcessAttachments
&& (hasText || images.length > 0 || visibleTools.length > 0);
const attachedFiles = shouldHideProcessAttachments
? (message._attachedFiles || []).filter((file) => file.source !== 'tool-result')
: (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 && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;
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 && (
)}