import { useState, useEffect, useMemo } from "react"; import { Link, useNavigate, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useSidebar } from "../context/SidebarContext"; import { queryKeys } from "../lib/queryKeys"; import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge"; import { AgentActionButtons } from "../components/AgentActionButtons"; import { MembershipAction } from "../components/MembershipAction"; import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; import { PageTabBar } from "../components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships, } from "../hooks/useResourceMemberships"; import { getAdapterLabel } from "../adapters/adapter-display-registry"; const roleLabels = AGENT_ROLE_LABELS as Record; type FilterTab = "all" | "active" | "paused" | "error"; // Agents in these states never appear in the agents list — `terminated` is // hidden like an archived company, and `pending_approval` is a hiring gate that // lives in the task thread, not an agent run state (PAP-75). const HIDDEN_AGENT_STATUSES = new Set(["terminated", "pending_approval"]); function matchesFilter(status: string, tab: FilterTab): boolean { if (tab === "all") return true; if (tab === "active") return status === "active" || status === "running" || status === "idle"; if (tab === "paused") return status === "paused"; if (tab === "error") return status === "error"; return true; } function filterAgents(agents: Agent[], tab: FilterTab): Agent[] { return agents .filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab)) .sort((a, b) => a.name.localeCompare(b.name)); } function getConfiguredModel(agent: Agent): string | null { const value = agent.adapterConfig?.model; if (typeof value !== "string") return null; const model = value.trim(); return model.length > 0 ? model : null; } function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] { return nodes .reduce((acc, node) => { const filteredReports = filterOrgTree(node.reports, tab); // Hidden agents (terminated / pending_approval) never render as a row, but // any visible reports are promoted so the tree doesn't lose live agents. if (HIDDEN_AGENT_STATUSES.has(node.status)) { acc.push(...filteredReports); return acc; } if (matchesFilter(node.status, tab) || filteredReports.length > 0) { acc.push({ ...node, reports: filteredReports }); } return acc; }, []) .sort((a, b) => a.name.localeCompare(b.name)); } export function Agents() { const { selectedCompanyId } = useCompany(); const { openNewAgent } = useDialogActions(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const location = useLocation(); const { isMobile } = useSidebar(); const pathSegment = location.pathname.split("/").pop() ?? "all"; const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all"; const [view, setView] = useState<"list" | "org">("org"); const forceListView = isMobile; const effectiveView: "list" | "org" = forceListView ? "list" : view; const { data: agents, isLoading, error } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: orgTree } = useQuery({ queryKey: queryKeys.org(selectedCompanyId!), queryFn: () => agentsApi.org(selectedCompanyId!), enabled: !!selectedCompanyId && effectiveView === "org", }); const { data: runs } = useQuery({ queryKey: [...queryKeys.liveRuns(selectedCompanyId!), "agents-page"], queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), enabled: !!selectedCompanyId, refetchInterval: 15_000, }); const membershipsQuery = useResourceMemberships(selectedCompanyId); const membershipMutation = useResourceMembershipMutation(selectedCompanyId); // Map agentId -> first live run + live run count const liveRunByAgent = useMemo(() => { const map = new Map(); for (const r of runs ?? []) { if (r.status !== "running" && r.status !== "queued") continue; const existing = map.get(r.agentId); if (existing) { existing.liveCount += 1; continue; } map.set(r.agentId, { runId: r.id, liveCount: 1 }); } return map; }, [runs]); const agentMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); useEffect(() => { setBreadcrumbs([{ label: "Agents" }]); }, [setBreadcrumbs]); if (!selectedCompanyId) { return ; } if (isLoading) { return ; } const filtered = filterAgents(agents ?? [], tab); const filteredOrg = filterOrgTree(orgTree ?? [], tab); return (
navigate(`/agents/${v}`)}> navigate(`/agents/${v}`)} />
{/* View toggle */} {!forceListView && (
)}
{filtered.length > 0 && (

{filtered.length} agent{filtered.length !== 1 ? "s" : ""}

)} {error &&

{error.message}

} {agents && agents.length === 0 && ( )} {/* List view */} {effectiveView === "list" && filtered.length > 0 && (
{filtered.map((agent) => { const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; return ( ) : ( )} meta={
} trailing={
{liveRunByAgent.has(agent.id) ? ( ) : ( )}
{liveRunByAgent.has(agent.id) && ( )}
{/* Row actions mirror the agent detail page; stop the click from bubbling to the row link so buttons don't navigate. */}
{ e.preventDefault(); e.stopPropagation(); }} >
membershipMutation.mutate({ resourceType: "agent", resourceId: agent.id, resourceName: agent.name, state: "joined", })} onLeave={() => membershipMutation.mutate({ resourceType: "agent", resourceId: agent.id, resourceName: agent.name, state: "left", })} />
} /> ); })}
)} {effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (

No agents match the selected filter.

)} {/* Org chart view */} {effectiveView === "org" && filteredOrg.length > 0 && (
{filteredOrg.map((node) => ( ))}
)} {effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (

No agents match the selected filter.

)} {effectiveView === "org" && orgTree && orgTree.length === 0 && (

No organizational hierarchy defined.

)}
); } function OrgTreeNode({ node, depth, agentMap, liveRunByAgent, tab, memberships, membershipMutation, }: { node: OrgNode; depth: number; agentMap: Map; liveRunByAgent: Map; tab: FilterTab; memberships: ReturnType["data"]; membershipMutation: ReturnType; }) { const agent = agentMap.get(node.id); const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain"); const membershipState = resourceMembershipState(memberships, "agent", node.id); const pending = membershipMutation.isPending && membershipMutation.variables?.resourceType === "agent" && membershipMutation.variables.resourceId === node.id; return (
{hasInvalidOrgChain ? ( ) : ( )}
{node.name} {roleLabels[node.role] ?? node.role} {agent?.title ? ` - ${agent.title}` : ""}
{liveRunByAgent.has(node.id) ? ( ) : ( )}
{liveRunByAgent.has(node.id) && ( )} {agent && (
)}
membershipMutation.mutate({ resourceType: "agent", resourceId: node.id, resourceName: node.name, state: "joined", })} onLeave={() => membershipMutation.mutate({ resourceType: "agent", resourceId: node.id, resourceName: node.name, state: "left", })} />
{node.reports && node.reports.length > 0 && (
{node.reports.map((child) => ( ))}
)}
); } /** * Provider/model + heartbeat columns shared by the list and org views. The * model and adapter label share one fixed-width cell, each line truncating with * an ellipsis so a long model id can never overlap the heartbeat column. The * heartbeat is single-line (`whitespace-nowrap`) and wide enough for a full * date like "Apr 30, 2026". */ function AgentMetaColumns({ agent }: { agent: Agent }) { const model = getConfiguredModel(agent); const adapterLabel = getAdapterLabel(agent.adapterType); return ( <>
{model ?? "—"}
{adapterLabel}
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} ); } function LiveRunIndicator({ agentRef, runId, liveCount, }: { agentRef: string; runId: string; liveCount: number; }) { return ( e.stopPropagation()} > Live{liveCount > 1 ? ` (${liveCount})` : ""} ); }