diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index a61f24819..03946dda6 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -17,10 +17,18 @@ 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 +129,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 +168,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 +189,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 +224,144 @@ 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 +447,8 @@ export function AgentsPage({ onAgentCreated, now = Date.now(), initialTab = "definitions", + initialSelectedDefinitionId, + navigate, }: { readonly directory: APIQuery; readonly onAgentCreated: (definition: AgentDefinition) => void; @@ -280,11 +457,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 +526,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 +538,32 @@ 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 +610,7 @@ export function AgentsPage({ instances={ instancesByDefinition.get(definition.id) ?? [] } + onSelect={setSelectedDefinitionId} /> ))} @@ -494,6 +708,9 @@ export function AgentsRoute() { queryKey: tenantKeys.agentDirectory(selectedTenantId), }); }} + navigate={(to) => { + window.location.assign(to); + }} /> ); } 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"'); + }); +});