0131cf3449
Add per-agent maxConcurrentRuns (1-10) controlling how many runs execute simultaneously. Implements agent-level start lock, optimistic claim-then-execute flow, atomic token accounting via SQL expressions, and proper status resolution when parallel runs finish. Updates UI config form, live run count display, and SSE invalidation to avoid unnecessary refetches on run event streams. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { agentsApi, type OrgNode } from "../api/agents";
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { StatusBadge } from "../components/StatusBadge";
|
|
import { EntityRow } from "../components/EntityRow";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { relativeTime, cn } from "../lib/utils";
|
|
import { PageTabBar } from "../components/PageTabBar";
|
|
import { Tabs } from "@/components/ui/tabs";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
|
|
import type { Agent } from "@paperclip/shared";
|
|
|
|
const adapterLabels: Record<string, string> = {
|
|
claude_local: "Claude",
|
|
codex_local: "Codex",
|
|
process: "Process",
|
|
http: "HTTP",
|
|
};
|
|
|
|
const roleLabels: Record<string, string> = {
|
|
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
|
|
engineer: "Engineer", designer: "Designer", pm: "PM",
|
|
qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
|
|
};
|
|
|
|
type FilterTab = "all" | "active" | "paused" | "error";
|
|
|
|
function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean {
|
|
if (status === "terminated") return showTerminated;
|
|
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, showTerminated: boolean): Agent[] {
|
|
return agents.filter((a) => matchesFilter(a.status, tab, showTerminated));
|
|
}
|
|
|
|
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
|
|
return nodes.reduce<OrgNode[]>((acc, node) => {
|
|
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
|
|
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
|
|
acc.push({ ...node, reports: filteredReports });
|
|
}
|
|
return acc;
|
|
}, []);
|
|
}
|
|
|
|
export function Agents() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { openNewAgent } = useDialog();
|
|
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 [showTerminated, setShowTerminated] = useState(false);
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
|
|
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.heartbeats(selectedCompanyId!),
|
|
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
refetchInterval: 15_000,
|
|
});
|
|
|
|
// Map agentId -> first live run + live run count
|
|
const liveRunByAgent = useMemo(() => {
|
|
const map = new Map<string, { runId: string; liveCount: number }>();
|
|
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<string, Agent>();
|
|
for (const a of agents ?? []) map.set(a.id, a);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Agents" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
if (!selectedCompanyId) {
|
|
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
|
}
|
|
|
|
const filtered = filterAgents(agents ?? [], tab, showTerminated);
|
|
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
|
|
<PageTabBar
|
|
items={[
|
|
{ value: "all", label: "All" },
|
|
{ value: "active", label: "Active" },
|
|
{ value: "paused", label: "Paused" },
|
|
{ value: "error", label: "Error" },
|
|
]}
|
|
value={tab}
|
|
onValueChange={(v) => navigate(`/agents/${v}`)}
|
|
/>
|
|
</Tabs>
|
|
<div className="flex items-center gap-2">
|
|
{/* Filters */}
|
|
<div className="relative">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-2 py-1.5 text-xs transition-colors border border-border",
|
|
filtersOpen || showTerminated ? "text-foreground bg-accent" : "text-muted-foreground hover:bg-accent/50"
|
|
)}
|
|
onClick={() => setFiltersOpen(!filtersOpen)}
|
|
>
|
|
<SlidersHorizontal className="h-3 w-3" />
|
|
Filters
|
|
{showTerminated && <span className="ml-0.5 px-1 bg-foreground/10 rounded text-[10px]">1</span>}
|
|
</button>
|
|
{filtersOpen && (
|
|
<div className="absolute right-0 top-full mt-1 z-50 w-48 border border-border bg-popover shadow-md p-1">
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent/50 transition-colors"
|
|
onClick={() => setShowTerminated(!showTerminated)}
|
|
>
|
|
<span className={cn(
|
|
"flex items-center justify-center h-3.5 w-3.5 border border-border rounded-sm",
|
|
showTerminated && "bg-foreground"
|
|
)}>
|
|
{showTerminated && <span className="text-background text-[10px] leading-none">✓</span>}
|
|
</span>
|
|
Show terminated
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* View toggle */}
|
|
{!forceListView && (
|
|
<div className="flex items-center border border-border">
|
|
<button
|
|
className={cn(
|
|
"p-1.5 transition-colors",
|
|
effectiveView === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
|
)}
|
|
onClick={() => setView("list")}
|
|
>
|
|
<List className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
className={cn(
|
|
"p-1.5 transition-colors",
|
|
effectiveView === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
|
)}
|
|
onClick={() => setView("org")}
|
|
>
|
|
<GitBranch className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<Button size="sm" onClick={openNewAgent}>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
New Agent
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{filtered.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
|
|
)}
|
|
|
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
|
|
|
{agents && agents.length === 0 && (
|
|
<EmptyState
|
|
icon={Bot}
|
|
message="Create your first agent to get started."
|
|
action="New Agent"
|
|
onAction={openNewAgent}
|
|
/>
|
|
)}
|
|
|
|
{/* List view */}
|
|
{effectiveView === "list" && filtered.length > 0 && (
|
|
<div className="border border-border">
|
|
{filtered.map((agent) => {
|
|
return (
|
|
<EntityRow
|
|
key={agent.id}
|
|
title={agent.name}
|
|
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
|
onClick={() => navigate(`/agents/${agent.id}`)}
|
|
leading={
|
|
<span className="relative flex h-2.5 w-2.5">
|
|
<span
|
|
className={`absolute inline-flex h-full w-full rounded-full ${
|
|
agent.status === "running"
|
|
? "bg-cyan-400 animate-pulse"
|
|
: agent.status === "active"
|
|
? "bg-green-400"
|
|
: agent.status === "paused"
|
|
? "bg-yellow-400"
|
|
: agent.status === "pending_approval"
|
|
? "bg-amber-400"
|
|
: agent.status === "error"
|
|
? "bg-red-400"
|
|
: "bg-neutral-400"
|
|
}`}
|
|
/>
|
|
</span>
|
|
}
|
|
trailing={
|
|
<div className="flex items-center gap-3">
|
|
<span className="sm:hidden">
|
|
{liveRunByAgent.has(agent.id) ? (
|
|
<LiveRunIndicator
|
|
agentId={agent.id}
|
|
runId={liveRunByAgent.get(agent.id)!.runId}
|
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
|
navigate={navigate}
|
|
/>
|
|
) : (
|
|
<StatusBadge status={agent.status} />
|
|
)}
|
|
</span>
|
|
<div className="hidden sm:flex items-center gap-3">
|
|
{liveRunByAgent.has(agent.id) && (
|
|
<LiveRunIndicator
|
|
agentId={agent.id}
|
|
runId={liveRunByAgent.get(agent.id)!.runId}
|
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
|
navigate={navigate}
|
|
/>
|
|
)}
|
|
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
|
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground w-16 text-right">
|
|
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
|
</span>
|
|
<span className="w-20 flex justify-end">
|
|
<StatusBadge status={agent.status} />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
No agents match the selected filter.
|
|
</p>
|
|
)}
|
|
|
|
{/* Org chart view */}
|
|
{effectiveView === "org" && filteredOrg.length > 0 && (
|
|
<div className="border border-border py-1">
|
|
{filteredOrg.map((node) => (
|
|
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
No agents match the selected filter.
|
|
</p>
|
|
)}
|
|
|
|
{effectiveView === "org" && orgTree && orgTree.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
No organizational hierarchy defined.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OrgTreeNode({
|
|
node,
|
|
depth,
|
|
navigate,
|
|
agentMap,
|
|
liveRunByAgent,
|
|
}: {
|
|
node: OrgNode;
|
|
depth: number;
|
|
navigate: (path: string) => void;
|
|
agentMap: Map<string, Agent>;
|
|
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
|
|
}) {
|
|
const agent = agentMap.get(node.id);
|
|
|
|
const statusColor =
|
|
node.status === "running"
|
|
? "bg-cyan-400 animate-pulse"
|
|
: node.status === "active"
|
|
? "bg-green-400"
|
|
: node.status === "paused"
|
|
? "bg-yellow-400"
|
|
: node.status === "pending_approval"
|
|
? "bg-amber-400"
|
|
: node.status === "error"
|
|
? "bg-red-400"
|
|
: "bg-neutral-400";
|
|
|
|
return (
|
|
<div style={{ paddingLeft: depth * 24 }}>
|
|
<button
|
|
className="flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left"
|
|
onClick={() => navigate(`/agents/${node.id}`)}
|
|
>
|
|
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
|
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm font-medium">{node.name}</span>
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
{roleLabels[node.role] ?? node.role}
|
|
{agent?.title ? ` - ${agent.title}` : ""}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<span className="sm:hidden">
|
|
{liveRunByAgent.has(node.id) ? (
|
|
<LiveRunIndicator
|
|
agentId={node.id}
|
|
runId={liveRunByAgent.get(node.id)!.runId}
|
|
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
|
navigate={navigate}
|
|
/>
|
|
) : (
|
|
<StatusBadge status={node.status} />
|
|
)}
|
|
</span>
|
|
<div className="hidden sm:flex items-center gap-3">
|
|
{liveRunByAgent.has(node.id) && (
|
|
<LiveRunIndicator
|
|
agentId={node.id}
|
|
runId={liveRunByAgent.get(node.id)!.runId}
|
|
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
|
navigate={navigate}
|
|
/>
|
|
)}
|
|
{agent && (
|
|
<>
|
|
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
|
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground w-16 text-right">
|
|
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className="w-20 flex justify-end">
|
|
<StatusBadge status={node.status} />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{node.reports && node.reports.length > 0 && (
|
|
<div className="border-l border-border/50 ml-4">
|
|
{node.reports.map((child) => (
|
|
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LiveRunIndicator({
|
|
agentId,
|
|
runId,
|
|
liveCount,
|
|
navigate,
|
|
}: {
|
|
agentId: string;
|
|
runId: string;
|
|
liveCount: number;
|
|
navigate: (path: string) => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/agents/${agentId}/runs/${runId}`);
|
|
}}
|
|
>
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
|
</span>
|
|
<span className="text-[11px] font-medium text-blue-400">
|
|
Live{liveCount > 1 ? ` (${liveCount})` : ""}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|