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 } from "../api/agents"; import { companySkillsApi } from "../api/companySkills"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; 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, help } from "../components/agent-config-primitives"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { assetsApi } from "../api/assets"; 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 { PackageFileTree, buildFileTree } from "../components/PackageFileTree"; 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 { Skeleton } from "@/components/ui/skeleton"; import { Tabs } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; 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, HelpCircle, } from "lucide-react"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { TooltipProvider } from "@/components/ui/tooltip"; 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 AgentSkillSnapshot, 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"; import { applyAgentSkillSnapshot, arraysEqual } from "../lib/agent-skills-state"; 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 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 redactHomePathUserSegments(value); try { return JSON.stringify(redactHomePathUserSegmentsInValue(value)); } catch { return redactHomePathUserSegments(String(value)); } } function isMarkdown(pathValue: string) { return pathValue.toLowerCase().endsWith(".md"); } 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", }; 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" | "instructions" | "configuration" | "skills" | "runs" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "instructions" || value === "prompts") return "instructions"; if (value === "configure" || value === "configuration") return "configuration"; if (value === "skills") return "skills"; if (value === "budget") return "budget"; 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 setsEqual(left: Set, right: Set) { if (left.size !== right.size) return false; for (const value of left) { if (!right.has(value)) return false; } return true; } 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 }: { operation: WorkspaceOperation }) { 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}] {redactHomePathUserSegments(chunk.chunk)}
))}
)}
)}
); } function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOperation[] }) { 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
                    {redactHomePathUserSegments(operation.stderrExcerpt)}
                  
)} {operation.stdoutExcerpt && operation.stdoutExcerpt.trim() && (
stdout excerpt
                    {redactHomePathUserSegments(operation.stdoutExcerpt)}
                  
)} {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 needsDashboardData = activeView === "dashboard"; const needsRunData = activeView === "runs" || Boolean(urlRunId); const shouldLoadHeartbeats = needsDashboardData || needsRunData; 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) && needsDashboardData, }); const { data: heartbeats } = useQuery({ queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined), queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined), enabled: !!resolvedCompanyId && !!agent?.id && shouldLoadHeartbeats, }); const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(resolvedCompanyId!), queryFn: () => issuesApi.list(resolvedCompanyId!), enabled: !!resolvedCompanyId && needsDashboardData, }); const { data: allAgents } = useQuery({ queryKey: queryKeys.agents.list(resolvedCompanyId!), queryFn: () => agentsApi.list(resolvedCompanyId!), enabled: !!resolvedCompanyId && needsDashboardData, }); 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 === "instructions" ? "instructions" : 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: (canCreateAgents: boolean) => agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, 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 === "instructions") { crumbs.push({ label: "Instructions" }); } else if (activeView === "configuration") { crumbs.push({ label: "Configuration" }); } else if (activeView === "skills") { 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" || activeView === "instructions") && (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 && (
)} {/* Mobile bottom Save/Cancel bar */} {isMobile && showConfigActionBar && (
)} {/* View content */} {activeView === "dashboard" && ( )} {activeView === "instructions" && ( )} {activeView === "configuration" && ( )} {activeView === "skills" && ( )} {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: Agent; 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: Agent; agentId: string; companyId?: string; 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 [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, hidePromptTemplate, hideInstructionsFile, }: { agent: Agent; companyId?: string; 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 }; hidePromptTemplate?: boolean; hideInstructionsFile?: 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]); return (
updateAgent.mutate(patch)} isSaving={isConfigSaving} adapterModels={adapterModels} onDirtyChange={onDirtyChange} onSaveActionChange={onSaveActionChange} onCancelActionChange={onCancelActionChange} hideInlineSave hidePromptTemplate={hidePromptTemplate} hideInstructionsFile={hideInstructionsFile} sectionLayout="cards" />

Permissions

