import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, useBeforeUnload } 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 { activityApi } from "../api/activity"; import { issuesApi } from "../api/issues"; import { usePanel } from "../context/PanelContext"; import { useSidebar } from "../context/SidebarContext"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; import { getUIAdapter, buildTranscript } from "../adapters"; import type { TranscriptEntry } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; import { Tabs, TabsContent } 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, CheckCircle2, XCircle, Clock, Timer, Loader2, Slash, RotateCcw, Trash2, Plus, Key, Eye, EyeOff, Copy, ChevronRight, ArrowLeft, } 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 REDACTED_ENV_VALUE = "***REDACTED***"; const SECRET_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; function shouldRedactSecretValue(key: string, value: unknown): boolean { if (SECRET_ENV_KEY_RE.test(key)) return true; if (typeof value !== "string") return false; return JWT_VALUE_RE.test(value); } function redactEnvValue(key: string, value: unknown): string { if ( typeof value === "object" && value !== null && !Array.isArray(value) && (value as { type?: unknown }).type === "secret_ref" ) { return "***SECRET_REF***"; } if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE; if (value === null || value === undefined) return ""; if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } } function formatEnvForDisplay(envValue: unknown): string { const env = asRecord(envValue); if (!env) return ""; const keys = Object.keys(env); if (keys.length === 0) return ""; return keys .sort() .map((key) => `${key}=${redactEnvValue(key, env[key])}`) .join("\n"); } const sourceLabels: Record = { timer: "Timer", assignment: "Assignment", on_demand: "On-demand", automation: "Automation", }; type AgentDetailTab = "overview" | "configuration" | "runs" | "issues" | "costs" | "keys"; function parseAgentDetailTab(value: string | null): AgentDetailTab { if (value === "configuration") return value; if (value === "runs") return value; if (value === "issues") return value; if (value === "costs") return value; if (value === "keys") return value; return "overview"; } function usageNumber(usage: Record | null, ...keys: string[]) { if (!usage) return 0; for (const key of keys) { const value = usage[key]; if (typeof value === "number" && Number.isFinite(value)) return value; } return 0; } function runMetrics(run: HeartbeatRun) { const usage = (run.usageJson ?? null) as Record | null; const result = (run.resultJson ?? null) as Record | null; const input = usageNumber(usage, "inputTokens", "input_tokens"); const output = usageNumber(usage, "outputTokens", "output_tokens"); const cached = usageNumber( usage, "cachedInputTokens", "cached_input_tokens", "cache_read_input_tokens", ); const cost = usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") || usageNumber(result, "total_cost_usd", "cost_usd", "costUsd"); return { input, output, cached, cost, totalTokens: input + output, }; } type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } export function AgentDetail() { const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>(); const { selectedCompanyId } = useCompany(); const { 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 activeTab = urlRunId ? "runs" as AgentDetailTab : parseAgentDetailTab(urlTab ?? null); const [configDirty, setConfigDirty] = useState(false); const [configSaving, setConfigSaving] = useState(false); const saveConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null); const { isMobile } = useSidebar(); const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); 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 && a.status !== "terminated"); const mobileLiveRun = useMemo( () => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null, [heartbeats], ); const agentAction = useMutation({ mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { 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); } }, onSuccess: (data, action) => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); } if (action === "invoke" && data && typeof data === "object" && "id" in data) { navigate(`/agents/${agentId}/runs/${(data as HeartbeatRun).id}`); } }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Action failed"); }, }); const resetTaskSession = useMutation({ mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentId!) }); }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to reset session"); }, }); const updatePermissions = useMutation({ mutationFn: (canCreateAgents: boolean) => agentsApi.updatePermissions(agentId!, { canCreateAgents }), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); } }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to update permissions"); }, }); useEffect(() => { setBreadcrumbs([ { label: "Agents", href: "/agents" }, { label: agent?.name ?? agentId ?? "Agent" }, ]); }, [setBreadcrumbs, agent, agentId]); useEffect(() => { closePanel(); return () => closePanel(); }, []); // eslint-disable-line react-hooks/exhaustive-deps useBeforeUnload( useCallback((event) => { if (!configDirty) return; event.preventDefault(); event.returnValue = ""; }, [configDirty]), ); const setActiveTab = useCallback((nextTab: string) => { if (configDirty && !window.confirm("You have unsaved changes. Discard them?")) return; const next = parseAgentDetailTab(nextTab); navigate(`/agents/${agentId}/${next}`, { replace: !!urlRunId }); }, [agentId, navigate, configDirty, urlRunId]); if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!agent) return null; const isPendingApproval = agent.status === "pending_approval"; const showConfigActionBar = activeTab === "configuration" && configDirty; return (
{/* Header */}

