"use client"; import dynamic from "next/dynamic"; import type { UIMessage } from "ai"; import { memo, useMemo, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ChainOfThought, type ChainPart } from "./chain-of-thought"; import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks"; import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks"; import type { ReportConfig } from "./charts/types"; import { DiffCard } from "./diff-viewer"; import { SyntaxBlock } from "./syntax-block"; // Lazy-load ReportCard (uses Recharts which is heavy) const ReportCard = dynamic( () => import("./charts/report-card").then((m) => ({ default: m.ReportCard, })), { ssr: false, loading: () => (
), }, ); /* ─── Silent-reply leak filter ─── */ const _SILENT_TOKEN = "NO_REPLY"; function isLeakedSilentToken(text: string): boolean { const t = text.trim(); if (!t) {return false;} if (new RegExp(`^${_SILENT_TOKEN}\\W*$`).test(t)) {return true;} if (_SILENT_TOKEN.startsWith(t) && t.length >= 2 && t.length < _SILENT_TOKEN.length) {return true;} return false; } /* ─── Part grouping ─── */ type MessageSegment = | { type: "text"; text: string } | { type: "chain"; parts: ChainPart[] } | { type: "report-artifact"; config: ReportConfig } | { type: "diff-artifact"; diff: string } | { type: "subagent-card"; task: string; label?: string; status: "running" | "done" | "error" }; /** Map AI SDK tool state string to a simplified status */ function toolStatus(state: string): "running" | "done" | "error" { if (state === "output-available") { return "done"; } if (state === "error") { return "error"; } return "running"; } /** * Group consecutive non-text parts (reasoning + tools) into chain-of-thought * blocks, with text parts standing alone between them. */ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { const segments: MessageSegment[] = []; let chain: ChainPart[] = []; const flush = (textFollows?: boolean) => { if (chain.length > 0) { // If text content follows this chain, all tools must have // completed — force any stuck "running" tools to "done". if (textFollows) { for (const cp of chain) { if (cp.kind === "tool" && cp.status === "running") { cp.status = "done"; } } } segments.push({ type: "chain", parts: [...chain] }); chain = []; } }; for (const part of parts) { if (part.type === "text") { const text = (part as { type: "text"; text: string }).text; if (isLeakedSilentToken(text)) { continue; } flush(true); if (hasReportBlocks(text)) { segments.push( ...(splitReportBlocks(text) as MessageSegment[]), ); } else if (hasDiffBlocks(text)) { for (const seg of splitDiffBlocks(text)) { if (seg.type === "diff-artifact") { segments.push({ type: "diff-artifact", diff: seg.diff }); } else { segments.push({ type: "text", text: seg.text }); } } } else { segments.push({ type: "text", text }); } } else if (part.type === "reasoning") { const rp = part as { type: "reasoning"; text: string; state?: string; }; // Skip lifecycle/compaction status labels — they add noise // (e.g. "Preparing response...", "Optimizing session context...") const statusLabels = [ "Preparing response...", "Optimizing session context...", ]; const isStatus = statusLabels.some((l) => rp.text.startsWith(l), ); if (!isStatus) { chain.push({ kind: "reasoning", text: rp.text, isStreaming: rp.state === "streaming", }); } } else if (part.type === "dynamic-tool") { const tp = part as { type: "dynamic-tool"; toolName: string; toolCallId: string; state: string; input?: unknown; output?: unknown; }; if (tp.toolName === "sessions_spawn") { flush(true); const args = asRecord(tp.input); const task = typeof args?.task === "string" ? args.task : "Subagent task"; const label = typeof args?.label === "string" ? args.label : undefined; segments.push({ type: "subagent-card", task, label, status: toolStatus(tp.state) }); } else { chain.push({ kind: "tool", toolName: tp.toolName, toolCallId: tp.toolCallId, status: toolStatus(tp.state), args: asRecord(tp.input), output: asRecord(tp.output), }); } } else if (part.type.startsWith("tool-")) { // Handles both live SSE parts (input/output fields) and // persisted JSONL parts (args/result fields from tool-invocation) const tp = part as { type: string; toolCallId: string; toolName?: string; state?: string; title?: string; input?: unknown; output?: unknown; // Persisted JSONL format uses args/result instead args?: unknown; result?: unknown; errorText?: string; }; const resolvedToolName = tp.title ?? tp.toolName ?? part.type.replace("tool-", ""); if (resolvedToolName === "sessions_spawn") { flush(true); const args = asRecord(tp.input) ?? asRecord(tp.args); const task = typeof args?.task === "string" ? args.task : "Subagent task"; const label = typeof args?.label === "string" ? args.label : undefined; const resolvedState = tp.state ?? (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available"); segments.push({ type: "subagent-card", task, label, status: toolStatus(resolvedState) }); } else { // Persisted tool-invocation parts have no state field but // include result/output/errorText to indicate completion. const resolvedState = tp.state ?? (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available"); chain.push({ kind: "tool", toolName: resolvedToolName, toolCallId: tp.toolCallId, status: toolStatus(resolvedState), args: asRecord(tp.input) ?? asRecord(tp.args), output: asRecord(tp.output) ?? asRecord(tp.result), }); } } } flush(); return segments; } /** Safely cast unknown to Record if it's a non-null object */ function asRecord( val: unknown, ): Record | undefined { if (val && typeof val === "object" && !Array.isArray(val)) { return val as Record; } return undefined; } /* ─── Attachment parsing for sent messages ─── */ function parseAttachments( text: string, ): { paths: string[]; message: string } | null { const match = text.match(/\[Attached files: (.+?)\]/); if (!match) {return null;} const afterIdx = (match.index ?? 0) + match[0].length; const message = text.slice(afterIdx).trim(); const paths = match[1] .split(", ") .map((p) => p.trim()) .filter(Boolean); return { paths, message }; } function getCategoryFromPath( filePath: string, ): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" { const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; if ( [ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic", ].includes(ext) ) {return "image";} if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";} if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";} if (ext === "pdf") {return "pdf";} if ( [ "js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java", "cpp", "c", "h", "css", "html", "json", "yaml", "yml", "toml", "md", "sh", "bash", "sql", "swift", "kt", ].includes(ext) ) {return "code";} if ( [ "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv", "pages", "numbers", "key", ].includes(ext) ) {return "document";} return "other"; } function _shortenPath(path: string): string { return path .replace(/^\/Users\/[^/]+/, "~") .replace(/^\/home\/[^/]+/, "~") .replace(/^[A-Z]:\\Users\\[^\\]+/, "~"); } const _attachCategoryMeta: Record = { image: { bg: "rgba(16, 185, 129, 0.15)", fg: "#10b981" }, video: { bg: "rgba(139, 92, 246, 0.15)", fg: "#8b5cf6" }, audio: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" }, pdf: { bg: "rgba(239, 68, 68, 0.15)", fg: "#ef4444" }, code: { bg: "rgba(59, 130, 246, 0.15)", fg: "#3b82f6" }, document: { bg: "rgba(107, 114, 128, 0.15)", fg: "#6b7280" }, other: { bg: "rgba(107, 114, 128, 0.10)", fg: "#9ca3af" }, }; function _AttachFileIcon({ category }: { category: string }) { const props = { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, }; switch (category) { case "image": return ( ); case "video": return ( ); case "audio": return ( ); case "pdf": return ( ); case "code": return ( ); case "document": return ( ); default: return ( ); } } function AttachedFilesCard({ paths }: { paths: string[] }) { return (
{paths.map((filePath, i) => { const category = getCategoryFromPath(filePath); const src = category === "image" ? `/api/workspace/raw-file?path=${encodeURIComponent(filePath)}` : `/api/workspace/thumbnail?path=${encodeURIComponent(filePath)}&size=200`; const ext = filePath.split(".").pop()?.toUpperCase() ?? ""; return (
{filePath.split("/").pop() { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> {category !== "image" && ( {ext} )}
); })}
); } /* ─── File path detection for clickable inline code ─── */ /** * Detect whether an inline code string looks like a local file/directory path. * Matches anything starting with: * ~/ (home-relative) * / (absolute) * ./ (current-dir-relative) * ../ (parent-dir-relative) * Must contain at least one `/` separator to distinguish from plain commands. */ function looksLikeFilePath(text: string): boolean { const t = text.trim(); if (!t || t.length < 3 || t.length > 500) {return false;} // Full path prefix if (t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../")) { const afterPrefix = t.startsWith("~/") ? t.slice(2) : t.startsWith("../") ? t.slice(3) : t.startsWith("./") ? t.slice(2) : t.slice(1); return afterPrefix.includes("/") || afterPrefix.includes("."); } // Bare filename with a known extension (e.g. "Rachapoom-Passport.pdf") const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i; if (fileExtPattern.test(t) && !t.includes(" ")) { return true; } return false; } /** Check if text looks like a filename (allows spaces, used for bold text). */ function looksLikeFileName(text: string): boolean { const t = text.trim(); if (!t || t.length < 3 || t.length > 300) {return false;} const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i; return fileExtPattern.test(t); } /** Open a file path using the system default application. */ async function openFilePath(path: string, reveal = false) { try { const res = await fetch("/api/workspace/open-file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, reveal }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); console.error("Failed to open file:", data); } } catch (err) { console.error("Failed to open file:", err); } } type FilePathClickHandler = ( path: string, ) => Promise | boolean | void; /** Convert file:// URLs to local paths for in-app preview routing. */ function normalizePathReference(value: string): string { const trimmed = value.trim(); if (!trimmed.startsWith("file://")) { return trimmed; } try { const url = new URL(trimmed); if (url.protocol !== "file:") { return trimmed; } const decoded = decodeURIComponent(url.pathname); // Windows file URLs are /C:/... in URL form if (/^\/[A-Za-z]:\//.test(decoded)) { return decoded.slice(1); } return decoded; } catch { return trimmed; } } /** Clickable file path inline code element */ function FilePathCode({ path, children, onFilePathClick, }: { path: string; children: React.ReactNode; onFilePathClick?: FilePathClickHandler; }) { const [status, setStatus] = useState<"idle" | "opening" | "error">("idle"); const handleClick = async (e: React.MouseEvent) => { e.preventDefault(); setStatus("opening"); try { if (onFilePathClick) { const handled = await onFilePathClick(path); if (handled === false) { setStatus("error"); setTimeout(() => setStatus("idle"), 2000); return; } setStatus("idle"); } else { const res = await fetch("/api/workspace/open-file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }), }); if (!res.ok) { setStatus("error"); setTimeout(() => setStatus("idle"), 2000); } else { setStatus("idle"); } } } catch { setStatus("error"); setTimeout(() => setStatus("idle"), 2000); } }; const handleContextMenu = async (e: React.MouseEvent) => { // Right-click reveals in Finder instead of opening e.preventDefault(); await openFilePath(path, true); }; return ( {children} ); } /* ─── Markdown component overrides for chat ─── */ function createMarkdownComponents( onFilePathClick?: FilePathClickHandler, ): Components { return { // Open external links in new tab; intercept local file-path links a: ({ href, children, ...props }) => { const rawHref = typeof href === "string" ? href : ""; const normalizedHref = normalizePathReference(rawHref); const isExternal = rawHref && (rawHref.startsWith("http://") || rawHref.startsWith("https://") || rawHref.startsWith("//")); const isWorkspaceAppLink = rawHref.startsWith("/workspace"); const isLocalPathLink = !isWorkspaceAppLink && (Boolean(rawHref.startsWith("file://")) || looksLikeFilePath(normalizedHref)); return ( { if (!isLocalPathLink || !onFilePathClick) {return;} e.preventDefault(); void onFilePathClick(normalizedHref); }} > {children} ); }, // Route local image paths through raw-file API so workspace images render img: ({ src, alt, ...props }) => { const resolvedSrc = typeof src === "string" && !src.startsWith("http://") && !src.startsWith("https://") && !src.startsWith("data:") ? `/api/workspace/raw-file?path=${encodeURIComponent(src)}` : src; return ( // eslint-disable-next-line @next/next/no-img-element {alt ); }, // Syntax-highlighted fenced code blocks pre: ({ children, ...props }) => { const child = Array.isArray(children) ? children[0] : children; if ( child && typeof child === "object" && "type" in child && (child as { type?: string }).type === "code" ) { const codeEl = child as { props?: { className?: string; children?: string; }; }; const className = codeEl.props?.className ?? ""; const langMatch = className.match(/language-(\w+)/); const lang = langMatch?.[1] ?? ""; const code = typeof codeEl.props?.children === "string" ? codeEl.props.children.replace(/\n$/, "") : ""; // Diff language: render as DiffCard if (lang === "diff") { return ; } // Known language: syntax-highlight with shiki if (lang) { return (
{lang}
); } } // Fallback: default pre rendering return
{children}
; }, // Inline code — detect file paths and make them clickable code: ({ children, className, ...props }) => { // If this code has a language class, it's inside a
 and
			// will be handled by the pre override above. Just return raw.
			if (className?.startsWith("language-")) {
				return (
					
						{children}
					
				);
			}

			// Check if the inline code content looks like a file path
			const text = typeof children === "string" ? children : "";
			const normalizedText = normalizePathReference(text);
			if (normalizedText && looksLikeFilePath(normalizedText)) {
				return (
					
						{children}
					
				);
			}

			// Regular inline code
			return {children};
		},
		// Bold text — detect filenames and make them clickable
		strong: ({ children, ...props }) => {
			const text = typeof children === "string" ? children
				: Array.isArray(children) ? children.filter((c) => typeof c === "string").join("")
				: "";
			if (text && looksLikeFileName(text)) {
				return (
					
						
							{children}
						
					
				);
			}
			return {children};
		},
	};
}

