import type { UIMessage } from "@ai-sdk/react";
import { memo } from "react";
import { AssistantResponse, ChatBubble, ToolUseRow } from "~/components/runs/v3/ai/AIChatMessages";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
// ---------------------------------------------------------------------------
// AgentMessageView — renders an AI SDK UIMessage[] conversation.
//
// Extracted from the playground route so it can be reused on the run details
// page when the user picks the Agent view.
//
// UIMessage part types (AI SDK):
// text — markdown text content
// reasoning — model reasoning/thinking
// tool-{name} — tool call with input/output/state
// source-url — citation link
// source-document — citation document reference
// file — file attachment (image, etc.)
// step-start — visual separator between steps
// data-{name} — custom data parts (rendered as a small popover)
// ---------------------------------------------------------------------------
export function AgentMessageView({ messages }: { messages: UIMessage[] }) {
return (
{messages.map((msg) => (
))}
);
}
// Memoized so stable messages (anything older than the one currently
// streaming) don't re-render on every chunk. This matters a lot during
// `resumeStream()` history replay, where each re-render would otherwise
// re-run Prism highlighting on every tool-call CodeBlock in the list.
//
// Default shallow prop comparison is fine: AI SDK's useChat keeps stable
// references for messages that haven't changed, so only the last message
// (the one receiving new chunks) re-renders.
export const MessageBubble = memo(function MessageBubble({ message }: { message: UIMessage }) {
if (message.role === "user") {
const text =
message.parts
?.filter((p) => p.type === "text")
.map((p) => (p as { type: "text"; text: string }).text)
.join("") ?? "";
return (
{text}
);
}
if (message.role === "assistant") {
const hasContent = message.parts && message.parts.length > 0;
if (!hasContent) return null;
return
{renderAssistantParts(message.parts ?? [])}
;
}
return null;
});
// Group consecutive data-* parts (rendered as inline DataPartPopover pills)
// under a single "Tool calls:" label with a flex-wrap row so they have a
// proper gap between them. Non-data parts pass through to renderPart
// unchanged.
function renderAssistantParts(parts: UIMessage["parts"]) {
const nodes: React.ReactNode[] = [];
let i = 0;
while (i < parts.length) {
const type = parts[i].type as string;
if (type?.startsWith?.("data-") && type !== "data-subagent-run") {
const groupStart = i;
const group: UIMessage["parts"] = [];
while (
i < parts.length &&
(parts[i].type as string)?.startsWith?.("data-") &&
(parts[i].type as string) !== "data-subagent-run"
) {
group.push(parts[i]);
i++;
}
nodes.push(
);
} else {
nodes.push(renderPart(parts[i], i));
i++;
}
}
return nodes;
}
// URLs in `source-url`/`file` parts come from streamed agent/tool data, so an
// unsafe scheme like `javascript:` would become a clickable XSS payload once it
// reaches an href/src. Allow only http(s)/blob (and data: for inline images),
// and return null for anything else so the caller can skip the link/image.
export function toSafeUrl(value: unknown, allowDataImage = false): string | null {
if (typeof value !== "string") return null;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return null;
}
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "blob:") {
return value;
}
if (allowDataImage && parsed.protocol === "data:" && /^data:image\//i.test(value)) {
return value;
}
return null;
}
export function renderPart(part: UIMessage["parts"][number], i: number) {
const p = part as any;
const type = part.type as string;
// Text — markdown rendered via AssistantResponse
if (type === "text") {
return p.text ? : null;
}
// Reasoning — amber-bordered italic block
if (type === "reasoning") {
return (
{p.text ?? ""}
);
}
// Tool call — type: "tool-{name}" with toolCallId, input, output, state
if (type.startsWith("tool-")) {
const toolName = type.slice(5);
// Sub-agent tool: output is a UIMessage with parts
const isSubAgent =
p.output != null && typeof p.output === "object" && Array.isArray(p.output.parts);
// For sub-agent tools, show the last text part as the "output" tab
// (mirrors what toModelOutput typically sends to the parent LLM)
// instead of dumping the full UIMessage JSON.
let resultOutput: string | undefined;
if (isSubAgent) {
const lastText = (p.output.parts as any[])
.filter((part: any) => part.type === "text" && part.text)
.pop();
resultOutput = lastText?.text ?? undefined;
} else if (p.output != null) {
resultOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output, null, 2);
}
// Status label for the tool row. AI SDK 7 HITL adds the
// approval-requested / approval-responded states between input-available
// and output-available, so surface those alongside the existing states.
let resultSummary: string | undefined;
if (p.state === "input-streaming" || p.state === "input-available") {
resultSummary = "calling...";
} else if (p.state === "approval-requested") {
resultSummary = "awaiting approval";
} else if (p.state === "approval-responded" || p.state === "output-denied") {
resultSummary = p.approval?.approved
? "approved"
: `denied${p.approval?.reason ? `: ${p.approval.reason}` : ""}`;
} else if (p.state === "output-error") {
resultSummary = `error: ${p.errorText ?? "unknown"}`;
}
return (
);
}
// Source URL — clickable citation link
if (type === "source-url") {
const safeUrl = toSafeUrl(p.url);
const label = p.title || p.url;
// Unsafe scheme: render the citation text without a clickable link.
if (!safeUrl) {
return label ? (
);
}
// File — render as image if image type, otherwise as download link
if (type === "file") {
const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/");
if (isImage) {
const safeSrc = toSafeUrl(p.url, true); // allow data: URIs for inline images
// Unsafe scheme: fall back to the filename, matching the non-image branch.
if (!safeSrc) {
return p.filename ? (
{p.filename}
) : null;
}
return (
);
}
const safeUrl = toSafeUrl(p.url);
// Unsafe scheme: show the filename without a clickable download link.
if (!safeUrl) {
return p.filename ? (