"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
{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
{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 (
);
})}
);
});