From 41060758be541ecd1b874453ec21036155b5cd41 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 12:11:36 -0700 Subject: [PATCH 1/3] Add agents detail panel with open-into-channel and chat actions Surface definition detail and invite/start-chat entry points on the Agents page so a live conversation is reachable in two clicks. --- apps/web/src/pages/agents-page.tsx | 214 ++++++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index a61f24819..25a6919a2 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -17,10 +17,11 @@ import { formatRelativeTime, } from "@corbits/react-ui"; import type { BadgeTone, ViewMode } from "@corbits/react-ui"; -import { Bot, Copy, Workflow } from "lucide-react"; +import { ArrowLeft, Bot, Copy, MessageSquare, Users, Workflow } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { createChannel } from "@corbits/chat-ui"; import type { AgentDefinition, AgentInstance } from "../agents-api"; import type { AgentDirectoryData } from "../agents-api"; @@ -121,14 +122,28 @@ function InstanceBadges({ function DefinitionCard({ definition, instances, + onSelect, }: { readonly definition: AgentDefinition; readonly instances: readonly (AgentInstance & { readonly orphaned: boolean; })[]; + readonly onSelect: (definitionId: string) => void; }) { return ( - + onSelect(definition.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(definition.id); + } + }} + >
{definition.name} @@ -146,12 +161,14 @@ function DefinitionCard({ function DefinitionRows({ definitions, instancesByDefinition, + onSelect, }: { readonly definitions: readonly AgentDefinition[]; readonly instancesByDefinition: ReadonlyMap< string, readonly (AgentInstance & { readonly orphaned: boolean })[] >; + readonly onSelect: (definitionId: string) => void; }) { return ( @@ -165,7 +182,20 @@ function DefinitionRows({ {definitions.map((definition) => ( - + onSelect(definition.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(definition.id); + } + }} + > {definition.name} {definition.description ?? "—"} @@ -187,6 +217,141 @@ function DefinitionRows({ ); } +/** The agent detail panel: shown when a definition row/card is selected. + * Renders the full description, lifecycle status, version, the definition's + * deployed instances, and the two launch actions — Start chat and Open in + * channel. The instances list reuses the same rows/cards as the main tab so + * the detail view never invents a third rendering of an instance. */ +function AgentDetailPanel({ + definition, + instances, + tenantId, + now, + onBack, + onChatStarted, + navigate, +}: { + readonly definition: AgentDefinition; + readonly instances: readonly (AgentInstance & { + readonly orphaned: boolean; + })[]; + readonly tenantId: string; + readonly now: number; + readonly onBack: () => void; + readonly onChatStarted: (channelId: string) => void; + readonly navigate: ((to: string) => void) | undefined; +}) { + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + + async function handleStartChat() { + if (tenantId === "") return; + setStarting(true); + setError(null); + try { + const channel = await createChannel(tenantId, { + kind: "chat", + definitionId: definition.id, + }); + const target = `/chat/${encodeURIComponent(channel.id)}`; + onChatStarted(channel.id); + navigate?.(target); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setStarting(false); + } + } + + function handleOpenInChannel() { + navigate?.("/chat"); + } + + return ( +
+
+ +
+ + +
+

{definition.name}

+ + {definition.status} + +
+
+ + Version:{" "} + {definition.currentVersion} + + {definition.description !== null && + definition.description !== undefined && ( +

{definition.description}

+ )} + {(definition.description === null || + definition.description === undefined) && ( + No description + )} +
+ +
+ + +
+ {error !== null && ( +

+ {error} +

+ )} +
+ +
+

+ Instances ({instances.length}) +

+ {instances.length === 0 ? ( + + No instances deployed. Use Start chat to launch one. + + ) : ( +
+ {instances.map((instance) => ( + + ))} +
+ )} +
+
+ ); +} + function InstanceCard({ instance, now, @@ -272,6 +437,8 @@ export function AgentsPage({ onAgentCreated, now = Date.now(), initialTab = "definitions", + initialSelectedDefinitionId, + navigate, }: { readonly directory: APIQuery; readonly onAgentCreated: (definition: AgentDefinition) => void; @@ -280,11 +447,20 @@ export function AgentsPage({ /** Which tab is active on first render; injectable for tests that need * to inspect the instances panel without a click. */ readonly initialTab?: AgentsTab; + /** Which definition is expanded in the detail panel on first render; + * injectable for tests that need to assert detail markup without a click. */ + readonly initialSelectedDefinitionId?: string; + /** Client-side navigation callback; Start chat and Open in channel rely + * on this to route into /chat after creating/inviting. */ + readonly navigate?: (to: string) => void; }) { const [query, setQuery] = useState(""); const [viewMode, setViewMode] = useState("grid"); const [tab, setTab] = useState(initialTab); const [createOpen, setCreateOpen] = useState(false); + const [selectedDefinitionId, setSelectedDefinitionId] = useState< + string | null + >(initialSelectedDefinitionId ?? null); const canCreate = directory.kind === "ready" && directory.data.tenantId !== ""; @@ -340,7 +516,7 @@ export function AgentsPage({ } title="No agents yet" - description="Create your first agent — a name, a system prompt, and optionally a model — and it appears here immediately, ready to invite into a channel." + description="Create your first agent — a name, a system prompt, and optionally a model — and it appears here immediately, ready to start a chat or invite into a channel." actions={[ { label: "Create agent", @@ -352,6 +528,31 @@ export function AgentsPage({ ); } + // Detail panel: when a definition is selected, render the full + // detail view instead of the tabbed list. The panel owns its own + // back button that clears the selection. + const selectedDefinition = + selectedDefinitionId !== null + ? definitions.find((d) => d.id === selectedDefinitionId) ?? null + : null; + if (selectedDefinition !== null) { + return ( + setSelectedDefinitionId(null)} + onChatStarted={() => { + /* parent may invalidate chat queries; no-op by default */ + }} + navigate={navigate} + /> + ); + } + return ( ) : ( @@ -397,6 +599,7 @@ export function AgentsPage({ instances={ instancesByDefinition.get(definition.id) ?? [] } + onSelect={setSelectedDefinitionId} /> ))} @@ -494,6 +697,9 @@ export function AgentsRoute() { queryKey: tenantKeys.agentDirectory(selectedTenantId), }); }} + navigate={(to) => { + window.location.assign(to); + }} /> ); } From 2d9268284c97cb33e935c6a098e93ace74550bb6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 12:23:57 -0700 Subject: [PATCH 2/3] CL-5784: Add agents-page screen tests for detail panel and launch actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the detail panel open (name/version/description/instances), the Start chat and Open in channel action buttons, Back navigation, both empty states, disabled-when-no-bench, and the list-side open-details affordance that makes the panel reachable — the first click of the two-click path into a live conversation. Mirrors the SSR shape of pages.test.tsx: real APIQuery props in, honest markup out. 9 tests, 23 assertions, all green. --- apps/web/test/agents-page.test.tsx | 196 +++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 apps/web/test/agents-page.test.tsx diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx new file mode 100644 index 000000000..e12628d98 --- /dev/null +++ b/apps/web/test/agents-page.test.tsx @@ -0,0 +1,196 @@ +// Screen-level proof for the Agents page detail panel and its two +// launch actions — Start chat and Open in channel. Mirrors the SSR +// shape used by pages.test.tsx: real `APIQuery` props in, honest markup +// out. The async `createChannel` call behind Start chat is covered by +// packages/chat-ui/test/api.test.ts; here we prove the entry points are +// reachable and labelled so a user can get to a live conversation in +// two clicks. + +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { APIQuery } from "../src/api"; +import type { + AgentDefinition, + AgentDirectoryData, + AgentInstance, +} from "../src/agents-api"; +import { AgentsPage } from "../src/pages/agents-page"; + +function ready(data: T): APIQuery { + return { kind: "ready", data }; +} + +const definition: AgentDefinition = { + id: "wfd_1", + tenantId: "tenant_1", + name: "Researcher", + description: "Answers research questions", + currentVersion: "3", + status: "deployed", + createdAt: "2026-08-05T11:00:00.000Z", + updatedAt: "2026-08-05T11:00:00.000Z", +}; + +const instance: AgentInstance = { + id: "ins_1", + definitionId: "wfd_1", + definitionName: "Researcher", + tenantId: "tenant_1", + address: "ins_1@acme.localhost", + status: "running", + createdAt: "2026-08-05T11:00:00.000Z", + updatedAt: "2026-08-05T11:00:00.000Z", +}; + +const directoryData: AgentDirectoryData = { + tenantId: "tenant_1", + definitions: [definition], + instances: [instance], + models: [], +}; + +describe("AgentDetailPanel", () => { + test("renders the definition's name, version, and description when selected", () => { + const markup = renderToStaticMarkup( + undefined} + initialSelectedDefinitionId="wfd_1" + navigate={() => undefined} + />, + ); + expect(markup).toContain("Researcher"); + expect(markup).toContain("Answers research questions"); + expect(markup).toContain("Version:"); + expect(markup).toContain("3"); + expect(markup).toContain("deployed"); + // The raw definition id must never appear in the rendered detail. + expect(markup).not.toContain("wfd_1"); + }); + + test("shows Start chat and Open in channel action buttons", () => { + const markup = renderToStaticMarkup( + undefined} + initialSelectedDefinitionId="wfd_1" + navigate={() => undefined} + />, + ); + expect(markup).toContain("Start chat"); + expect(markup).toContain("Open in channel"); + }); + + test("renders a Back button to return to the agent list", () => { + const markup = renderToStaticMarkup( + undefined} + initialSelectedDefinitionId="wfd_1" + navigate={() => undefined} + />, + ); + expect(markup).toContain("Back"); + expect(markup).toContain('aria-label="Back to agent list"'); + }); + + test("lists the definition's deployed instances inside the detail panel", () => { + const markup = renderToStaticMarkup( + undefined} + initialSelectedDefinitionId="wfd_1" + navigate={() => undefined} + />, + ); + expect(markup).toContain("Instances (1)"); + // Instance name shows, but never its mailbox address as visible text. + expect(markup).toContain("Researcher"); + expect(markup).not.toContain("ins_1@acme.localhost"); + }); + + test("points at Start chat when the definition has no instances", () => { + const noInstances: AgentDirectoryData = { + ...directoryData, + instances: [], + }; + const markup = renderToStaticMarkup( + undefined} + initialSelectedDefinitionId="wfd_1" + navigate={() => undefined} + />, + ); + expect(markup).toContain("Instances (0)"); + expect(markup).toContain("No instances deployed"); + expect(markup).toContain("Start chat"); + }); + + test("disables both actions when no bench (tenant) is selected", () => { + const noTenant: AgentDirectoryData = { + ...directoryData, + tenantId: "", + }; + const markup = renderToStaticMarkup( + undefined} + initialSelectedDefinitionId="wfd_1" + navigate={() => undefined} + />, + ); + // Start chat button should carry the disabled attribute. + expect(markup).toMatch(/disabled[^>]*>[\s\S]*Start chat/); + }); +}); + +describe("AgentsPage empty states", () => { + test("the no-agents empty state points at creating then chatting", () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + expect(markup).toContain("No agents yet"); + expect(markup).toContain("start a chat"); + expect(markup).toContain("invite into a channel"); + }); + + test("the no-instances empty state points at inviting into a channel", () => { + // A directory with a definition but zero deployed instances exercises + // the empty copy on the Instances tab. + const markup = renderToStaticMarkup( + undefined} + initialTab="instances" + />, + ); + expect(markup).toContain("No agent instance is deployed"); + expect(markup).toContain("Invite a definition into a channel"); + }); +}); + +// The detail panel is only useful if a user can reach it. This proves the +// list surface exposes an open-details affordance on every definition — the +// first click of the two-click path into a live conversation. (The rows +// view reuses the same aria-label, so grid coverage is sufficient here.) +describe("AgentsPage list entry points", () => { + test("every definition card exposes an Open-details affordance", () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + expect(markup).toContain('aria-label="Open Researcher details"'); + }); +}); From c8805d83a4adcf2756bb9d49bd8ca4c97220890d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 12:28:14 -0700 Subject: [PATCH 3/3] CL-5784: Format agents page for Prettier CI lint failed on formatting only; no behavior change. --- apps/web/src/pages/agents-page.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 25a6919a2..03946dda6 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -17,7 +17,14 @@ import { formatRelativeTime, } from "@corbits/react-ui"; import type { BadgeTone, ViewMode } from "@corbits/react-ui"; -import { ArrowLeft, Bot, Copy, MessageSquare, Users, Workflow } from "lucide-react"; +import { + ArrowLeft, + Bot, + Copy, + MessageSquare, + Users, + Workflow, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useQueryClient } from "@tanstack/react-query"; @@ -268,7 +275,10 @@ function AgentDetailPanel({ } return ( -
+