From f1aee72c9f94b6f6a83c8ffbc5d29d4cf948d93f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:18:14 -0700 Subject: [PATCH] feat(web): Workers roster at /workers and a worker page at /workers/:id (CL-9565) --- apps/web/src/pages/agents-page.tsx | 165 ------------------- apps/web/src/pages/worker-page.tsx | 227 ++++++++++++++++++++++++++ apps/web/src/pages/workers-page.tsx | 236 ++++++++++++++++++++++++++++ apps/web/src/path-ids.test.ts | 4 +- apps/web/src/path-ids.ts | 10 +- apps/web/src/routes.tsx | 39 +++-- 6 files changed, 503 insertions(+), 178 deletions(-) delete mode 100644 apps/web/src/pages/agents-page.tsx create mode 100644 apps/web/src/pages/worker-page.tsx create mode 100644 apps/web/src/pages/workers-page.tsx diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx deleted file mode 100644 index 9072bc667..000000000 --- a/apps/web/src/pages/agents-page.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// A roster and nothing else: status at a glance, restart when an agent -// is not running. Create-agent lives in the sidebar's "+" menu. - -import { - Badge, - Button, - PageShell, - RichEmptyState, - StatusDot, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - toast, -} from "@corbits/react-ui"; -import { Robot } from "@/lib/icons"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { reportError } from "@corbits/error-sink"; - -import { QueryView } from "@/lib/api-query"; -import { tenantKeys } from "../query-client"; -import { isAgentNotRunning, listChatAgents, type ChatAgent } from "@/chat/threads-api"; -import { describeRestartFailure, redeployWorkbenchAgent } from "../workbench-create"; -import { useBench } from "../bench-context"; -import { useTenantQuery } from "../routines-api"; -import { StageTopBar } from "../shell/stage-top-bar"; - -// `not-running` once the latest deployment has gone terminal (a hub -// restart releases every prior allocation) — restart is the only way -// forward. -export function agentRosterStatus( - agent: Pick, -): "live" | "starting" | "not-running" { - if (agent.liveAddress !== null) return "live"; - return isAgentNotRunning(agent) ? "not-running" : "starting"; -} - -export function AgentsRosterList({ - tenantId, - agents, -}: { - readonly tenantId: string; - readonly agents: readonly ChatAgent[]; -}) { - const queryClient = useQueryClient(); - const restart = useMutation({ - mutationFn: (agent: ChatAgent) => redeployWorkbenchAgent(tenantId, agent), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: tenantKeys.agents(tenantId) }); - }, - onError: (cause) => { - reportError(cause, { operation: "agent_restart", tenantId }); - toast(describeRestartFailure(cause)); - }, - }); - 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 ( - - - - Agent - Status - - - - - {agents.map((agent) => { - const status = agentRosterStatus(agent); - return ( - - - {agent.name} - - - - {status === "live" ? ( - - ) : null} - - {status === "live" - ? "Live" - : restart.isPending && restart.variables?.id === agent.id - ? "Starting…" - : status === "not-running" - ? "Not running" - : "Starting"} - - - - - {status === "not-running" ? ( - - ) : null} - - - ); - })} - -
- ); -} - -export function AgentsRoute() { - const { selectedTenantId } = useBench(); - const agentsQuery = useTenantQuery( - tenantKeys.agents(selectedTenantId ?? "none"), - selectedTenantId !== null, - () => listChatAgents(selectedTenantId as string), - // Keep polling while any agent is not live, so a released→deployed - // (or a restart landing) transition is seen without a reload. - (agents) => ((agents?.some((agent) => agent.liveAddress === null) ?? false) ? 3000 : false), - ); - - return ( -
- -
- - {selectedTenantId === null ? ( - } - title="Select a workbench" - description="Pick a workbench from the switcher to see the agents it can chat with." - /> - ) : ( -
- - {(agents) => } - -
- )} -
-
-
- ); -} diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx new file mode 100644 index 000000000..34dfe651c --- /dev/null +++ b/apps/web/src/pages/worker-page.tsx @@ -0,0 +1,227 @@ +// One worker: its instructions (editable), its model, and the workbench it +// lives on. Permissions and delete arrive with their own screens. + +import { useState } from "react"; +import { Button, Card, CardDescription, CardTitle, PageShell, toast } from "@corbits/react-ui"; +import { reportError } from "@corbits/error-sink"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { WorkbenchAvatar } from "@/chat/avatar"; +import { isMyraAgent, listChatAgents, type ChatAgent } from "@/chat/threads-api"; +import { ChatCircle } from "@/lib/icons"; +import { QueryView, describeApiError } from "@/lib/api-query"; +import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy"; +import { readAgentMcpHandles, readAgentSource } from "../agent-source-read"; +import { useBench } from "../bench-context"; +import { Link } from "../navigation"; +import { tenantKeys } from "../query-client"; +import { useTenantQuery } from "../routines-api"; +import { StageTopBar } from "../shell/stage-top-bar"; +import { WORKERS_PATH_PREFIX } from "../path-ids"; +import { workbenchPath } from "../workbench-path"; +import { StatusPill, workerStatus } from "./workers-page"; + +function sourceKey(tenantId: string, agentId: string) { + return [...tenantKeys.agents(tenantId), "source", agentId] as const; +} + +function InstructionsCard({ + tenantId, + agent, +}: { + readonly tenantId: string; + readonly agent: ChatAgent; +}) { + const queryClient = useQueryClient(); + const editable = !isMyraAgent(agent); + const source = useQuery({ + queryKey: sourceKey(tenantId, agent.id), + queryFn: () => readAgentSource(tenantId, agent.id, agent.assetName), + }); + const [draft, setDraft] = useState(null); + const saved = source.data?.systemPrompt ?? ""; + const value = draft ?? saved; + const dirty = draft !== null && draft !== saved; + + // Editing means republishing the agent's source and redeploying it, the + // same pipeline that created it; the stock hub has no in-place prompt edit. + const save = useMutation({ + mutationFn: async (systemPrompt: string) => { + const slug = agentSlugFromSourceAssetName(agent.assetName); + if (slug === null) throw new Error(`${agent.assetName} is not an editable worker`); + const mcpHandles = await readAgentMcpHandles(tenantId, agent.id, agent.assetName); + await deployAgentSource({ + tenantId, + input: { name: agent.name, systemPrompt, slug, mcpHandles }, + }); + }, + onSuccess: async () => { + setDraft(null); + toast("Instructions saved"); + await queryClient.invalidateQueries({ + queryKey: tenantKeys.agents(tenantId), + }); + }, + onError: (cause) => { + reportError(cause, { operation: "worker_instructions_save", tenantId }); + toast(describeApiError(cause, "save these instructions")); + }, + }); + + return ( + + Instructions + + How this worker should behave. It reads these at the start of every run. + + {source.isPending ? ( +

Loading instructions…

+ ) : source.isError ? ( +

+ {describeApiError(source.error, "load these instructions")} +

+ ) : ( + <> +