Files
paperclip/ui/src/pages/AgentDetail.tsx
Forgotten 080964a361 Refactor NewAgentDialog and AgentDetail to use AgentConfigForm
NewAgentDialog now delegates adapter/heartbeat config to AgentConfigForm in
create mode. AgentDetail replaces its inline ConfigSection/ConfigField/ConfigBool
components with AgentConfigForm in edit mode, cutting ~250 lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 20:07:30 -06:00

832 lines
31 KiB
TypeScript

import { useEffect, useState, useRef } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { AgentProperties } from "../components/AgentProperties";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { cn } from "../lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
MoreHorizontal,
Play,
Pause,
ChevronDown,
ChevronRight,
CheckCircle2,
XCircle,
Clock,
Timer,
Loader2,
Slash,
RotateCcw,
Trash2,
Plus,
} from "lucide-react";
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-400" },
failed: { icon: XCircle, color: "text-red-400" },
running: { icon: Loader2, color: "text-cyan-400" },
queued: { icon: Clock, color: "text-yellow-400" },
timed_out: { icon: Timer, color: "text-orange-400" },
cancelled: { icon: Slash, color: "text-neutral-400" },
};
const sourceLabels: Record<string, string> = {
timer: "Timer",
assignment: "Assignment",
on_demand: "On-demand",
automation: "Automation",
};
export function AgentDetail() {
const { agentId } = useParams<{ agentId: string }>();
const { selectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel();
const { openNewIssue } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const { data: agent, isLoading, error } = useQuery({
queryKey: queryKeys.agents.detail(agentId!),
queryFn: () => agentsApi.get(agentId!),
enabled: !!agentId,
});
const { data: runtimeState } = useQuery({
queryKey: queryKeys.agents.runtimeState(agentId!),
queryFn: () => agentsApi.runtimeState(agentId!),
enabled: !!agentId,
});
const { data: heartbeats } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId),
queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId),
enabled: !!selectedCompanyId && !!agentId,
});
const { data: allIssues } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: allAgents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agentId);
const agentAction = useMutation({
mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate" | "resetSession") => {
if (!agentId) return Promise.reject(new Error("No agent ID"));
switch (action) {
case "invoke": return agentsApi.invoke(agentId);
case "pause": return agentsApi.pause(agentId);
case "resume": return agentsApi.resume(agentId);
case "terminate": return agentsApi.terminate(agentId);
case "resetSession": return agentsApi.resetSession(agentId);
}
},
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
}
},
onError: (err) => {
setActionError(err instanceof Error ? err.message : "Action failed");
},
});
useEffect(() => {
setBreadcrumbs([
{ label: "Agents", href: "/agents" },
{ label: agent?.name ?? agentId ?? "Agent" },
]);
}, [setBreadcrumbs, agent, agentId]);
useEffect(() => {
if (agent) {
openPanel(<AgentProperties agent={agent} runtimeState={runtimeState ?? undefined} />);
}
return () => closePanel();
}, [agent, runtimeState]); // eslint-disable-line react-hooks/exhaustive-deps
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!agent) return null;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{agent.name}</h2>
<p className="text-sm text-muted-foreground">
{roleLabels[agent.role] ?? agent.role}
{agent.title ? ` - ${agent.title}` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => agentAction.mutate("invoke")}
disabled={agentAction.isPending}
>
<Play className="h-3.5 w-3.5 mr-1" />
Invoke
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openNewIssue({ assigneeAgentId: agentId })}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Assign Task
</Button>
{agent.status === "active" || agent.status === "running" ? (
<Button
variant="outline"
size="sm"
onClick={() => agentAction.mutate("pause")}
disabled={agentAction.isPending}
>
<Pause className="h-3.5 w-3.5 mr-1" />
Pause
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => agentAction.mutate("resume")}
disabled={agentAction.isPending}
>
<Play className="h-3.5 w-3.5 mr-1" />
Resume
</Button>
)}
<StatusBadge status={agent.status} />
{/* Overflow menu */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs">
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
agentAction.mutate("resetSession");
setMoreOpen(false);
}}
>
<RotateCcw className="h-3 w-3" />
Reset Session
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
agentAction.mutate("terminate");
setMoreOpen(false);
}}
>
<Trash2 className="h-3 w-3" />
Terminate
</button>
</PopoverContent>
</Popover>
</div>
</div>
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="configuration">Configuration</TabsTrigger>
<TabsTrigger value="runs">Runs{heartbeats ? ` (${heartbeats.length})` : ""}</TabsTrigger>
<TabsTrigger value="issues">Issues ({assignedIssues.length})</TabsTrigger>
<TabsTrigger value="costs">Costs</TabsTrigger>
</TabsList>
{/* OVERVIEW TAB */}
<TabsContent value="overview" className="space-y-6 mt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Summary card */}
<div className="border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-medium">Summary</h3>
<div className="space-y-2 text-sm">
<SummaryRow label="Adapter">
<span className="font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
{String((agent.adapterConfig as Record<string, unknown>)?.model ?? "") !== "" && (
<span className="text-muted-foreground ml-1">
({String((agent.adapterConfig as Record<string, unknown>).model)})
</span>
)}
</SummaryRow>
<SummaryRow label="Heartbeat">
{(agent.runtimeConfig as Record<string, unknown>)?.heartbeat
? (() => {
const hb = (agent.runtimeConfig as Record<string, unknown>).heartbeat as Record<string, unknown>;
if (!hb.enabled) return <span className="text-muted-foreground">Disabled</span>;
const sec = Number(hb.intervalSec) || 300;
return <span>Every {sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`}</span>;
})()
: <span className="text-muted-foreground">Not configured</span>
}
</SummaryRow>
<SummaryRow label="Last heartbeat">
{agent.lastHeartbeatAt
? <span>{relativeTime(agent.lastHeartbeatAt)}</span>
: <span className="text-muted-foreground">Never</span>
}
</SummaryRow>
<SummaryRow label="Session">
{runtimeState?.sessionId
? <span className="font-mono text-xs">{runtimeState.sessionId.slice(0, 16)}...</span>
: <span className="text-muted-foreground">No session</span>
}
</SummaryRow>
{runtimeState && (
<SummaryRow label="Total spend">
<span>{formatCents(runtimeState.totalCostCents)}</span>
</SummaryRow>
)}
</div>
</div>
{/* Org card */}
<div className="border border-border rounded-lg p-4 space-y-3">
<h3 className="text-sm font-medium">Organization</h3>
<div className="space-y-2 text-sm">
<SummaryRow label="Reports to">
{reportsToAgent ? (
<Link
to={`/agents/${reportsToAgent.id}`}
className="text-blue-400 hover:underline"
>
{reportsToAgent.name}
</Link>
) : (
<span className="text-muted-foreground">Nobody (top-level)</span>
)}
</SummaryRow>
{directReports.length > 0 && (
<div>
<span className="text-xs text-muted-foreground">Direct reports</span>
<div className="mt-1 space-y-1">
{directReports.map((r) => (
<Link
key={r.id}
to={`/agents/${r.id}`}
className="flex items-center gap-2 text-sm text-blue-400 hover:underline"
>
<span className="relative flex h-2 w-2">
<span className={`absolute inline-flex h-full w-full rounded-full ${
r.status === "active" ? "bg-green-400" : r.status === "error" ? "bg-red-400" : "bg-neutral-400"
}`} />
</span>
{r.name}
<span className="text-muted-foreground text-xs">({roleLabels[r.role] ?? r.role})</span>
</Link>
))}
</div>
</div>
)}
{agent.capabilities && (
<div>
<span className="text-xs text-muted-foreground">Capabilities</span>
<p className="text-sm mt-0.5">{agent.capabilities}</p>
</div>
)}
</div>
</div>
</div>
</TabsContent>
{/* CONFIGURATION TAB */}
<TabsContent value="configuration" className="mt-4">
<ConfigurationTab agent={agent} />
</TabsContent>
{/* RUNS TAB */}
<TabsContent value="runs" className="mt-4">
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} />
</TabsContent>
{/* ISSUES TAB */}
<TabsContent value="issues" className="mt-4">
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No assigned issues.</p>
) : (
<div className="border border-border rounded-md">
{assignedIssues.map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.id.slice(0, 8)}
title={issue.title}
onClick={() => navigate(`/issues/${issue.id}`)}
trailing={<StatusBadge status={issue.status} />}
/>
))}
</div>
)}
</TabsContent>
{/* COSTS TAB */}
<TabsContent value="costs" className="mt-4">
<CostsTab agent={agent} runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} />
</TabsContent>
</Tabs>
</div>
);
}
/* ---- Helper components ---- */
function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">{label}</span>
<div className="flex items-center gap-1">{children}</div>
</div>
);
}
/* ---- Configuration Tab ---- */
function ConfigurationTab({ agent }: { agent: Agent }) {
const queryClient = useQueryClient();
const { data: adapterModels } = useQuery({
queryKey: ["adapter-models", agent.adapterType],
queryFn: () => agentsApi.adapterModels(agent.adapterType),
});
const updateAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
},
});
return (
<div className="max-w-2xl border border-border rounded-lg overflow-hidden">
<AgentConfigForm
mode="edit"
agent={agent}
onSave={(patch) => updateAgent.mutate(patch)}
adapterModels={adapterModels}
/>
</div>
);
}
/* ---- Runs Tab ---- */
function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string }) {
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
if (runs.length === 0) {
return <p className="text-sm text-muted-foreground">No runs yet.</p>;
}
// Sort by created descending
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return (
<div className="border border-border rounded-md">
{sorted.map((run) => {
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
const StatusIcon = statusInfo.icon;
const isExpanded = expandedRunId === run.id;
const usage = run.usageJson as Record<string, unknown> | null;
const totalTokens = usage
? (Number(usage.input_tokens ?? 0) + Number(usage.output_tokens ?? 0))
: 0;
const cost = usage ? Number(usage.cost_usd ?? usage.total_cost_usd ?? 0) : 0;
const summary = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? "";
return (
<div key={run.id} className="border-b border-border last:border-b-0">
<button
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors text-left"
onClick={() => setExpandedRunId(isExpanded ? null : run.id)}
>
<StatusIcon className={cn("h-4 w-4 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
<span className="font-mono text-xs text-muted-foreground shrink-0">
{run.id.slice(0, 8)}
</span>
<span className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
: "bg-neutral-800 text-neutral-400"
)}>
{sourceLabels[run.invocationSource] ?? run.invocationSource}
</span>
<span className="flex-1 truncate text-muted-foreground text-xs">
{summary ? summary.slice(0, 80) : ""}
</span>
<div className="flex items-center gap-3 shrink-0">
{totalTokens > 0 && (
<span className="text-xs text-muted-foreground">{formatTokens(totalTokens)} tok</span>
)}
{cost > 0 && (
<span className="text-xs text-muted-foreground">${cost.toFixed(3)}</span>
)}
<span className="text-xs text-muted-foreground">
{relativeTime(run.createdAt)}
</span>
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</div>
</button>
{isExpanded && <RunDetail run={run} />}
</div>
);
})}
</div>
);
}
/* ---- Run Detail (expanded) ---- */
function RunDetail({ run }: { run: HeartbeatRun }) {
const queryClient = useQueryClient();
const usage = run.usageJson as Record<string, unknown> | null;
const cancelRun = useMutation({
mutationFn: () => heartbeatsApi.cancel(run.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
},
});
return (
<div className="px-4 pb-4 space-y-4 bg-accent/10">
{/* Status timeline */}
<div className="flex items-center gap-6 text-xs">
<div>
<span className="text-muted-foreground">Status: </span>
<StatusBadge status={run.status} />
</div>
{run.startedAt && (
<div>
<span className="text-muted-foreground">Started: </span>
<span>{formatDate(run.startedAt)} {new Date(run.startedAt).toLocaleTimeString()}</span>
</div>
)}
{run.finishedAt && (
<div>
<span className="text-muted-foreground">Finished: </span>
<span>{formatDate(run.finishedAt)} {new Date(run.finishedAt).toLocaleTimeString()}</span>
</div>
)}
{run.startedAt && run.finishedAt && (
<div>
<span className="text-muted-foreground">Duration: </span>
<span>{Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s</span>
</div>
)}
</div>
{/* Token breakdown */}
{usage && (
<div className="flex items-center gap-6 text-xs">
<div>
<span className="text-muted-foreground">Input: </span>
<span>{formatTokens(Number(usage.input_tokens ?? 0))}</span>
</div>
<div>
<span className="text-muted-foreground">Output: </span>
<span>{formatTokens(Number(usage.output_tokens ?? 0))}</span>
</div>
{Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0) > 0 && (
<div>
<span className="text-muted-foreground">Cached: </span>
<span>{formatTokens(Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0))}</span>
</div>
)}
{Number(usage.cost_usd ?? usage.total_cost_usd ?? 0) > 0 && (
<div>
<span className="text-muted-foreground">Cost: </span>
<span>${Number(usage.cost_usd ?? usage.total_cost_usd ?? 0).toFixed(4)}</span>
</div>
)}
</div>
)}
{/* Session info */}
{(run.sessionIdBefore || run.sessionIdAfter) && (
<div className="flex items-center gap-6 text-xs">
{run.sessionIdBefore && (
<div>
<span className="text-muted-foreground">Session before: </span>
<span className="font-mono">{run.sessionIdBefore.slice(0, 16)}...</span>
</div>
)}
{run.sessionIdAfter && (
<div>
<span className="text-muted-foreground">Session after: </span>
<span className="font-mono">{run.sessionIdAfter.slice(0, 16)}...</span>
</div>
)}
</div>
)}
{/* Error */}
{run.error && (
<div className="text-xs">
<span className="text-red-400">Error: </span>
<span className="text-red-300">{run.error}</span>
{run.errorCode && <span className="text-muted-foreground ml-2">({run.errorCode})</span>}
</div>
)}
{/* Exit info */}
{run.exitCode !== null && (
<div className="text-xs">
<span className="text-muted-foreground">Exit code: </span>
<span>{run.exitCode}</span>
{run.signal && <span className="text-muted-foreground ml-2">(signal: {run.signal})</span>}
</div>
)}
{/* Cancel button for running */}
{(run.status === "running" || run.status === "queued") && (
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/30"
onClick={() => cancelRun.mutate()}
disabled={cancelRun.isPending}
>
{cancelRun.isPending ? "Cancelling..." : "Cancel Run"}
</Button>
)}
<Separator />
{/* Log viewer */}
<LogViewer runId={run.id} status={run.status} />
</div>
);
}
/* ---- Log Viewer ---- */
function LogViewer({ runId, status }: { runId: string; status: string }) {
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
const [loading, setLoading] = useState(true);
const logEndRef = useRef<HTMLDivElement>(null);
const isLive = status === "running" || status === "queued";
// Fetch events
const { data: initialEvents } = useQuery({
queryKey: ["run-events", runId],
queryFn: () => heartbeatsApi.events(runId, 0, 200),
});
useEffect(() => {
if (initialEvents) {
setEvents(initialEvents);
setLoading(false);
}
}, [initialEvents]);
// Auto-scroll
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [events]);
// Poll for live updates
useEffect(() => {
if (!isLive) return;
const interval = setInterval(async () => {
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
try {
const newEvents = await heartbeatsApi.events(runId, maxSeq, 100);
if (newEvents.length > 0) {
setEvents((prev) => [...prev, ...newEvents]);
}
} catch {
// ignore polling errors
}
}, 2000);
return () => clearInterval(interval);
}, [runId, isLive, events]);
if (loading) {
return <p className="text-xs text-muted-foreground">Loading events...</p>;
}
if (events.length === 0) {
return <p className="text-xs text-muted-foreground">No log events.</p>;
}
const levelColors: Record<string, string> = {
info: "text-foreground",
warn: "text-yellow-400",
error: "text-red-400",
};
const streamColors: Record<string, string> = {
stdout: "text-foreground",
stderr: "text-red-300",
system: "text-blue-300",
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground">Events ({events.length})</span>
{isLive && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span>
Live
</span>
)}
</div>
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs max-h-80 overflow-y-auto space-y-0.5">
{events.map((evt) => {
const color = evt.color
?? (evt.level ? levelColors[evt.level] : null)
?? (evt.stream ? streamColors[evt.stream] : null)
?? "text-foreground";
return (
<div key={evt.id} className="flex gap-2">
<span className="text-neutral-600 shrink-0 select-none w-16">
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
</span>
{evt.stream && (
<span className={cn("shrink-0 w-12", streamColors[evt.stream] ?? "text-neutral-500")}>
[{evt.stream}]
</span>
)}
<span className={cn("break-all", color)}>
{evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")}
</span>
</div>
);
})}
<div ref={logEndRef} />
</div>
</div>
);
}
/* ---- Costs Tab ---- */
function CostsTab({
agent,
runtimeState,
runs,
}: {
agent: Agent;
runtimeState?: AgentRuntimeState;
runs: HeartbeatRun[];
}) {
const budgetPct =
agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
const runsWithCost = runs
.filter((r) => {
const u = r.usageJson as Record<string, unknown> | null;
return u && (u.cost_usd || u.total_cost_usd || u.input_tokens);
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return (
<div className="space-y-6 max-w-2xl">
{/* Cumulative totals */}
{runtimeState && (
<div className="border border-border rounded-lg p-4">
<h3 className="text-sm font-medium mb-3">Cumulative Totals</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<span className="text-xs text-muted-foreground block">Input tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">Output tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalOutputTokens)}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">Cached tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalCachedInputTokens)}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">Total cost</span>
<span className="text-lg font-semibold">{formatCents(runtimeState.totalCostCents)}</span>
</div>
</div>
</div>
)}
{/* Monthly budget */}
<div className="border border-border rounded-lg p-4">
<h3 className="text-sm font-medium mb-3">Monthly Budget</h3>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Utilization</span>
<span>
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
budgetPct > 90 ? "bg-red-400" : budgetPct > 70 ? "bg-yellow-400" : "bg-green-400"
)}
style={{ width: `${Math.min(100, budgetPct)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">{budgetPct}% utilized</p>
</div>
{/* Per-run cost table */}
{runsWithCost.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-3">Per-Run Costs</h3>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-accent/20">
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Date</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Run</th>
<th className="text-right px-3 py-2 font-medium text-muted-foreground">Input</th>
<th className="text-right px-3 py-2 font-medium text-muted-foreground">Output</th>
<th className="text-right px-3 py-2 font-medium text-muted-foreground">Cost</th>
</tr>
</thead>
<tbody>
{runsWithCost.map((run) => {
const u = run.usageJson as Record<string, unknown>;
return (
<tr key={run.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
<td className="px-3 py-2 text-right">{formatTokens(Number(u.input_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right">{formatTokens(Number(u.output_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right">
{(u.cost_usd || u.total_cost_usd)
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
: "-"
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}