/* ─── Chat message ─── */

export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick, onFilePathClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void; onFilePathClick?: FilePathClickHandler }) {
	const isUser = message.role === "user";
	const segments = groupParts(message.parts);
	const markdownComponents = useMemo(
		() => createMarkdownComponents(onFilePathClick),
		[onFilePathClick],
	);

	if (isUser) {
		// User: right-aligned subtle pill
		const textContent = segments
			.filter(
				(s): s is { type: "text"; text: string } =>
					s.type === "text",
			)
			.map((s) => s.text)
			.join("\n");

		// Parse attachment prefix from sent messages
		const attachmentInfo = parseAttachments(textContent);

		if (attachmentInfo) {
			return (
				
{/* Attachment previews — standalone above the text bubble */} {/* Text bubble */} {attachmentInfo.message && (

{attachmentInfo.message}

)}
); } return (

{textContent}

); } // Find the last text segment index for streaming optimization const lastTextIdx = isStreaming ? segments.reduce((acc, s, i) => (s.type === "text" ? i : acc), -1) : -1; // Assistant: free-flowing text, left-aligned, NO bubble return (
{segments.map((segment, index) => { if (segment.type === "text") { // Detect agent error messages const errorMatch = segment.text.match( /^\[error\]\s*([\s\S]*)$/, ); if (errorMatch) { return (
{errorMatch[1].trim()}
); } // During streaming, render the active text as plain text // to avoid expensive ReactMarkdown re-parses on every token. // Switch to full markdown once streaming ends. if (index === lastTextIdx) { return ( {segment.text} ); } return ( {segment.text} ); } if (segment.type === "report-artifact") { return ( ); } if (segment.type === "diff-artifact") { return ( ); } if (segment.type === "subagent-card") { const truncatedTask = segment.task.length > 80 ? segment.task.slice(0, 80) + "..." : segment.task; const isRunning = segment.status === "running"; return ( ); } return ( ); })}
); });