fix(panel): agents page — truthful counts, merged stat row, dense org grid

Total Agents now counts the roster (backend total_agents is live
containers only) and Active reads by_state.active — running/ready never
existed in the backend enum, so the counter was structurally zero.
Board + Main PM fold into one leadership band, cards compress to a
status dot + single detail line, and the grid densifies with per-team
count badges.
This commit is contained in:
Renn F
2026-07-15 16:06:38 +02:00
parent 2efc2f9542
commit 9b0a8b0fae
8 changed files with 600 additions and 147 deletions
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentDefinition } from "@/lib/agent-definitions";
// CEO feedback round 2: Total Agents must reflect the full roster, not the
// orchestrator's live-instance count, and Board + Main PM must fold into one
// "Leadership" band instead of a lone Main PM card wasting a full row.
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock("@/hooks/use-usage", () => ({
useAgentUsage: () => ({ data: [] }),
}));
const { useOrchestratorStatus, useWaitingAgents, useAgentDefinitions } =
vi.hoisted(() => ({
useOrchestratorStatus: vi.fn(),
useWaitingAgents: vi.fn(),
useAgentDefinitions: vi.fn(),
}));
vi.mock("@/hooks/use-agents", () => ({
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
}));
vi.mock("@/components/agents", () => ({
OrchestratorStatusCards: ({ rosterCount }: { rosterCount: number }) => (
<div data-testid="orchestrator-status-cards" data-roster-count={rosterCount} />
),
WaitingAgentsAlert: () => <div data-testid="waiting-agents-alert" />,
AgentGrid: ({
title,
agents,
}: {
title: string;
agents: AgentDefinition[];
}) => (
<div data-testid={"grid-" + title}>
{agents.map((a) => a.id).join(",")}
</div>
),
}));
import AgentsPage from "../page";
const AGENTS: AgentDefinition[] = [
{
id: "product-owner",
name: "Product Owner",
role: "product_owner" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "head-marketing",
name: "Head of Marketing",
role: "head_marketing" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "auditor",
name: "Auditor",
role: "auditor" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "main-pm",
name: "Main PM",
role: "main_pm" as AgentDefinition["role"],
team: "main_pm" as AgentDefinition["team"],
},
{
id: "be-dev-1",
name: "Backend Dev 1",
role: "developer" as AgentDefinition["role"],
team: "backend" as AgentDefinition["team"],
},
];
describe("AgentsPage", () => {
beforeEach(() => {
useAgentDefinitions.mockReturnValue({ data: AGENTS, isLoading: false });
useOrchestratorStatus.mockReturnValue({
data: {
total_agents: 2, // deliberately far below the roster size
by_state: { active: 2 },
waiting_count: 0,
agents: [],
},
isLoading: false,
error: undefined,
refetch: vi.fn(),
});
useWaitingAgents.mockReturnValue({ data: undefined });
});
it("passes the full roster size as the truthful Total Agents count, not the backend's live-instance total", () => {
render(<AgentsPage />);
const cards = screen.getByTestId("orchestrator-status-cards");
expect(cards).toHaveAttribute("data-roster-count", "5");
});
it("folds Board and Main PM into one Leadership group instead of separate sections", () => {
render(<AgentsPage />);
expect(screen.getByTestId("grid-Leadership")).toHaveTextContent(
"product-owner,head-marketing,auditor,main-pm",
);
expect(screen.queryByTestId("grid-Board")).not.toBeInTheDocument();
expect(screen.queryByTestId("grid-Main PM")).not.toBeInTheDocument();
});
it("still renders the per-cell grids", () => {
render(<AgentsPage />);
expect(screen.getByTestId("grid-Backend Cell")).toHaveTextContent(
"be-dev-1",
);
});
it("does not render the Support grid when no support agents match", () => {
render(<AgentsPage />);
expect(screen.queryByTestId("grid-Support")).not.toBeInTheDocument();
});
});
+13 -19
View File
@@ -87,8 +87,15 @@ export default function AgentsPage() {
/>
) : (
<>
{/* Status Overview */}
<OrchestratorStatusCards status={status} isLoading={isLoading} />
{/* Status Overview — Total Agents is the full roster size, not the
orchestrator's live-instance count, so it stays truthful even
when most of the roster isn't currently spawned. */}
<OrchestratorStatusCards
status={status}
isLoading={isLoading}
rosterCount={agents.length}
rosterLoading={agentsLoading}
/>
{/* Waiting Agents Alert */}
{waitingAgents && (
@@ -97,23 +104,14 @@ export default function AgentsPage() {
</>
)}
{/* Agent Grids - Dynamically loaded from API */}
{/* Agent Grids - Dynamically loaded from API. Board + Main PM fold into
one Leadership band so a lone Main PM card never wastes a full row. */}
<AgentGrid
title="Board"
agents={getBoardAgents(agents)}
title="Leadership"
agents={[...getBoardAgents(agents), ...getMainPm(agents)]}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
<AgentGrid
title="Main PM"
agents={getMainPm(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
<AgentGrid
@@ -122,7 +120,6 @@ export default function AgentsPage() {
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={5}
/>
<AgentGrid
@@ -131,7 +128,6 @@ export default function AgentsPage() {
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={5}
/>
<AgentGrid
@@ -140,7 +136,6 @@ export default function AgentsPage() {
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
{/* Support section: the CEO-direct helpers — Intake/Prompter, Secretary,
@@ -152,7 +147,6 @@ export default function AgentsPage() {
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
)}
</div>
@@ -0,0 +1,151 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentDefinition } from "@/lib/agent-definitions";
import type { AgentStatusResponse } from "@/types";
vi.mock("@/hooks/use-agents", () => ({
useStopAgent: () => ({ mutateAsync: vi.fn() }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// Render the dropdown inline so its items are queryable without Radix's
// portal/pointer machinery — mirrors task-header's test convention
// (task-header-ceo-approve.test.tsx).
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => null,
}));
vi.mock("../spawn-agent-dialog", () => ({
SpawnAgentDialog: ({ agentName }: { agentName: string }) => (
<div data-testid="spawn-dialog">{agentName}</div>
),
}));
import { AgentCard } from "../agent-card";
const AGENT = {
id: "be-dev-1",
name: "Backend Dev 1",
role: "developer",
team: "backend",
} as unknown as AgentDefinition;
function statusOf(
overrides: Partial<AgentStatusResponse> = {},
): AgentStatusResponse {
return {
agent_id: "be-dev-1",
state: "active",
task_id: null,
error_count: 0,
started_at: null,
waiting_for: null,
...overrides,
};
}
describe("AgentCard", () => {
it("renders state as a colored dot with an inline label, not the old badge chrome", () => {
render(
<AgentCard agent={AGENT} agentStatus={statusOf({ state: "active" })} />,
);
expect(document.querySelector(".bg-green-500.rounded-full")).toBeTruthy();
expect(screen.getByText("active")).toBeInTheDocument();
});
it("renders role and team as a single muted line", () => {
render(<AgentCard agent={AGENT} agentStatus={null} />);
expect(screen.getByText("developer • backend")).toBeInTheDocument();
});
it("prioritizes an error detail line over waiting and task", () => {
render(
<AgentCard
agent={AGENT}
agentStatus={statusOf({
error_count: 2,
waiting_for: "a reply",
task_id: "abcdef1234567890",
})}
/>,
);
expect(screen.getByText("2 errors")).toBeInTheDocument();
expect(screen.queryByText(/Waiting/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Task /)).not.toBeInTheDocument();
});
it("falls back to a task detail line when there is no error or wait", () => {
render(
<AgentCard
agent={AGENT}
agentStatus={statusOf({ task_id: "abcdef1234567890" })}
/>,
);
expect(screen.getByText(/Task abcdef12/)).toBeInTheDocument();
});
it("offers Spawn for a down agent, and View Details + Stop for an active one", () => {
const { rerender } = render(
<AgentCard agent={AGENT} agentStatus={statusOf({ state: "stopped" })} />,
);
expect(screen.getByTestId("spawn-dialog")).toBeInTheDocument();
expect(screen.queryByText("View Details")).not.toBeInTheDocument();
rerender(
<AgentCard agent={AGENT} agentStatus={statusOf({ state: "active" })} />,
);
expect(screen.queryByTestId("spawn-dialog")).not.toBeInTheDocument();
expect(screen.getByText("View Details")).toBeInTheDocument();
expect(screen.getByText("Stop Gracefully")).toBeInTheDocument();
expect(screen.getByText("Force Stop")).toBeInTheDocument();
});
it("keeps the actions menu trigger accessible by name", () => {
render(<AgentCard agent={AGENT} agentStatus={statusOf()} />);
expect(
screen.getByRole("button", { name: "Agent actions" }),
).toBeInTheDocument();
});
it("shows a compact one-line token/cost readout when usage data is present", () => {
render(
<AgentCard
agent={AGENT}
agentStatus={statusOf()}
usageRow={{
agent_slug: "be-dev-1",
tokens_input: 8000,
tokens_output: 4300,
total_tokens: 12300,
cost_usd: 0.0421,
pct_of_total: 0.1,
}}
/>,
);
expect(screen.getByText(/12\.3K tok/)).toBeInTheDocument();
expect(screen.getByText(/\$0\.0421/)).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentDefinition } from "@/lib/agent-definitions";
// Isolate AgentGrid from the real AgentCard (which pulls in mutation hooks,
// dropdown, and SpawnAgentDialog) — this file only covers grid-level
// concerns: the section header's count badge, one card per agent, and the
// loading-skeleton branch.
vi.mock("../agent-card", () => ({
AgentCard: ({ agent }: { agent: AgentDefinition }) => (
<div data-testid="agent-card">{agent.name}</div>
),
}));
import { AgentGrid } from "../agent-grid";
const AGENTS: AgentDefinition[] = [
{ id: "be-dev-1", name: "Backend Dev 1", role: null, team: null },
{ id: "be-dev-2", name: "Backend Dev 2", role: null, team: null },
];
describe("AgentGrid", () => {
it("shows a count badge inline with the section title", () => {
render(
<AgentGrid
title="Backend Cell"
agents={AGENTS}
agentStatuses={{}}
isLoading={false}
/>,
);
expect(screen.getByText("Backend Cell")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
it("renders one AgentCard per agent", () => {
render(
<AgentGrid
title="Backend Cell"
agents={AGENTS}
agentStatuses={{}}
isLoading={false}
/>,
);
const cards = screen.getAllByTestId("agent-card");
expect(cards).toHaveLength(2);
expect(cards[0]).toHaveTextContent("Backend Dev 1");
expect(cards[1]).toHaveTextContent("Backend Dev 2");
});
it("shows skeleton placeholders while loading, not the real cards", () => {
render(
<AgentGrid
title="Backend Cell"
agents={AGENTS}
agentStatuses={{}}
isLoading
/>,
);
expect(screen.queryByTestId("agent-card")).not.toBeInTheDocument();
});
it("reflects a zero-agent group truthfully in the count badge", () => {
render(
<AgentGrid title="Support" agents={[]} agentStatuses={{}} isLoading={false} />,
);
expect(screen.getByText("Support")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { OrchestratorStatusCards } from "../orchestrator-status";
// CEO feedback: "Total Agents shows 0 even though the full 25-agent roster
// renders below" — the card was reading status.total_agents, the backend's
// live in-memory instance count (roboco/runtime/orchestrator.py
// get_status_summary: len(self._instances)), not the roster. And "Active"
// keyed off by_state.running / by_state.ready, neither of which the backend
// ever emits (OrchestratorAgentState is offline/starting/active/
// waiting_short/waiting_long/idle/stopping) — so it silently always read 0.
// Both are now truthful: Total Agents = the roster prop, Active = by_state.active.
describe("OrchestratorStatusCards", () => {
it("shows the roster size for Total Agents, independent of the backend's live-instance total_agents", () => {
render(
<OrchestratorStatusCards
status={{
total_agents: 2,
by_state: { active: 2 },
waiting_count: 0,
agents: [],
}}
isLoading={false}
rosterCount={25}
/>,
);
expect(screen.getByTestId("stat-total-agents")).toHaveTextContent("25");
});
it("counts Active from the real 'active' backend state, ignoring the nonexistent running/ready keys", () => {
render(
<OrchestratorStatusCards
status={{
total_agents: 5,
by_state: { running: 99, ready: 99, active: 3, idle: 4 },
waiting_count: 1,
agents: [],
}}
isLoading={false}
rosterCount={25}
/>,
);
expect(screen.getByTestId("stat-active")).toHaveTextContent("3");
expect(screen.getByTestId("stat-waiting")).toHaveTextContent("1");
});
it("shows Stopped when there is no status snapshot (orchestrator unreachable)", () => {
render(
<OrchestratorStatusCards
status={undefined}
isLoading={false}
rosterCount={25}
/>,
);
expect(screen.getByTestId("stat-orchestrator")).toHaveTextContent(
"Stopped",
);
});
it("shows Running once a status snapshot resolves, even with zero active agents", () => {
render(
<OrchestratorStatusCards
status={{ total_agents: 0, by_state: {}, waiting_count: 0, agents: [] }}
isLoading={false}
rosterCount={25}
/>,
);
expect(screen.getByTestId("stat-orchestrator")).toHaveTextContent(
"Running",
);
expect(screen.getByTestId("stat-active")).toHaveTextContent("0");
});
it("skeletons the roster cell while the roster query is loading, independent of the status query", () => {
render(
<OrchestratorStatusCards
status={{
total_agents: 0,
by_state: { active: 1 },
waiting_count: 0,
agents: [],
}}
isLoading={false}
rosterCount={0}
rosterLoading
/>,
);
expect(screen.queryByTestId("stat-total-agents")).not.toBeInTheDocument();
// Active isn't gated by rosterLoading — it already resolved.
expect(screen.getByTestId("stat-active")).toHaveTextContent("1");
});
});
+54 -58
View File
@@ -5,11 +5,7 @@ import { useStopAgent } from "@/hooks/use-agents";
import { AgentStatusResponse } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import {
Card,
CardContent,
@@ -26,7 +22,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Activity, Square } from "lucide-react";
import { toast } from "sonner";
import { AgentStateBadge } from "./agent-state-badge";
import { cn } from "@/lib/utils";
import { agentStateDescription, stateColors } from "./agent-state-badge";
import { SpawnAgentDialog } from "./spawn-agent-dialog";
import type { AgentUsageRow } from "@/types";
@@ -59,24 +56,39 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
}
};
// One secondary line, priority error > waiting > task, so a card never
// grows past two content rows regardless of how much is going on.
const detail = agentStatus?.error_count
? {
text:
agentStatus.error_count +
(agentStatus.error_count === 1 ? " error" : " errors"),
className: "text-red-500",
}
: agentStatus?.waiting_for
? { text: "Waiting: " + agentStatus.waiting_for, className: "text-yellow-600" }
: agentStatus?.task_id
? { text: "Task " + agentStatus.task_id.slice(0, 8) + "…", className: "text-muted-foreground" }
: null;
return (
<Card className={isActive ? "border-green-500/50" : ""}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
<Card className={cn("gap-2 py-3", isActive && "border-green-500/50")}>
<CardHeader className="gap-0.5 px-3">
<div className="flex items-center justify-between gap-1">
<CardTitle className="truncate text-sm">
{agent.name || "Unknown Agent"}
</CardTitle>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Agent actions</TooltipContent>
</Tooltip>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Agent actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
@@ -106,51 +118,35 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardDescription className="text-xs">
<CardDescription className="truncate text-xs">
{agent.role?.replace(/_/g, " ") || "N/A"}
{agent.team && " • " + agent.team.replace(/_/g, " ")}
</CardDescription>
</CardHeader>
<CardContent>
<AgentStateBadge state={state} />
{agentStatus?.task_id && (
<p className="text-xs text-muted-foreground mt-2">
Task: {agentStatus.task_id.slice(0, 8)}...
</p>
)}
{agentStatus?.waiting_for && (
<p className="text-xs text-yellow-600 mt-2 truncate">
Waiting: {agentStatus.waiting_for}
</p>
)}
{agentStatus && agentStatus.error_count > 0 && (
<p className="text-xs text-red-500 mt-2">
Errors: {agentStatus.error_count}
<CardContent className="px-3">
<HelpTip label={agentStateDescription(state)}>
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
stateColors[state] || "bg-gray-400",
)}
/>
{state.replace(/_/g, " ")}
</span>
</HelpTip>
{detail && (
<p className={cn("mt-1 truncate text-xs", detail.className)}>
{detail.text}
</p>
)}
{usageRow && (
<div className="mt-3 pt-2 border-t">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>
{usageRow.total_tokens >= 1_000
? (usageRow.total_tokens / 1_000).toFixed(1) + "K"
: String(usageRow.total_tokens)}{" "}
tokens
</span>
<span className="font-medium text-foreground">
${usageRow.cost_usd.toFixed(4)}
</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
className="h-full rounded-full bg-[var(--chart-1)]"
style={{
width:
Math.min(100, (usageRow.total_tokens / 30_000) * 100) + "%",
}}
/>
</div>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{usageRow.total_tokens >= 1_000
? (usageRow.total_tokens / 1_000).toFixed(1) + "K"
: String(usageRow.total_tokens)}{" "}
tok · ${usageRow.cost_usd.toFixed(4)}
</p>
)}
</CardContent>
</Card>
+19 -16
View File
@@ -1,5 +1,6 @@
import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
import { Badge } from "@/components/ui/badge";
import { Card, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentCard } from "./agent-card";
@@ -10,35 +11,37 @@ interface AgentGridProps {
agentStatuses: Record<string, AgentStatusResponse>;
agentUsage?: Record<string, AgentUsageRow>;
isLoading: boolean;
columns?: number;
}
// Compact cards need far less width per card than the old large ones, so
// wide screens fit up to 8 across instead of wrapping a tall, ragged grid.
const GRID_COLS =
"grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8";
export function AgentGrid({
title,
agents,
agentStatuses,
agentUsage,
isLoading,
columns = 4,
}: AgentGridProps) {
const gridCols =
{
3: "md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4",
4: "md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-5",
5: "md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5",
}[columns] ||
"md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-5";
return (
<div>
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className={"grid gap-4 " + gridCols}>
<div className="mb-3 flex items-center gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</h2>
<Badge variant="secondary" className="text-xs">
{agents.length}
</Badge>
</div>
<div className={"grid gap-3 " + GRID_COLS}>
{isLoading
? Array.from({ length: agents.length || 3 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-24" />
<Card key={i} className="gap-2 py-3">
<CardHeader className="gap-1 px-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</CardHeader>
</Card>
))
@@ -1,5 +1,5 @@
import { OrchestratorStatus as OrchestratorStatusType } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Server, Users, Clock, Activity } from "lucide-react";
@@ -7,81 +7,97 @@ import { Server, Users, Clock, Activity } from "lucide-react";
interface OrchestratorStatusCardsProps {
status: OrchestratorStatusType | undefined;
isLoading: boolean;
/** Full org roster size (from useAgentDefinitions), NOT the backend's
* `status.total_agents` that field is the orchestrator's live in-memory
* instance count (roboco/runtime/orchestrator.py get_status_summary:
* `len(self._instances)`), so it read 0 whenever nothing was spawned even
* though the full 25-agent roster renders below. Total Agents must reflect
* the roster, not who's currently running. */
rosterCount: number;
rosterLoading?: boolean;
}
/** One compact stat row instead of 4 separate full cards same information,
* a fraction of the chrome. */
export function OrchestratorStatusCards({
status,
isLoading,
rosterCount,
rosterLoading = false,
}: OrchestratorStatusCardsProps) {
// Calculate running agents from by_state
const runningCount = status?.by_state?.running || 0;
const readyCount = status?.by_state?.ready || 0;
const activeCount = runningCount + readyCount;
// The orchestrator's by_state keys are the real OrchestratorAgentState
// values (offline/starting/active/waiting_short/waiting_long/idle/stopping)
// — "running"/"ready" are not states it ever emits, so keying off those (as
// this card previously did) always read 0. "active" is the one state the
// orchestrator sets while a spawned agent is actually doing work.
const activeCount = status?.by_state?.active ?? 0;
const waitingCount = status?.waiting_count ?? 0;
// The orchestrator service is up whenever its status query resolves — agent
// count is shown separately in the cards below. (Previously this read
// `total_agents > 0`, so an idle-but-healthy orchestrator showed "Stopped".)
// count is shown separately in the cells below.
const isRunning = status !== undefined;
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Orchestrator</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<Card className="py-0">
<CardContent className="grid grid-cols-1 divide-y divide-border sm:grid-cols-4 sm:divide-x sm:divide-y-0">
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Server className="h-4 w-4" />
Orchestrator
</div>
{isLoading ? (
<Skeleton className="h-8 w-24" />
<Skeleton className="h-6 w-16" />
) : (
<Badge className={isRunning ? "bg-green-500" : "bg-red-500"}>
<Badge
data-testid="stat-orchestrator"
className={isRunning ? "bg-green-500" : "bg-red-500"}
>
{isRunning ? "Running" : "Stopped"}
</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Agents</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-8 w-12" />
</div>
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Users className="h-4 w-4" />
Total Agents
</div>
{rosterLoading ? (
<Skeleton className="h-6 w-8" />
) : (
<div className="text-2xl font-bold">
{status?.total_agents || 0}
</div>
<span data-testid="stat-total-agents" className="text-xl font-bold">
{rosterCount}
</span>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
</div>
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Activity className="h-4 w-4" />
Active
</div>
{isLoading ? (
<Skeleton className="h-8 w-12" />
<Skeleton className="h-6 w-8" />
) : (
<div className="text-2xl font-bold">{activeCount}</div>
<span data-testid="stat-active" className="text-xl font-bold">
{activeCount}
</span>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Waiting</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
</div>
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
Waiting
</div>
{isLoading ? (
<Skeleton className="h-8 w-12" />
<Skeleton className="h-6 w-8" />
) : (
<div className="text-2xl font-bold">
{status?.waiting_count || 0}
</div>
<span data-testid="stat-waiting" className="text-xl font-bold">
{waitingCount}
</span>
)}
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
);
}