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>
832 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|