{agent.name}

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

{agent.status === "paused" ? ( ) : ( )} {mobileLiveRun && ( )} {/* Overflow menu */}
{actionError &&

{actionError}

} {isPendingApproval && (

This agent is pending board approval and cannot be invoked yet.

)} {/* Floating Save/Cancel (desktop) */} {!isMobile && (
)} {/* Mobile bottom Save/Cancel bar */} {isMobile && showConfigActionBar && (
)} {/* 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 }
{/* Org card */}

Organization

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

{agent.capabilities}

)}
{/* RUNS TAB */} {/* CONFIGURATION 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}
); } function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: string }) { const navigate = useNavigate(); if (runs.length === 0) return null; const sorted = [...runs].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued"); const run = liveRun ?? sorted[0]; const isLive = run.status === "running" || run.status === "queued"; const metrics = runMetrics(run); const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const StatusIcon = statusInfo.icon; const summary = run.resultJson ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") : run.error ?? ""; return (
{isLive && ( )}

{isLive ? "Live Run" : "Latest Run"}

{run.id.slice(0, 8)} {sourceLabels[run.invocationSource] ?? run.invocationSource} {relativeTime(run.createdAt)}
{summary && (

{summary}

)} {(metrics.totalTokens > 0 || metrics.cost > 0) && (
{metrics.totalTokens > 0 && {formatTokens(metrics.totalTokens)} tokens} {metrics.cost > 0 && ${metrics.cost.toFixed(3)}}
)}
); } /* ---- Configuration Tab ---- */ function ConfigurationTab({ agent, onDirtyChange, onSaveActionChange, onCancelActionChange, onSavingChange, updatePermissions, }: { agent: Agent; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; }) { 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) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); }, }); const { data: configRevisions } = useQuery({ queryKey: queryKeys.agents.configRevisions(agent.id), queryFn: () => agentsApi.listConfigRevisions(agent.id), }); const rollbackConfig = useMutation({ mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); }, }); useEffect(() => { onSavingChange(updateAgent.isPending); }, [onSavingChange, updateAgent.isPending]); return (
updateAgent.mutate(patch)} isSaving={updateAgent.isPending} adapterModels={adapterModels} onDirtyChange={onDirtyChange} onSaveActionChange={onSaveActionChange} onCancelActionChange={onCancelActionChange} hideInlineSave />

Permissions

Can create new agents

Configuration Revisions

