"use client"; import { useState, useRef, useCallback, useEffect } from "react"; import { Bot, ChevronRight, Brain, AlertCircle, CheckCircle2, XCircle, X, Loader2, Clock, Copy, Check, Monitor, Cloud, Cpu, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { api } from "@/shared/api"; import type { AgentTask, Agent, AgentRuntime } from "@/shared/types/agent"; import { redactSecrets } from "../utils/redact"; // ─── Types ────────────────────────────────────────────────────────────────── interface TimelineItem { seq: number; type: "tool_use" | "tool_result" | "thinking" | "text" | "error"; tool?: string; content?: string; input?: Record; output?: string; } interface AgentTranscriptDialogProps { open: boolean; onOpenChange: (open: boolean) => void; task: AgentTask; items: TimelineItem[]; agentName: string; isLive?: boolean; } // ─── Color mapping for timeline segments ──────────────────────────────────── type EventColor = "agent" | "thinking" | "tool" | "result" | "error"; function getEventColor(item: TimelineItem): EventColor { switch (item.type) { case "text": return "agent"; case "thinking": return "thinking"; case "tool_use": return "tool"; case "tool_result": return "result"; case "error": return "error"; default: return "result"; } } const colorClasses: Record = { agent: { bg: "bg-emerald-400/60", bgActive: "bg-emerald-500", label: "bg-emerald-500" }, thinking: { bg: "bg-violet-400/60", bgActive: "bg-violet-500", label: "bg-violet-500/20 text-violet-700 dark:text-violet-300" }, tool: { bg: "bg-blue-400/60", bgActive: "bg-blue-500", label: "bg-blue-500/20 text-blue-700 dark:text-blue-300" }, result: { bg: "bg-slate-300/60 dark:bg-slate-600/60", bgActive: "bg-slate-400 dark:bg-slate-500", label: "bg-muted text-muted-foreground" }, error: { bg: "bg-red-400/60", bgActive: "bg-red-500", label: "bg-red-500/20 text-red-700 dark:text-red-300" }, }; // ─── Helpers ──────────────────────────────────────────────────────────────── function getEventLabel(item: TimelineItem): string { switch (item.type) { case "text": return "Agent"; case "thinking": return "Thinking"; case "tool_use": return item.tool ?? "Tool"; case "tool_result": return item.tool ? `${item.tool}` : "Result"; case "error": return "Error"; default: return "Event"; } } function getEventSummary(item: TimelineItem): string { switch (item.type) { case "text": return item.content?.split("\n").filter(Boolean).pop() ?? ""; case "thinking": return item.content?.slice(0, 200) ?? ""; case "tool_use": { if (!item.input) return ""; const inp = item.input as Record; if (inp.query) return inp.query; if (inp.file_path) return shortenPath(inp.file_path); if (inp.path) return shortenPath(inp.path); if (inp.pattern) return inp.pattern; if (inp.description) return String(inp.description); if (inp.command) { const cmd = String(inp.command); return cmd.length > 120 ? cmd.slice(0, 120) + "..." : cmd; } if (inp.prompt) { const p = String(inp.prompt); return p.length > 120 ? p.slice(0, 120) + "..." : p; } if (inp.skill) return String(inp.skill); for (const v of Object.values(inp)) { if (typeof v === "string" && v.length > 0 && v.length < 120) return v; } return ""; } case "tool_result": return item.output?.slice(0, 200) ?? ""; case "error": return item.content ?? ""; default: return ""; } } function shortenPath(p: string): string { const parts = p.split("/"); if (parts.length <= 3) return p; return ".../" + parts.slice(-2).join("/"); } function formatDuration(start: string, end: string): string { const ms = new Date(end).getTime() - new Date(start).getTime(); const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const secs = seconds % 60; return `${minutes}m ${secs}s`; } function formatElapsedMs(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const secs = seconds % 60; return `${minutes}m ${secs}s`; } // ─── Main dialog ──────────────────────────────────────────────────────────── export function AgentTranscriptDialog({ open, onOpenChange, task, items, agentName, isLive = false, }: AgentTranscriptDialogProps) { const [selectedIdx, setSelectedIdx] = useState(null); const [elapsed, setElapsed] = useState(""); const [copied, setCopied] = useState(false); const [agentInfo, setAgentInfo] = useState(null); const [runtimeInfo, setRuntimeInfo] = useState(null); const eventRefs = useRef>(new Map()); const scrollContainerRef = useRef(null); // Fetch agent and runtime metadata when dialog opens useEffect(() => { if (!open) return; let cancelled = false; if (task.agent_id) { api.getAgent(task.agent_id).then((agent) => { if (!cancelled) setAgentInfo(agent); }).catch(() => {}); } if (task.runtime_id) { api.listRuntimes().then((runtimes) => { if (cancelled) return; const rt = runtimes.find((r) => r.id === task.runtime_id); if (rt) setRuntimeInfo(rt); }).catch(() => {}); } return () => { cancelled = true; }; }, [open, task.agent_id, task.runtime_id]); // Elapsed time for live tasks useEffect(() => { if (!isLive || (!task.started_at && !task.dispatched_at)) return; const startRef = task.started_at ?? task.dispatched_at!; const update = () => setElapsed(formatElapsedMs(Date.now() - new Date(startRef).getTime())); update(); const interval = setInterval(update, 1000); return () => clearInterval(interval); }, [isLive, task.started_at, task.dispatched_at]); // Click a timeline segment → scroll to event const handleSegmentClick = useCallback((idx: number) => { setSelectedIdx(idx); const el = eventRefs.current.get(idx); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); } }, []); // Copy all events as text const handleCopyAll = useCallback(() => { const text = items .map((item) => { const label = getEventLabel(item); const summary = getEventSummary(item); return `[${label}] ${summary}`; }) .join("\n"); navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, [items]); // Duration const duration = task.started_at && task.completed_at ? formatDuration(task.started_at, task.completed_at) : isLive ? elapsed : null; const toolCount = items.filter((i) => i.type === "tool_use").length; // Status display const statusBadge = isLive ? ( Running ) : task.status === "completed" ? ( Completed ) : task.status === "failed" ? ( Failed ) : ( {task.status} ); return ( Agent Execution Transcript {/* ── Header ─────────────────────────────────────────────── */}
{/* Top row: agent name, status, actions */}
{task.agent_id ? ( ) : (
)} {agentName}
{statusBadge}
{/* Metadata chips row */}
{/* Runtime provider */} {runtimeInfo?.provider && ( }> {formatProvider(runtimeInfo.provider)} )} {/* Runtime environment */} {runtimeInfo && ( : } > {runtimeInfo.name} ({runtimeInfo.runtime_mode}) )} {/* Agent type / description */} {agentInfo?.description && ( }> {agentInfo.description.length > 40 ? agentInfo.description.slice(0, 40) + "..." : agentInfo.description} )} {/* Duration */} {duration && ( }> {duration} )} {/* Event counts */} {toolCount > 0 && ( {toolCount} tool calls )} {items.length} events {/* Created time */} {task.created_at && ( {new Date(task.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", })} )}
{/* ── Timeline progress bar ─────────────────────────────── */} {items.length > 0 && (
)} {/* ── Event list ─────────────────────────────────────────── */}
{items.length === 0 ? (
{isLive ? (
Waiting for events...
) : ( "No execution data recorded." )}
) : (
{items.map((item, idx) => ( { if (el) eventRefs.current.set(idx, el); else eventRefs.current.delete(idx); }} item={item} index={idx} isSelected={selectedIdx === idx} onClick={() => setSelectedIdx(idx === selectedIdx ? null : idx)} /> ))}
)}
); } // ─── Timeline bar (colored segments) ──────────────────────────────────────── // ─── Metadata chip ────────────────────────────────────────────────────────── function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) { return ( {icon} {children} ); } function formatProvider(provider: string): string { const map: Record = { claude: "Claude Code", "claude-code": "Claude Code", codex: "Codex", }; return map[provider.toLowerCase()] ?? provider; } // ─── Timeline bar (colored segments) ──────────────────────────────────────── function TimelineBar({ items, selectedIdx, onSegmentClick, }: { items: TimelineItem[]; selectedIdx: number | null; onSegmentClick: (idx: number) => void; }) { // Group consecutive items of the same color into segments for cleaner display const segments: { startIdx: number; endIdx: number; color: EventColor; count: number }[] = []; let currentColor: EventColor | null = null; let currentStart = 0; for (let i = 0; i < items.length; i++) { const item = items[i]!; const color = getEventColor(item); if (color !== currentColor) { if (currentColor !== null) { segments.push({ startIdx: currentStart, endIdx: i - 1, color: currentColor, count: i - currentStart }); } currentColor = color; currentStart = i; } } if (currentColor !== null) { segments.push({ startIdx: currentStart, endIdx: items.length - 1, color: currentColor, count: items.length - currentStart }); } return (
{segments.map((seg, segIdx) => { const isSelected = selectedIdx !== null && selectedIdx >= seg.startIdx && selectedIdx <= seg.endIdx; const color = colorClasses[seg.color]; // Width proportional to number of events in segment const widthPercent = (seg.count / items.length) * 100; return ( ); })}
); } // ─── Transcript event row ─────────────────────────────────────────────────── interface TranscriptEventRowProps { item: TimelineItem; index: number; isSelected: boolean; onClick: () => void; } const TranscriptEventRow = ({ ref, item, index, isSelected, onClick, }: TranscriptEventRowProps & { ref?: React.Ref }) => { const [expanded, setExpanded] = useState(false); const color = getEventColor(item); const label = getEventLabel(item); const summary = getEventSummary(item); const hasDetail = (item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) || (item.type === "tool_result" && item.output && item.output.length > 0) || (item.type === "thinking" && item.content && item.content.length > 0) || (item.type === "text" && item.content && item.content.split("\n").length > 1) || (item.type === "error" && item.content && item.content.length > 0); return (
{/* Type label badge */} {item.type === "thinking" && } {item.type === "error" && } {label} {/* Summary */}
{hasDetail && ( )} {summary || "(empty)"}
{/* Seq number / index */} #{item.seq}
{/* Expanded detail */} {hasDetail && (
)}
); }; // ─── Event detail content ─────────────────────────────────────────────────── function EventDetailContent({ item }: { item: TimelineItem }) { switch (item.type) { case "tool_use": return (
          {item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
        
); case "tool_result": return (
          {item.output
            ? item.output.length > 4000
              ? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
              : redactSecrets(item.output)
            : ""}
        
); case "thinking": return (
          {item.content ?? ""}
        
); case "text": return (
          {item.content ?? ""}
        
); case "error": return (
          {item.content ?? ""}
        
); default: return null; } }