mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user