import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill, type AgentPermissionUpdate, } from "../api/agents"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { ApiError } from "../api/client"; import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; 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 { StatusBadge } from "../components/StatusBadge"; import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; import { MarkdownBody } from "../components/MarkdownBody"; import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { ScrollToBottom } from "../components/ScrollToBottom"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Tabs } from "@/components/ui/tabs"; 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, ChevronDown, ArrowLeft, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; import { isUuidLike, type Agent, type AgentDetail as AgentDetailRecord, type BudgetPolicySummary, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent, type WorkspaceOperation, } from "@paperclipai/shared"; import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils"; import { agentRouteRef } from "../lib/utils"; const runStatusIcons: Record = { succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" }, running: { icon: Loader2, color: "text-cyan-600 dark:text-cyan-400" }, queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" }, timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" }, cancelled: { icon: Slash, color: "text-neutral-500 dark: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 redactPathText(value: string, censorUsernameInLogs: boolean) { return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs }); } function redactPathValue(value: T, censorUsernameInLogs: boolean): T { return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs }); } 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, censorUsernameInLogs: boolean): 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 redactPathText(value, censorUsernameInLogs); try { return JSON.stringify(redactPathValue(value, censorUsernameInLogs)); } catch { return redactPathText(String(value), censorUsernameInLogs); } } function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): 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], censorUsernameInLogs)}`) .join("\n"); } const sourceLabels: Record = { timer: "Timer", assignment: "Assignment", on_demand: "On-demand", automation: "Automation", }; const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32; type ScrollContainer = Window | HTMLElement; function isWindowContainer(container: ScrollContainer): container is Window { return container === window; } function isElementScrollContainer(element: HTMLElement): boolean { const overflowY = window.getComputedStyle(element).overflowY; return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; } function findScrollContainer(anchor: HTMLElement | null): ScrollContainer { let parent = anchor?.parentElement ?? null; while (parent) { if (isElementScrollContainer(parent)) return parent; parent = parent.parentElement; } return window; } function readScrollMetrics(container: ScrollContainer): { scrollHeight: number; distanceFromBottom: number } { if (isWindowContainer(container)) { const pageHeight = Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, ); const viewportBottom = window.scrollY + window.innerHeight; return { scrollHeight: pageHeight, distanceFromBottom: Math.max(0, pageHeight - viewportBottom), }; } const viewportBottom = container.scrollTop + container.clientHeight; return { scrollHeight: container.scrollHeight, distanceFromBottom: Math.max(0, container.scrollHeight - viewportBottom), }; } function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBehavior = "auto") { if (isWindowContainer(container)) { const pageHeight = Math.max( document.documentElement.scrollHeight, document.body.scrollHeight, ); window.scrollTo({ top: pageHeight, behavior }); return; } container.scrollTo({ top: container.scrollHeight, behavior }); } type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "configure" || value === "configuration") return "configuration"; if (value === "skills") return value; if (value === "budget") return value; if (value === "runs") return value; return "dashboard"; } 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 = visibleRunCostUsd(usage, result); 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; } function asNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function parseStoredLogContent(content: string): RunLogChunk[] { const parsed: RunLogChunk[] = []; for (const line of content.split("\n")) { 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 log lines. } } return parsed; } function workspaceOperationPhaseLabel(phase: WorkspaceOperation["phase"]) { switch (phase) { case "worktree_prepare": return "Worktree setup"; case "workspace_provision": return "Provision"; case "workspace_teardown": return "Teardown"; case "worktree_cleanup": return "Worktree cleanup"; default: return phase; } } function workspaceOperationStatusTone(status: WorkspaceOperation["status"]) { switch (status) { case "succeeded": return "border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-300"; case "failed": return "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300"; case "running": return "border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300"; case "skipped": return "border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300"; default: return "border-border bg-muted/40 text-muted-foreground"; } } function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation["status"] }) { return ( {status.replace("_", " ")} ); } function WorkspaceOperationLogViewer({ operation, censorUsernameInLogs, }: { operation: WorkspaceOperation; censorUsernameInLogs: boolean; }) { const [open, setOpen] = useState(false); const { data: logData, isLoading, error } = useQuery({ queryKey: ["workspace-operation-log", operation.id], queryFn: () => heartbeatsApi.workspaceOperationLog(operation.id), enabled: open && Boolean(operation.logRef), refetchInterval: open && operation.status === "running" ? 2000 : false, }); const chunks = useMemo( () => (logData?.content ? parseStoredLogContent(logData.content) : []), [logData?.content], ); return (
{open && (
{isLoading &&
Loading log...
} {error && (
{error instanceof Error ? error.message : "Failed to load workspace operation log"}
)} {!isLoading && !error && chunks.length === 0 && (
No persisted log lines.
)} {chunks.length > 0 && (
{chunks.map((chunk, index) => (
{new Date(chunk.ts).toLocaleTimeString("en-US", { hour12: false })} [{chunk.stream}] {redactPathText(chunk.chunk, censorUsernameInLogs)}
))}
)}
)}
); } function WorkspaceOperationsSection({ operations, censorUsernameInLogs, }: { operations: WorkspaceOperation[]; censorUsernameInLogs: boolean; }) { if (operations.length === 0) return null; return (
Workspace ({operations.length})
{operations.map((operation) => { const metadata = asRecord(operation.metadata); return (
{workspaceOperationPhaseLabel(operation.phase)}
{relativeTime(operation.startedAt)} {operation.finishedAt && ` to ${relativeTime(operation.finishedAt)}`}
{operation.command && (
Command: {operation.command}
)} {operation.cwd && (
Working dir: {operation.cwd}
)} {(asNonEmptyString(metadata?.branchName) || asNonEmptyString(metadata?.baseRef) || asNonEmptyString(metadata?.worktreePath) || asNonEmptyString(metadata?.repoRoot) || asNonEmptyString(metadata?.cleanupAction)) && (
{asNonEmptyString(metadata?.branchName) && (
Branch: {metadata?.branchName as string}
)} {asNonEmptyString(metadata?.baseRef) && (
Base ref: {metadata?.baseRef as string}
)} {asNonEmptyString(metadata?.worktreePath) && (
Worktree: {metadata?.worktreePath as string}
)} {asNonEmptyString(metadata?.repoRoot) && (
Repo root: {metadata?.repoRoot as string}
)} {asNonEmptyString(metadata?.cleanupAction) && (
Cleanup: {metadata?.cleanupAction as string}
)}
)} {typeof metadata?.created === "boolean" && (
{metadata.created ? "Created by this run" : "Reused existing workspace"}
)} {operation.stderrExcerpt && operation.stderrExcerpt.trim() && (
stderr excerpt
                    {redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
                  
)} {operation.stdoutExcerpt && operation.stdoutExcerpt.trim() && (
stdout excerpt
                    {redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
                  
)} {operation.logRef && ( )}
); })}
); } export function AgentDetail() { const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{ companyPrefix?: string; agentId: string; tab?: string; runId?: string; }>(); const { companies, selectedCompanyId, setSelectedCompanyId } = 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 activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(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 routeAgentRef = agentId ?? ""; const routeCompanyId = useMemo(() => { if (!companyPrefix) return null; const requestedPrefix = companyPrefix.toUpperCase(); return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null; }, [companies, companyPrefix]); const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined; const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId)); 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(routeAgentRef), lookupCompanyId ?? null], queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), enabled: canFetchAgent, }); const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; const agentLookupRef = agent?.id ?? routeAgentRef; const resolvedAgentId = agent?.id ?? null; const { data: runtimeState } = useQuery({ queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef), queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined), enabled: Boolean(resolvedAgentId), }); const { data: heartbeats } = useQuery({ queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined), queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined), enabled: !!resolvedCompanyId && !!agent?.id, }); const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(resolvedCompanyId!), queryFn: () => issuesApi.list(resolvedCompanyId!), enabled: !!resolvedCompanyId, }); const { data: allAgents } = useQuery({ queryKey: queryKeys.agents.list(resolvedCompanyId!), queryFn: () => agentsApi.list(resolvedCompanyId!), enabled: !!resolvedCompanyId, }); const { data: budgetOverview } = useQuery({ queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"), queryFn: () => budgetsApi.overview(resolvedCompanyId!), enabled: !!resolvedCompanyId, refetchInterval: 30_000, staleTime: 5_000, }); const assignedIssues = (allIssues ?? []) .filter((i) => i.assigneeAgentId === agent?.id) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); const agentBudgetSummary = useMemo(() => { const matched = budgetOverview?.policies.find( (policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef), ); if (matched) return matched; const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0; const spentMonthlyCents = agent?.spentMonthlyCents ?? 0; return { policyId: "", companyId: resolvedCompanyId ?? "", scopeType: "agent", scopeId: agent?.id ?? routeAgentRef, scopeName: agent?.name ?? "Agent", metric: "billed_cents", windowKind: "calendar_month_utc", amount: budgetMonthlyCents, observedAmount: spentMonthlyCents, remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents), utilizationPercent: budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, isActive: budgetMonthlyCents > 0, status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok", paused: agent?.status === "paused", pauseReason: agent?.pauseReason ?? null, windowStart: new Date(), windowEnd: new Date(), } satisfies BudgetPolicySummary; }, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]); const mobileLiveRun = useMemo( () => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null, [heartbeats], ); useEffect(() => { if (!agent) return; if (urlRunId) { if (routeAgentRef !== canonicalAgentRef) { navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); } return; } const canonicalTab = activeView === "configuration" ? "configuration" : activeView === "skills" ? "skills" : activeView === "runs" ? "runs" : activeView === "budget" ? "budget" : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); return; } }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]); useEffect(() => { if (!agent?.companyId || agent.companyId === selectedCompanyId) return; setSelectedCompanyId(agent.companyId, { source: "route_sync" }); }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); const agentAction = useMutation({ mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { if (!agentLookupRef) return Promise.reject(new Error("No agent reference")); switch (action) { case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined); case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined); case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined); case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined); } }, onSuccess: (data, action) => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); if (agent?.id) { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) }); } } if (action === "invoke" && data && typeof data === "object" && "id" in data) { navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`); } }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Action failed"); }, }); const budgetMutation = useMutation({ mutationFn: (amount: number) => budgetsApi.upsertPolicy(resolvedCompanyId!, { scopeType: "agent", scopeId: agent?.id ?? routeAgentRef, amount, windowKind: "calendar_month_utc", }), onSuccess: () => { if (!resolvedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) }); }, }); const updateIcon = useMutation({ mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); } }, }); const resetTaskSession = useMutation({ mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) }); }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to reset session"); }, }); const updatePermissions = useMutation({ mutationFn: (permissions: AgentPermissionUpdate) => agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined), onSuccess: () => { setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); } }, onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to update permissions"); }, }); useEffect(() => { const crumbs: { label: string; href?: string }[] = [ { label: "Agents", href: "/agents" }, ]; const agentName = agent?.name ?? routeAgentRef ?? "Agent"; if (activeView === "dashboard" && !urlRunId) { crumbs.push({ label: agentName }); } else { crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` }); if (urlRunId) { crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` }); crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); } else if (activeView === "configuration") { crumbs.push({ label: "Configuration" }); // } else if (activeView === "skills") { // TODO: bring back later // crumbs.push({ label: "Skills" }); } else if (activeView === "runs") { crumbs.push({ label: "Runs" }); } else if (activeView === "budget") { crumbs.push({ label: "Budget" }); } else { crumbs.push({ label: "Dashboard" }); } } setBreadcrumbs(crumbs); }, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]); useEffect(() => { closePanel(); return () => closePanel(); }, [closePanel]); useBeforeUnload( useCallback((event) => { if (!configDirty) return; event.preventDefault(); event.returnValue = ""; }, [configDirty]), ); if (isLoading) return ; if (error) return

{error.message}

; if (!agent) return null; if (!urlRunId && !urlTab) { return ; } const isPendingApproval = agent.status === "pending_approval"; const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving); return (
{/* Header */}
updateIcon.mutate(icon)} >

{agent.name}

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

{agent.status === "paused" ? ( ) : ( )} {mobileLiveRun && ( Live )} {/* Overflow menu */}
{!urlRunId && ( navigate(`/agents/${canonicalAgentRef}/${value}`)} > navigate(`/agents/${canonicalAgentRef}/${value}`)} /> )} {actionError &&

{actionError}

} {isPendingApproval && (

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

)} {/* Floating Save/Cancel (desktop) */} {!isMobile && showConfigActionBar && (
)} {/* Mobile bottom Save/Cancel bar */} {isMobile && showConfigActionBar && (
)} {/* View content */} {activeView === "dashboard" && ( )} {activeView === "configuration" && ( )} {/* {activeView === "skills" && ( )} */}{/* TODO: bring back later */} {activeView === "runs" && ( )} {activeView === "budget" && resolvedCompanyId ? (
budgetMutation.mutate(amount)} variant="plain" />
) : null}
); } /* ---- Helper components ---- */ function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: string }) { 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 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"}

View details →
{run.id.slice(0, 8)} {sourceLabels[run.invocationSource] ?? run.invocationSource} {relativeTime(run.createdAt)}
{summary && (
{summary}
)}
); } /* ---- Agent Overview (main single-page view) ---- */ function AgentOverview({ agent, runs, assignedIssues, runtimeState, agentId, agentRouteId, }: { agent: AgentDetailRecord; runs: HeartbeatRun[]; assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; runtimeState?: AgentRuntimeState; agentId: string; agentRouteId: string; }) { return (
{/* Latest Run */} {/* Charts */}
{/* Recent Issues */}

Recent Issues

See All →
{assignedIssues.length === 0 ? (

No assigned issues.

) : (
{assignedIssues.slice(0, 10).map((issue) => ( } /> ))} {assignedIssues.length > 10 && (
+{assignedIssues.length - 10} more issues
)}
)}
{/* Costs */}

Costs

); } /* ---- Costs Section (inline) ---- */ function CostsSection({ runtimeState, runs, }: { runtimeState?: AgentRuntimeState; runs: HeartbeatRun[]; }) { const runsWithCost = runs .filter((r) => { const metrics = runMetrics(r); return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0; }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return (
{runtimeState && (
Input tokens {formatTokens(runtimeState.totalInputTokens)}
Output tokens {formatTokens(runtimeState.totalOutputTokens)}
Cached tokens {formatTokens(runtimeState.totalCachedInputTokens)}
Total cost {formatCents(runtimeState.totalCostCents)}
)} {runsWithCost.length > 0 && (
{runsWithCost.slice(0, 10).map((run) => { const metrics = runMetrics(run); return ( ); })}
Date Run Input Output Cost
{formatDate(run.createdAt)} {run.id.slice(0, 8)} {formatTokens(metrics.input)} {formatTokens(metrics.output)} {metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-" }
)}
); } /* ---- Agent Configure Page ---- */ function AgentConfigurePage({ agent, agentId, companyId, onDirtyChange, onSaveActionChange, onCancelActionChange, onSavingChange, updatePermissions, }: { agent: AgentDetailRecord; agentId: string; companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); const [revisionsOpen, setRevisionsOpen] = useState(false); const { data: configRevisions } = useQuery({ queryKey: queryKeys.agents.configRevisions(agent.id), queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId), }); const rollbackConfig = useMutation({ mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); }, }); return (

API Keys

{/* Configuration Revisions — collapsible at the bottom */}
{revisionsOpen && (
{(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"}

))}
)}
)}
); } /* ---- Configuration Tab ---- */ function ConfigurationTab({ agent, companyId, onDirtyChange, onSaveActionChange, onCancelActionChange, onSavingChange, updatePermissions, }: { agent: AgentDetailRecord; companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const lastAgentRef = useRef(agent); const { data: adapterModels } = useQuery({ queryKey: companyId ? queryKeys.agents.adapterModels(companyId, agent.adapterType) : ["agents", "none", "adapter-models", agent.adapterType], queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), enabled: Boolean(companyId), }); const updateAgent = useMutation({ mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), onMutate: () => { setAwaitingRefreshAfterSave(true); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); }, onError: () => { setAwaitingRefreshAfterSave(false); }, }); useEffect(() => { if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) { setAwaitingRefreshAfterSave(false); } lastAgentRef.current = agent; }, [agent, awaitingRefreshAfterSave]); const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave; useEffect(() => { onSavingChange(isConfigSaving); }, [onSavingChange, isConfigSaving]); const canCreateAgents = Boolean(agent.permissions?.canCreateAgents); const canAssignTasks = Boolean(agent.access?.canAssignTasks); const taskAssignSource = agent.access?.taskAssignSource ?? "none"; const taskAssignLocked = agent.role === "ceo" || canCreateAgents; const taskAssignHint = taskAssignSource === "ceo_role" ? "Enabled automatically for CEO agents." : taskAssignSource === "agent_creator" ? "Enabled automatically while this agent can create new agents." : taskAssignSource === "explicit_grant" ? "Enabled via explicit company permission grant." : "Disabled unless explicitly granted."; return (
updateAgent.mutate(patch)} isSaving={isConfigSaving} adapterModels={adapterModels} onDirtyChange={onDirtyChange} onSaveActionChange={onSaveActionChange} onCancelActionChange={onCancelActionChange} hideInlineSave sectionLayout="cards" />

Permissions

Can create new agents

Lets this agent create or hire agents and implicitly assign tasks.

Can assign tasks

{taskAssignHint}

); } function SkillsTab({ agent }: { agent: Agent }) { const instructionsPath = typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0 ? agent.adapterConfig.instructionsFilePath : null; const { data, isLoading, error } = useQuery({ queryKey: queryKeys.skills.available, queryFn: () => agentsApi.availableSkills(), }); const skills = data?.skills ?? []; return (

Skills

Skills are reusable instruction bundles the agent can invoke from its local tool environment. This view shows the current instructions file and the skills currently visible to the local agent runtime.

Agent: {agent.name}

Instructions file
{instructionsPath ?? "No instructions file configured for this agent."}
Available skills
{isLoading ? (

Loading available skills…

) : error ? (

{error instanceof Error ? error.message : "Failed to load available skills."}

) : skills.length === 0 ? (

No local skills were found.

) : (
{skills.map((skill) => ( ))}
)}
); } function SkillRow({ skill }: { skill: AvailableSkill }) { return (
{skill.name} {skill.isPaperclipManaged ? "Paperclip" : "Local"}

{skill.description || "No description available."}

); } /* ---- Runs Tab ---- */ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) { 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 (
{run.id.slice(0, 8)} {sourceLabels[run.invocationSource] ?? run.invocationSource} {relativeTime(run.createdAt)}
{summary && ( {summary.slice(0, 60)} )} {(metrics.totalTokens > 0 || metrics.cost > 0) && (
{metrics.totalTokens > 0 && {formatTokens(metrics.totalTokens)} tok} {metrics.cost > 0 && ${metrics.cost.toFixed(3)}}
)} ); } function RunsTab({ runs, companyId, agentId, agentRouteId, selectedRunId, adapterType, }: { runs: HeartbeatRun[]; companyId: string; agentId: string; agentRouteId: string; selectedRunId: string | null; adapterType: string; }) { 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 (
Back to runs
); } 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: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) { const queryClient = useQueryClient(); const navigate = useNavigate(); const { data: hydratedRun } = useQuery({ queryKey: queryKeys.runDetail(initialRun.id), queryFn: () => heartbeatsApi.get(initialRun.id), enabled: Boolean(initialRun.id), }); const run = hydratedRun ?? initialRun; const metrics = runMetrics(run); const [sessionOpen, setSessionOpen] = useState(false); const [claudeLoginResult, setClaudeLoginResult] = useState(null); useEffect(() => { setClaudeLoginResult(null); }, [run.id]); const cancelRun = useMutation({ mutationFn: () => heartbeatsApi.cancel(run.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); }, }); const canResumeLostRun = run.errorCode === "process_lost" && run.status === "failed"; const resumePayload = useMemo(() => { const payload: Record = { resumeFromRunId: run.id, }; const context = asRecord(run.contextSnapshot); if (!context) return payload; const issueId = asNonEmptyString(context.issueId); const taskId = asNonEmptyString(context.taskId); const taskKey = asNonEmptyString(context.taskKey); const commentId = asNonEmptyString(context.wakeCommentId) ?? asNonEmptyString(context.commentId); if (issueId) payload.issueId = issueId; if (taskId) payload.taskId = taskId; if (taskKey) payload.taskKey = taskKey; if (commentId) payload.commentId = commentId; return payload; }, [run.contextSnapshot, run.id]); const resumeRun = useMutation({ mutationFn: async () => { const result = await agentsApi.wakeup(run.agentId, { source: "on_demand", triggerDetail: "manual", reason: "resume_process_lost_run", payload: resumePayload, }, run.companyId); if (!("id" in result)) { throw new Error("Resume request was skipped because the agent is not currently invokable."); } return result; }, onSuccess: (resumedRun) => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); navigate(`/agents/${agentRouteId}/runs/${resumedRun.id}`); }, }); const canRetryRun = run.status === "failed" || run.status === "timed_out"; const retryPayload = useMemo(() => { const payload: Record = {}; const context = asRecord(run.contextSnapshot); if (!context) return payload; const issueId = asNonEmptyString(context.issueId); const taskId = asNonEmptyString(context.taskId); const taskKey = asNonEmptyString(context.taskKey); if (issueId) payload.issueId = issueId; if (taskId) payload.taskId = taskId; if (taskKey) payload.taskKey = taskKey; return payload; }, [run.contextSnapshot]); const retryRun = useMutation({ mutationFn: async () => { const result = await agentsApi.wakeup(run.agentId, { source: "on_demand", triggerDetail: "manual", reason: "retry_failed_run", payload: retryPayload, }, run.companyId); if (!("id" in result)) { throw new Error("Retry was skipped because the agent is not currently invokable."); } return result; }, onSuccess: (newRun) => { queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); navigate(`/agents/${agentRouteId}/runs/${newRun.id}`); }, }); 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, run.companyId))); 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 runClaudeLogin = useMutation({ mutationFn: () => agentsApi.loginWithClaude(run.agentId, run.companyId), onSuccess: (data) => { setClaudeLoginResult(data); }, }); const isRunning = run.status === "running" && !!run.startedAt && !run.finishedAt; const [elapsedSec, setElapsedSec] = useState(() => { if (!run.startedAt) return 0; return Math.max(0, Math.round((Date.now() - new Date(run.startedAt).getTime()) / 1000)); }); useEffect(() => { if (!isRunning || !run.startedAt) return; const startMs = new Date(run.startedAt).getTime(); setElapsedSec(Math.max(0, Math.round((Date.now() - startMs) / 1000))); const id = setInterval(() => { setElapsedSec(Math.max(0, Math.round((Date.now() - startMs) / 1000))); }, 1000); return () => clearInterval(id); }, [isRunning, run.startedAt]); 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 displayDurationSec = durationSec ?? (isRunning ? elapsedSec : 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") && ( )} {canResumeLostRun && ( )} {canRetryRun && !canResumeLostRun && ( )}
{resumeRun.isError && (
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
)} {retryRun.isError && (
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"}
)} {startTime && (
{startTime} {endTime && } {endTime}
{relativeTime(run.startedAt!)} {run.finishedAt && <> → {relativeTime(run.finishedAt)}}
{displayDurationSec !== null && (
Duration: {displayDurationSec >= 60 ? `${Math.floor(displayDurationSec / 60)}m ${displayDurationSec % 60}s` : `${displayDurationSec}s`}
)}
)} {run.error && (
{run.error} {run.errorCode && ({run.errorCode})}
)} {run.errorCode === "claude_auth_required" && adapterType === "claude_local" && (
{runClaudeLogin.isError && (

{runClaudeLogin.error instanceof Error ? runClaudeLogin.error.message : "Failed to run Claude login"}

)} {claudeLoginResult?.loginUrl && (

Login URL: {claudeLoginResult.loginUrl}

)} {claudeLoginResult && ( <> {!!claudeLoginResult.stdout && (
                        {claudeLoginResult.stdout}
                      
)} {!!claudeLoginResult.stderr && (
                        {claudeLoginResult.stderr}
                      
)} )}
)} {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) => (
{issue.title}
{issue.identifier ?? issue.issueId.slice(0, 8)} ))}
)} {/* 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(false); const [isStreamingConnected, setIsStreamingConnected] = useState(false); const [transcriptMode, setTranscriptMode] = useState("nice"); const logEndRef = useRef(null); const pendingLogLineRef = useRef(""); const scrollContainerRef = useRef(null); const isFollowingRef = useRef(false); const lastMetricsRef = useRef<{ scrollHeight: number; distanceFromBottom: number }>({ scrollHeight: 0, distanceFromBottom: Number.POSITIVE_INFINITY, }); const isLive = run.status === "running" || run.status === "queued"; const { data: workspaceOperations = [] } = useQuery({ queryKey: queryKeys.runWorkspaceOperations(run.id), queryFn: () => heartbeatsApi.workspaceOperations(run.id), refetchInterval: isLive ? 2000 : false, }); function isRunLogUnavailable(err: unknown): boolean { return err instanceof ApiError && err.status === 404; } 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 getScrollContainer = useCallback((): ScrollContainer => { if (scrollContainerRef.current) return scrollContainerRef.current; const container = findScrollContainer(logEndRef.current); scrollContainerRef.current = container; return container; }, []); const updateFollowingState = useCallback(() => { const container = getScrollContainer(); const metrics = readScrollMetrics(container); lastMetricsRef.current = metrics; const nearBottom = metrics.distanceFromBottom <= LIVE_SCROLL_BOTTOM_TOLERANCE_PX; isFollowingRef.current = nearBottom; setIsFollowing((prev) => (prev === nearBottom ? prev : nearBottom)); }, [getScrollContainer]); useEffect(() => { scrollContainerRef.current = null; lastMetricsRef.current = { scrollHeight: 0, distanceFromBottom: Number.POSITIVE_INFINITY, }; if (!isLive) { isFollowingRef.current = false; setIsFollowing(false); return; } updateFollowingState(); }, [isLive, run.id, updateFollowingState]); useEffect(() => { if (!isLive) return; const container = getScrollContainer(); updateFollowingState(); if (container === window) { window.addEventListener("scroll", updateFollowingState, { passive: true }); } else { container.addEventListener("scroll", updateFollowingState, { passive: true }); } window.addEventListener("resize", updateFollowingState); return () => { if (container === window) { window.removeEventListener("scroll", updateFollowingState); } else { container.removeEventListener("scroll", updateFollowingState); } window.removeEventListener("resize", updateFollowingState); }; }, [isLive, run.id, getScrollContainer, updateFollowingState]); // Auto-scroll only for live runs when following useEffect(() => { if (!isLive || !isFollowingRef.current) return; const container = getScrollContainer(); const previous = lastMetricsRef.current; const current = readScrollMetrics(container); const growth = Math.max(0, current.scrollHeight - previous.scrollHeight); const expectedDistance = previous.distanceFromBottom + growth; const movedAwayBy = current.distanceFromBottom - expectedDistance; // If user moved away from bottom between updates, release auto-follow immediately. if (movedAwayBy > LIVE_SCROLL_BOTTOM_TOLERANCE_PX) { isFollowingRef.current = false; setIsFollowing(false); lastMetricsRef.current = current; return; } scrollToContainerBottom(container, "auto"); const after = readScrollMetrics(container); lastMetricsRef.current = after; if (!isFollowingRef.current) { isFollowingRef.current = true; } setIsFollowing((prev) => (prev ? prev : true)); }, [events.length, logLines.length, isLive, getScrollContainer]); // Fetch persisted shell log useEffect(() => { let cancelled = false; pendingLogLineRef.current = ""; setLogLines([]); setLogOffset(0); setLogError(null); if (!run.logRef && !isLive) { 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) { if (isLive && isRunLogUnavailable(err)) { setLogLoading(false); return; } 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 || isStreamingConnected) 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, isStreamingConnected, events]); // Poll shell log for running runs useEffect(() => { if (!isLive || isStreamingConnected) 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 (err) { if (isRunLogUnavailable(err)) return; // ignore polling errors } }, 2000); return () => clearInterval(interval); }, [run.id, isLive, isStreamingConnected, logOffset]); // Stream live updates from websocket (primary path for running runs). useEffect(() => { if (!isLive) return; let closed = false; let reconnectTimer: number | null = null; let socket: WebSocket | null = null; const scheduleReconnect = () => { if (closed) return; reconnectTimer = window.setTimeout(connect, 1500); }; const connect = () => { if (closed) return; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`; socket = new WebSocket(url); socket.onopen = () => { setIsStreamingConnected(true); }; socket.onmessage = (message) => { const rawMessage = typeof message.data === "string" ? message.data : ""; if (!rawMessage) return; let event: LiveEvent; try { event = JSON.parse(rawMessage) as LiveEvent; } catch { return; } if (event.companyId !== run.companyId) return; const payload = asRecord(event.payload); const eventRunId = asNonEmptyString(payload?.runId); if (!payload || eventRunId !== run.id) return; if (event.type === "heartbeat.run.log") { const chunk = typeof payload.chunk === "string" ? payload.chunk : ""; if (!chunk) return; const streamRaw = asNonEmptyString(payload.stream); const stream = streamRaw === "stderr" || streamRaw === "system" ? streamRaw : "stdout"; const ts = asNonEmptyString((payload as Record).ts) ?? event.createdAt; setLogLines((prev) => [...prev, { ts, stream, chunk }]); return; } if (event.type !== "heartbeat.run.event") return; const seq = typeof payload.seq === "number" ? payload.seq : null; if (seq === null || !Number.isFinite(seq)) return; const streamRaw = asNonEmptyString(payload.stream); const stream = streamRaw === "stdout" || streamRaw === "stderr" || streamRaw === "system" ? streamRaw : null; const levelRaw = asNonEmptyString(payload.level); const level = levelRaw === "info" || levelRaw === "warn" || levelRaw === "error" ? levelRaw : null; const liveEvent: HeartbeatRunEvent = { id: seq, companyId: run.companyId, runId: run.id, agentId: run.agentId, seq, eventType: asNonEmptyString(payload.eventType) ?? "event", stream, level, color: asNonEmptyString(payload.color), message: asNonEmptyString(payload.message), payload: asRecord(payload.payload), createdAt: new Date(event.createdAt), }; setEvents((prev) => { if (prev.some((existing) => existing.seq === seq)) return prev; return [...prev, liveEvent]; }); }; socket.onerror = () => { socket?.close(); }; socket.onclose = () => { setIsStreamingConnected(false); scheduleReconnect(); }; }; connect(); return () => { closed = true; setIsStreamingConnected(false); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); if (socket) { socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null; socket.close(1000, "run_detail_unmount"); } }; }, [isLive, run.companyId, run.id, run.agentId]); const censorUsernameInLogs = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: () => instanceSettingsApi.getGeneral(), }).data?.censorUsernameInLogs === true; const adapterInvokePayload = useMemo(() => { const evt = events.find((e) => e.eventType === "adapter.invoke"); return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs); }, [censorUsernameInLogs, events]); const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const transcript = useMemo( () => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }), [adapter, censorUsernameInLogs, logLines], ); useEffect(() => { setTranscriptMode("nice"); }, [run.id]); 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-600 dark:text-yellow-400", error: "text-red-600 dark:text-red-400", }; const streamColors: Record = { stdout: "text-foreground", stderr: "text-red-600 dark:text-red-300", system: "text-blue-600 dark: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(" ")}
)} {Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
Command notes
    {adapterInvokePayload.commandNotes .filter((value): value is string => typeof value === "string" && value.trim().length > 0) .map((note, idx) => (
  • {note}
  • ))}
)} {adapterInvokePayload.prompt !== undefined && (
Prompt
                {typeof adapterInvokePayload.prompt === "string"
                  ? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
                  : JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
              
)} {adapterInvokePayload.context !== undefined && (
Context
                {JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
              
)} {adapterInvokePayload.env !== undefined && (
Environment
                {formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
              
)}
)}
Transcript ({transcript.length})
{(["nice", "raw"] as const).map((mode) => ( ))}
{isLive && !isFollowing && ( )} {isLive && ( Live )}
{logError && (
{logError}
)}
{(run.status === "failed" || run.status === "timed_out") && (
Failure details
{run.error && (
Error: {redactPathText(run.error, censorUsernameInLogs)}
)} {run.stderrExcerpt && run.stderrExcerpt.trim() && (
stderr excerpt
                {redactPathText(run.stderrExcerpt, censorUsernameInLogs)}
              
)} {run.resultJson && (
adapter result JSON
                {JSON.stringify(redactPathValue(run.resultJson, censorUsernameInLogs), null, 2)}
              
)} {run.stdoutExcerpt && run.stdoutExcerpt.trim() && !run.resultJson && (
stdout excerpt
                {redactPathText(run.stdoutExcerpt, censorUsernameInLogs)}
              
)}
)} {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 ? redactPathText(evt.message, censorUsernameInLogs) : evt.payload ? JSON.stringify(redactPathValue(evt.payload, censorUsernameInLogs)) : ""}
); })}
)}
); } /* ---- Keys Tab ---- */ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: 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, companyId), }); const createKey = useMutation({ mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default", companyId), 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, companyId), 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 */} {revokedKeys.length > 0 && (

Revoked Keys

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