feat: coding agent tab in trace detail view

- Add conditional 'Agent' tab for traces tagged 'opencode'
- Tool usage breakdown: horizontal bar chart by tool category (file/search/shell/LSP)
- File changes timeline: chronological view of file reads, edits, and creates
- Pure CSS charts, no external dependencies
This commit is contained in:
Vectry
2026-02-10 03:49:12 +00:00
parent f0ce0f7884
commit 42b5379ce1

View File

@@ -18,13 +18,19 @@ import {
DollarSign, DollarSign,
AlertCircle, AlertCircle,
Terminal, Terminal,
Cpu,
FileEdit,
Search,
Eye,
FolderOpen,
FilePlus,
} from "lucide-react"; } from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils"; import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
import { DecisionTree } from "./decision-tree"; import { DecisionTree } from "./decision-tree";
import { TraceAnalytics } from "./trace-analytics"; import { TraceAnalytics } from "./trace-analytics";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR"; type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
type TabType = "tree" | "analytics" | "decisions" | "spans" | "events"; type TabType = "tree" | "analytics" | "decisions" | "spans" | "events" | "agent";
interface DecisionPoint { interface DecisionPoint {
id: string; id: string;
@@ -278,6 +284,14 @@ export function TraceDetail({
icon={Activity} icon={Activity}
label={`Events (${events.length})`} label={`Events (${events.length})`}
/> />
{trace.tags.includes("opencode") && (
<TabButton
active={activeTab === "agent"}
onClick={() => setActiveTab("agent")}
icon={Cpu}
label="Agent"
/>
)}
</div> </div>
</div> </div>
@@ -304,6 +318,9 @@ export function TraceDetail({
)} )}
{activeTab === "spans" && <SpansTab spans={spans} />} {activeTab === "spans" && <SpansTab spans={spans} />}
{activeTab === "events" && <EventsTab events={events} />} {activeTab === "events" && <EventsTab events={events} />}
{activeTab === "agent" && (
<CodingAgentTab spans={spans} events={events} />
)}
</div> </div>
</div> </div>
); );
@@ -566,3 +583,239 @@ function EventsTab({ events }: { events: Event[] }) {
</div> </div>
); );
} }
const toolCategoryColors: Record<string, { bar: string; label: string }> = {
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<string, number> = {};
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<string, FileInteractionKind> = {
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<string, unknown>;
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 (
<div className="flex flex-col items-center justify-center py-20">
<div className="p-4 rounded-2xl bg-neutral-800/50 mb-4">
<Cpu className="w-8 h-8 text-neutral-600" />
</div>
<p className="text-neutral-500 text-sm">No coding agent data available</p>
</div>
);
}
return (
<div className="space-y-6">
{sortedTools.length > 0 && (
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-5">
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-blue-500/10">
<Terminal className="w-4 h-4 text-blue-400" />
</div>
<h3 className="text-sm font-semibold text-neutral-200 uppercase tracking-wider">
Tool Usage Breakdown
</h3>
<span className="ml-auto text-xs text-neutral-500">
{toolCallSpans.length} total calls
</span>
</div>
<div className="space-y-2.5">
{sortedTools.map(([toolName, count]) => {
const category = getToolCategory(toolName);
const colors = toolCategoryColors[category];
const widthPercent = maxToolCount > 0 ? (count / maxToolCount) * 100 : 0;
return (
<div key={toolName} className="group">
<div className="flex items-center gap-3">
<span className={cn("text-xs font-mono w-36 truncate shrink-0", colors.label)} title={toolName}>
{toolName}
</span>
<div className="flex-1 h-6 bg-neutral-800/60 rounded-md overflow-hidden relative">
<div
className={cn("h-full rounded-md transition-all duration-500 ease-out opacity-80 group-hover:opacity-100", colors.bar)}
style={{ width: `${Math.max(widthPercent, 2)}%` }}
/>
</div>
<span className="text-xs font-mono text-neutral-300 w-8 text-right tabular-nums">
{count}
</span>
</div>
</div>
);
})}
</div>
<div className="flex items-center gap-4 mt-5 pt-4 border-t border-neutral-800">
{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 (
<div key={category} className="flex items-center gap-1.5">
<div className={cn("w-2.5 h-2.5 rounded-sm", colors.bar)} />
<span className="text-xs text-neutral-500 capitalize">{category}</span>
</div>
);
})}
</div>
</div>
)}
{fileInteractions.length > 0 && (
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-5">
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-amber-500/10">
<FolderOpen className="w-4 h-4 text-amber-400" />
</div>
<h3 className="text-sm font-semibold text-neutral-200 uppercase tracking-wider">
File Changes Timeline
</h3>
<span className="ml-auto text-xs text-neutral-500">
{fileInteractions.length} interactions
</span>
</div>
<div className="relative">
<div className="absolute left-[15px] top-2 bottom-2 w-px bg-neutral-800" />
<div className="space-y-1">
{fileInteractions.map((interaction, idx) => {
const InteractionIcon = getFileInteractionIcon(interaction.kind);
const colorClass = getFileInteractionColor(interaction.kind);
return (
<div
key={`${interaction.filePath}-${idx}`}
className="flex items-center gap-3 pl-1 py-1.5 group"
>
<div className={cn("relative z-10 p-1.5 rounded-md border shrink-0", colorClass)}>
<InteractionIcon className="w-3 h-3" />
</div>
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-xs font-mono text-neutral-300 truncate" title={interaction.filePath}>
{truncateFilePath(interaction.filePath)}
</span>
<span className={cn(
"shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide border",
colorClass
)}>
{interaction.kind}
</span>
</div>
<span className="text-[11px] text-neutral-600 shrink-0 tabular-nums">
{formatRelativeTime(interaction.timestamp)}
</span>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}