From 97b779227f960d955af3da370efd39666e3a0bbd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:30:20 -0700 Subject: [PATCH 1/5] =?UTF-8?q?Add=20tests=20for=20Agents=20list=20Status/?= =?UTF-8?q?Model/Runs=C2=B77d=20and=20bulk=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the new agentRosterStatus/runsInLast7Days helpers and the per-row checkbox + header select-all rendering the roster table gains. --- apps/web/test/agents-page.test.tsx | 141 +++++++++++++++--- .../test/stage-chrome-consistency.test.tsx | 1 + 2 files changed, 119 insertions(+), 23 deletions(-) diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx index b78034651..b18ffb079 100644 --- a/apps/web/test/agents-page.test.tsx +++ b/apps/web/test/agents-page.test.tsx @@ -1,14 +1,19 @@ -// 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, + 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 +31,76 @@ 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("AgentsPage", () => { test("teaches what will appear once a bench has no agents yet", () => { const markup = renderToStaticMarkup( @@ -35,6 +108,8 @@ describe("AgentsPage", () => { tenantId="tnt_1" definitions={[]} workbenches={new Map()} + instances={[]} + now={NOW} selectedId={null} onSelect={noop} createOpen={false} @@ -45,23 +120,16 @@ describe("AgentsPage", () => { 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"); }); test("renders a humanized display name for a definition with no description, alongside its slug", () => { @@ -90,6 +156,8 @@ describe("AgentsPage", () => { tenantId="tnt_1" definitions={[undescribed]} workbenches={new Map()} + instances={[]} + now={NOW} selectedId={null} onSelect={noop} createOpen={false} @@ -117,6 +185,8 @@ describe("AgentsPage", () => { ], ]) } + instances={[]} + now={NOW} selectedId="wfd_1" onSelect={noop} createOpen={false} @@ -130,12 +200,14 @@ 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,6 +225,8 @@ describe("AgentsPage", () => { tenantId={null} definitions={[]} workbenches={new Map()} + instances={[]} + now={NOW} selectedId={null} onSelect={noop} createOpen={false} @@ -161,4 +236,24 @@ describe("AgentsPage", () => { ); 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"'); + expect(markup).not.toContain("Duplicate"); + }); }); diff --git a/apps/web/test/stage-chrome-consistency.test.tsx b/apps/web/test/stage-chrome-consistency.test.tsx index ea6e71523..204666f4e 100644 --- a/apps/web/test/stage-chrome-consistency.test.tsx +++ b/apps/web/test/stage-chrome-consistency.test.tsx @@ -51,6 +51,7 @@ describe("stage chrome consistency (CL-6368)", () => { }, ]} workbenches={new Map()} + instances={[]} selectedId={null} onSelect={noop} createOpen={false} From e7e83dd2c91a75d1ad4f5482f8d68bbedf2f434d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:30:20 -0700 Subject: [PATCH 2/5] =?UTF-8?q?CL-6469:=20Agents=20list=20to=20spec=20?= =?UTF-8?q?=E2=80=94=20Status/Model/Runs=C2=B77d=20columns,=20bulk=20bar,?= =?UTF-8?q?=20New=20agent=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roster table gains Status (folded from a definition's deployed/stopped state and its live instances), Model (lazy per-row fetch), and Runs·7d (counted from top-level runs created in the trailing week) alongside bulk row selection (useListSelection/SelectionCheckbox) and a floating BulkActionBar (Duplicate/Archive/Move/Delete). The top-bar action is now "New agent" per the page-action contract. Duplicate/Archive/Move/ Delete surface as an honest not-yet-wired toast — the hub exposes no bulk mutation endpoint for any of them yet. Adds Archive/Trash to the shared icon re-export surface. --- apps/web/src/pages/agents-page.tsx | 328 ++++++++++++++++++++++++----- packages/icons/src/index.tsx | 2 + 2 files changed, 282 insertions(+), 48 deletions(-) diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 38d549a3a..7f46b0f22 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -11,9 +11,11 @@ import { Badge, + BulkActionBar, Button, PageShell, RichEmptyState, + SelectionCheckbox, Skeleton, Table, TableBody, @@ -21,26 +23,30 @@ import { 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, Copy, FolderOpen, Plus, Robot, Trash } 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, 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 +60,109 @@ 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", +}; + +const AGENT_ROSTER_STATUS_TONE: Record = { + running: "success", + idle: "neutral", + 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; +} + +const AGENT_BULK_ACTIONS = [ + { id: "duplicate", label: "Duplicate", icon: Copy }, + { id: "archive", label: "Archive", icon: Archive }, + { id: "move", label: "Move", icon: FolderOpen }, + { id: "delete", label: "Delete", icon: Trash }, +] as const; + +/** 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,17 +300,22 @@ 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 + * Duplicate/Archive/Move/Delete — none of those four mutate anything yet + * (the hub exposes no bulk endpoint for any of them), so each one is a + * clearly-labelled no-op toast rather than a button that lies about doing + * something. */ export function AgentsPage({ tenantId, definitions, workbenches, + instances, + now = Date.now(), selectedId, onSelect, createOpen, @@ -214,6 +328,8 @@ 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; @@ -221,6 +337,23 @@ export function AgentsPage({ readonly onCreated: (definition: AgentDefinition) => 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"; + + function runBulkAction(label: string) { + toast(`${label} isn't wired to the hub yet.`); + } return (
@@ -234,7 +367,7 @@ export function AgentsPage({ onClick={() => onCreateOpenChange(true)} aria-label="Create an agent" > - Create + New agent ) : null } @@ -253,45 +386,120 @@ 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} - -
-
- - {definition.description !== null && - definition.description !== undefined && - definition.description !== "" - ? definition.description - : "—"} - - - {workbenches.get(definition.id)?.length ?? 0} - -
- ))} + {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 + : "—"} + + + + {AGENT_ROSTER_STATUS_LABEL[status]} + + + + {tenantId !== null ? ( + + ) : ( + — + )} + + + {runsInLast7Days(definition.id, instances, now)} + +
+ ); + })}
@@ -320,6 +528,21 @@ export function AgentsPage({ }} /> ) : null} + + {AGENT_BULK_ACTIONS.map(({ id, label, icon: Icon }) => ( + + ))} + ); } @@ -335,6 +558,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 +607,7 @@ export function AgentsRoute({ tenantId={selectedTenantId} definitions={definitions} workbenches={workbenches} + instances={runsQuery.data ?? []} selectedId={selectedId} onSelect={(id) => navigate( diff --git a/packages/icons/src/index.tsx b/packages/icons/src/index.tsx index 0bb1d0693..7bee11e60 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, @@ -75,6 +76,7 @@ export { Stack, Star, SquaresFour, + Trash, User, UserCircle, UserPlus, From afdc2ee0dc1958518bd6c0877fb3d345c7ee9c05 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:42:41 -0700 Subject: [PATCH 3/5] Fix Agents bulk bar and status colors per peer review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk bar now wires Archive to the real setAgentDefinitionStatus PUT (same route the single-agent Archive button already uses), and drops Duplicate/Move/Delete — none of the three has a real bulk primitive, and a button that can't do what it says is worse than no button. Status tones now adopt react-ui's own run-status convention instead of an invented mapping: Running is the blue "live" tone with a pulsing StatusDot, Idle is the green "healthy" tone — Blocked/Archived were already right. --- apps/web/src/pages/agents-page.tsx | 109 ++++++++++++------ apps/web/test/agents-page.test.tsx | 15 +++ .../test/stage-chrome-consistency.test.tsx | 1 + 3 files changed, 91 insertions(+), 34 deletions(-) diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 7f46b0f22..91e151dd1 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -17,6 +17,7 @@ import { RichEmptyState, SelectionCheckbox, Skeleton, + StatusDot, Table, TableBody, TableCell, @@ -27,7 +28,7 @@ import { useListSelection, } from "@corbits/react-ui"; import type { BadgeTone, SelectionCheckboxState } from "@corbits/react-ui"; -import { Archive, Copy, FolderOpen, Plus, Robot, Trash } from "@corbits/icons"; +import { Archive, Plus, Robot } from "@corbits/icons"; import { useEffect, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -36,6 +37,7 @@ import { describeApiError, QueryView } from "@corbits/api-query"; import { getAgentCapabilities, listTopLevelRuns, + setAgentDefinitionStatus, useAgentDirectory, type AgentCapabilities, type AgentDefinition, @@ -75,9 +77,15 @@ const AGENT_ROSTER_STATUS_LABEL: Record = { 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: "success", - idle: "neutral", + running: "info", + idle: "success", blocked: "danger", archived: "neutral", }; @@ -111,13 +119,6 @@ export function runsInLast7Days( ).length; } -const AGENT_BULK_ACTIONS = [ - { id: "duplicate", label: "Duplicate", icon: Copy }, - { id: "archive", label: "Archive", icon: Archive }, - { id: "move", label: "Move", icon: FolderOpen }, - { id: "delete", label: "Delete", icon: Trash }, -] as const; - /** 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 @@ -305,10 +306,13 @@ function AgentDetailPanel({ * 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 - * Duplicate/Archive/Move/Delete — none of those four mutate anything yet - * (the hub exposes no bulk endpoint for any of them), so each one is a - * clearly-labelled no-op toast rather than a button that lies about doing - * something. + * 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, @@ -321,6 +325,7 @@ export function AgentsPage({ createOpen, onCreateOpenChange, onCreated, + onArchiveSelected, }: { readonly tenantId: string | null; readonly definitions: readonly AgentDefinitionWithDisplayName[]; @@ -335,6 +340,7 @@ export function AgentsPage({ 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( @@ -351,10 +357,6 @@ export function AgentsPage({ ? true : "indeterminate"; - function runBulkAction(label: string) { - toast(`${label} isn't wired to the hub yet.`); - } - return (
- - {AGENT_ROSTER_STATUS_LABEL[status]} - + + {status === "running" ? ( + + ) : null} + + {AGENT_ROSTER_STATUS_LABEL[status]} + + {tenantId !== null ? ( @@ -529,19 +541,20 @@ export function AgentsPage({ /> ) : null} - {AGENT_BULK_ACTIONS.map(({ id, label, icon: Icon }) => ( - - ))} +
); @@ -623,6 +636,34 @@ export function AgentsRoute({ queryKey: tenantKeys.agentDirectory(selectedTenantId), }); }} + onArchiveSelected={(ids) => { + if (ids.length === 0) return; + void Promise.all( + ids.map((id) => + setAgentDefinitionStatus(selectedTenantId, id, "stopped"), + ), + ) + .then(() => { + void queryClient.invalidateQueries({ + queryKey: tenantKeys.agentDirectory(selectedTenantId), + }); + void queryClient.invalidateQueries({ + queryKey: ["agent-top-level-runs", selectedTenantId], + }); + toast( + ids.length === 1 + ? "Archived 1 agent" + : `Archived ${ids.length} agents`, + ); + }) + .catch(() => + toast( + ids.length === 1 + ? "Couldn't archive that agent" + : "Couldn't archive those agents", + ), + ); + }} /> ); } diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx index b18ffb079..3dbf78692 100644 --- a/apps/web/test/agents-page.test.tsx +++ b/apps/web/test/agents-page.test.tsx @@ -115,6 +115,7 @@ describe("AgentsPage", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain("No agents yet"); @@ -135,12 +136,16 @@ describe("AgentsPage", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain("Triage bot"); expect(markup).toContain("triage-bot"); expect(markup).toContain("Sorts inbound issues."); 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", () => { @@ -163,6 +168,7 @@ describe("AgentsPage", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain("Research Analyst"); @@ -192,6 +198,7 @@ describe("AgentsPage", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain('href="/w/wb_1/settings/agents"'); @@ -213,6 +220,7 @@ describe("AgentsPage", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain('aria-label="Create an agent"'); @@ -232,6 +240,7 @@ describe("AgentsPage", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).not.toContain('aria-label="Create an agent"'); @@ -250,10 +259,16 @@ describe("AgentsPage", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); 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 204666f4e..ebf2bbfef 100644 --- a/apps/web/test/stage-chrome-consistency.test.tsx +++ b/apps/web/test/stage-chrome-consistency.test.tsx @@ -57,6 +57,7 @@ describe("stage chrome consistency (CL-6368)", () => { createOpen={false} onCreateOpenChange={noop} onCreated={noop} + onArchiveSelected={noop} />, ); expect(markup).toContain('data-testid="stage-top-bar"'); From 4cace1d0ffa324521384cccbb4a1a3e34bc70c23 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:54:01 -0700 Subject: [PATCH 4/5] Add tests for partial-failure bulk archive Covers archiveDefinitions/archiveResultToast: one id failing must not hide or roll back the ids that already succeeded server-side, and the toast copy must report an honest count for partial/full success/failure. --- apps/web/test/agents-page.test.tsx | 72 +++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx index 3dbf78692..25ec80982 100644 --- a/apps/web/test/agents-page.test.tsx +++ b/apps/web/test/agents-page.test.tsx @@ -10,6 +10,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { AgentsPage, agentRosterStatus, + archiveDefinitions, + archiveResultToast, runsInLast7Days, } from "../src/pages/agents-page"; import type { AgentDefinitionWithDisplayName } from "../src/agents-directory"; @@ -53,10 +55,9 @@ 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" })], - ), + agentRosterStatus({ ...triage, status: "stopped" }, [ + instance({ definitionId: "wfd_1", status: "running" }), + ]), ).toBe("archived"); }); @@ -101,6 +102,65 @@ describe("runsInLast7Days", () => { }); }); +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( @@ -127,9 +187,7 @@ describe("AgentsPage", () => { tenantId="tnt_1" definitions={[triage]} workbenches={new Map()} - instances={[ - instance({ definitionId: "wfd_1", status: "running" }), - ]} + instances={[instance({ definitionId: "wfd_1", status: "running" })]} now={NOW} selectedId={null} onSelect={noop} From 8362b51a4645539347b420c0f60c51b486a54ba0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 22:54:01 -0700 Subject: [PATCH 5/5] Fix partial-failure bulk archive and drop unused Trash icon export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onArchiveSelected used Promise.all, which rejects on the first rejection even though the other archive calls already succeeded server-side — the roster kept showing stale state and the toast implied nothing happened. Switch to Promise.allSettled (archiveDefinitions), always invalidate the definitions/runs queries regardless of outcome, and toast a real count via archiveResultToast. Also runs prettier on the touched files and drops packages/icons' Trash export, unused since Delete was removed from the bulk bar. --- apps/web/src/pages/agents-page.tsx | 94 +++++++++++++++++++++--------- packages/icons/src/index.tsx | 1 - 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 91e151dd1..63a78ae71 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -119,6 +119,53 @@ export function runsInLast7Days( ).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 @@ -392,7 +439,9 @@ export function AgentsPage({ - allSelected ? selection.clear() : selection.selectAll() + allSelected + ? selection.clear() + : selection.selectAll() } rowLabel="all agents" ariaLabel="Select all agents" @@ -455,7 +504,9 @@ export function AgentsPage({ ); }} > - event.stopPropagation()}> + event.stopPropagation()} + > @@ -638,31 +689,20 @@ export function AgentsRoute({ }} onArchiveSelected={(ids) => { if (ids.length === 0) return; - void Promise.all( - ids.map((id) => - setAgentDefinitionStatus(selectedTenantId, id, "stopped"), - ), - ) - .then(() => { - void queryClient.invalidateQueries({ - queryKey: tenantKeys.agentDirectory(selectedTenantId), - }); - void queryClient.invalidateQueries({ - queryKey: ["agent-top-level-runs", selectedTenantId], - }); - toast( - ids.length === 1 - ? "Archived 1 agent" - : `Archived ${ids.length} agents`, - ); - }) - .catch(() => - toast( - ids.length === 1 - ? "Couldn't archive that agent" - : "Couldn't archive those agents", - ), - ); + 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/packages/icons/src/index.tsx b/packages/icons/src/index.tsx index 7bee11e60..9a692c4e0 100644 --- a/packages/icons/src/index.tsx +++ b/packages/icons/src/index.tsx @@ -76,7 +76,6 @@ export { Stack, Star, SquaresFour, - Trash, User, UserCircle, UserPlus,