import {
IconAlertTriangle,
IconArrowDown,
IconCheck,
IconChevronDown,
IconCircleX,
IconClock,
IconExternalLink,
IconLoader2,
IconTool,
} from "@tabler/icons-react";
import React from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { ActionChatUiSurface } from "../chat/action-chat-ui-surface.js";
import { resolveToolRenderer } from "../chat/tool-render-registry.js";
import {
resolveBuiltinActionChatRenderer,
resolveBuiltinFallbackToolRenderer,
isBuiltinDataWidgetActionRenderer,
} from "../chat/widgets/builtin-tool-renderers.js";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "../components/ui/message-scroller.js";
import { HighlightedCodeBlock as SharedHighlightedCodeBlock } from "../HighlightedCodeBlock.js";
import { McpAppRenderer } from "../mcp-apps/McpAppRenderer.js";
import { humanizeToolName } from "../tool-display.js";
import { cn } from "../utils.js";
import type {
AgentConversationAttachment,
AgentConversationArtifact,
AgentConversationMessage,
AgentConversationMessagePart,
AgentConversationNotice,
AgentConversationToolCall,
} from "./types.js";
export interface AgentConversationProps {
messages: AgentConversationMessage[];
loading?: boolean;
error?: string | null;
streaming?: boolean;
className?: string;
timelineClassName?: string;
emptyTitle?: string;
emptyDescription?: string;
composer?: React.ReactNode;
}
export function AgentConversation({
messages,
loading = false,
error,
className,
timelineClassName,
emptyTitle = "No messages yet",
emptyDescription,
composer,
}: AgentConversationProps) {
return (
{error && (
{error}
)}
{loading && messages.length === 0 ? (
}
title="Loading session..."
/>
) : messages.length === 0 ? (
}
title={emptyTitle}
description={emptyDescription}
/>
) : (
messages.map((message) => (
))
)}
(
)}
/>
{composer}
);
}
function ConversationEmpty({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description?: string;
}) {
return (
{icon}
{title}
{description &&
{description}}
);
}
export function AgentConversationMessageView({
message,
}: {
message: AgentConversationMessage;
}) {
const parts = message.parts ?? legacyPartsForMessage(message);
return (
{message.attachments && message.attachments.length > 0 && (
{message.attachments.map((attachment, i) => (
))}
)}
{parts.map((part) => (
))}
);
}
function legacyPartsForMessage(
message: AgentConversationMessage,
): AgentConversationMessagePart[] {
return [
...(message.text
? [
{
id: `${message.id}-text`,
type: "text" as const,
text: message.text,
},
]
: []),
...(message.tools ?? []).map((tool) => ({
id: `${message.id}-tool-${tool.id}`,
type: "tool" as const,
tool,
})),
...(message.notices ?? []).map((notice) => ({
id: `${message.id}-notice-${notice.id}`,
type: "notice" as const,
notice,
})),
...(message.artifacts ?? []).map((artifact) => ({
id: `${message.id}-artifact-${artifact.id}`,
type: "artifact" as const,
artifact,
})),
];
}
function ConversationMessagePartView({
part,
}: {
part: AgentConversationMessagePart;
}) {
return (
{part.type === "text" ? (
) : part.type === "tool" ? (
) : part.type === "notice" ? (
) : (
)}
);
}
// ─── Shiki syntax highlighter (lazy-loaded) ──────────────────────────────────
type ShikiHighlighter = {
codeToHtml: (
code: string,
options: {
lang: string;
themes: { light: string; dark: string };
defaultColor?: false | "light" | "dark";
},
) => string | Promise;
getLoadedLanguages: () => string[];
};
let _highlighterLoader: Promise | null = null;
function loadConversationHighlighter(): Promise {
if (!_highlighterLoader) {
_highlighterLoader = (async () => {
// Use the JavaScript regex engine instead of Oniguruma WASM (~608 KB saved).
// forgiving:true degrades unsupported patterns gracefully instead of throwing.
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] =
await Promise.all([
import("shiki/core"),
import("shiki/engine/javascript"),
]);
return createHighlighterCore({
themes: [
import("shiki/themes/github-light-default.mjs"),
import("shiki/themes/github-dark-default.mjs"),
],
langs: [
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
import("shiki/langs/jsx.mjs"),
import("shiki/langs/tsx.mjs"),
import("shiki/langs/json.mjs"),
import("shiki/langs/css.mjs"),
import("shiki/langs/html.mjs"),
import("shiki/langs/markdown.mjs"),
import("shiki/langs/bash.mjs"),
import("shiki/langs/shellscript.mjs"),
import("shiki/langs/python.mjs"),
import("shiki/langs/yaml.mjs"),
import("shiki/langs/sql.mjs"),
],
engine: createJavaScriptRegexEngine({ forgiving: true }),
}) as unknown as Promise;
})().catch((err) => {
_highlighterLoader = null;
throw err;
});
}
return _highlighterLoader;
}
function ConversationMarkdown({ text }: { text: string }) {
return (
{children};
}
return (
openMarkdownLink(event, href)}
>
{children}
);
},
pre(props: React.HTMLAttributes) {
const { children, ...rest } = props;
if (React.isValidElement(children)) {
const childProps = children.props as {
className?: string;
children?: React.ReactNode;
};
const langMatch = (childProps.className ?? "").match(
/\blanguage-([\w+-]+)\b/,
);
if (langMatch) {
const code = extractCodeText(childProps.children).replace(
/\n$/,
"",
);
return (
);
}
}
return {children};
},
}}
>
{text}
);
}
function extractCodeText(node: React.ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(extractCodeText).join("");
if (React.isValidElement(node)) {
return extractCodeText(
(node.props as { children?: React.ReactNode }).children,
);
}
return "";
}
function openMarkdownLink(
event: React.MouseEvent,
href: string | undefined,
) {
if (!href) return;
let url: URL;
try {
url = new URL(href, window.location.href);
} catch {
event.preventDefault();
return;
}
if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
event.preventDefault();
return;
}
event.preventDefault();
window.open(url.href, "_blank", "noopener,noreferrer");
}
function parseJsonText(value: string | undefined): unknown {
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
function parseJsonObject(value: string | undefined): Record {
const parsed = parseJsonText(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record)
: {};
}
function ConversationToolCall({ tool }: { tool: AgentConversationToolCall }) {
const resultJson = parseJsonText(tool.result);
const nativeToolContext = {
toolName: tool.name,
args: tool.args ?? parseJsonObject(tool.input),
resultText: tool.result,
resultJson: tool.resultJson ?? resultJson,
isRunning: tool.state === "running" || tool.state === "activity",
chatUI: tool.chatUI,
};
const NativeToolRenderer =
resolveBuiltinActionChatRenderer(nativeToolContext) ??
resolveToolRenderer(nativeToolContext) ??
resolveBuiltinFallbackToolRenderer(nativeToolContext);
if (NativeToolRenderer) {
return (
);
}
const hasDetails = Boolean(tool.input || tool.result || tool.mcpApp);
const icon =
tool.state === "running" || tool.state === "activity" ? (
) : tool.state === "errored" ? (
) : (
);
const content = (
<>
{icon}
{humanizeToolName(tool.name)}
{tool.summary && (
{tool.summary}
)}
>
);
if (!hasDetails) {
return {content}
;
}
return (
{content}
{tool.mcpApp &&
}
{tool.input && (
input
{tool.input}
)}
{tool.result && (
result
{tool.result}
)}
);
}
function ConversationNotice({ notice }: { notice: AgentConversationNotice }) {
return (
{notice.title && {notice.title}}
{notice.text}
{notice.action}
);
}
function ConversationArtifact({
artifact,
}: {
artifact: AgentConversationArtifact;
}) {
return (
{artifact.path ? (
{artifact.path}
) : (
{artifact.label}
)}
{artifact.url && (
Open
)}
);
}
function ConversationAttachmentChip({
attachment,
}: {
attachment: AgentConversationAttachment;
}) {
if (attachment.dataUrl) {
return (
{attachment.name}
);
}
return (
{attachment.name}
{attachment.size !== undefined && (
{formatBytes(attachment.size)}
)}
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}