import { memo, useMemo, type ComponentType, type ReactNode } from "react";
import * as Collapsible from "@radix-ui/react-collapsible";
import {
Bot,
Loader2,
ChevronDown,
ChevronRight,
Terminal,
FileEdit,
FileSearch,
Search,
PencilLine,
Globe,
ClipboardList,
Settings,
Sparkles,
type LucideProps,
} from "lucide-react";
import { cn } from "../lib/utils";
import { formatDuration } from "../utils/format";
import type { Run, ToolCategory } from "../types/run";
import type { SessionPart, ToolPart, ReasoningPart } from "../types/parts";
import type { AgentBranding } from "../types/branding";
import type { CustomToolRenderer } from "../types/tool-display";
import { InlineToolItem } from "./inline-tool-item";
import { InlineThinkingItem } from "./inline-thinking-item";
import { Markdown } from "../markdown/markdown";
/**
* One row on the run's timeline spine: a connector line + accent dot in a
* narrow gutter, content to the right. Mirrors AgentTimeline's row so a run
* reads as separated, distinct steps — not one filled box.
*/
function SpineRow({
accentClassName,
isLast,
children,
}: {
accentClassName: string;
isLast: boolean;
children: ReactNode;
}) {
return (
{!isLast && (
)}
{children}
);
}
import {
OpenUIArtifactRenderer,
type OpenUIAction,
type OpenUIComponentNode,
} from "../openui/openui-artifact-renderer";
// ---------------------------------------------------------------------------
// Default branding
// ---------------------------------------------------------------------------
const DEFAULT_BRANDING: AgentBranding = {
label: "Agent",
accentClass: "text-primary",
bgClass: "bg-[var(--accent-surface-soft)]",
containerBgClass: "bg-muted",
borderClass: "border-border",
iconClass: "",
textClass: "text-primary",
};
const ASSISTANT_SHELL =
"min-w-0 flex-1 space-y-3 rounded-[26px] border border-[var(--border-subtle)] bg-[color:color-mix(in_srgb,var(--bg-card)_94%,transparent)] px-5 py-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
function AssistantShell({
branding,
isStreaming,
children,
}: {
branding: AgentBranding;
isStreaming: boolean;
children: ReactNode;
}) {
return (
{branding.label}
{isStreaming ? (
Thinking
) : null}
{children}
);
}
// ---------------------------------------------------------------------------
// Category icon mapping
// ---------------------------------------------------------------------------
const CATEGORY_ICON_MAP: Record> = {
command: Terminal,
write: FileEdit,
read: FileSearch,
search: Search,
edit: PencilLine,
task: Bot,
web: Globe,
todo: ClipboardList,
other: Settings,
};
const CATEGORY_ORDER: ToolCategory[] = [
"command",
"write",
"edit",
"read",
"search",
"web",
"task",
"todo",
"other",
];
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface RunGroupProps {
run: Run;
partMap: Record;
collapsed: boolean;
onToggle: () => void;
branding?: AgentBranding;
renderToolDetail?: CustomToolRenderer;
headerActions?: ReactNode;
renderToolActions?: (
part: ToolPart,
options: {
run: Run;
messageId: string;
partIndex: number;
},
) => ReactNode;
}
const OPENUI_NODE_TYPES = new Set([
"heading",
"text",
"badge",
"stat",
"key_value",
"code",
"markdown",
"table",
"actions",
"separator",
"stack",
"grid",
"card",
]);
function isOpenUINode(value: unknown): value is OpenUIComponentNode {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof (value as Record).type === "string" &&
OPENUI_NODE_TYPES.has((value as Record).type as string)
);
}
function extractOpenUISchema(output: unknown): OpenUIComponentNode[] | null {
if (output == null) return null;
if (isOpenUINode(output)) return [output];
if (Array.isArray(output) && output.length > 0 && output.every(isOpenUINode)) {
return output as OpenUIComponentNode[];
}
if (typeof output === "object" && !Array.isArray(output)) {
const obj = output as Record;
for (const key of ["openui", "schema", "ui"]) {
if (obj[key] == null) continue;
const inner = obj[key];
if (typeof inner === "string") {
try {
const parsed = JSON.parse(inner);
return extractOpenUISchema(parsed);
} catch {
continue;
}
}
const nested = extractOpenUISchema(inner);
if (nested) return nested;
}
}
if (typeof output === "string") {
try {
return extractOpenUISchema(JSON.parse(output));
} catch {
return null;
}
}
return null;
}
function getOpenUISummary(output: unknown) {
if (!output || typeof output !== "object" || Array.isArray(output)) {
return null;
}
const summary = (output as Record).summary;
return typeof summary === "string" && summary.trim() ? summary.trim() : null;
}
function isOpenUITool(part: ToolPart) {
const normalized = part.tool.toLowerCase().replace(/^tool:/, "");
return normalized.includes("openui");
}
// ---------------------------------------------------------------------------
// Stat badges
// ---------------------------------------------------------------------------
function CategoryBadges({ categories }: { categories: Set }) {
const sorted = useMemo(
() => CATEGORY_ORDER.filter((category) => categories.has(category)),
[categories],
);
if (sorted.length === 0) return null;
return (
{sorted.map((cat) => {
const Icon = CATEGORY_ICON_MAP[cat] ?? Settings;
return (
);
})}
);
}
function renderSummary(run: Run) {
const parts: string[] = [];
if (run.stats.toolCount > 0) {
parts.push(`${run.stats.toolCount} tool${run.stats.toolCount === 1 ? "" : "s"}`);
}
if (run.stats.textPartCount > 0) {
parts.push(`${run.stats.textPartCount} response${run.stats.textPartCount === 1 ? "" : "s"}`);
}
if (run.stats.thinkingDurationMs > 0) {
parts.push(`${formatDuration(run.stats.thinkingDurationMs)} thinking`);
}
return parts.join(", ");
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Collapsible container for a consecutive group of assistant messages (a "run").
* Shows a summary header with stats and renders tool/thinking/text parts.
*/
export const RunGroup = memo(
({
run,
partMap,
collapsed,
onToggle,
branding = DEFAULT_BRANDING,
renderToolDetail,
headerActions,
renderToolActions,
}: RunGroupProps) => {
// Flatten all parts from all messages in this run
const allParts = useMemo(() => {
const parts: Array<{
part: SessionPart;
msgId: string;
index: number;
}> = [];
for (const msg of run.messages) {
const msgParts = partMap[msg.id] ?? [];
msgParts.forEach((part, index) => {
parts.push({ part, msgId: msg.id, index });
});
}
return parts;
}, [run.messages, partMap]);
const { stats, isStreaming } = run;
const hasRenderableParts = allParts.some(({ part }) => {
if (part.type === "tool" || part.type === "reasoning") {
return true;
}
return part.type === "text" && !part.synthetic && part.text.trim().length > 0;
});
if (!hasRenderableParts) {
if (!isStreaming) {
return null;
}
return (
);
}
const showTraceChrome = allParts.some(({ part }) => {
if (part.type === "reasoning") {
return true;
}
if (part.type === "tool") {
return !isOpenUITool(part as ToolPart);
}
return false;
});
if (!showTraceChrome) {
return (
{allParts.map(({ part, msgId, index }) => {
const key = `${msgId}-${index}`;
if (part.type === "tool" && isOpenUITool(part as ToolPart)) {
const toolPart = part as ToolPart;
const schema = extractOpenUISchema(toolPart.state.output);
const summary = getOpenUISummary(toolPart.state.output);
if (toolPart.state.status === "completed" && schema) {
return (
{summary ? (
{summary}
) : null}
);
}
if (toolPart.state.status === "running") {
return (
Building view…
);
}
}
if (part.type === "text" && !part.synthetic && part.text.trim()) {
return (
{part.text}
);
}
return null;
})}
);
}
// Renderable rows: skip empty/synthetic text so spine dots map to real steps.
const rows = allParts.filter(({ part }) => {
if (part.type === "tool" || part.type === "reasoning") return true;
return part.type === "text" && !part.synthetic && part.text.trim().length > 0;
});
const dotAccent = (part: SessionPart): string => {
if (part.type === "reasoning") return "bg-[var(--brand-glow)]";
if (part.type === "text") return "bg-primary";
return "bg-[var(--border-hover)]";
};
return (
onToggle()}>
{/* Header — a quiet row, not a filled box */}
{collapsed ? (
) : (
)}
{branding.label}
{renderSummary(run) ? (
{renderSummary(run)}
) : null}
{collapsed && run.summaryText ? (
{run.summaryText}
) : null}
{isStreaming ? (
Running
) : (
Done
)}
{headerActions ? (
{headerActions}
) : null}
{/* Collapsed preview */}
{collapsed && run.summaryText ? (
{run.summaryText}
) : null}
{/* Expanded — separated steps on a timeline spine, no wrapping box */}
{rows.map(({ part, msgId, index }, rowIndex) => {
const key = `${msgId}-${index}`;
const isLast = rowIndex === rows.length - 1;
let node: ReactNode = null;
if (part.type === "tool") {
if (isOpenUITool(part as ToolPart)) {
const toolPart = part as ToolPart;
const schema = extractOpenUISchema(toolPart.state.output);
const summary = getOpenUISummary(toolPart.state.output);
if (toolPart.state.status === "completed" && schema) {
node = (
{summary ? (
{summary}
) : null}
);
} else if (toolPart.state.status === "running") {
node = (
Building view…
);
}
}
if (node === null) {
node = (
);
}
} else if (part.type === "reasoning") {
node = (
);
} else if (part.type === "text" && !part.synthetic && part.text.trim()) {
node = (
{part.text}
);
}
if (!node) return null;
return (
{node}
);
})}
);
},
);
RunGroup.displayName = "RunGroup";