{configRevisions?.length ?? 0}
{(configRevisions ?? []).length === 0 ? (

No configuration revisions yet.

) : (
{(configRevisions ?? []).slice(0, 10).map((revision) => (
{revision.id.slice(0, 8)} · {formatDate(revision.createdAt)} · {revision.source}

Changed:{" "} {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"}

))}
)}
); } /* ---- Runs Tab ---- */ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) { const navigate = useNavigate(); const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const StatusIcon = statusInfo.icon; const metrics = runMetrics(run); const summary = run.resultJson ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") : run.error ?? ""; return ( ); } function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null; adapterType: string }) { const navigate = useNavigate(); const { isMobile } = useSidebar(); 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() ); // On mobile, don't auto-select so the list shows first; on desktop, auto-select latest const effectiveRunId = isMobile ? selectedRunId : (selectedRunId ?? sorted[0]?.id ?? null); const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null; // Mobile: show either run list OR run detail with back button if (isMobile) { if (selectedRun) { return (
); } return (
{sorted.map((run) => ( ))}
); } // Desktop: side-by-side layout return (
{/* Left: run list — border stretches full height, content sticks */}
{sorted.map((run) => ( ))}
{/* Right: run detail — natural height, page scrolls */} {selectedRun && (
)}
); } /* ---- Run Detail (expanded) ---- */ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) { const queryClient = useQueryClient(); const navigate = useNavigate(); const metrics = runMetrics(run); const [sessionOpen, setSessionOpen] = useState(false); const cancelRun = useMutation({ mutationFn: () => heartbeatsApi.cancel(run.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); }, }); const { data: touchedIssues } = useQuery({ queryKey: queryKeys.runIssues(run.id), queryFn: () => activityApi.issuesForRun(run.id), }); const touchedIssueIds = useMemo( () => Array.from(new Set((touchedIssues ?? []).map((issue) => issue.issueId))), [touchedIssues], ); const clearSessionsForTouchedIssues = useMutation({ mutationFn: async () => { if (touchedIssueIds.length === 0) return 0; await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId))); return touchedIssueIds.length; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(run.agentId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(run.agentId) }); queryClient.invalidateQueries({ queryKey: queryKeys.runIssues(run.id) }); }, }); const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }; const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null; const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null; const durationSec = run.startedAt && run.finishedAt ? Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000) : null; const hasMetrics = metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0; const hasSession = !!(run.sessionIdBefore || run.sessionIdAfter); const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter; const sessionId = run.sessionIdAfter || run.sessionIdBefore; const hasNonZeroExit = run.exitCode !== null && run.exitCode !== 0; return (
{/* Run summary card */}
{/* Left column: status + timing */}
{(run.status === "running" || run.status === "queued") && ( )}
{startTime && (
{startTime} {endTime && } {endTime}
{relativeTime(run.startedAt!)} {run.finishedAt && <> → {relativeTime(run.finishedAt)}}
{durationSec !== null && (
Duration: {durationSec >= 60 ? `${Math.floor(durationSec / 60)}m ${durationSec % 60}s` : `${durationSec}s`}
)}
)} {run.error && (
{run.error} {run.errorCode && ({run.errorCode})}
)} {hasNonZeroExit && (
Exit code {run.exitCode} {run.signal && (signal: {run.signal})}
)}
{/* Right column: metrics */} {hasMetrics && (
Input
{formatTokens(metrics.input)}
Output
{formatTokens(metrics.output)}
Cached
{formatTokens(metrics.cached)}
Cost
{metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-"}
)}
{/* Collapsible session row */} {hasSession && (
{sessionOpen && (
{run.sessionIdBefore && (
{sessionChanged ? "Before" : "ID"}
)} {sessionChanged && run.sessionIdAfter && (
After
)} {touchedIssueIds.length > 0 && (
{clearSessionsForTouchedIssues.isError && (

{clearSessionsForTouchedIssues.error instanceof Error ? clearSessionsForTouchedIssues.error.message : "Failed to clear sessions"}

)}
)}
)}
)}
{/* Issues touched by this run */} {touchedIssues && touchedIssues.length > 0 && (
Issues Touched ({touchedIssues.length})
{touchedIssues.map((issue) => ( ))}
)} {/* stderr excerpt for failed runs */} {run.stderrExcerpt && (
stderr
{run.stderrExcerpt}
)} {/* stdout excerpt when no log is available */} {run.stdoutExcerpt && !run.logRef && (
stdout
{run.stdoutExcerpt}
)} {/* Log viewer */}
); } /* ---- Log Viewer ---- */ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) { const [events, setEvents] = useState([]); const [logLines, setLogLines] = useState>([]); const [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(!!run.logRef); const [logError, setLogError] = useState(null); const [logOffset, setLogOffset] = useState(0); const [isFollowing, setIsFollowing] = useState(true); const logEndRef = useRef(null); const pendingLogLineRef = useRef(""); const isLive = run.status === "running" || run.status === "queued"; function appendLogContent(content: string, finalize = false) { if (!content && !finalize) return; const combined = `${pendingLogLineRef.current}${content}`; const split = combined.split("\n"); pendingLogLineRef.current = split.pop() ?? ""; if (finalize && pendingLogLineRef.current) { split.push(pendingLogLineRef.current); pendingLogLineRef.current = ""; } const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = []; for (const line of split) { const trimmed = line.trim(); if (!trimmed) continue; try { const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown }; const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout"; const chunk = typeof raw.chunk === "string" ? raw.chunk : ""; const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString(); if (!chunk) continue; parsed.push({ ts, stream, chunk }); } catch { // ignore malformed lines } } if (parsed.length > 0) { setLogLines((prev) => [...prev, ...parsed]); } } // Fetch events const { data: initialEvents } = useQuery({ queryKey: ["run-events", run.id], queryFn: () => heartbeatsApi.events(run.id, 0, 200), }); useEffect(() => { if (initialEvents) { setEvents(initialEvents); setLoading(false); } }, [initialEvents]); const updateFollowingState = useCallback(() => { const el = logEndRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const inView = rect.top <= window.innerHeight && rect.bottom >= 0; setIsFollowing((prev) => (prev === inView ? prev : inView)); }, []); useEffect(() => { if (!isLive) return; setIsFollowing(true); }, [isLive, run.id]); useEffect(() => { if (!isLive) return; updateFollowingState(); window.addEventListener("scroll", updateFollowingState, { passive: true }); window.addEventListener("resize", updateFollowingState); return () => { window.removeEventListener("scroll", updateFollowingState); window.removeEventListener("resize", updateFollowingState); }; }, [isLive, updateFollowingState]); // Auto-scroll only for live runs when following useEffect(() => { if (isLive && isFollowing) { logEndRef.current?.scrollIntoView({ behavior: "smooth" }); } }, [events, logLines, isLive, isFollowing]); // Fetch persisted shell log useEffect(() => { let cancelled = false; pendingLogLineRef.current = ""; setLogLines([]); setLogOffset(0); setLogError(null); if (!run.logRef) { setLogLoading(false); return () => { cancelled = true; }; } setLogLoading(true); const firstLimit = typeof run.logBytes === "number" && run.logBytes > 0 ? Math.min(Math.max(run.logBytes + 1024, 256_000), 2_000_000) : 256_000; const load = async () => { try { let offset = 0; let first = true; while (!cancelled) { const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000); if (cancelled) break; appendLogContent(result.content, result.nextOffset === undefined); const next = result.nextOffset ?? offset + result.content.length; setLogOffset(next); offset = next; first = false; if (result.nextOffset === undefined || isLive) break; } } catch (err) { if (!cancelled) { setLogError(err instanceof Error ? err.message : "Failed to load run log"); } } finally { if (!cancelled) setLogLoading(false); } }; void load(); return () => { cancelled = true; }; }, [run.id, run.logRef, run.logBytes, isLive]); // 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(run.id, maxSeq, 100); if (newEvents.length > 0) { setEvents((prev) => [...prev, ...newEvents]); } } catch { // ignore polling errors } }, 2000); return () => clearInterval(interval); }, [run.id, isLive, events]); // Poll shell log for running runs useEffect(() => { if (!isLive || !run.logRef) return; const interval = setInterval(async () => { try { const result = await heartbeatsApi.log(run.id, logOffset, 256_000); if (result.content) { appendLogContent(result.content, result.nextOffset === undefined); } if (result.nextOffset !== undefined) { setLogOffset(result.nextOffset); } else if (result.content.length > 0) { setLogOffset((prev) => prev + result.content.length); } } catch { // ignore polling errors } }, 2000); return () => clearInterval(interval); }, [run.id, run.logRef, isLive, logOffset]); const adapterInvokePayload = useMemo(() => { const evt = events.find((e) => e.eventType === "adapter.invoke"); return asRecord(evt?.payload ?? null); }, [events]); const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]); if (loading && logLoading) { return

Loading run logs...

; } if (events.length === 0 && logLines.length === 0 && !logError) { 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 (
{adapterInvokePayload && (
Invocation
{typeof adapterInvokePayload.adapterType === "string" && (
Adapter: {adapterInvokePayload.adapterType}
)} {typeof adapterInvokePayload.cwd === "string" && (
Working dir: {adapterInvokePayload.cwd}
)} {typeof adapterInvokePayload.command === "string" && (
Command: {[ adapterInvokePayload.command, ...(Array.isArray(adapterInvokePayload.commandArgs) ? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string") : []), ].join(" ")}
)} {adapterInvokePayload.prompt !== undefined && (
Prompt
                {typeof adapterInvokePayload.prompt === "string"
                  ? adapterInvokePayload.prompt
                  : JSON.stringify(adapterInvokePayload.prompt, null, 2)}
              
)} {adapterInvokePayload.context !== undefined && (
Context
                {JSON.stringify(adapterInvokePayload.context, null, 2)}
              
)} {adapterInvokePayload.env !== undefined && (
Environment
                {formatEnvForDisplay(adapterInvokePayload.env)}
              
)}
)}
Transcript ({transcript.length})
{isLive && !isFollowing && ( )} {isLive && ( Live )}
{transcript.length === 0 && !run.logRef && (
No persisted transcript for this run.
)} {transcript.map((entry, idx) => { const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false }); const grid = "grid grid-cols-[auto_auto_1fr] gap-x-3 items-baseline"; const tsCell = "text-neutral-600 select-none w-16"; const lblCell = "w-20"; const contentCell = "min-w-0 whitespace-pre-wrap break-words"; const expandCell = "col-span-full md:col-start-3 md:col-span-1"; if (entry.kind === "assistant") { return (
{time} assistant {entry.text}
); } if (entry.kind === "thinking") { return (
{time} thinking {entry.text}
); } if (entry.kind === "user") { return (
{time} user {entry.text}
); } if (entry.kind === "tool_call") { return (
{time} tool_call {entry.name}
                  {JSON.stringify(entry.input, null, 2)}
                
); } if (entry.kind === "tool_result") { return (
{time} tool_result {entry.isError ? error : }
                  {(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
                
); } if (entry.kind === "init") { return (
{time} init model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}
); } if (entry.kind === "result") { return (
{time} result tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)} {(entry.subtype || entry.isError || entry.errors.length > 0) && (
subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"} {entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
)} {entry.text && (
{entry.text}
)}
); } const rawText = entry.text; const label = entry.kind === "stderr" ? "stderr" : entry.kind === "system" ? "system" : "stdout"; const color = entry.kind === "stderr" ? "text-red-300" : entry.kind === "system" ? "text-blue-300" : "text-neutral-500"; return (
{time} {label} {rawText}
) })} {logError &&
{logError}
}
{(run.status === "failed" || run.status === "timed_out") && (
Failure details
{run.error && (
Error: {run.error}
)} {run.stderrExcerpt && run.stderrExcerpt.trim() && (
stderr excerpt
                {run.stderrExcerpt}
              
)} {run.resultJson && (
adapter result JSON
                {JSON.stringify(run.resultJson, null, 2)}
              
)} {run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
stdout excerpt
                {run.stdoutExcerpt}
              
)}
)} {events.length > 0 && (
Events ({events.length})
{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({ runtimeState, runs, }: { runtimeState?: AgentRuntimeState; runs: HeartbeatRun[]; }) { 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)}
)} {/* 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) : ""}
))}
)}
); }