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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user