diff --git a/apps/web/src/components/trace-detail.tsx b/apps/web/src/components/trace-detail.tsx index ffd4156..ea899e3 100644 --- a/apps/web/src/components/trace-detail.tsx +++ b/apps/web/src/components/trace-detail.tsx @@ -18,13 +18,19 @@ import { DollarSign, AlertCircle, Terminal, + Cpu, + FileEdit, + Search, + Eye, + FolderOpen, + FilePlus, } from "lucide-react"; import { cn, formatDuration, formatRelativeTime } from "@/lib/utils"; import { DecisionTree } from "./decision-tree"; import { TraceAnalytics } from "./trace-analytics"; type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR"; -type TabType = "tree" | "analytics" | "decisions" | "spans" | "events"; +type TabType = "tree" | "analytics" | "decisions" | "spans" | "events" | "agent"; interface DecisionPoint { id: string; @@ -278,6 +284,14 @@ export function TraceDetail({ icon={Activity} label={`Events (${events.length})`} /> + {trace.tags.includes("opencode") && ( + setActiveTab("agent")} + icon={Cpu} + label="Agent" + /> + )} @@ -304,6 +318,9 @@ export function TraceDetail({ )} {activeTab === "spans" && } {activeTab === "events" && } + {activeTab === "agent" && ( + + )} ); @@ -566,3 +583,239 @@ function EventsTab({ events }: { events: Event[] }) { ); } + +const toolCategoryColors: Record = { + file: { bar: "bg-blue-500", label: "text-blue-400" }, + search: { bar: "bg-purple-500", label: "text-purple-400" }, + shell: { bar: "bg-amber-500", label: "text-amber-400" }, + lsp: { bar: "bg-emerald-500", label: "text-emerald-400" }, + other: { bar: "bg-neutral-500", label: "text-neutral-400" }, +}; + +function getToolCategory(toolName: string): string { + const lower = toolName.toLowerCase(); + if (["read", "write", "edit", "glob", "file"].some((k) => lower.includes(k))) return "file"; + if (["grep", "search", "find"].some((k) => lower.includes(k))) return "search"; + if (["bash", "shell", "terminal", "exec"].some((k) => lower.includes(k))) return "shell"; + if (["lsp", "diagnostics", "definition", "references", "symbols", "rename"].some((k) => lower.includes(k))) return "lsp"; + return "other"; +} + +function truncateFilePath(filePath: string): string { + const segments = filePath.replace(/\\/g, "/").split("/").filter(Boolean); + if (segments.length <= 3) return segments.join("/"); + return ".../" + segments.slice(-3).join("/"); +} + +type FileInteractionKind = "read" | "edit" | "create"; + +interface FileInteraction { + kind: FileInteractionKind; + filePath: string; + timestamp: string; + source: string; +} + +function getFileInteractionIcon(kind: FileInteractionKind) { + switch (kind) { + case "read": + return Eye; + case "edit": + return FileEdit; + case "create": + return FilePlus; + } +} + +function getFileInteractionColor(kind: FileInteractionKind): string { + switch (kind) { + case "read": + return "text-blue-400 bg-blue-500/10 border-blue-500/20"; + case "edit": + return "text-amber-400 bg-amber-500/10 border-amber-500/20"; + case "create": + return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20"; + } +} + +function CodingAgentTab({ spans, events }: { spans: Span[]; events: Event[] }) { + const toolCallSpans = spans.filter((s) => s.type === "TOOL_CALL"); + + const toolCounts: Record = {}; + for (const span of toolCallSpans) { + toolCounts[span.name] = (toolCounts[span.name] || 0) + 1; + } + + const sortedTools = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]); + const maxToolCount = sortedTools.length > 0 ? sortedTools[0][1] : 0; + + const fileInteractions: FileInteraction[] = []; + + for (const event of events) { + if (event.name === "file.edited" && event.metadata.filePath) { + fileInteractions.push({ + kind: "edit", + filePath: String(event.metadata.filePath), + timestamp: event.timestamp, + source: "event", + }); + } + } + + const fileToolPatterns: Record = { + read: "read", + glob: "read", + grep: "read", + edit: "edit", + write: "create", + }; + + for (const span of toolCallSpans) { + const lower = span.name.toLowerCase(); + for (const [pattern, kind] of Object.entries(fileToolPatterns)) { + if (lower.includes(pattern)) { + let filePath = ""; + if (span.metadata.filePath) { + filePath = String(span.metadata.filePath); + } else if (span.input && typeof span.input === "object" && span.input !== null) { + const inputObj = span.input as Record; + if (inputObj.filePath) filePath = String(inputObj.filePath); + else if (inputObj.path) filePath = String(inputObj.path); + else if (inputObj.pattern) filePath = String(inputObj.pattern); + } + if (filePath) { + fileInteractions.push({ + kind, + filePath, + timestamp: span.startedAt, + source: span.name, + }); + } + break; + } + } + } + + fileInteractions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + const hasData = sortedTools.length > 0 || fileInteractions.length > 0; + + if (!hasData) { + return ( +
+
+ +
+

No coding agent data available

+
+ ); + } + + return ( +
+ {sortedTools.length > 0 && ( +
+
+
+ +
+

+ Tool Usage Breakdown +

+ + {toolCallSpans.length} total calls + +
+
+ {sortedTools.map(([toolName, count]) => { + const category = getToolCategory(toolName); + const colors = toolCategoryColors[category]; + const widthPercent = maxToolCount > 0 ? (count / maxToolCount) * 100 : 0; + return ( +
+
+ + {toolName} + +
+
+
+ + {count} + +
+
+ ); + })} +
+
+ {Object.entries(toolCategoryColors).map(([category, colors]) => { + const categoryCount = sortedTools + .filter(([name]) => getToolCategory(name) === category) + .reduce((sum, [, c]) => sum + c, 0); + if (categoryCount === 0) return null; + return ( +
+
+ {category} +
+ ); + })} +
+
+ )} + + {fileInteractions.length > 0 && ( +
+
+
+ +
+

+ File Changes Timeline +

+ + {fileInteractions.length} interactions + +
+
+
+
+ {fileInteractions.map((interaction, idx) => { + const InteractionIcon = getFileInteractionIcon(interaction.kind); + const colorClass = getFileInteractionColor(interaction.kind); + return ( +
+
+ +
+
+ + {truncateFilePath(interaction.filePath)} + + + {interaction.kind} + +
+ + {formatRelativeTime(interaction.timestamp)} + +
+ ); + })} +
+
+
+ )} +
+ ); +}