diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 38d549a3a..63a78ae71 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -11,36 +11,44 @@ import { Badge, + BulkActionBar, Button, PageShell, RichEmptyState, + SelectionCheckbox, Skeleton, + StatusDot, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, + toast, + useListSelection, } from "@corbits/react-ui"; -import type { BadgeTone } from "@corbits/react-ui"; -import { Robot } from "@corbits/icons"; -import { useEffect, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import type { BadgeTone, SelectionCheckboxState } from "@corbits/react-ui"; +import { Archive, Plus, Robot } from "@corbits/icons"; +import { useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { describeApiError, QueryView } from "@corbits/api-query"; import { getAgentCapabilities, + listTopLevelRuns, + setAgentDefinitionStatus, useAgentDirectory, type AgentCapabilities, type AgentDefinition, + type AgentInstance, } from "../agents-api"; import { purposeAgentDefinitions, type AgentDefinitionWithDisplayName, } from "../agents-directory"; import { useBench } from "../bench-context"; -import { rowActivationProps } from "../activatable-row"; +import { isAdditiveSelectClick } from "../activatable-row"; import { Link } from "../navigation"; import { useBenchActivity } from "../shell/bench-activity"; import { AGENTS_PATH_PREFIX, agentIdFromPath } from "../path-ids"; @@ -54,6 +62,155 @@ const DEFINITION_STATUS_TONE: Record = { stopped: "neutral", }; +/** The roster's Status column folds a definition's own deployed/stopped + * state together with its live instances' statuses — a stopped definition + * always reads Archived; a deployed one reads Running while any instance + * is actively running, Blocked while any instance is erroring, otherwise + * Idle. `instances` is expected to already be a tenant's top-level runs + * (`listTopLevelRuns`), never the folded per-workbench-host noise. */ +export type AgentRosterStatus = "running" | "idle" | "blocked" | "archived"; + +const AGENT_ROSTER_STATUS_LABEL: Record = { + running: "Running", + idle: "Idle", + blocked: "Blocked", + archived: "Archived", +}; + +// Adopts `@corbits/react-ui`'s own run-status convention +// (`RUN_STATUS_TONE`/`workflow-run.ts`) rather than inventing a mapping: +// running is the blue "live/streaming" tone (with a pulsing `StatusDot`, +// the same liveness marker that vocabulary already carries); idle is the +// green "healthy, nothing wrong" tone (`pill-ok` in the spec); blocked and +// archived were already right. +const AGENT_ROSTER_STATUS_TONE: Record = { + running: "info", + idle: "success", + blocked: "danger", + archived: "neutral", +}; + +export function agentRosterStatus( + definition: AgentDefinition, + instances: readonly AgentInstance[], +): AgentRosterStatus { + if (definition.status === "stopped") return "archived"; + const own = instances.filter( + (instance) => instance.definitionId === definition.id, + ); + if (own.some((instance) => instance.status === "running")) return "running"; + if (own.some((instance) => instance.status === "error")) return "blocked"; + return "idle"; +} + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +/** How many of a definition's instances were created in the trailing 7 + * days — the roster's "Runs · 7d" column. */ +export function runsInLast7Days( + definitionId: string, + instances: readonly AgentInstance[], + now: number, +): number { + return instances.filter( + (instance) => + instance.definitionId === definitionId && + now - new Date(instance.createdAt).getTime() <= SEVEN_DAYS_MS, + ).length; +} + +export type ArchiveDefinitionsResult = { + readonly succeededIds: readonly string[]; + readonly failedIds: readonly string[]; +}; + +/** + * Archives every selected id independently — `Promise.allSettled`, never + * `Promise.all`, so one id failing server-side can't hide (or roll back) + * the ids that already succeeded. The caller invalidates its queries and + * toasts off the returned counts regardless of whether anything failed. + */ +export async function archiveDefinitions( + ids: readonly string[], + archive: (id: string) => Promise, +): Promise { + const results = await Promise.allSettled(ids.map((id) => archive(id))); + const succeededIds: string[] = []; + const failedIds: string[] = []; + results.forEach((result, index) => { + const id = ids[index]; + if (id === undefined) return; + if (result.status === "fulfilled") succeededIds.push(id); + else failedIds.push(id); + }); + return { succeededIds, failedIds }; +} + +/** The toast copy for a bulk archive — an honest count either way, never + * a blanket success/failure message that could describe a partial run. */ +export function archiveResultToast({ + succeededIds, + failedIds, +}: ArchiveDefinitionsResult): string { + const total = succeededIds.length + failedIds.length; + if (failedIds.length === 0) { + return succeededIds.length === 1 + ? "Archived 1 agent" + : `Archived ${succeededIds.length} agents`; + } + if (succeededIds.length === 0) { + return failedIds.length === 1 + ? "Couldn't archive that agent" + : "Couldn't archive those agents"; + } + return `Archived ${succeededIds.length} of ${total} — the rest failed`; +} + +/** The short model name for a definition's capabilities — fetched lazily, + * per row, the same route (and the same plain fetch-effect, no react-query + * client required) `AgentDetailPanel` below already uses; a load or fetch + * failure degrades to a dash rather than blocking the row. */ +function AgentModelCell({ + tenantId, + definitionId, +}: { + readonly tenantId: string; + readonly definitionId: string; +}) { + const [capabilities, setCapabilities] = useState< + | { readonly status: "loading" } + | { readonly status: "ready"; readonly data: AgentCapabilities } + | { readonly status: "error" } + >({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setCapabilities({ status: "loading" }); + getAgentCapabilities(tenantId, definitionId) + .then((data) => { + if (!cancelled) setCapabilities({ status: "ready", data }); + }) + .catch(() => { + if (!cancelled) setCapabilities({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [tenantId, definitionId]); + + if (capabilities.status === "loading") { + return ; + } + if (capabilities.status === "error") { + return —; + } + return ( + + {capabilities.data.model ?? "Default"} + + ); +} + /** A workbench instance running a given agent definition — just enough to * link to its own settings Agents tab (`workbenchSettingsPath`), which * takes the workbench's own id directly, never a tenant id. */ @@ -191,22 +348,31 @@ function AgentDetailPanel({ /** * The roster stage: a flat table of every definition this bench owns — - * name, description, and how many open workbenches (agent DMs) currently - * run it — rows, never cards, per the owner's "rows over grids" rule for - * this slice. Selecting a row opens its detail alongside the table; "Create" - * opens `CreateAgentPanel`. There is no second "New agent" mint action here - * — creating an agent stays the one workbench-creation verb the rest of - * the shell already offers. + * name, status, model, and how often it has run in the last week — rows, + * never cards, per the owner's "rows over grids" rule for this slice. + * Selecting a row opens its detail alongside the table; "New agent" opens + * `CreateAgentPanel`. Rows are also bulk-selectable (checkbox + shift/cmd + * range select, `useListSelection`) with a floating `BulkActionBar` for + * Archive — the only bulk action with a real backend primitive + * (`setAgentDefinitionStatus`, the same PUT the single-agent Archive + * button on the detail page already uses). Duplicate/Move/Delete are not + * offered here: batch duplication needs slug-collision handling the detail + * page's single-agent duplicate never had to solve, and Move/Delete have + * no backend primitive at all — a button that cannot do what it says is + * worse than no button. */ export function AgentsPage({ tenantId, definitions, workbenches, + instances, + now = Date.now(), selectedId, onSelect, createOpen, onCreateOpenChange, onCreated, + onArchiveSelected, }: { readonly tenantId: string | null; readonly definitions: readonly AgentDefinitionWithDisplayName[]; @@ -214,13 +380,29 @@ export function AgentsPage({ string, readonly DefinitionWorkbenchInstance[] >; + readonly instances: readonly AgentInstance[]; + readonly now?: number; readonly selectedId: string | null; readonly onSelect: (id: string | null) => void; readonly createOpen: boolean; readonly onCreateOpenChange: (open: boolean) => void; readonly onCreated: (definition: AgentDefinition) => void; + readonly onArchiveSelected: (ids: readonly string[]) => void; }) { const selected = definitions.find((d) => d.id === selectedId) ?? null; + const definitionIds = useMemo( + () => definitions.map((definition) => definition.id), + [definitions], + ); + const selection = useListSelection({ ids: definitionIds }); + const allSelected = + definitions.length > 0 && selection.selectedCount === definitions.length; + const headerChecked: SelectionCheckboxState = + selection.selectedCount === 0 + ? false + : allSelected + ? true + : "indeterminate"; return (
@@ -234,7 +416,7 @@ export function AgentsPage({ onClick={() => onCreateOpenChange(true)} aria-label="Create an agent" > - Create + New agent ) : null } @@ -253,45 +435,134 @@ export function AgentsPage({ - Name - Description - Workbenches + + + allSelected + ? selection.clear() + : selection.selectAll() + } + rowLabel="all agents" + ariaLabel="Select all agents" + className="opacity-100" + /> + + Agent + + Description + + Status + + Model + + + Runs · 7d + - {definitions.map((definition) => ( - - onSelect( - selectedId === definition.id ? null : definition.id, - ), - )} - > - -
- {definition.displayName} - - {definition.name} + {definitions.map((definition) => { + const isSelected = selection.isSelected(definition.id); + const status = agentRosterStatus(definition, instances); + return ( + { + if ( + event.shiftKey || + isAdditiveSelectClick(event) + ) { + selection.toggle(definition.id, { + shiftKey: event.shiftKey, + }); + return; + } + onSelect( + selectedId === definition.id + ? null + : definition.id, + ); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + onSelect( + selectedId === definition.id + ? null + : definition.id, + ); + }} + > + event.stopPropagation()} + > + + selection.toggle(definition.id, modifiers) + } + rowLabel={definition.displayName} + /> + + +
+ + {definition.displayName} + + + {definition.name} + +
+
+ + {definition.description !== null && + definition.description !== undefined && + definition.description !== "" + ? definition.description + : "—"} + + + + {status === "running" ? ( + + ) : null} + + {AGENT_ROSTER_STATUS_LABEL[status]} + -
-
- - {definition.description !== null && - definition.description !== undefined && - definition.description !== "" - ? definition.description - : "—"} - - - {workbenches.get(definition.id)?.length ?? 0} - -
- ))} + + + {tenantId !== null ? ( + + ) : ( + — + )} + + + {runsInLast7Days(definition.id, instances, now)} + + + ); + })}
@@ -320,6 +591,22 @@ export function AgentsPage({ }} /> ) : null} + + + ); } @@ -335,6 +622,14 @@ export function AgentsRoute({ const queryClient = useQueryClient(); const directory = useAgentDirectory(selectedTenantId ?? undefined); const activity = useBenchActivity(selectedTenantId); + // Powers the roster's Status and "Runs · 7d" columns; a failed fetch here + // degrades those two columns to Idle/0 rather than blocking the page — + // the definitions listing above is what makes the page usable at all. + const runsQuery = useQuery({ + queryKey: ["agent-top-level-runs", selectedTenantId], + queryFn: () => listTopLevelRuns(selectedTenantId as string), + enabled: selectedTenantId !== null, + }); const [createOpen, setCreateOpen] = useState(false); const selectedId = agentIdFromPath(path); @@ -376,6 +671,7 @@ export function AgentsRoute({ tenantId={selectedTenantId} definitions={definitions} workbenches={workbenches} + instances={runsQuery.data ?? []} selectedId={selectedId} onSelect={(id) => navigate( @@ -391,6 +687,23 @@ export function AgentsRoute({ queryKey: tenantKeys.agentDirectory(selectedTenantId), }); }} + onArchiveSelected={(ids) => { + if (ids.length === 0) return; + void archiveDefinitions(ids, (id) => + setAgentDefinitionStatus(selectedTenantId, id, "stopped"), + ).then((result) => { + // Invalidate regardless of outcome: a partial failure still + // archived some ids server-side, so the roster must not keep + // showing them as active. + void queryClient.invalidateQueries({ + queryKey: tenantKeys.agentDirectory(selectedTenantId), + }); + void queryClient.invalidateQueries({ + queryKey: ["agent-top-level-runs", selectedTenantId], + }); + toast(archiveResultToast(result)); + }); + }} /> ); } diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx index b78034651..25ec80982 100644 --- a/apps/web/test/agents-page.test.tsx +++ b/apps/web/test/agents-page.test.tsx @@ -1,14 +1,21 @@ -// Agents roster (CL-6354): a flat table of every definition a bench owns — -// rows, never cards — with a "Workbenches" column counting how many open -// agent DMs currently run each one. `AgentsPage` is the presentational -// half (same split `LibraryPage`/`LibraryRoute` use in `pages.test.tsx`); -// no live hub here, just the render given real-shaped props. +// Agents roster (CL-6354, CL-6469): a flat table of every definition a +// bench owns — rows, never cards — with Status/Model/Runs·7d columns and a +// bulk-select bar. `AgentsPage` is the presentational half (same split +// `LibraryPage`/`LibraryRoute` use in `pages.test.tsx`); no live hub here, +// just the render given real-shaped props. import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; -import { AgentsPage } from "../src/pages/agents-page"; +import { + AgentsPage, + agentRosterStatus, + archiveDefinitions, + archiveResultToast, + runsInLast7Days, +} from "../src/pages/agents-page"; import type { AgentDefinitionWithDisplayName } from "../src/agents-directory"; +import type { AgentInstance } from "../src/agents-api"; // `name` is the immutable kebab identifier; `displayName` is what // `withDisplayNames` (CL-6413) derives from the definition's description @@ -26,8 +33,134 @@ const triage: AgentDefinitionWithDisplayName = { updatedAt: "2026-08-01T00:00:00.000Z", }; +function instance( + overrides: Partial & { readonly definitionId: string }, +): AgentInstance { + return { + id: "run_1", + definitionName: "triage-bot", + tenantId: "tnt_1", + address: "run_1@bench", + status: "running", + createdAt: "2026-08-19T00:00:00.000Z", + updatedAt: "2026-08-19T00:00:00.000Z", + ...overrides, + }; +} + +const NOW = new Date("2026-08-20T00:00:00.000Z").getTime(); + const noop = () => undefined; +describe("agentRosterStatus", () => { + test("a stopped definition always reads Archived, regardless of its instances", () => { + expect( + agentRosterStatus({ ...triage, status: "stopped" }, [ + instance({ definitionId: "wfd_1", status: "running" }), + ]), + ).toBe("archived"); + }); + + test("a deployed definition with a running instance reads Running", () => { + expect( + agentRosterStatus(triage, [ + instance({ definitionId: "wfd_1", status: "running" }), + ]), + ).toBe("running"); + }); + + test("a deployed definition with only an erroring instance reads Blocked", () => { + expect( + agentRosterStatus(triage, [ + instance({ definitionId: "wfd_1", status: "error" }), + ]), + ).toBe("blocked"); + }); + + test("a deployed definition with no live instances reads Idle", () => { + expect(agentRosterStatus(triage, [])).toBe("idle"); + }); +}); + +describe("runsInLast7Days", () => { + test("counts only this definition's instances created within the trailing week", () => { + const instances = [ + instance({ + definitionId: "wfd_1", + createdAt: "2026-08-19T00:00:00.000Z", + }), + instance({ + definitionId: "wfd_1", + createdAt: "2026-08-01T00:00:00.000Z", + }), + instance({ + definitionId: "wfd_other", + createdAt: "2026-08-19T00:00:00.000Z", + }), + ]; + expect(runsInLast7Days("wfd_1", instances, NOW)).toBe(1); + }); +}); + +describe("archiveDefinitions", () => { + test("one id failing does not roll back or hide the ids that succeeded", async () => { + const result = await archiveDefinitions( + ["wfd_1", "wfd_2", "wfd_3"], + (id) => + id === "wfd_2" + ? Promise.reject(new Error("504")) + : Promise.resolve(undefined), + ); + expect(result.succeededIds).toEqual(["wfd_1", "wfd_3"]); + expect(result.failedIds).toEqual(["wfd_2"]); + }); + + test("every id succeeding reports no failures", async () => { + const result = await archiveDefinitions(["wfd_1", "wfd_2"], () => + Promise.resolve(undefined), + ); + expect(result.succeededIds).toEqual(["wfd_1", "wfd_2"]); + expect(result.failedIds).toEqual([]); + }); + + test("every id failing reports no successes", async () => { + const result = await archiveDefinitions(["wfd_1", "wfd_2"], () => + Promise.reject(new Error("504")), + ); + expect(result.succeededIds).toEqual([]); + expect(result.failedIds).toEqual(["wfd_1", "wfd_2"]); + }); +}); + +describe("archiveResultToast", () => { + test("reports an honest partial count rather than a blanket success or failure", () => { + expect( + archiveResultToast({ + succeededIds: ["a", "b", "c", "d"], + failedIds: ["e"], + }), + ).toBe("Archived 4 of 5 — the rest failed"); + }); + + test("reports full success", () => { + expect(archiveResultToast({ succeededIds: ["a"], failedIds: [] })).toBe( + "Archived 1 agent", + ); + expect( + archiveResultToast({ succeededIds: ["a", "b"], failedIds: [] }), + ).toBe("Archived 2 agents"); + }); + + test("reports full failure", () => { + expect(archiveResultToast({ succeededIds: [], failedIds: ["a"] })).toBe( + "Couldn't archive that agent", + ); + expect( + archiveResultToast({ succeededIds: [], failedIds: ["a", "b"] }), + ).toBe("Couldn't archive those agents"); + }); +}); + describe("AgentsPage", () => { test("teaches what will appear once a bench has no agents yet", () => { const markup = renderToStaticMarkup( @@ -35,46 +168,42 @@ describe("AgentsPage", () => { tenantId="tnt_1" definitions={[]} workbenches={new Map()} + instances={[]} + now={NOW} selectedId={null} onSelect={noop} createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain("No agents yet"); }); - test("renders one row per definition with its workbench count", () => { + test("renders one row per definition with its status, name, and slug", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain("Triage bot"); expect(markup).toContain("triage-bot"); expect(markup).toContain("Sorts inbound issues."); - expect(markup).toContain(">3<"); - expect(markup).not.toContain("New agent"); - expect(markup).not.toContain("Create new agent"); + expect(markup).toContain("Running"); + // Running carries the live dot (react-ui's StatusDot, `live` prop) — + // the spec's liveness marker for an actively-running agent. + expect(markup).toContain('aria-label="Live"'); }); test("renders a humanized display name for a definition with no description, alongside its slug", () => { @@ -90,11 +219,14 @@ describe("AgentsPage", () => { tenantId="tnt_1" definitions={[undescribed]} workbenches={new Map()} + instances={[]} + now={NOW} selectedId={null} onSelect={noop} createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain("Research Analyst"); @@ -117,11 +249,14 @@ describe("AgentsPage", () => { ], ]) } + instances={[]} + now={NOW} selectedId="wfd_1" onSelect={noop} createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain('href="/w/wb_1/settings/agents"'); @@ -130,20 +265,24 @@ describe("AgentsPage", () => { expect(markup).toContain(">Support<"); }); - test("offers Create, never the retired New agent mint action", () => { + test("offers New agent as the top-bar create action, per the top-nav page-action contract", () => { const markup = renderToStaticMarkup( , ); expect(markup).toContain('aria-label="Create an agent"'); + expect(markup).toContain("New agent"); }); test("no create affordance without a resolved bench", () => { @@ -152,13 +291,42 @@ describe("AgentsPage", () => { tenantId={null} definitions={[]} workbenches={new Map()} + instances={[]} + now={NOW} selectedId={null} onSelect={noop} createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).not.toContain('aria-label="Create an agent"'); }); + + test("carries a selection checkbox per row and a header select-all, but no bulk bar with nothing selected", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('aria-label="Select all agents"'); + expect(markup).toContain('aria-label="Select Triage bot"'); + // BulkActionBar renders nothing at count 0 — none of its labels should + // leak into the page while nothing is selected. + expect(markup).not.toContain("Duplicate"); + expect(markup).not.toContain("Move"); + expect(markup).not.toContain("Delete"); + expect(markup).not.toContain("data-bulk-action"); + }); }); diff --git a/apps/web/test/stage-chrome-consistency.test.tsx b/apps/web/test/stage-chrome-consistency.test.tsx index ea6e71523..ebf2bbfef 100644 --- a/apps/web/test/stage-chrome-consistency.test.tsx +++ b/apps/web/test/stage-chrome-consistency.test.tsx @@ -51,11 +51,13 @@ describe("stage chrome consistency (CL-6368)", () => { }, ]} workbenches={new Map()} + instances={[]} selectedId={null} onSelect={noop} createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain('data-testid="stage-top-bar"'); diff --git a/packages/icons/src/index.tsx b/packages/icons/src/index.tsx index 0bb1d0693..9a692c4e0 100644 --- a/packages/icons/src/index.tsx +++ b/packages/icons/src/index.tsx @@ -17,6 +17,7 @@ import type { ReactNode } from "react"; export type { Icon, IconProps }; export { + Archive, ArrowBendUpLeft, ArrowClockwise, ArrowDown,