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(
AI SDK data parts:
{group.map((g, k) => renderPart(g, groupStart + k))}
); } 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 ? (
{label}
) : null; } return (
{label}
); } // Source document — citation label if (type === "source-document") { return (
{p.title} {p.mediaType ? ` (${p.mediaType})` : ""}
); } // 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 ( {p.filename ); } const safeUrl = toSafeUrl(p.url); // Unsafe scheme: show the filename without a clickable download link. if (!safeUrl) { return p.filename ? (
{p.filename}
) : null; } return (
{p.filename ?? "Download file"}
); } // Step start — subtle dashed separator with centered label if (type === "step-start") { return (
step
); } // Data parts — type: "data-{name}", show as labeled JSON popover if (type.startsWith("data-")) { const dataName = type.slice(5); return ; } return null; } function DataPartPopover({ name, data }: { name: string; data: unknown }) { const formatted = JSON.stringify(data, null, 2); return (
data-{name}
{formatted}
); }