import { useEffect, useState, useRef } from "react"; import { useParams, useNavigate, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi, type AgentKey } 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, Key, Eye, EyeOff, Copy, } from "lucide-react"; import { Input } from "@/components/ui/input"; import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared"; const runStatusIcons: Record = { 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 = { 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(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(); } return () => closePanel(); }, [agent, runtimeState]); // eslint-disable-line react-hooks/exhaustive-deps if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!agent) return null; return (
{/* Header */}

{agent.name}

{roleLabels[agent.role] ?? agent.role} {agent.title ? ` - ${agent.title}` : ""}

{agent.status === "active" || agent.status === "running" ? ( ) : ( )} {/* Overflow menu */}
{actionError &&

{actionError}

} Overview Configuration Runs{heartbeats ? ` (${heartbeats.length})` : ""} Issues ({assignedIssues.length}) Costs API Keys {/* OVERVIEW TAB */}
{/* Summary card */}

Summary

{adapterLabels[agent.adapterType] ?? agent.adapterType} {String((agent.adapterConfig as Record)?.model ?? "") !== "" && ( ({String((agent.adapterConfig as Record).model)}) )} {(agent.runtimeConfig as Record)?.heartbeat ? (() => { const hb = (agent.runtimeConfig as Record).heartbeat as Record; if (!hb.enabled) return Disabled; const sec = Number(hb.intervalSec) || 300; return Every {sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`}; })() : Not configured } {agent.lastHeartbeatAt ? {relativeTime(agent.lastHeartbeatAt)} : Never } {runtimeState?.sessionId ? {runtimeState.sessionId.slice(0, 16)}... : No session } {runtimeState && ( {formatCents(runtimeState.totalCostCents)} )}
{/* Org card */}

Organization

{reportsToAgent ? ( {reportsToAgent.name} ) : ( Nobody (top-level) )} {directReports.length > 0 && (
Direct reports
{directReports.map((r) => ( {r.name} ({roleLabels[r.role] ?? r.role}) ))}
)} {agent.capabilities && (
Capabilities

{agent.capabilities}

)}
{/* CONFIGURATION TAB */} {/* RUNS TAB */} {/* ISSUES TAB */} {assignedIssues.length === 0 ? (

No assigned issues.

) : (
{assignedIssues.map((issue) => ( navigate(`/issues/${issue.id}`)} trailing={} /> ))}
)}
{/* COSTS TAB */} {/* KEYS TAB */}
); } /* ---- Helper components ---- */ function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } /* ---- 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) => agentsApi.update(agent.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); }, }); return (
updateAgent.mutate(patch)} adapterModels={adapterModels} />
); } /* ---- Runs Tab ---- */ function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string }) { const [expandedRunId, setExpandedRunId] = useState(null); if (runs.length === 0) { return

No runs yet.

; } // Sort by created descending const sorted = [...runs].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); return (
{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 | 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).summary ?? (run.resultJson as Record).result ?? "") : run.error ?? ""; return (
{isExpanded && }
); })}
); } /* ---- Run Detail (expanded) ---- */ function RunDetail({ run }: { run: HeartbeatRun }) { const queryClient = useQueryClient(); const usage = run.usageJson as Record | null; const cancelRun = useMutation({ mutationFn: () => heartbeatsApi.cancel(run.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); }, }); return (
{/* Status timeline */}
Status:
{run.startedAt && (
Started: {formatDate(run.startedAt)} {new Date(run.startedAt).toLocaleTimeString()}
)} {run.finishedAt && (
Finished: {formatDate(run.finishedAt)} {new Date(run.finishedAt).toLocaleTimeString()}
)} {run.startedAt && run.finishedAt && (
Duration: {Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s
)}
{/* Token breakdown */} {usage && (
Input: {formatTokens(Number(usage.input_tokens ?? 0))}
Output: {formatTokens(Number(usage.output_tokens ?? 0))}
{Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0) > 0 && (
Cached: {formatTokens(Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0))}
)} {Number(usage.cost_usd ?? usage.total_cost_usd ?? 0) > 0 && (
Cost: ${Number(usage.cost_usd ?? usage.total_cost_usd ?? 0).toFixed(4)}
)}
)} {/* Session info */} {(run.sessionIdBefore || run.sessionIdAfter) && (
{run.sessionIdBefore && (
Session before: {run.sessionIdBefore.slice(0, 16)}...
)} {run.sessionIdAfter && (
Session after: {run.sessionIdAfter.slice(0, 16)}...
)}
)} {/* Error */} {run.error && (
Error: {run.error} {run.errorCode && ({run.errorCode})}
)} {/* Exit info */} {run.exitCode !== null && (
Exit code: {run.exitCode} {run.signal && (signal: {run.signal})}
)} {/* Cancel button for running */} {(run.status === "running" || run.status === "queued") && ( )} {/* Log viewer */}
); } /* ---- Log Viewer ---- */ function LogViewer({ runId, status }: { runId: string; status: string }) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const logEndRef = useRef(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

Loading events...

; } if (events.length === 0) { return

No log events.

; } const levelColors: Record = { info: "text-foreground", warn: "text-yellow-400", error: "text-red-400", }; const streamColors: Record = { stdout: "text-foreground", stderr: "text-red-300", system: "text-blue-300", }; return (
Events ({events.length}) {isLive && ( Live )}
{events.map((evt) => { const color = evt.color ?? (evt.level ? levelColors[evt.level] : null) ?? (evt.stream ? streamColors[evt.stream] : null) ?? "text-foreground"; return (
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })} {evt.stream && ( [{evt.stream}] )} {evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")}
); })}
); } /* ---- 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 | 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 (
{/* Cumulative totals */} {runtimeState && (

Cumulative Totals

Input tokens {formatTokens(runtimeState.totalInputTokens)}
Output tokens {formatTokens(runtimeState.totalOutputTokens)}
Cached tokens {formatTokens(runtimeState.totalCachedInputTokens)}
Total cost {formatCents(runtimeState.totalCostCents)}
)} {/* Monthly budget */}

Monthly Budget

Utilization {formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
90 ? "bg-red-400" : budgetPct > 70 ? "bg-yellow-400" : "bg-green-400" )} style={{ width: `${Math.min(100, budgetPct)}%` }} />

{budgetPct}% utilized

{/* Per-run cost table */} {runsWithCost.length > 0 && (

Per-Run Costs

{runsWithCost.map((run) => { const u = run.usageJson as Record; return ( ); })}
Date Run Input Output Cost
{formatDate(run.createdAt)} {run.id.slice(0, 8)} {formatTokens(Number(u.input_tokens ?? 0))} {formatTokens(Number(u.output_tokens ?? 0))} {(u.cost_usd || u.total_cost_usd) ? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}` : "-" }
)}
); } /* ---- Keys Tab ---- */ function KeysTab({ agentId }: { agentId: string }) { const queryClient = useQueryClient(); const [newKeyName, setNewKeyName] = useState(""); const [newToken, setNewToken] = useState(null); const [tokenVisible, setTokenVisible] = useState(false); const [copied, setCopied] = useState(false); const { data: keys, isLoading } = useQuery({ queryKey: queryKeys.agents.keys(agentId), queryFn: () => agentsApi.listKeys(agentId), }); const createKey = useMutation({ mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default"), onSuccess: (data) => { setNewToken(data.token); setTokenVisible(true); setNewKeyName(""); queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) }); }, }); const revokeKey = useMutation({ mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) }); }, }); function copyToken() { if (!newToken) return; navigator.clipboard.writeText(newToken); setCopied(true); setTimeout(() => setCopied(false), 2000); } const activeKeys = (keys ?? []).filter((k: AgentKey) => !k.revokedAt); const revokedKeys = (keys ?? []).filter((k: AgentKey) => k.revokedAt); return (
{/* New token banner */} {newToken && (

API key created — copy it now, it will not be shown again.

{tokenVisible ? newToken : newToken.replace(/./g, "•")} {copied && Copied!}
)} {/* Create new key */}

Create API Key

API keys allow this agent to authenticate calls to the Paperclip server.

setNewKeyName(e.target.value)} className="h-8 text-sm" onKeyDown={(e) => { if (e.key === "Enter") createKey.mutate(); }} />
{/* Active keys */} {isLoading &&

Loading keys...

} {!isLoading && activeKeys.length === 0 && !newToken && (

No active API keys.

)} {activeKeys.length > 0 && (

Active Keys

{activeKeys.map((key: AgentKey) => (
{key.name} Created {formatDate(key.createdAt)}
))}
)} {/* Revoked keys (collapsed) */} {revokedKeys.length > 0 && (

Revoked Keys

{revokedKeys.map((key: AgentKey) => (
{key.name} Revoked {key.revokedAt ? formatDate(key.revokedAt) : ""}
))}
)}
); }