import { useState, type KeyboardEvent, type ReactNode } from "react"; import { AlertTriangle, CheckCircle2, CircleDot, FileText, Info, } from "lucide-react"; import { cn } from "../lib/utils"; import { type MessageRole } from "./chat-message"; import { UserMessage } from "./user-message"; import { Markdown } from "../markdown/markdown"; import { ThinkingIndicator } from "./thinking-indicator"; import { type ToolCallData } from "../run/tool-call-feed"; import { ToolCallGroup, ToolCallStep } from "../run/tool-call-step"; import type { ToolPart } from "../types/parts"; export type AgentTimelineTone = "default" | "info" | "success" | "warning" | "error"; export interface AgentTimelineMessageItem { id: string; kind: "message"; role: MessageRole; content: string; toolCalls?: ReactNode; isStreaming?: boolean; timestamp?: Date; after?: ReactNode; } export interface AgentTimelineToolItem { id: string; kind: "tool"; call: ToolCallData; /** Source tool part, so a consumer's `renderToolActions` gets the real * input/output (the flat `call` is display-only). */ part?: ToolPart; } export interface AgentTimelineToolGroupItem { id: string; kind: "tool_group"; title?: string; calls: ToolCallData[]; /** Source tool parts, parallel to `calls`. */ parts?: ToolPart[]; } export interface AgentTimelineStatusItem { id: string; kind: "status"; label: string; detail?: string; tone?: AgentTimelineTone; } export interface AgentTimelineArtifactItem { id: string; kind: "artifact"; title: string; description?: string; meta?: ReactNode; icon?: ReactNode; tone?: AgentTimelineTone; action?: ReactNode; onClick?: () => void; } export interface AgentTimelineCustomItem { id: string; kind: "custom"; content: ReactNode; } export type AgentTimelineItem = | AgentTimelineMessageItem | AgentTimelineToolItem | AgentTimelineToolGroupItem | AgentTimelineStatusItem | AgentTimelineArtifactItem | AgentTimelineCustomItem; export interface AgentTimelineProps { items: AgentTimelineItem[]; isThinking?: boolean; emptyState?: ReactNode; className?: string; /** Optional actions rendered beside each tool item (e.g. "open in artifacts"). * Receives the source tool part carried on the item. */ renderToolActions?: (part: ToolPart) => ReactNode; /** When set, collapse the timeline to the first N spine rows behind a * "Show N more steps" toggle. Omit to always show every row. */ collapseAfter?: number; } const TONE_STYLES: Record = { default: { dot: "bg-[var(--border-hover)]", card: "border-border bg-card", text: "text-foreground", icon: CircleDot, }, info: { dot: "bg-[var(--surface-info-text)]", card: "border-[var(--surface-info-border)] bg-[var(--surface-info-bg)]", text: "text-[var(--surface-info-text)]", icon: Info, }, success: { dot: "bg-[var(--surface-success-text)]", card: "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)]", text: "text-[var(--surface-success-text)]", icon: CheckCircle2, }, warning: { dot: "bg-[var(--surface-warning-text)]", card: "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)]", text: "text-[var(--surface-warning-text)]", icon: AlertTriangle, }, error: { dot: "bg-[var(--surface-danger-text)]", card: "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)]", text: "text-[var(--surface-danger-text)]", icon: AlertTriangle, }, }; function formatTime(date: Date): string { return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } interface AgentTimelineRowProps { isLast: boolean; accentClassName: string; /** Dot top-offset. Defaults to aligning with the first text line; card rows * pass CARD_ROW_DOT to center the dot on the card header instead. */ dotClassName?: string; children: ReactNode; } // Centers the dot on a RunRowShell card header (2.5rem: h-6 badge + py-2), // so tool and reasoning rows read as dot-aligned rather than top-anchored. const CARD_ROW_DOT = "mt-[calc((2.5rem-var(--timeline-dot-size))/2)]"; function AgentTimelineRow({ isLast, accentClassName, dotClassName = "mt-2", children }: AgentTimelineRowProps) { return (
{!isLast && ( )}
{children}
); } function AssistantMessage({ item }: { item: AgentTimelineMessageItem }) { return (
{item.timestamp && (
{formatTime(item.timestamp)}
)} {item.content && ( {item.content} )} {item.isStreaming && ( )} {item.toolCalls &&
{item.toolCalls}
} {item.after && (
{item.after}
)}
); } function StatusCard({ item }: { item: AgentTimelineStatusItem }) { const tone = TONE_STYLES[item.tone ?? "default"]; const Icon = tone.icon; return (
{item.label}
{item.detail && (
{item.detail}
)}
); } function ArtifactCard({ item }: { item: AgentTimelineArtifactItem }) { const tone = TONE_STYLES[item.tone ?? "default"]; const content = (
{item.icon ?? }
{item.title}
{item.description && (
{item.description}
)} {item.meta && (
{item.meta}
)}
{item.action &&
{item.action}
}
); if (!item.onClick) return content; return (
) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); item.onClick?.(); } }} className="block w-full text-left transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60" > {content}
); } /** * AgentTimeline — unified mixed-content timeline for agent-backed sandbox * sessions. Renders messages, tool steps, status cards, and artifact handoffs in * a single execution narrative. */ export function AgentTimeline({ items, isThinking, emptyState, className, renderToolActions, collapseAfter, }: AgentTimelineProps) { const [expanded, setExpanded] = useState(false); if (items.length === 0 && !isThinking) { return emptyState ? (
{emptyState}
) : null; } const renderedItems: AgentTimelineItem[] = isThinking ? [...items, { id: "__thinking__", kind: "custom", content: }] : items; const isUserMessage = (item: AgentTimelineItem) => item.kind === "message" && item.role === "user"; // Items on the vertical connector (user messages render off-spine). const timelineItems = renderedItems.filter((item) => !isUserMessage(item)); const limit = collapseAfter ?? Infinity; const collapsible = timelineItems.length > limit; const collapsed = collapsible && !expanded; // While collapsed, keep only the first `limit` spine rows (and any user // messages that precede them); the rest hides behind the toggle. let renderList = renderedItems; let hiddenCount = 0; if (collapsed) { const visible: AgentTimelineItem[] = []; let spineCount = 0; for (const item of renderedItems) { if (spineCount >= limit) break; visible.push(item); if (!isUserMessage(item)) spineCount += 1; } renderList = visible; hiddenCount = timelineItems.length - limit; } const visibleSpine = renderList.filter((item) => !isUserMessage(item)); // When a toggle row follows, no real row is last — the toggle owns the tail. const lastSpineItem = collapsible ? undefined : visibleSpine[visibleSpine.length - 1]; return (
{renderList.map((item) => { // User messages: right-aligned bubble, off-spine — needs its own vertical // rhythm so it doesn't sit flush against the status/tool/agent row below. if (item.kind === "message" && item.role === "user") { return (
); } const isLast = item === lastSpineItem; if (item.kind === "message") { return ( ); } if (item.kind === "tool") { return ( ); } if (item.kind === "tool_group") { return ( {item.calls.map((call, callIndex) => { const part = item.parts?.[callIndex]; return ( ); })} ); } if (item.kind === "status") { return ( ); } if (item.kind === "artifact") { return ( ); } // custom return ( {(item as AgentTimelineCustomItem).content} ); })} {collapsible ? ( ) : null}
); }