Improve Agents page: org-first view, filter tabs in header, tree filtering

- Default view is now org chart instead of list
- Filter tabs (All/Active/Paused/Error) moved inline with page heading
- filterOrgTree() recursively filters org nodes, preserving parents that have
  matching descendants even if the parent itself doesn't match the tab filter
- agentMap memoized for O(1) lookup when rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 20:07:36 -06:00
parent 080964a361
commit 580a3ab647

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
@@ -30,12 +30,27 @@ const roleLabels: Record<string, string> = {
type FilterTab = "all" | "active" | "paused" | "error"; type FilterTab = "all" | "active" | "paused" | "error";
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" || status === "terminated";
return true;
}
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] { function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
if (tab === "all") return agents; return agents.filter((a) => matchesFilter(a.status, tab));
if (tab === "active") return agents.filter((a) => a.status === "active" || a.status === "running" || a.status === "idle"); }
if (tab === "paused") return agents.filter((a) => a.status === "paused");
if (tab === "error") return agents.filter((a) => a.status === "error" || a.status === "terminated"); function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
return agents; if (tab === "all") return nodes;
return nodes.reduce<OrgNode[]>((acc, node) => {
const filteredReports = filterOrgTree(node.reports, tab);
if (matchesFilter(node.status, tab) || filteredReports.length > 0) {
acc.push({ ...node, reports: filteredReports });
}
return acc;
}, []);
} }
export function Agents() { export function Agents() {
@@ -44,7 +59,7 @@ export function Agents() {
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate(); const navigate = useNavigate();
const [tab, setTab] = useState<FilterTab>("all"); const [tab, setTab] = useState<FilterTab>("all");
const [view, setView] = useState<"list" | "org">("list"); const [view, setView] = useState<"list" | "org">("org");
const { data: agents, isLoading, error } = useQuery({ const { data: agents, isLoading, error } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -58,6 +73,12 @@ export function Agents() {
enabled: !!selectedCompanyId && view === "org", enabled: !!selectedCompanyId && view === "org",
}); });
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
useEffect(() => { useEffect(() => {
setBreadcrumbs([{ label: "Agents" }]); setBreadcrumbs([{ label: "Agents" }]);
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
@@ -67,11 +88,22 @@ export function Agents() {
} }
const filtered = filterAgents(agents ?? [], tab); const filtered = filterAgents(agents ?? [], tab);
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Agents</h2> <div className="flex items-center gap-4">
<h2 className="text-lg font-semibold">Agents</h2>
<Tabs value={tab} onValueChange={(v) => setTab(v as FilterTab)}>
<TabsList>
<TabsTrigger value="all">All{agents ? ` (${agents.length})` : ""}</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="paused">Paused</TabsTrigger>
<TabsTrigger value="error">Error</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View toggle */} {/* View toggle */}
<div className="flex items-center border border-border rounded-md"> <div className="flex items-center border border-border rounded-md">
@@ -101,17 +133,6 @@ export function Agents() {
</div> </div>
</div> </div>
{view === "list" && (
<Tabs value={tab} onValueChange={(v) => setTab(v as FilterTab)}>
<TabsList>
<TabsTrigger value="all">All{agents ? ` (${agents.length})` : ""}</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="paused">Paused</TabsTrigger>
<TabsTrigger value="error">Error</TabsTrigger>
</TabsList>
</Tabs>
)}
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>} {isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
@@ -199,14 +220,20 @@ export function Agents() {
)} )}
{/* Org chart view */} {/* Org chart view */}
{view === "org" && orgTree && orgTree.length > 0 && ( {view === "org" && filteredOrg.length > 0 && (
<div className="py-4"> <div className="border border-border rounded-md py-1">
{orgTree.map((node) => ( {filteredOrg.map((node) => (
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} /> <OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} />
))} ))}
</div> </div>
)} )}
{view === "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>
)}
{view === "org" && orgTree && orgTree.length === 0 && ( {view === "org" && orgTree && orgTree.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No organizational hierarchy defined. No organizational hierarchy defined.
@@ -220,19 +247,30 @@ function OrgTreeNode({
node, node,
depth, depth,
navigate, navigate,
agentMap,
}: { }: {
node: OrgNode; node: OrgNode;
depth: number; depth: number;
navigate: (path: string) => void; navigate: (path: string) => void;
agentMap: Map<string, Agent>;
}) { }) {
const agent = agentMap.get(node.id);
const statusColor = const statusColor =
node.status === "active" || node.status === "running" node.status === "running"
? "bg-green-400" ? "bg-cyan-400 animate-pulse"
: node.status === "paused" : node.status === "active"
? "bg-yellow-400" ? "bg-green-400"
: node.status === "error" : node.status === "paused"
? "bg-red-400" ? "bg-yellow-400"
: "bg-neutral-400"; : node.status === "error"
? "bg-red-400"
: "bg-neutral-400";
const budgetPct =
agent && agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
return ( return (
<div style={{ paddingLeft: depth * 24 }}> <div style={{ paddingLeft: depth * 24 }}>
@@ -247,14 +285,46 @@ function OrgTreeNode({
<span className="text-sm font-medium">{node.name}</span> <span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2"> <span className="text-xs text-muted-foreground ml-2">
{roleLabels[node.role] ?? node.role} {roleLabels[node.role] ?? node.role}
{agent?.title ? ` - ${agent.title}` : ""}
</span> </span>
</div> </div>
<StatusBadge status={node.status} /> <div className="flex items-center gap-3 shrink-0">
{agent && (
<>
<span className="text-xs text-muted-foreground font-mono">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
</span>
{agent.lastHeartbeatAt && (
<span className="text-xs text-muted-foreground">
{relativeTime(agent.lastHeartbeatAt)}
</span>
)}
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
budgetPct > 90
? "bg-red-400"
: budgetPct > 70
? "bg-yellow-400"
: "bg-green-400"
}`}
style={{ width: `${Math.min(100, budgetPct)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-20 text-right">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
</>
)}
<StatusBadge status={node.status} />
</div>
</button> </button>
{node.reports && node.reports.length > 0 && ( {node.reports && node.reports.length > 0 && (
<div className="border-l border-border/50 ml-4"> <div className="border-l border-border/50 ml-4">
{node.reports.map((child) => ( {node.reports.map((child) => (
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} /> <OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} />
))} ))}
</div> </div>
)} )}