');
+ expect(html).toContain("kpi kpi-danger");
+ expect(html).toContain('5');
+ expect(html).not.toContain("");
+ });
+
+ it("renders page headers without description paragraphs", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('Overview
');
+ expect(html).not.toContain("");
+ });
+});
+
+describe("OverviewView", () => {
+ it("shows missing values instead of zero while collections load", () => {
+ const html = renderToStaticMarkup(
+ undefined}
+ onOpenSession={() => undefined}
+ onNavigate={() => undefined}
+ onConfigureConnection={() => undefined}
+ />,
+ );
+ expect(html).toContain("Overview");
+ expect(html).not.toMatch(/kpi-value">0);
+ expect(html).toContain('kpi-value">—<');
+ expect(html).not.toContain("recovery-banner");
+ });
+
+ it("offers backend recovery only when both collections fail with a gateway status", () => {
+ const html = renderToStaticMarkup(
+ undefined}
+ onOpenSession={() => undefined}
+ onNavigate={() => undefined}
+ onConfigureConnection={() => undefined}
+ />,
+ );
+ expect(html).toContain("recovery-banner");
+ expect(html).toContain("Agent Core backend is not ready. Open Docker startup guide");
+ });
+});
+
+describe("resource and overview views", () => {
+ const session = (id: string, overrides: Partial = {}): AgentSession => ({
+ id, object: "agent.session",
+ agent: { id: "agent_a", name: "Reviewer", model: "model-a" } as AgentSession["agent"],
+ environment: { type: "none" } as AgentSession["environment"],
+ status: "idle", error: null, metadata: {}, required_actions: [], vault_ids: [], usage: null,
+ created_at: 1_000, last_active_at: 2_000,
+ ...overrides,
+ });
+
+ it("lists Sessions with status, ID and an error tip for failures", () => {
+ const html = renderToStaticMarkup(
+ undefined}
+ onOpenSession={() => undefined}
+ />,
+ );
+ expect(html).toContain("Session log");
+ expect(html).toContain("Model provider returned 429.");
+ expect(html).toContain('aria-label="Error"');
+ expect(html).toContain('title="sess_failed_0001"');
+ expect(html).not.toContain("table-error");
+ });
+
+ it("marks retained runtime figures as stale after a refresh failure", () => {
+ const hosted = session("s_hosted", { environment: { type: "openai_hosted" } as AgentSession["environment"] });
+ const observation = {
+ id: "s_hosted", object: "agent.runtime_observation", session_id: "s_hosted", resolved_at: 1, environment_id: "env",
+ mode: "openai_hosted", provider_type: "docker",
+ instance: { kind: "managed_allocation", allocation_id: "alloc", device_id: null, connection_generation: null },
+ lifecycle_state: "active", status: "observed", reason: null, allocation_created_at: 1, observed_at: 1, started_at: 1,
+ cpu: { usage_seconds_total: 1, capacity_cores: 2, usage_cores: 0.5, utilization_ratio: null },
+ memory: { usage_bytes: 100, limit_bytes: 400 },
+ } as RuntimeObservation;
+ const html = renderToStaticMarkup(
+ undefined} onOpenSession={() => undefined} onNavigate={() => undefined} onConfigureConnection={() => undefined}
+ />,
+ );
+ expect(html).toContain("Stale");
+ expect(html).toContain("Runtime: Runtime snapshot exceeded the Web target budget.");
+ expect(html).not.toContain("recovery-banner");
+ });
+});
diff --git a/apps/web/src/components/console-ui.tsx b/apps/web/src/components/console-ui.tsx
new file mode 100644
index 000000000..a4c5bc08f
--- /dev/null
+++ b/apps/web/src/components/console-ui.tsx
@@ -0,0 +1,309 @@
+import { CircleHelp, RefreshCw, type LucideIcon } from "lucide-react";
+import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+
+/**
+ * Shared page grammar for the administrator console. Every page uses the same
+ * header, body padding, section heading, metric strip, status and meter
+ * vocabulary so that screens read as one product.
+ */
+
+const TIP_WIDTH = 288;
+
+/**
+ * Explanations live behind a small circled question mark instead of lines of
+ * small print. The tip opens on hover, keyboard focus or click and renders in a
+ * portal so framed containers cannot clip it.
+ */
+export function HelpTip({ children, label }: { children: ReactNode; label?: string }) {
+ const { t } = useTranslation();
+ const buttonRef = useRef(null);
+ const [position, setPosition] = useState<{ top: number; left: number; above: boolean } | null>(null);
+ const [pinned, setPinned] = useState(false);
+ const id = useId();
+
+ const show = useCallback(() => {
+ const rect = buttonRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ const left = Math.min(Math.max(8, rect.left - 12), window.innerWidth - TIP_WIDTH - 8);
+ const above = rect.bottom + 160 > window.innerHeight && rect.top > 180;
+ setPosition({ top: above ? rect.top - 8 : rect.bottom + 8, left, above });
+ }, []);
+ const hide = useCallback(() => {
+ setPosition(null);
+ setPinned(false);
+ }, []);
+
+ useEffect(() => {
+ if (!position) return;
+ const close = () => hide();
+ window.addEventListener("scroll", close, true);
+ window.addEventListener("resize", close);
+ return () => {
+ window.removeEventListener("scroll", close, true);
+ window.removeEventListener("resize", close);
+ };
+ }, [hide, position]);
+
+ return (
+ <>
+
+ {/* The explanation stays in the document for assistive technology; the popover is its visual copy. */}
+ {children}
+ {position && typeof document !== "undefined" ? createPortal(
+
+ {children}
+
,
+ document.body,
+ ) : null}
+ >
+ );
+}
+
+export function PageHeader({
+ title,
+ help,
+ actions,
+ headingId,
+}: {
+ title: ReactNode;
+ help?: ReactNode;
+ actions?: ReactNode;
+ headingId?: string;
+}) {
+ return (
+
+ );
+}
+
+export function RefreshButton({
+ onClick,
+ refreshing = false,
+ disabled = false,
+ label,
+ updatedAt,
+}: {
+ onClick: () => void;
+ refreshing?: boolean;
+ disabled?: boolean;
+ label?: string;
+ /** Shown as the button's tooltip instead of a visible timestamp. */
+ updatedAt?: string | null;
+}) {
+ const { t } = useTranslation();
+ const name = refreshing ? t("actions.refreshing") : label ?? t("actions.refresh");
+ return (
+
+ );
+}
+
+export function PageBody({ children, className }: { children: ReactNode; className?: string }) {
+ return {children}
;
+}
+
+export function Section({
+ title,
+ help,
+ actions,
+ headingId,
+ children,
+ className,
+}: {
+ title: ReactNode;
+ help?: ReactNode;
+ actions?: ReactNode;
+ headingId: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+export type Tone = "ok" | "warning" | "danger" | "neutral" | "pending";
+
+export function StatusDot({ tone, label }: { tone: Tone; label: ReactNode }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+export function KpiStrip({ children, label }: { children: ReactNode; label: string }) {
+ return {children}
;
+}
+
+export function Kpi({
+ label,
+ value,
+ help,
+ tone,
+}: {
+ label: ReactNode;
+ value: ReactNode;
+ help?: ReactNode;
+ tone?: Tone;
+}) {
+ return (
+
+
-
+ {label}
+ {help ? {help} : null}
+
+ -
+ {tone ? : null}
+ {value}
+
+
+ );
+}
+
+/** A ratio against a limit. The fill carries severity; the track stays in the same ramp. */
+export function Meter({
+ value,
+ limit,
+ label,
+ warnAt = 0.9,
+ dangerAt = 1,
+ color,
+}: {
+ value: number | null;
+ limit: number | null;
+ label: string;
+ warnAt?: number;
+ dangerAt?: number;
+ /** Fixed identity colour for a share (for example a status); disables the threshold tones. */
+ color?: string;
+}) {
+ const ratio = value !== null && limit !== null && limit > 0 ? Math.min(1, Math.max(0, value / limit)) : null;
+ const tone = ratio === null ? "unknown" : color ? "share" : ratio >= dangerAt ? "danger" : ratio >= warnAt ? "warning" : "ok";
+ return (
+
+ 0 ? 3 : 0)}%`, background: color }} />
+
+ );
+}
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+}: {
+ icon?: LucideIcon;
+ title: ReactNode;
+ description?: ReactNode;
+ action?: ReactNode;
+}) {
+ return (
+
+ {Icon ?
: null}
+
{title}
+ {description ?
{description}
: null}
+ {action ?
{action}
: null}
+
+ );
+}
+
+export function SegmentedControl({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: ReadonlyArray<{ value: T; label: string }>;
+ onChange: (value: T) => void;
+}) {
+ const groupRef = useRef(null);
+ const move = (delta: number) => {
+ const index = options.findIndex((option) => option.value === value);
+ const next = options[(index + delta + options.length) % options.length];
+ if (!next) return;
+ onChange(next.value);
+ window.requestAnimationFrame(() => groupRef.current?.querySelector('[aria-checked="true"]')?.focus());
+ };
+ return (
+ {
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); move(1); }
+ if (event.key === "ArrowLeft" || event.key === "ArrowUp") { event.preventDefault(); move(-1); }
+ }}
+ >
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/features/agents/AgentCatalog.css b/apps/web/src/features/agents/AgentCatalog.css
index b42590015..48fd04687 100644
--- a/apps/web/src/features/agents/AgentCatalog.css
+++ b/apps/web/src/features/agents/AgentCatalog.css
@@ -1,263 +1,108 @@
-.agents-catalog-scroll {
- min-width: 0;
- min-height: 0;
- flex: 1;
- padding: 24px;
- overflow: auto;
-}
-
-.agent-catalog-section {
- display: grid;
- gap: 14px;
-}
-
-.agent-catalog-section + .agent-catalog-section {
- margin-top: 34px;
-}
-
-.agent-catalog-section > h2,
-.agent-catalog-section > header h2 {
- margin: 0;
- color: var(--fg);
- font-size: 18px;
- font-weight: 600;
- line-height: 24px;
-}
-
-.agent-catalog-section > header p {
- margin: 3px 0 0;
- color: var(--fg-muted);
- font-size: 12px;
- line-height: 17px;
-}
-
-.agent-card-grid,
-.agent-template-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(min(245px, 100%), 1fr));
- gap: 12px;
-}
-
-.agent-catalog-card {
- min-width: 0;
- min-height: 190px;
- color: var(--fg);
- text-align: left;
- background: var(--surface);
- border: 1px solid var(--line);
- border-radius: 10px;
- box-shadow: var(--shadow-control);
- transition: background-color 150ms var(--ease-settle), border-color 150ms var(--ease-settle), transform 150ms var(--ease-settle);
-}
+/* Resources › Agents: the shared filter bar and data table grammar. */
-.agent-catalog-slot,
-.agent-catalog-slot > .agent-catalog-card {
- height: 100%;
-}
-
-button.agent-catalog-card,
-.agent-card-main {
- cursor: pointer;
-}
-
-.agent-catalog-card:hover:not(:disabled),
-.agent-catalog-card:focus-within {
- background: var(--hover);
- border-color: var(--line-strong);
-}
-
-.agent-catalog-card:focus-visible,
-.agent-card-main:focus-visible {
- outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
- outline-offset: 2px;
-}
-
-.agent-catalog-card:disabled,
-.agent-card-main:disabled {
- cursor: not-allowed;
- opacity: 0.58;
-}
-
-.agent-card-main,
-button.agent-catalog-card {
+.agents-toolbar {
display: flex;
- width: 100%;
- min-width: 0;
flex-direction: column;
- align-items: flex-start;
- padding: 18px;
- color: inherit;
- text-align: left;
-}
-
-.agent-card-main {
- background: transparent;
- border: 0;
-}
-
-.agent-saved-card {
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.agent-saved-card .agent-card-main {
- flex: 1;
-}
-
-.agent-card-icon {
- display: inline-flex;
- width: 34px;
- height: 34px;
- flex: 0 0 34px;
- align-items: center;
- justify-content: center;
- margin-bottom: 18px;
- color: var(--fg);
- background: var(--tile);
- border: 1px solid var(--line);
- border-radius: 9px;
+ gap: 8px;
}
-.agent-catalog-card > strong,
-.agent-card-main > strong {
- display: block;
+.agents-filter-bar .search-control {
+ width: 288px;
max-width: 100%;
- overflow: hidden;
- font-size: 15px;
- font-weight: 550;
- line-height: 21px;
- text-overflow: ellipsis;
- white-space: nowrap;
}
-.agent-card-description {
- display: -webkit-box;
- min-height: 38px;
- margin-top: 5px;
- overflow: hidden;
- color: var(--fg-muted);
- font-size: 12px;
- line-height: 18px;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
+.agent-table th[scope="row"] {
+ min-width: 200px;
}
-.agent-card-meta {
+.agent-table-name {
display: flex;
- width: 100%;
min-width: 0;
align-items: center;
- margin-top: auto;
- padding-top: 16px;
- gap: 8px;
- color: var(--fg-muted);
- font-size: 10px;
- line-height: 14px;
+ gap: 2px;
}
-.agent-card-meta code {
+.agent-table-name .table-link {
min-width: 0;
- flex: 1;
+}
+
+.agent-table-name .table-link strong {
+ max-width: 280px;
overflow: hidden;
- color: inherit;
- font-family: var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
-.agent-card-opening {
- margin-top: 8px;
- color: var(--accent);
- font-size: 11px;
+.agent-table-name .table-link:disabled {
+ cursor: default;
}
-.agent-saved-card > footer {
- display: flex;
- min-height: 44px;
- align-items: center;
- justify-content: flex-end;
- padding: 8px 12px;
- border-top: 1px solid var(--line);
+.agent-copy-id {
+ width: 22px;
+ height: 22px;
+ flex: 0 0 22px;
+ color: var(--fg-subtle);
+ opacity: 0;
}
-.agent-create-card,
-.agent-template-card {
- border-style: dashed;
- box-shadow: none;
+.agent-table tr:hover .agent-copy-id,
+.agent-copy-id:focus-visible {
+ opacity: 1;
}
-.agent-create-card .agent-card-icon {
- color: var(--surface);
- background: var(--accent);
- border-color: transparent;
+.agent-copy-id:hover {
+ color: var(--fg);
}
-.agent-template-card .agent-card-icon {
+.agent-table-opening {
+ display: block;
+ margin-top: 2px;
color: var(--accent);
- background: color-mix(in srgb, var(--accent) 10%, var(--surface));
- border-color: color-mix(in srgb, var(--accent) 24%, var(--line));
+ font-size: 11.5px;
+ font-weight: 400;
}
-.agent-catalog-empty {
- display: flex;
- align-items: center;
- padding: 12px 14px;
- gap: 8px;
- color: var(--fg-muted);
- font-size: 12px;
- background: var(--surface-subtle);
- border: 1px solid var(--line);
- border-radius: 7px;
+.agent-table-model {
+ display: inline-block;
+ max-width: 220px;
+ overflow: hidden;
+ vertical-align: bottom;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.agent-catalog-empty strong {
- color: var(--fg);
- font-weight: 500;
+.agent-table-help {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
}
-.agent-catalog-empty .button {
- margin-left: auto;
+.agent-table td.agent-usage-pending {
+ color: var(--fg-subtle);
}
-.agent-catalog-more {
- display: flex;
- min-height: 36px;
+.agent-start-action {
+ display: inline-flex;
align-items: center;
- justify-content: flex-end;
- gap: 10px;
- color: var(--fg-muted);
- font-size: 11px;
+ gap: 2px;
}
-.agent-catalog-more .button {
- gap: 5px;
+.agent-table .agent-session-start {
+ width: auto;
+ height: 26px;
+ padding: 0 8px;
+ font-size: 12.5px;
}
-@media (max-width: 640px) {
- .agents-catalog-scroll {
- padding: 16px 12px 28px;
- }
-
- .agent-card-grid,
- .agent-template-grid {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .agent-catalog-section + .agent-catalog-section {
- margin-top: 28px;
- }
-
- .agent-catalog-empty {
- align-items: flex-start;
- flex-direction: column;
- }
-
- .agent-catalog-empty .button {
- margin-left: 0;
+@media (hover: none) {
+ .agent-copy-id {
+ opacity: 1;
}
+}
- .agent-catalog-more {
- justify-content: space-between;
+@media (max-width: 640px) {
+ .agents-filter-bar .search-control {
+ width: 100%;
+ flex: 1 1 100%;
}
}
diff --git a/apps/web/src/features/agents/AgentCatalog.test.tsx b/apps/web/src/features/agents/AgentCatalog.test.tsx
index b8379e7dd..384034be4 100644
--- a/apps/web/src/features/agents/AgentCatalog.test.tsx
+++ b/apps/web/src/features/agents/AgentCatalog.test.tsx
@@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest";
import type { SavedAgent } from "@agents-core-web/agents-client";
-import { AgentCatalog, twoRowSavedAgentCapacity } from "./AgentCatalog";
+import { AgentCatalog } from "./AgentCatalog";
+import type { AgentUsageModel } from "./AgentUsage";
+import { aggregateAgentUsage, type UsageSessionRecord } from "./agent-usage";
+import { type AgentUsageController, initialAgentUsageState } from "./use-agent-usage";
function savedAgent(index: number): SavedAgent {
return {
@@ -23,50 +26,88 @@ function savedAgent(index: number): SavedAgent {
};
}
-function renderCatalog(expanded = false, isFiltering = false): string {
+function controller(overrides: Partial = {}): AgentUsageController {
+ return {
+ ...initialAgentUsageState,
+ setRange: () => undefined,
+ cancel: () => undefined,
+ resume: () => undefined,
+ reload: () => undefined,
+ ...overrides,
+ };
+}
+
+function renderCatalog(agents: SavedAgent[], options: { usage?: AgentUsageModel | null; hasSavedAgents?: boolean; isFiltering?: boolean } = {}): string {
return renderToStaticMarkup(
savedAgent(index))}
+ agents={agents}
busy={false}
coreReady
- expanded={expanded}
- hasSavedAgents
- isFiltering={isFiltering}
+ hasSavedAgents={options.hasSavedAgents ?? agents.length > 0}
+ isFiltering={options.isFiltering ?? false}
openingAgentId={null}
+ usage={options.usage}
vaultCatalog={null}
onClearSearch={() => undefined}
onCreate={() => undefined}
onEdit={() => undefined}
- onExpandedChange={() => undefined}
onStartSession={() => undefined}
- onUseTemplate={() => undefined}
/>,
);
}
describe("Agent catalog", () => {
- it("reserves the first card for Create and bounds saved Agents to two responsive rows", () => {
- expect(twoRowSavedAgentCapacity(1)).toBe(1);
- expect(twoRowSavedAgentCapacity(2)).toBe(3);
- expect(twoRowSavedAgentCapacity(3)).toBe(5);
- expect(twoRowSavedAgentCapacity(4)).toBe(7);
+ it("lists every saved Agent as a table row that opens its setup view", () => {
+ const agents = Array.from({ length: 12 }, (_, index) => savedAgent(index));
+ agents[1] = { ...agents[1]!, x_agents_core: { harness: "claude_sdk" }, tools: [{ type: "function" }, { type: "function" }] };
+ const html = renderCatalog(agents);
+
+ expect(html).toContain('');
+ for (const column of ["Agent", "Model", "Harness", "Tools", "Updated"]) expect(html).toContain(`| ${column} | `);
+ expect(html.match(/ {
+ const agents = [savedAgent(0), savedAgent(1), savedAgent(2)];
+ const pending = renderCatalog(agents, { usage: { controller: controller({ status: "loading" }), report: null } });
+ expect(pending).toContain('| Sessions | ');
+ expect(pending).toContain("Coverage");
+ expect(pending.match(/agent-usage-pending">… agent.id), null) },
+ });
+ expect(ready).toContain('2 | ');
+ expect(ready).toContain('1,200 | ');
+ expect(ready).toContain('50% | ');
+ // Reported usage is missing, not zero.
+ expect(ready).toContain('No data | ');
+ expect(ready).not.toContain('0% | ');
+ // An Agent without Sessions shows a real zero count and no derived figures.
+ expect(ready).toContain('0 | — | — | — | ');
});
- it("shows every saved Agent after expansion or while filtering", () => {
- const expanded = renderCatalog(true);
- expect(expanded).toContain("Edit Agent 3 (agent_3)");
- expect(expanded).toContain('aria-expanded="true"');
- expect(expanded).toContain("Show less");
+ it("offers creation when nothing is saved and clearing when a search matches nothing", () => {
+ const empty = renderCatalog([], { hasSavedAgents: false });
+ expect(empty).toContain("No saved Agents");
+ expect(empty).toContain("Create agent");
- const filtered = renderCatalog(false, true);
- expect(filtered).toContain("Edit Agent 3 (agent_3)");
- expect(filtered).not.toContain("Show 3 more");
+ const noMatch = renderCatalog([], { hasSavedAgents: true, isFiltering: true });
+ expect(noMatch).toContain("No matching Agents");
+ expect(noMatch).toContain("Clear search");
});
});
diff --git a/apps/web/src/features/agents/AgentCatalog.tsx b/apps/web/src/features/agents/AgentCatalog.tsx
index a89f60805..06482b82b 100644
--- a/apps/web/src/features/agents/AgentCatalog.tsx
+++ b/apps/web/src/features/agents/AgentCatalog.tsx
@@ -1,32 +1,18 @@
-import { Bot, ChevronDown, ChevronUp, MessageSquare, PanelsTopLeft, Plus } from "lucide-react";
-import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
+import { Bot, Check, Copy, MessageSquare, SearchX } from "lucide-react";
+import { useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
-import i18n from "../../i18n";
import type { SavedAgent } from "@agents-core-web/agents-client";
+import { EmptyState, HelpTip } from "../../components/console-ui";
+import { formatDateTime, formatInteger, formatRelative } from "../../lib/format";
import type { VaultCatalog } from "../vaults/vault-catalog";
+import { harnessLabel } from "./AgentForm";
+import { AgentUsageCells, type AgentUsageModel } from "./AgentUsage";
import { sessionAdmissionBlocker } from "./session-admission";
-import { AGENT_TEMPLATES, type AgentTemplate } from "./agent-templates";
import "./AgentCatalog.css";
-const useIsomorphicLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
-
-export function twoRowSavedAgentCapacity(columnCount: number): number {
- return Math.max(1, Math.floor(columnCount) * 2 - 1);
-}
-
-function resolvedGridColumnCount(grid: HTMLElement): number {
- const template = window.getComputedStyle(grid).gridTemplateColumns.trim();
- if (!template || template === "none") return 1;
- return Math.max(1, template.split(/\s+/u).length);
-}
-
-function formatShortDate(seconds: number): string {
- return new Intl.DateTimeFormat(i18n.resolvedLanguage ?? "en", { month: "short", day: "numeric" }).format(new Date(seconds * 1000));
-}
-
function AgentSessionStartAction({
agent,
busy,
@@ -41,8 +27,9 @@ function AgentSessionStartAction({
const { t } = useTranslation("agents");
const blocker = sessionAdmissionBlocker(agent, vaultCatalog);
const descriptionId = useId();
+ const reason = blocker ? t("catalog.sessionUnavailable", { reason: blocker }) : null;
return (
-
+
{blocker ? t("catalog.unavailable") : t("catalog.startSession")}
- {blocker ? (
-
- {t("catalog.sessionUnavailable", { reason: blocker })}
-
+ {reason ? (
+ <>
+ {reason}
+ {reason}
+ >
) : null}
);
}
+function CopyAgentId({ id }: { id: string }) {
+ const { t } = useTranslation("agents");
+ const [copied, setCopied] = useState(false);
+ useEffect(() => {
+ if (!copied) return;
+ const timer = window.setTimeout(() => setCopied(false), 1_500);
+ return () => window.clearTimeout(timer);
+ }, [copied]);
+ const label = copied ? t("catalog.copied") : t("catalog.copyId", { id });
+ return (
+
+ );
+}
+
+/** Saved Agents as one table row each; a row opens the Agent's setup view. */
export function AgentCatalog({
agents,
busy,
coreReady,
hasSavedAgents,
+ isFiltering,
openingAgentId,
+ usage = null,
vaultCatalog,
onClearSearch,
onCreate,
onEdit,
- onExpandedChange,
onStartSession,
- onUseTemplate,
- expanded,
- isFiltering,
}: {
agents: SavedAgent[];
busy: boolean;
coreReady: boolean;
- expanded: boolean;
hasSavedAgents: boolean;
isFiltering: boolean;
openingAgentId: string | null;
+ usage?: AgentUsageModel | null;
vaultCatalog: VaultCatalog | null;
onClearSearch: () => void;
- onCreate: (returnFocus: HTMLButtonElement) => void;
- onEdit: (agent: SavedAgent, returnFocus: HTMLButtonElement) => void;
- onExpandedChange: (expanded: boolean) => void;
+ onCreate: () => void;
+ onEdit: (agent: SavedAgent) => void;
onStartSession: (agentId: string) => void;
- onUseTemplate: (template: AgentTemplate, returnFocus: HTMLButtonElement) => void;
}) {
- const { t } = useTranslation("agents");
- const count = (value: number) => new Intl.NumberFormat(i18n.resolvedLanguage ?? "en").format(value);
- const interactionDisabled = busy;
- const gridId = useId();
- const gridRef = useRef(null);
- const [columnCount, setColumnCount] = useState(1);
- const savedCapacity = twoRowSavedAgentCapacity(columnCount);
- const canExpand = !isFiltering && agents.length > savedCapacity;
- const visibleAgents = isFiltering || expanded ? agents : agents.slice(0, savedCapacity);
- const hiddenAgentCount = Math.max(0, agents.length - savedCapacity);
+ const { t, i18n } = useTranslation("agents");
+ const locale = i18n.resolvedLanguage;
+ const now = Math.floor(Date.now() / 1_000);
- useIsomorphicLayoutEffect(() => {
- const grid = gridRef.current;
- if (!grid) return;
-
- const updateColumnCount = () => {
- const next = resolvedGridColumnCount(grid);
- setColumnCount((current) => current === next ? current : next);
- };
- updateColumnCount();
-
- if (typeof ResizeObserver === "undefined") {
- window.addEventListener("resize", updateColumnCount);
- return () => window.removeEventListener("resize", updateColumnCount);
- }
-
- const observer = new ResizeObserver(updateColumnCount);
- observer.observe(grid);
- return () => observer.disconnect();
- }, []);
+ if (!agents.length) {
+ return hasSavedAgents ? (
+ {t("catalog.clearSearch")}}
+ />
+ ) : (
+ {t("catalog.create")}
+ ) : undefined}
+ />
+ );
+ }
return (
-
-
- {t("catalog.listLabel")}
-
-
-
-
- {visibleAgents.map((agent) => (
-
-
-
-
- ))}
-
- {canExpand ? (
-
- {expanded ? t("catalog.showingAll", { count: agents.length, formattedCount: count(agents.length) }) : t("catalog.more", { count: hiddenAgentCount, formattedCount: count(hiddenAgentCount) })}
-
-
- ) : null}
- {!agents.length ? (
-
- {hasSavedAgents ? t("catalog.noMatch") : t("catalog.noSaved")}
- {hasSavedAgents ? t("catalog.trySearch") : t("catalog.emptyHelp")}
- {hasSavedAgents ? : null}
-
- ) : null}
-
-
-
-
-
- {AGENT_TEMPLATES.map((template) => (
-
-
-
- ))}
-
-
+
+
+
+
+ | {t("catalog.columns.agent")} |
+ {t("catalog.columns.model")} |
+ {t("catalog.columns.harness")} |
+ {t("catalog.columns.tools")} |
+ {usage ? (
+ <>
+ {t("usage.sessions")} |
+ {t("usage.tokens")} |
+
+ {t("usage.coverage")}{t("usage.coverageHelp")}
+ |
+ {t("usage.lastActive")} |
+ >
+ ) : null}
+ {t("catalog.columns.updated")} |
+ {t("catalog.columns.actions")} |
+
+
+
+ {agents.map((agent) => {
+ const opening = openingAgentId === agent.id;
+ const name = agent.name || t("catalog.untitled");
+ const harness = agent.x_agents_core?.harness;
+ const open = () => {
+ if (!busy && !opening) onEdit(agent);
+ };
+ return (
+
+ |
+
+
+
+
+ {opening ? {t("catalog.opening")} : null}
+ |
+ {agent.model} |
+ {harness ? harnessLabel(harness) : t("catalog.coreDefaultHarness")} |
+ {formatInteger(agent.tools.length, locale)} |
+ {usage ? : null}
+
+
+ |
+ event.stopPropagation()}>
+
+ |
+
+ );
+ })}
+
+
);
}
diff --git a/apps/web/src/features/agents/AgentForm.tsx b/apps/web/src/features/agents/AgentForm.tsx
index e208567e3..0eaf7bc73 100644
--- a/apps/web/src/features/agents/AgentForm.tsx
+++ b/apps/web/src/features/agents/AgentForm.tsx
@@ -27,7 +27,7 @@ interface AgentFormProps {
onSubmit: (input: AgentFormSubmitInput) => Promise
;
}
-function harnessLabel(harness: CoreHarnessKind): string {
+export function harnessLabel(harness: CoreHarnessKind): string {
if (harness === "claude_sdk") return "Claude SDK";
if (harness === "mcode") return "MiniMax Code";
return "Codex";
diff --git a/apps/web/src/features/agents/AgentSetupView.test.tsx b/apps/web/src/features/agents/AgentSetupView.test.tsx
index 3519dde13..a83ce9152 100644
--- a/apps/web/src/features/agents/AgentSetupView.test.tsx
+++ b/apps/web/src/features/agents/AgentSetupView.test.tsx
@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
import type { SavedAgent } from "@agents-core-web/agents-client";
import { AgentSetupView } from "./AgentSetupView";
-import { AGENT_TEMPLATES, valuesFromAgentTemplate } from "./agent-templates";
const savedAgent: SavedAgent = {
id: "agent/with space",
@@ -99,28 +98,6 @@ describe("Agent setup page", () => {
expect(html).toContain("Saving…");
});
- it("prefills a create-only starter template in both the form and request preview", () => {
- const template = AGENT_TEMPLATES[0]!;
- const html = renderToStaticMarkup(
- undefined}
- onCreate={async () => undefined}
- onStartSession={() => undefined}
- />,
- );
-
- expect(html).toContain(`value="${template.name}"`);
- expect(html).toContain(template.instructions);
- expect(html).toContain(`"name": "${template.name}"`);
- expect(html).not.toContain("Save changes");
- expect(html).not.toContain("Saved definition");
- });
-
it("renders an existing Agent as an editable resource instead of a details step", () => {
const html = renderToStaticMarkup(
void;
@@ -111,11 +110,13 @@ export function AgentSetupView({
onDeleteRequest?: () => void;
onStartSession: (agentId: string) => void;
onUpdate?: (agentId: string, input: UpdateAgentInput) => Promise;
+ /** Usage statistics for an existing Agent, shown first in the aside. */
+ usagePanel?: ReactNode;
}) {
const { t } = useTranslation("agents");
const isEditing = Boolean(agent);
const [draft, setDraft] = useState(() => {
- const values = agent ? valuesFromAgent(agent, vaultCatalog) : initialValues ?? valuesFromAgent(undefined, vaultCatalog);
+ const values = valuesFromAgent(agent, vaultCatalog);
const harness = !agent && !values.harness && defaultHarness && enabledHarnesses?.includes(defaultHarness)
? defaultHarness
: values.harness;
@@ -161,7 +162,7 @@ export function AgentSetupView({
-
{savedAgent?.name || initialValues?.name || t("setup.newAgent")}
+ {savedAgent?.name || t("setup.newAgent")}
@@ -224,6 +225,7 @@ export function AgentSetupView({