From 6aa72c3a40a8da62163a138f4b19a2a9c94cb3bd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 23:24:41 -0700 Subject: [PATCH] feat(web): Workflows lists only workflows; Agents is a roster with a Chat action (CL-8455) --- apps/web/src/agent-deploy.ts | 18 +- apps/web/src/agents-api.ts | 228 +------ apps/web/src/agents-directory.ts | 79 --- apps/web/src/command-palette-provider.tsx | 34 +- apps/web/src/pages/agent-detail-page.tsx | 325 ---------- apps/web/src/pages/agents-page.tsx | 566 +++--------------- .../src/pages/run-status-tone-parity.test.ts | 8 - apps/web/src/path-ids.test.ts | 6 - apps/web/src/path-ids.ts | 4 - apps/web/src/routes.tsx | 27 +- apps/web/src/routines-api.ts | 36 +- 11 files changed, 154 insertions(+), 1177 deletions(-) delete mode 100644 apps/web/src/agents-directory.ts delete mode 100644 apps/web/src/pages/agent-detail-page.tsx diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index d0ca8e990d..7efec4ccf7 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -172,6 +172,22 @@ export async function pushAgentSource( ); } +/** Every created agent's source asset name, `agent--source` — the one + * naming convention this pipeline owns, so callers can recognize a created + * agent's asset without a lookup. */ +export function agentDeploySourceAssetName(slug: string): string { + return `agent-${slug}-source`; +} + +const AGENT_DEPLOY_SOURCE_ASSET_NAME = /^agent-.+-source$/; + +/** True for any asset this deploy pipeline named — used to keep created + * agents (and Myra, checked separately by callers) out of surfaces that + * list real workflows, since both are `workflow`-kind assets. */ +export function isAgentDeploySourceAssetName(name: string): boolean { + return AGENT_DEPLOY_SOURCE_ASSET_NAME.test(name); +} + export type NewAgentInput = { readonly name: string; readonly systemPrompt: string; @@ -199,7 +215,7 @@ export async function deployAgentSource( if (!isValidSlug(slug)) { throw new AgentDeployError("this name doesn't produce a usable agent address"); } - const assetName = `agent-${slug}-source`; + const assetName = agentDeploySourceAssetName(slug); const packageName = `@workbench-agent/${slug}`; const tenantResponse = await fetchImpl(`/api/tenants/${encodeURIComponent(args.tenantId)}`); diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 263bc3eb4b..b1b094420b 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -1,37 +1,25 @@ -// The Agents page's one seam to the hub: agent definitions (templates -// an agent can be launched from), their deployed instances, and the -// tenant's model catalog — each fetched with the platform's own wire -// schemas, validated at the boundary exactly like every other query in -// `./api.ts`. Kept separate from that file because these three -// endpoints are tenant-scoped (the path needs a resolved `tenantId` -// before it can even be built), unlike the fixed `/api/me/...` paths -// `useAPIQuery` there is built around. - -import { - ModelResponse, - RunApprovalsResponse, - WorkflowDefinitionResponse, - WorkflowRunHealth, - WorkflowRunResponse, - paginatedSchema, -} from "@intx/types"; +// The hub seams this app still needs for agents: definitions (for the +// sidebar's own-agent-DM listing in `shell/bench-activity.ts`), top-level +// runs (for `chat/threads-api.ts`'s live-address resolution), and the +// create-agent deploy mutation. The Agents page itself now reads through +// `chat/threads-api.ts`'s `listChatAgents` — the same chat-partner listing +// — so this file no longer carries a roster/detail data model of its own. + +import { WorkflowDefinitionResponse, WorkflowRunResponse, paginatedSchema } from "@intx/types"; import { type } from "arktype"; import type { ArkErrors } from "arktype"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { APIQuery } from "@/lib/api-query"; -import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; -import { isChatPickerModelName } from "./settings/inference/model-capability"; +import { ApiQueryError } from "@/lib/api-query"; import { deployAgentSource, type DeployedAgent, type NewAgentInput } from "./agent-deploy"; +import { chatKeys } from "./chat-path"; import { tenantKeys } from "./query-client"; export type AgentDefinition = typeof WorkflowDefinitionResponse.infer; export type AgentInstance = typeof WorkflowRunResponse.infer; -export type CatalogModel = typeof ModelResponse.infer; const DefinitionsPage = paginatedSchema(WorkflowDefinitionResponse); const InstancesPage = paginatedSchema(WorkflowRunResponse); -const ModelsPage = paginatedSchema(ModelResponse); // The REST pagination ceiling (see `vendor/intx/hub-api/src/pagination.ts`). // A bench with more agents or instances than this needs real pagination on @@ -72,18 +60,11 @@ export function listAgentDefinitions(tenantId: string): Promise page.data); } -export function listAgentInstances(tenantId: string): Promise { - return getJSON(`/api/tenants/${tenantId}/workflows/runs?limit=${PAGE_LIMIT}`, InstancesPage).then( - (page) => page.data, - ); -} - /** * The tenant's genuine top-level deployment runs — every non-top-level * run (workbench host, invited agent, task) excluded server-side by the * native `GET /workflows/runs` listing's own predicate (`address IS NOT - * NULL AND anchorRunId = id`), the same one `isTopLevelRun` uses. Used - * wherever a page needs "real deployments only" — the Agent Directory. + * NULL AND anchorRunId = id`), the same one `isTopLevelRun` uses. * Single-tenant (accepted loss): the deleted route expanded the * requested tenant to its whole descendant subtree via * `getDescendantTenants`; the native listing filters one tenant, so a @@ -95,189 +76,11 @@ export function listTopLevelRuns(tenantId: string): Promise { - return getJSON(`/api/tenants/${tenantId}/catalog/models?limit=${PAGE_LIMIT}`, ModelsPage).then( - (page) => - page.data.filter((model) => !model.disabled && isChatPickerModelName(model.canonicalName)), - ); -} - -/** A single top-level run's own detail — `GET /workflows/runs/:runId`. For - * a deployment's anchor run (which is what `AgentInstance` already lists), - * this is the same record; fetched again here only right after a fresh - * deploy, before the roster's own listing has picked it up. */ -export function getAgentRun(tenantId: string, runId: string): Promise { - return getJSON( - `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}`, - WorkflowRunResponse, - ); -} - -export type AgentRunHealth = typeof WorkflowRunHealth.infer; - -/** `GET /workflows/runs/:runId/health` — liveness/readiness for a live run. */ -export function getAgentRunHealth(tenantId: string, runId: string): Promise { - return getJSON( - `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/health`, - WorkflowRunHealth, - ); -} - -const RunEvent = type({ seq: "number", type: "string", body: "Record" }); -export type AgentRunEvent = typeof RunEvent.infer; -const RunEventsResponse = type({ runId: "string", events: RunEvent.array() }); - -/** `GET /workflows/runs/:runId/events` — the run's committed, seq-ordered - * event log. */ -export function getAgentRunEvents( - tenantId: string, - runId: string, -): Promise { - return getJSON( - `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/events`, - RunEventsResponse, - ).then((page) => page.events); -} - -export type AgentRunApprovals = typeof RunApprovalsResponse.infer; - -/** `GET /workflows/runs/:runId/approvals` — the run's approval decisions, - * newest first. */ -export function getAgentRunApprovals(tenantId: string, runId: string): Promise { - return getJSON( - `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/approvals`, - RunApprovalsResponse, - ); -} - -export type AgentDirectoryData = { - readonly tenantId: string; - readonly definitions: readonly AgentDefinition[]; - readonly instances: readonly AgentInstance[]; - readonly models: readonly CatalogModel[]; - /** Set when the model catalog failed independently; definitions and - * instances still load so the page stays usable. */ - readonly modelsError?: string; -}; - -type ModelsOutcome = - | { readonly ok: true; readonly models: readonly CatalogModel[] } - | { readonly ok: false; readonly message: string }; - -/** - * Loads a bench's agent directory. Definitions and instances are required; - * the model catalog is best-effort so its failure alone never blanks the - * page — surfaced as `modelsError` rather than a silent empty catalog. - * `instances` comes from `listTopLevelRuns`, which already excludes every - * non-top-level run (workbench host, invited agent) server-side — the - * native `GET /workflows/runs` listing's own predicate — so this page - * never has to derive that exclusion itself from a tenant's workbenches. - */ -export async function loadAgentDirectory(tenantId: string): Promise { - const [definitions, instances, modelsOutcome] = await Promise.all([ - listAgentDefinitions(tenantId), - listTopLevelRuns(tenantId), - listCatalogModels(tenantId).then( - (models): ModelsOutcome => ({ ok: true, models }), - (cause: unknown): ModelsOutcome => ({ - ok: false, - message: cause instanceof Error ? cause.message : String(cause), - }), - ), - ]); - - return { - tenantId, - definitions, - instances, - models: modelsOutcome.ok ? modelsOutcome.models : [], - ...(modelsOutcome.ok ? {} : { modelsError: modelsOutcome.message }), - }; -} - -/** - * Loads a bench's full agent directory. One query owns definitions + - * instances + models (models are best-effort inside `loadAgentDirectory`, - * surfacing `modelsError`) so the page keeps a single loading/error - * envelope. Pass no reloadKey — invalidate `tenantKeys.agentDirectory(tenantId)` - * after create. - */ -export function useAgentDirectory(tenantId: string | undefined): APIQuery { - const result = useQuery({ - queryKey: - tenantId === undefined - ? (["tenant", "none", "agents", "directory"] as const) - : tenantKeys.agentDirectory(tenantId), - enabled: tenantId !== undefined, - queryFn: async () => { - if (tenantId === undefined) { - throw new Error("tenantId required when agent directory is enabled"); - } - try { - return await loadAgentDirectory(tenantId); - } catch (cause) { - if (cause instanceof ApiQueryError && cause.status === 401) { - throw new UnauthenticatedError(); - } - throw cause; - } - }, - }); - return toAPIQuery(result); -} - -/** A selected agent run's liveness/readiness, polled only while a detail - * view has it open — `enabled` gates the fetch on a run actually existing. */ -export function useAgentRunHealth( - tenantId: string | null, - runId: string | null, -): APIQuery { - const result = useQuery({ - queryKey: ["agent-run-health", tenantId, runId] as const, - enabled: tenantId !== null && runId !== null, - queryFn: () => getAgentRunHealth(tenantId as string, runId as string), - }); - return toAPIQuery(result); -} - -/** A selected agent run's committed event log. */ -export function useAgentRunEvents( - tenantId: string | null, - runId: string | null, -): APIQuery { - const result = useQuery({ - queryKey: ["agent-run-events", tenantId, runId] as const, - enabled: tenantId !== null && runId !== null, - queryFn: () => getAgentRunEvents(tenantId as string, runId as string), - }); - return toAPIQuery(result); -} - -/** A selected agent run's approval decisions, newest first. */ -export function useAgentRunApprovals( - tenantId: string | null, - runId: string | null, -): APIQuery { - const result = useQuery({ - queryKey: ["agent-run-approvals", tenantId, runId] as const, - enabled: tenantId !== null && runId !== null, - queryFn: () => getAgentRunApprovals(tenantId as string, runId as string), - }); - return toAPIQuery(result); -} - /** * Deploys a hand-authored agent through the stock workflow-deploy path - * (`agent-deploy.ts`), then invalidates the bench's agent directory so the - * roster picks up the new deployment without a manual refetch. + * (`agent-deploy.ts`), then invalidates the bench's agent directory and + * chat-agent roster so both pick up the new deployment without a manual + * refetch. */ export function useDeployAgentMutation(tenantId: string) { const queryClient = useQueryClient(); @@ -286,6 +89,7 @@ export function useDeployAgentMutation(tenantId: string) { deployAgentSource({ tenantId, input }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: tenantKeys.agentDirectory(tenantId) }); + void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); }, }); } diff --git a/apps/web/src/agents-directory.ts b/apps/web/src/agents-directory.ts deleted file mode 100644 index d045112485..0000000000 --- a/apps/web/src/agents-directory.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Binds `@corbits/agent-directory/client`'s user-facing-agent logic -// (workbench-host filtering, search, orphan detection) to this app's -// concrete `AgentDefinition`/`AgentInstance` types. Shared by shell col2 -// and the agents page so neither layer imports the other; the actual -// product rule lives in the package, not here. - -import { - definitionsById as definitionsByIdShared, - filterDefinitions as filterDefinitionsShared, - filterInstances as filterInstancesShared, - isOrphanedInstance as isOrphanedInstanceShared, - purposeAgentDefinitions as purposeAgentDefinitionsShared, - purposeAgentInstances as purposeAgentInstancesShared, - withDisplayName as withDisplayNameShared, - withDisplayNames as withDisplayNamesShared, - type WithDisplayName, -} from "@corbits/agent-directory/client"; - -import type { AgentDefinition, AgentInstance } from "./agents-api"; - -/** An `AgentDefinition` with its display name derived — its own - * description when the definition was created with one, otherwise a - * humanized reading of its immutable `name` slug. `name` itself stays the - * slug throughout; nothing here mutates it. */ -export type AgentDefinitionWithDisplayName = WithDisplayName; - -/** One definition's display name derived the same way the roster's are, so - * the same agent never reads under two different names across screens. */ -export function withAgentDisplayName(definition: AgentDefinition): AgentDefinitionWithDisplayName { - return withDisplayNameShared(definition); -} - -export function purposeAgentDefinitions( - definitions: readonly AgentDefinition[], -): readonly AgentDefinitionWithDisplayName[] { - return withDisplayNamesShared(purposeAgentDefinitionsShared(definitions)); -} - -/** - * `instances` is expected to already come from `listTopLevelRuns` - * (see `./agents-api.ts`), which excludes every non-top-level run - * (workbench host, invited agent, task) server-side — the native - * `GET /workflows/runs` listing's own predicate. That listing is - * single-tenant (accepted loss: no descendant-subtree rollup), - * so this still applies the shared name-based workbench-host filter as - * defense in depth. - */ -export function purposeAgentInstances( - instances: readonly AgentInstance[], -): readonly AgentInstance[] { - return purposeAgentInstancesShared(instances); -} - -export function filterDefinitions( - definitions: readonly AgentDefinition[], - query: string, -): readonly AgentDefinition[] { - return filterDefinitionsShared(definitions, query); -} - -export function filterInstances( - instances: readonly T[], - query: string, -): readonly T[] { - return filterInstancesShared(instances, query); -} - -export function isOrphanedInstance( - instance: AgentInstance, - definitionsById: ReadonlyMap, -): boolean { - return isOrphanedInstanceShared(instance, definitionsById); -} - -export function definitionsById( - definitions: readonly AgentDefinition[], -): ReadonlyMap { - return definitionsByIdShared(definitions); -} diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 86f4c1c09b..31bc3ddc97 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -15,9 +15,10 @@ import { type RecentEntry, } from "@/command-palette"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { listAgentDefinitions } from "./agents-api"; +import { listChatAgents } from "@/chat/threads-api"; +import { chatPath } from "./chat-path"; import { ACTION_COMMANDS, runActionCommand, type ActionCommandId } from "./command-palette-actions"; import { openCommandPalette, @@ -32,7 +33,7 @@ import { NAV_ROUTES } from "./routes"; import { ArtifactListPageSchema, useAPIQuery } from "./api"; import { isBenchMembership, useBench } from "./bench-context"; import { useCloseCanvas } from "./shell/canvas-availability"; -import { AGENTS_PATH_PREFIX, SKILLS_PATH_PREFIX } from "./path-ids"; +import { SKILLS_PATH_PREFIX } from "./path-ids"; import { listScheduledWorkflows, runScheduledWorkflowNow, useTenantQuery } from "./routines-api"; import { listSkills } from "./skills-api"; import { tenantKeys } from "./query-client"; @@ -144,23 +145,13 @@ export function CommandPaletteProvider({ })); }, [selectedTenantId, queryClient]); - // `useEntitySearch` carries an id and a title per result and nothing else, - // but `/agents/` needs the definition's own minted handle — which is - // exactly what the fetch just read. Recorded here as the list arrives so a - // selection resolves the real slug instead of guessing one back out of a - // display title. - const agentHandleById = useRef(new Map()); - + // A palette hit opens a chat with the agent, so the search source is the + // same chat-partner listing the roster and the Agents page read — its + // `id` is exactly what `chatPath` expects. const listAgentsForSearch = useCallback(async () => { if (selectedTenantId === null) return []; - const definitions = await listAgentDefinitions(selectedTenantId); - for (const definition of definitions) { - agentHandleById.current.set(definition.id, definition.name); - } - return definitions.map((definition) => ({ - id: definition.id, - name: definition.name, - })); + const agents = await listChatAgents(selectedTenantId); + return agents.map((agent) => ({ id: agent.id, name: agent.name })); }, [selectedTenantId]); const entitySearchSources = useMemo( @@ -453,12 +444,7 @@ export function CommandPaletteProvider({ } else if (id.startsWith("entity:agents:")) { const agentId = id.slice("entity:agents:".length); const title = agentItems.find((item) => item.id === id)?.title ?? agentId; - navigate( - detailPath(AGENTS_PATH_PREFIX, { - slug: agentHandleById.current.get(agentId) ?? "", - id: agentId, - }), - ); + navigate(chatPath(agentId)); pushRecent({ kind: "agents", id, title, subtitle: "Agent" }); } else if (id.startsWith("entity:routines:")) { const routineId = id.slice("entity:routines:".length); diff --git a/apps/web/src/pages/agent-detail-page.tsx b/apps/web/src/pages/agent-detail-page.tsx deleted file mode 100644 index e6c2be6248..0000000000 --- a/apps/web/src/pages/agent-detail-page.tsx +++ /dev/null @@ -1,325 +0,0 @@ -// The agent's own page, addressed by its definition id — `/agents/`. -// Everything here is stock read-only observability: the definition's own -// status, its live top-level run's address, health, event log, and -// approvals. There is no editor on this page — display name, system -// prompt, model, and skill pins were all authored through -// `@corbits/agent-directory` routes that no longer exist on the hub; a -// hand-authored agent's only lifecycle is deploy (`CreateAgentPanel`) and -// observe (this page). - -import { - Badge, - PageShell, - RichEmptyState, - Section, - Skeleton, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@corbits/react-ui"; -import type { BadgeTone } from "@corbits/react-ui"; -import { Robot } from "@/lib/icons"; -import type { ReactNode } from "react"; - -import { QueryView } from "@/lib/api-query"; - -import type { AgentDefinition, AgentInstance } from "../agents-api"; -import { - useAgentDirectory, - useAgentRunApprovals, - useAgentRunEvents, - useAgentRunHealth, -} from "../agents-api"; -import type { AgentDefinitionWithDisplayName } from "../agents-directory"; -import { withAgentDisplayName } from "../agents-directory"; -import { useBench } from "../bench-context"; -import { AGENTS_PATH_PREFIX } from "../path-ids"; -import { StageTopBar } from "../shell/stage-top-bar"; - -const STATUS_TONE: Record<"deployed" | "stopped", BadgeTone> = { - deployed: "success", - stopped: "neutral", -}; - -/** A run's state in the words a person uses for it, never the wire enum - * (DESIGN.md, "Copy"). */ -const RUN_STATUS_COPY: Record< - AgentInstance["status"], - { readonly label: string; readonly tone: BadgeTone } -> = { - deployed: { label: "Ready", tone: "neutral" }, - running: { label: "Running now", tone: "success" }, - updating: { label: "Updating", tone: "warning" }, - error: { label: "Failed", tone: "danger" }, - stopped: { label: "Stopped", tone: "neutral" }, -}; - -const HEALTH_TONE: Record<"ok" | "unhealthy" | "not_ready", BadgeTone> = { - ok: "success", - unhealthy: "danger", - not_ready: "neutral", -}; - -/** The message half of a non-ready `APIQuery` — "unauthenticated" carries - * no message of its own, so it reads as a plain sign-in prompt instead. */ -function queryFailureMessage( - query: - | { readonly kind: "unauthenticated" } - | { readonly kind: "error"; readonly message: string }, -): string { - return query.kind === "unauthenticated" ? "You're signed out." : query.message; -} - -function HealthRow({ tenantId, runId }: { readonly tenantId: string; readonly runId: string }) { - const health = useAgentRunHealth(tenantId, runId); - if (health.kind === "loading") return ; - if (health.kind !== "ready") { - return ( - - {queryFailureMessage(health)} - - ); - } - return ( - - - Liveness: {health.data.liveness} - - - Readiness: {health.data.readiness} - - - ); -} - -function EventsSection({ tenantId, runId }: { readonly tenantId: string; readonly runId: string }) { - const events = useAgentRunEvents(tenantId, runId); - if (events.kind === "loading") return ; - if (events.kind !== "ready") { - return ( -

- {queryFailureMessage(events)} -

- ); - } - if (events.data.length === 0) { - return

No events yet.

; - } - return ( - - - - Seq - Type - - - - {events.data.map((event) => ( - - {event.seq} - {event.type} - - ))} - -
- ); -} - -function ApprovalsSection({ - tenantId, - runId, -}: { - readonly tenantId: string; - readonly runId: string; -}) { - const approvals = useAgentRunApprovals(tenantId, runId); - if (approvals.kind === "loading") return ; - if (approvals.kind !== "ready") { - return ( -

- {queryFailureMessage(approvals)} -

- ); - } - if (approvals.data.approvals.length === 0) { - return

No approvals yet.

; - } - return ( - - - - Tool - Scope - Status - - - - {approvals.data.approvals.map((approval) => ( - - - {typeof approval.toolDefinition.name === "string" - ? approval.toolDefinition.name - : approval.id} - - {approval.scope ?? "once"} - {approval.status} - - ))} - -
- ); -} - -export function AgentDetailPage({ - tenantId, - definition, - run, -}: { - readonly tenantId: string; - readonly definition: AgentDefinitionWithDisplayName; - /** This definition's own live top-level run, if it has been triggered - * since deploy. `null` reads as "deployed, never yet run". */ - readonly run: AgentInstance | null; -}) { - const archived = definition.status === "stopped"; - - return ( -
- -
- -
-
-

{definition.displayName}

-

{definition.name}

-
- - {archived ? "Archived" : "Active"} - - {run !== null ? ( - - {RUN_STATUS_COPY[run.status].label} - - ) : null} -
-
- - {run === null ? ( -

- Deployed, but not yet run — send it a message to start its first run. -

- ) : ( - <> -
-
-
-
Address
-
{run.address}
-
-
-
Health
-
- -
-
-
-
- -
- -
- -
- -
- - )} -
-
-
-
- ); -} - -/** - * Resolves `/agents/` from the bench's already-loaded agent directory — - * there is no per-definition detail route on the hub anymore, so the - * definition and its own top-level run both come from the same - * `useAgentDirectory` query the roster keeps warm. `id` may be either the - * definition's id or its immutable slug (`detailPath` prefers the slug when - * it is a valid one). - */ -export function AgentDetailRoute({ - slug: id, -}: { - readonly slug: string; - /** Unused here — this page has no navigation of its own — kept so the - * router's uniform `render(path, navigate)` call shape needs no - * special case for this one route. */ - readonly navigate?: (to: string) => void; -}) { - const { selectedTenantId } = useBench(); - const directory = useAgentDirectory(selectedTenantId ?? undefined); - - function shell(body: ReactNode) { - return ( -
- - - {body} - -
- ); - } - - if (selectedTenantId === null) { - return shell( - } - title="Select a workbench" - description="Pick a workbench from the switcher to open its agents." - />, - ); - } - - if (directory.kind !== "ready") { - return shell( - - {() => null} - , - ); - } - - const definition: AgentDefinition | undefined = directory.data.definitions.find( - (candidate) => candidate.id === id || candidate.name === id, - ); - - if (definition === undefined) { - return shell( - } - title="No such agent" - description={`Nothing here answers to "${id}". It may have been renamed, or belong to another workbench.`} - />, - ); - } - - const run = - directory.data.instances.find((instance) => instance.definitionId === definition.id) ?? null; - - return ( - - ); -} diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index a113e58288..efcd46785f 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -1,16 +1,15 @@ -// Agents: the global roster — every deployed workflow this bench has, one -// row per definition (name, description, address, status, runs) — plus a -// create path that deploys a new one (`CreateAgentPanel`, over the stock -// workflow-deploy route). Editing an existing agent's instructions has no -// stock route anymore, so this page is observe-only: roster, detail, and -// create — never a second instructions editor. +// Agents: a roster and nothing else — display name, live/starting status, +// and a Chat action per agent. Everything this page used to carry (a +// detail panel, run health/events/approvals, bulk archive) leaned on hub +// routes or state no longer worth a whole screen; a chat with the agent is +// the one action anyone actually used. Create-agent lives in the sidebar's +// "+" menu, not here. import { Badge, Button, PageShell, RichEmptyState, - SelectionCheckbox, StatusDot, Table, TableBody, @@ -18,503 +17,106 @@ import { TableHead, TableHeader, TableRow, - useListSelection, } from "@corbits/react-ui"; -import type { BadgeTone, SelectionCheckboxState } from "@corbits/react-ui"; -import { ArrowSquareOut, Plus, Robot } from "@/lib/icons"; -import { detailPath } from "@/command-palette"; -import { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { Robot } from "@/lib/icons"; -import { describeApiError, QueryView } from "@/lib/api-query"; - -import { - getAgentRun, - listTopLevelRuns, - useAgentDirectory, - type AgentDefinition, - type AgentInstance, -} from "../agents-api"; -import type { DeployedAgent } from "../agent-deploy"; -import { purposeAgentDefinitions, type AgentDefinitionWithDisplayName } from "../agents-directory"; +import { QueryView } from "@/lib/api-query"; +import { chatKeys, chatPath } from "../chat-path"; +import { listChatAgents, type ChatAgent } from "@/chat/threads-api"; import { useBench } from "../bench-context"; -import { isAdditiveSelectClick } from "../activatable-row"; import { Link } from "../navigation"; -import { useBenchActivity } from "../shell/bench-activity"; -import { AGENTS_PATH_PREFIX, agentIdFromPath } from "../path-ids"; +import { useTenantQuery } from "../routines-api"; import { StageTopBar } from "../shell/stage-top-bar"; -import { workbenchSettingsPath } from "../workbench-path"; -import { CreateAgentPanel } from "./create-agent-panel"; - -const DEFINITION_STATUS_TONE: Record = { - deployed: "success", - stopped: "neutral", -}; - -const DEFINITION_STATUS_LABEL: Record = { - deployed: "Deployed", - stopped: "Stopped", -}; - -/** 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 Live 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: "Live", - idle: "Idle", - blocked: "Blocked", - archived: "Archived", -}; - -// Adopts `@corbits/react-ui`'s own run-status convention -// (`RUN_STATUS_TONE`/`workflow-run.ts`) rather than inventing a mapping: -// running is the blue "live/streaming" tone (with a pulsing `StatusDot`, -// the same liveness marker that vocabulary already carries); idle is the -// green "healthy, nothing wrong" tone (`pill-ok` in the spec); blocked and -// archived were already right. -export const AGENT_ROSTER_STATUS_TONE: Record = { - running: "info", - idle: "success", - blocked: "danger", - archived: "neutral", -}; - -export function agentRosterStatus( - definition: AgentDefinition, - instances: readonly AgentInstance[], -): AgentRosterStatus { - if (definition.status === "stopped") return "archived"; - const own = instances.filter((instance) => instance.definitionId === definition.id); - if (own.some((instance) => instance.status === "running")) return "running"; - if (own.some((instance) => instance.status === "error")) return "blocked"; - return "idle"; -} - -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; - -/** How many of a definition's instances were created in the trailing 7 - * days — the roster's "Runs · 7d" column. */ -export function runsInLast7Days( - definitionId: string, - instances: readonly AgentInstance[], - now: number, -): number { - return instances.filter( - (instance) => - instance.definitionId === definitionId && - now - new Date(instance.createdAt).getTime() <= SEVEN_DAYS_MS, - ).length; -} -/** Settled Runs · 7d content — a failed top-level-runs fetch is never the - * same as an honest count of zero. */ -export function agentRunsSettledContent( - definitionId: string, - instances: readonly AgentInstance[], - now: number, - instancesError: string | null, -): - | { readonly kind: "count"; readonly value: number } - | { readonly kind: "error"; readonly message: string } { - if (instancesError !== null) { - return { kind: "error", message: instancesError }; - } - return { - kind: "count", - value: runsInLast7Days(definitionId, instances, now), - }; +/** + * One agent's roster row: `Live` once it has a live run address, `Starting` + * while a deploy is still landing one (mid-first-deploy or mid-redeploy). + */ +export function agentRosterStatus(agent: Pick): "live" | "starting" { + return agent.liveAddress === null ? "starting" : "live"; } -/** 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. */ -export type DefinitionWorkbenchInstance = { - readonly id: string; - readonly title: string; -}; - -/** Definitions this bench's chats are launched against, grouped by - * definition id — the roster's "Workbenches" column and detail panel. - * `chats` comes from `useBenchActivity`, the same agent-DM listing the - * sidebar itself reads; the list here is exactly which rows the sidebar - * would show for a definition before its dedupe-by-title collapses - * same-named DMs across ancestor tenants. */ -export function workbenchesByDefinition( - chats: readonly { - readonly id: string; - readonly title: string; - readonly definitionId?: string | null; - }[], -): ReadonlyMap { - const byDefinition = new Map(); - for (const chat of chats) { - if (chat.definitionId === null || chat.definitionId === undefined) { - continue; - } - const list = byDefinition.get(chat.definitionId) ?? []; - list.push({ id: chat.id, title: chat.title }); - byDefinition.set(chat.definitionId, list); +export function AgentsRosterList({ agents }: { readonly agents: readonly ChatAgent[] }) { + if (agents.length === 0) { + return ( + } + title="No agents yet" + description="Create an agent — a name and a system prompt — from the + menu, and it appears here." + /> + ); } - return byDefinition; -} - -function AgentDetailPanel({ - definition, - workbenches, -}: { - readonly definition: AgentDefinitionWithDisplayName; - readonly workbenches: readonly DefinitionWorkbenchInstance[]; -}) { return ( - + + + + Agent + Status + + + + + {agents.map((agent) => { + const status = agentRosterStatus(agent); + return ( + + + {agent.name} + + + + {status === "live" ? ( + + ) : null} + + {status === "live" ? "Live" : "Starting"} + + + + + + + + ); + })} + +
); } -/** - * The roster stage: a flat table of every definition this bench owns — - * 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; the panel's Open - * control hops to `/agents/` for the full page. "New agent" opens - * `CreateAgentPanel`. Rows are also bulk-selectable (checkbox + shift/cmd - * range select, `useListSelection`) with a floating `BulkActionBar` for - * Archive — the only bulk action with a real backend primitive - * (`setAgentDefinitionStatus`, the same PUT the single-agent Archive - * button on the detail page already uses). Duplicate/Move/Delete are not - * offered here: batch duplication needs slug-collision handling the detail - * page's single-agent duplicate never had to solve, and Move/Delete have - * no backend primitive at all — a button that cannot do what it says is - * worse than no button. - */ -export function AgentsPage({ - tenantId, - definitions, - workbenches, - instances, - instancesError = null, - now = Date.now(), - selectedId, - onSelect, - createOpen, - onCreateOpenChange, - onCreated, -}: { - readonly tenantId: string | null; - readonly definitions: readonly AgentDefinitionWithDisplayName[]; - readonly workbenches: ReadonlyMap; - readonly instances: readonly AgentInstance[]; - /** When the top-level-runs fetch failed — Status/Runs · 7d must not pretend - * the history is empty. */ - readonly instancesError?: string | null; - readonly now?: number; - readonly selectedId: string | null; - readonly onSelect: (id: string | null) => void; - readonly createOpen: boolean; - readonly onCreateOpenChange: (open: boolean) => void; - readonly onCreated: (deployment: DeployedAgent) => void; -}) { - const selected = definitions.find((d) => d.id === selectedId) ?? null; - const definitionIds = useMemo( - () => definitions.map((definition) => definition.id), - [definitions], +export function AgentsRoute() { + const { selectedTenantId } = useBench(); + const agentsQuery = useTenantQuery( + chatKeys.agents(selectedTenantId ?? "none"), + selectedTenantId !== null, + () => listChatAgents(selectedTenantId as string), ); - const selection = useListSelection({ ids: definitionIds }); - const allSelected = definitions.length > 0 && selection.selectedCount === definitions.length; - const headerChecked: SelectionCheckboxState = - selection.selectedCount === 0 ? false : allSelected ? true : "indeterminate"; return (
onCreateOpenChange(true)} aria-label="Create an agent"> - New agent - - ) : null - } + subtitle="Every agent this workbench can chat with." /> -
-
- - {definitions.length === 0 ? ( - } - title="No agents yet" - description="Create an agent — a name and a system prompt — and it appears here and in the sidebar, ready to start a workbench." - /> - ) : ( -
- {instancesError !== null ? ( -

- {instancesError} -

- ) : null} - - - - - (allSelected ? selection.clear() : selection.selectAll())} - rowLabel="all agents" - ariaLabel="Select all agents" - className="opacity-100" - /> - - Agent - Address - Status - Runs · 7d - - - - {definitions.map((definition) => { - const isSelected = selection.isSelected(definition.id); - const runs = agentRunsSettledContent( - definition.id, - instances, - now, - instancesError, - ); - const status = - runs.kind === "error" ? null : agentRosterStatus(definition, instances); - const ownRun = instances.find( - (instance) => instance.definitionId === definition.id, - ); - 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} - - - - {ownRun?.address ?? "—"} - - - {status === null ? ( - — - ) : ( - - {status === "running" ? ( - - ) : null} - - {AGENT_ROSTER_STATUS_LABEL[status]} - - - )} - - - {runs.kind === "error" ? ( - — - ) : ( - runs.value - )} - - - ); - })} - -
-
- )} -
-
- {selected !== null ? ( -
- -
- ) : null} -
- {tenantId !== null ? ( - { - onCreateOpenChange(false); - onCreated(deployment); - }} - /> - ) : null} -
- ); -} - -export function AgentsRoute({ - path, - navigate, -}: { - readonly path: string; - readonly navigate: (to: string) => void; -}) { - const { selectedTenantId } = useBench(); - const directory = useAgentDirectory(selectedTenantId ?? undefined); - const activity = useBenchActivity(selectedTenantId); - // Powers the roster's Status and "Runs · 7d" columns. A failed fetch must - // not degrade those columns to Idle/0 — the definitions listing - // still makes the page usable, but Status/Runs admit the load failed. - 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); - - if (selectedTenantId === null) { - return ( -
- - - } - title="Select a workbench" - description="Pick a workbench from the switcher to see the agents it can start." - /> - -
- ); - } - - if (directory.kind !== "ready") { - return ( -
- +
- - {() => null} - + {selectedTenantId === null ? ( + } + title="Select a workbench" + description="Pick a workbench from the switcher to see the agents it can chat with." + /> + ) : ( +
+ + {(agents) => } + +
+ )}
- ); - } - - const definitions = purposeAgentDefinitions(directory.data.definitions); - const workbenches = workbenchesByDefinition(activity.kind === "ready" ? activity.chats : []); - - return ( - - navigate( - id === null ? AGENTS_PATH_PREFIX : `${AGENTS_PATH_PREFIX}/${encodeURIComponent(id)}`, - ) - } - createOpen={createOpen} - onCreateOpenChange={setCreateOpen} - onCreated={(deployment) => { - // The directory query already invalidated (`useDeployAgentMutation`); - // resolve this deployment's own run to learn its definitionId so the - // roster can select the row that just appeared. - void getAgentRun(selectedTenantId, deployment.id).then( - (run) => navigate(`${AGENTS_PATH_PREFIX}/${encodeURIComponent(run.definitionId)}`), - () => undefined, - ); - }} - /> +
); } diff --git a/apps/web/src/pages/run-status-tone-parity.test.ts b/apps/web/src/pages/run-status-tone-parity.test.ts index 453a185cc0..238319b5d9 100644 --- a/apps/web/src/pages/run-status-tone-parity.test.ts +++ b/apps/web/src/pages/run-status-tone-parity.test.ts @@ -19,7 +19,6 @@ import { describe, expect, test } from "bun:test"; import { RUN_STATUS_TONE } from "@corbits/react-ui"; import { statusTone } from "./insights-page"; -import { AGENT_ROSTER_STATUS_TONE } from "./agents-page"; describe("run-status tone parity with react-ui's RUN_STATUS_TONE", () => { test("Insights' statusTone agrees with canonical for every shared status", () => { @@ -28,11 +27,4 @@ describe("run-status tone parity with react-ui's RUN_STATUS_TONE", () => { expect(statusTone("running")).toBe(RUN_STATUS_TONE.running); expect(statusTone("stopped")).toBe(RUN_STATUS_TONE.stopped); }); - - test("Agents' roster status tone (a genuinely different vocabulary) still agrees where it overlaps", () => { - // AgentRosterStatus is its own enum, not RunStatus — this is the one - // local map the ticket calls defensible. It only shares one name - // ("running") with RunStatus, and it must keep agreeing on that one. - expect(AGENT_ROSTER_STATUS_TONE.running).toBe(RUN_STATUS_TONE.running); - }); }); diff --git a/apps/web/src/path-ids.test.ts b/apps/web/src/path-ids.test.ts index 247df2055b..9d6147c45a 100644 --- a/apps/web/src/path-ids.test.ts +++ b/apps/web/src/path-ids.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { AGENTS_PATH_PREFIX, - agentIdFromPath, detailSlugFromPath, settingsEntityIdFromPath, settingsSectionIdFromPath, @@ -34,12 +33,7 @@ describe("detailSlugFromPath", () => { }); describe("id extraction", () => { - test("decodes an escaped id", () => { - expect(agentIdFromPath("/agents/wfd%201")).toBe("wfd 1"); - }); - test("a malformed escape names no entity instead of throwing", () => { - expect(agentIdFromPath("/agents/%")).toBeNull(); expect(settingsSectionIdFromPath("/settings/%E0%A4%A")).toBeNull(); expect(settingsEntityIdFromPath("/settings/agents/%", "agents")).toBeNull(); }); diff --git a/apps/web/src/path-ids.ts b/apps/web/src/path-ids.ts index 7110d19b25..cc7bad3e3c 100644 --- a/apps/web/src/path-ids.ts +++ b/apps/web/src/path-ids.ts @@ -48,10 +48,6 @@ function entityIdFromTopLevelPath(path: string, prefix: string): string | null { return segment === null ? null : decodedOrNull(segment); } -export function agentIdFromPath(path: string): string | null { - return entityIdFromTopLevelPath(path, AGENTS_PATH_PREFIX); -} - export function skillIdFromPath(path: string): string | null { return entityIdFromTopLevelPath(path, SKILLS_PATH_PREFIX); } diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 0d789de020..c4abaf63e5 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -30,7 +30,6 @@ import type { Slug } from "@/lib/slug"; import { lazy, type ReactElement, type ReactNode } from "react"; import { - AGENTS_PATH_PREFIX, SKILLS_PATH_PREFIX, WORKFLOWS_PATH_PREFIX, detailSlugFromPath, @@ -76,9 +75,6 @@ const InsightsRoute = lazy(async () => ({ const SettingsRoute = lazy(async () => ({ default: (await import("./pages/settings-page")).SettingsRoute, })); -const AgentDetailRoute = lazy(async () => ({ - default: (await import("./pages/agent-detail-page")).AgentDetailRoute, -})); const SkillDetailRoute = lazy(async () => ({ default: (await import("./pages/skill-detail-page")).SkillDetailRoute, })); @@ -115,7 +111,6 @@ export const NEW_WORKBENCH_PATH = "/new"; * the agent's own screen. */ const SLUG_SEGMENT = "/:slug"; -export const AGENT_DETAIL_PATH = `${AGENTS_PATH_PREFIX}${SLUG_SEGMENT}`; export const SKILL_DETAIL_PATH = `${SKILLS_PATH_PREFIX}${SLUG_SEGMENT}`; /** @@ -147,16 +142,6 @@ function routineDetailSegment(path: string): string { return segment; } -/** A detail route only ever renders for a path `matchesRoute` already - * accepted, which is what makes the slug non-null here. */ -function detailRouteSlug(routePath: string, path: string): Slug { - const slug = slugForDetailRoute(routePath, path); - if (slug === null) { - throw new Error(`${routePath} rendered for a path with no slug: ${path}`); - } - return slug; -} - export type AppRoute = { readonly path: string; readonly label: string; @@ -283,21 +268,11 @@ export const APP_ROUTES: readonly AppRoute[] = [ icon: , render: (path: string) => , }, - { - path: AGENT_DETAIL_PATH, - label: "Agent", - icon: , - render: (path: string, navigate: (to: string) => void) => ( - - ), - }, { path: "/agents", label: "Agents", icon: , - render: (path: string, navigate: (to: string) => void) => ( - - ), + render: () => , }, { path: SKILL_DETAIL_PATH, diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index f2eeefbfaa..a36c44844b 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -18,6 +18,8 @@ import { useQuery } from "@tanstack/react-query"; import { WorkflowDeploymentResponse } from "@intx/types"; import type { APIQuery } from "@/lib/api-query"; import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; +import { isAgentDeploySourceAssetName } from "@/agent-deploy"; +import { MYRA_SOURCE_CONFIG } from "@/myra-source"; export const ScheduledWorkflowDefinition = type({ definitionId: "string", @@ -57,6 +59,15 @@ async function fetchJSON(path: string, schema: (data: unknown) => T | type.er return parsed; } +/** Myra's own deploy source and every agent `agent-deploy.ts` created are + * `workflow`-kind assets too, but they're chat partners, not workflows — + * excluded by the same asset naming the chat/deploy pipeline already owns + * (`MYRA_SOURCE_CONFIG.assetName`, `agent--source`), so this list + * never depends on runtime agent state to stay accurate. */ +function isAgentAssetName(name: string): boolean { + return name === MYRA_SOURCE_CONFIG.assetName || isAgentDeploySourceAssetName(name); +} + /** Every workflow deployment on this tenant, named from the backing * workflow asset. A deployment whose asset can't be resolved still lists, * under a placeholder name, rather than disappearing. */ @@ -68,16 +79,21 @@ export async function listScheduledWorkflows( fetchJSON(workflowAssetsPath(tenantId), WorkflowAssetsSchema), ]); const nameByAssetId = new Map(assets.map((asset) => [asset.id, asset.name])); - return deployments.map((deployment) => ({ - definitionId: deployment.id, - assetId: deployment.definitionAssetId, - name: nameByAssetId.get(deployment.definitionAssetId) ?? "Untitled workflow", - tenantId: deployment.tenantId, - status: deployment.status === "deployed" ? "deployed" : "stopped", - cron: "manual", - createdAt: deployment.createdAt, - updatedAt: deployment.createdAt, - })); + return deployments + .filter((deployment) => { + const name = nameByAssetId.get(deployment.definitionAssetId); + return name === undefined || !isAgentAssetName(name); + }) + .map((deployment) => ({ + definitionId: deployment.id, + assetId: deployment.definitionAssetId, + name: nameByAssetId.get(deployment.definitionAssetId) ?? "Untitled workflow", + tenantId: deployment.tenantId, + status: deployment.status === "deployed" ? "deployed" : "stopped", + cron: "manual", + createdAt: deployment.createdAt, + updatedAt: deployment.createdAt, + })); } /** No stock route reruns a deployment on demand yet. */