Can create new agents
); } /* ---- Prompts Tab ---- */ function PromptsTab({ agent, companyId, onDirtyChange, onSaveActionChange, onCancelActionChange, onSavingChange, }: { agent: Agent; companyId?: string; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; }) { const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const [selectedFile, setSelectedFile] = useState("AGENTS.md"); const [draft, setDraft] = useState(null); const [bundleDraft, setBundleDraft] = useState<{ mode: "managed" | "external"; rootPath: string; entryFile: string; } | null>(null); const [newFilePath, setNewFilePath] = useState(""); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [filePanelWidth, setFilePanelWidth] = useState(260); const containerRef = useRef(null); const [awaitingRefresh, setAwaitingRefresh] = useState(false); const lastFileVersionRef = useRef(null); const externalBundleRef = useRef<{ rootPath: string; entryFile: string; selectedFile: string; } | null>(null); const isLocal = agent.adapterType === "claude_local" || agent.adapterType === "codex_local" || agent.adapterType === "opencode_local" || agent.adapterType === "pi_local" || agent.adapterType === "hermes_local" || agent.adapterType === "cursor"; const { data: bundle, isLoading: bundleLoading } = useQuery({ queryKey: queryKeys.agents.instructionsBundle(agent.id), queryFn: () => agentsApi.instructionsBundle(agent.id, companyId), enabled: Boolean(companyId && isLocal), }); const persistedMode = bundle?.mode ?? "managed"; const persistedRootPath = persistedMode === "managed" ? (bundle?.managedRootPath ?? bundle?.rootPath ?? "") : (bundle?.rootPath ?? ""); const currentMode = bundleDraft?.mode ?? persistedMode; const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md"; const currentRootPath = bundleDraft?.rootPath ?? persistedRootPath; const fileOptions = useMemo( () => bundle?.files.map((file) => file.path) ?? [], [bundle], ); const bundleMatchesDraft = Boolean( bundle && currentMode === persistedMode && currentEntryFile === bundle.entryFile && currentRootPath === persistedRootPath, ); const visibleFilePaths = useMemo( () => bundleMatchesDraft ? [...new Set([currentEntryFile, ...fileOptions])] : [currentEntryFile], [bundleMatchesDraft, currentEntryFile, fileOptions], ); const fileTree = useMemo( () => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))), [visibleFilePaths], ); const selectedOrEntryFile = selectedFile || currentEntryFile; const selectedFileExists = bundleMatchesDraft && fileOptions.includes(selectedOrEntryFile); const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null; const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({ queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile), queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId), enabled: Boolean(companyId && isLocal && selectedFileExists), }); const updateBundle = useMutation({ mutationFn: (data: { mode?: "managed" | "external"; rootPath?: string | null; entryFile?: string; clearLegacyPromptTemplate?: boolean; }) => agentsApi.updateInstructionsBundle(agent.id, data, companyId), onMutate: () => setAwaitingRefresh(true), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); }, onError: () => setAwaitingRefresh(false), }); const saveFile = useMutation({ mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) => agentsApi.saveInstructionsFile(agent.id, data, companyId), onMutate: () => setAwaitingRefresh(true), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, variables.path) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); }, onError: () => setAwaitingRefresh(false), }); const deleteFile = useMutation({ mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId), onMutate: () => setAwaitingRefresh(true), onSuccess: (_, relativePath) => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); }, onError: () => setAwaitingRefresh(false), }); const uploadMarkdownImage = useMutation({ mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { if (!selectedCompanyId) throw new Error("Select a company to upload images"); return assetsApi.uploadImage(selectedCompanyId, file, namespace); }, }); useEffect(() => { if (!bundle) return; if (!bundleMatchesDraft) { if (selectedFile !== currentEntryFile) setSelectedFile(currentEntryFile); return; } const availablePaths = bundle.files.map((file) => file.path); if (availablePaths.length === 0) { if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile); return; } if (!availablePaths.includes(selectedFile)) { setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!); } }, [bundle, bundleMatchesDraft, currentEntryFile, selectedFile]); useEffect(() => { const nextExpanded = new Set(); for (const filePath of visibleFilePaths) { const parts = filePath.split("/"); let currentPath = ""; for (let i = 0; i < parts.length - 1; i++) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]!; nextExpanded.add(currentPath); } } setExpandedDirs((current) => (setsEqual(current, nextExpanded) ? current : nextExpanded)); }, [visibleFilePaths]); useEffect(() => { const versionKey = selectedFileExists && selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${currentMode}:${currentRootPath}:${selectedOrEntryFile}`; if (awaitingRefresh) { setAwaitingRefresh(false); setBundleDraft(null); setDraft(null); lastFileVersionRef.current = versionKey; return; } if (lastFileVersionRef.current !== versionKey) { setDraft(null); lastFileVersionRef.current = versionKey; } }, [awaitingRefresh, currentMode, currentRootPath, selectedFileDetail, selectedFileExists, selectedOrEntryFile]); useEffect(() => { if (!bundle) return; setBundleDraft((current) => { if (current) return current; return { mode: persistedMode, rootPath: persistedRootPath, entryFile: bundle.entryFile, }; }); }, [bundle, persistedMode, persistedRootPath]); useEffect(() => { if (!bundle || currentMode !== "external") return; externalBundleRef.current = { rootPath: currentRootPath, entryFile: currentEntryFile, selectedFile: selectedOrEntryFile, }; }, [bundle, currentEntryFile, currentMode, currentRootPath, selectedOrEntryFile]); const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : ""; const displayValue = draft ?? currentContent; const bundleDirty = Boolean( bundleDraft && ( bundleDraft.mode !== persistedMode || bundleDraft.rootPath !== persistedRootPath || bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md") ), ); const fileDirty = draft !== null && draft !== currentContent; const isDirty = bundleDirty || fileDirty; const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh; useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]); useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); useEffect(() => { onSaveActionChange(isDirty ? () => { const save = async () => { const shouldClearLegacy = Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive); if (bundleDirty && bundleDraft) { await updateBundle.mutateAsync({ mode: bundleDraft.mode, rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null, entryFile: bundleDraft.entryFile, }); } if (fileDirty) { await saveFile.mutateAsync({ path: selectedOrEntryFile, content: displayValue, clearLegacyPromptTemplate: shouldClearLegacy, }); } }; void save().catch(() => undefined); } : null); }, [ bundle, bundleDirty, bundleDraft, displayValue, fileDirty, isDirty, onSaveActionChange, saveFile, selectedOrEntryFile, updateBundle, ]); useEffect(() => { onCancelActionChange(isDirty ? () => { setDraft(null); if (bundle) { setBundleDraft({ mode: persistedMode, rootPath: persistedRootPath, entryFile: bundle.entryFile, }); } } : null); }, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath]); const handleSeparatorDrag = useCallback((event: React.MouseEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = filePanelWidth; const onMouseMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientX - startX; const next = Math.max(180, Math.min(500, startWidth + delta)); setFilePanelWidth(next); }; const onMouseUp = () => { document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }, [filePanelWidth]); if (!isLocal) { return (

Instructions bundles are only available for local adapters.

); } if (bundleLoading && !bundle) { return ; } return (
{(bundle?.warnings ?? []).length > 0 && (
{(bundle?.warnings ?? []).map((warning) => (
{warning}
))}
)}

Files

setNewFilePath(event.target.value)} placeholder="docs/TOOLS.md" className="font-mono text-sm" />
setExpandedDirs((current) => { const next = new Set(current); if (next.has(dirPath)) next.delete(dirPath); else next.add(dirPath); return next; })} onSelectFile={(filePath) => { setSelectedFile(filePath); if (!fileOptions.includes(filePath)) setDraft(""); }} onToggleCheck={() => {}} showCheckboxes={false} renderFileExtra={(node) => { const file = bundle?.files.find((entry) => entry.path === node.path); if (!file) return null; if (file.deprecated) { return ( virtual file Legacy inline prompt — this deprecated virtual file preserves the old promptTemplate content ); } return ( {file.isEntryFile ? "entry" : `${file.size}b`} ); }} />
{/* Draggable separator */}

{selectedOrEntryFile}

{selectedFileExists ? selectedFileSummary?.deprecated ? "Deprecated virtual file" : `${selectedFileDetail?.language ?? "text"} file` : "New file in this bundle"}

{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && ( )}
{selectedFileExists && fileLoading && !selectedFileDetail ? ( ) : isMarkdown(selectedOrEntryFile) ? ( setDraft(value ?? "")} placeholder="# Agent instructions" contentClassName="min-h-[420px] text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} /> ) : (