From 8f17b6fb52181efb2e24f0fa9577bc037dd286a4 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Tue, 17 Feb 2026 12:33:04 -0600 Subject: [PATCH] Build out agent management UI: detail page, create dialog, list view Add NewAgentDialog for creating agents with adapter config. Expand AgentDetail page with tabbed view (overview, runs, config, logs), run history timeline, and live status. Enhance Agents list page with richer cards and filtering. Update AgentProperties panel, API client, query keys, and utility helpers. Co-Authored-By: Claude Opus 4.6 --- ui/src/api/agents.ts | 4 +- ui/src/api/heartbeats.ts | 1 + ui/src/components/AgentProperties.tsx | 24 +- ui/src/components/CommandPalette.tsx | 9 +- ui/src/components/Layout.tsx | 2 + ui/src/components/NewAgentDialog.tsx | 664 ++++++++++++++++ ui/src/lib/queryKeys.ts | 1 + ui/src/lib/utils.ts | 20 + ui/src/pages/AgentDetail.tsx | 1004 ++++++++++++++++++++++--- ui/src/pages/Agents.tsx | 186 ++++- 10 files changed, 1798 insertions(+), 117 deletions(-) create mode 100644 ui/src/components/NewAgentDialog.tsx diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 02517dd9..b79f2b0e 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,4 +1,4 @@ -import type { Agent, AgentKeyCreated, HeartbeatRun } from "@paperclip/shared"; +import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared"; import { api } from "./client"; export interface OrgNode { @@ -20,6 +20,8 @@ export const agentsApi = { resume: (id: string) => api.post(`/agents/${id}/resume`, {}), terminate: (id: string) => api.post(`/agents/${id}/terminate`, {}), createKey: (id: string, name: string) => api.post(`/agents/${id}/keys`, { name }), + runtimeState: (id: string) => api.get(`/agents/${id}/runtime-state`), + resetSession: (id: string) => api.post(`/agents/${id}/runtime-state/reset-session`, {}), invoke: (id: string) => api.post(`/agents/${id}/heartbeat/invoke`, {}), wakeup: ( id: string, diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 7183c6e6..28238fce 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -14,4 +14,5 @@ export const heartbeatsApi = { api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>( `/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`, ), + cancel: (runId: string) => api.post(`/heartbeat-runs/${runId}/cancel`, {}), }; diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 592f5291..ef177fb9 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -1,12 +1,20 @@ -import type { Agent } from "@paperclip/shared"; +import type { Agent, AgentRuntimeState } from "@paperclip/shared"; import { StatusBadge } from "./StatusBadge"; import { formatCents, formatDate } from "../lib/utils"; import { Separator } from "@/components/ui/separator"; interface AgentPropertiesProps { agent: Agent; + runtimeState?: AgentRuntimeState; } +const adapterLabels: Record = { + claude_local: "Claude (local)", + codex_local: "Codex (local)", + process: "Process", + http: "HTTP", +}; + function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -16,7 +24,7 @@ function PropertyRow({ label, children }: { label: string; children: React.React ); } -export function AgentProperties({ agent }: AgentPropertiesProps) { +export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { return (
@@ -32,7 +40,7 @@ export function AgentProperties({ agent }: AgentPropertiesProps) { )} - {agent.adapterType} + {adapterLabels[agent.adapterType] ?? agent.adapterType} {agent.contextMode} @@ -60,6 +68,16 @@ export function AgentProperties({ agent }: AgentPropertiesProps) {
+ {runtimeState?.sessionId && ( + + {runtimeState.sessionId.slice(0, 12)}... + + )} + {runtimeState?.lastError && ( + + {runtimeState.lastError} + + )} {agent.lastHeartbeatAt && ( {formatDate(agent.lastHeartbeatAt)} diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 0d1e5c44..543f2412 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -34,7 +34,7 @@ export function CommandPalette() { const [open, setOpen] = useState(false); const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); - const { openNewIssue } = useDialog(); + const { openNewIssue, openNewAgent } = useDialog(); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -133,7 +133,12 @@ export function CommandPalette() { Create new issue C - go("/agents")}> + { + setOpen(false); + openNewAgent(); + }} + > Create new agent diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8176c6f0..39934abe 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; import { NewIssueDialog } from "./NewIssueDialog"; import { NewProjectDialog } from "./NewProjectDialog"; +import { NewAgentDialog } from "./NewAgentDialog"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; @@ -49,6 +50,7 @@ export function Layout() { +
); } diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx new file mode 100644 index 00000000..f05c5175 --- /dev/null +++ b/ui/src/components/NewAgentDialog.tsx @@ -0,0 +1,664 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { useDialog } from "../context/DialogContext"; +import { useCompany } from "../context/CompanyContext"; +import { agentsApi } from "../api/agents"; +import { queryKeys } from "../lib/queryKeys"; +import { AGENT_ROLES, AGENT_ADAPTER_TYPES } from "@paperclip/shared"; +import { + Dialog, + DialogContent, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Maximize2, + Minimize2, + Bot, + User, + Shield, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import { cn } from "../lib/utils"; + +const roleLabels: Record = { + ceo: "CEO", + cto: "CTO", + cmo: "CMO", + cfo: "CFO", + engineer: "Engineer", + designer: "Designer", + pm: "PM", + qa: "QA", + devops: "DevOps", + researcher: "Researcher", + general: "General", +}; + +const adapterLabels: Record = { + claude_local: "Claude (local)", + codex_local: "Codex (local)", + process: "Process", + http: "HTTP", +}; + +export function NewAgentDialog() { + const { newAgentOpen, closeNewAgent } = useDialog(); + const { selectedCompanyId, selectedCompany } = useCompany(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [expanded, setExpanded] = useState(false); + + // Identity + const [name, setName] = useState(""); + const [title, setTitle] = useState(""); + const [role, setRole] = useState("general"); + const [reportsTo, setReportsTo] = useState(""); + const [capabilities, setCapabilities] = useState(""); + + // Adapter + const [adapterType, setAdapterType] = useState("claude_local"); + const [cwd, setCwd] = useState(""); + const [promptTemplate, setPromptTemplate] = useState(""); + const [bootstrapPrompt, setBootstrapPrompt] = useState(""); + const [model, setModel] = useState(""); + + // claude_local specific + const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80); + const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(true); + + // codex_local specific + const [search, setSearch] = useState(false); + const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(true); + + // process specific + const [command, setCommand] = useState(""); + const [args, setArgs] = useState(""); + + // http specific + const [url, setUrl] = useState(""); + + // Heartbeat + const [heartbeatEnabled, setHeartbeatEnabled] = useState(true); + const [intervalSec, setIntervalSec] = useState(300); + const [wakeOnAssignment, setWakeOnAssignment] = useState(true); + const [wakeOnOnDemand, setWakeOnOnDemand] = useState(true); + const [wakeOnAutomation, setWakeOnAutomation] = useState(true); + const [cooldownSec, setCooldownSec] = useState(10); + + // Runtime + const [contextMode, setContextMode] = useState("thin"); + const [budgetMonthlyCents, setBudgetMonthlyCents] = useState(0); + const [timeoutSec, setTimeoutSec] = useState(900); + const [graceSec, setGraceSec] = useState(15); + + // Sections + const [adapterOpen, setAdapterOpen] = useState(true); + const [heartbeatOpen, setHeartbeatOpen] = useState(false); + const [runtimeOpen, setRuntimeOpen] = useState(false); + + // Popover states + const [roleOpen, setRoleOpen] = useState(false); + const [reportsToOpen, setReportsToOpen] = useState(false); + const [adapterTypeOpen, setAdapterTypeOpen] = useState(false); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && newAgentOpen, + }); + + const isFirstAgent = !agents || agents.length === 0; + const effectiveRole = isFirstAgent ? "ceo" : role; + + const createAgent = useMutation({ + mutationFn: (data: Record) => + agentsApi.create(selectedCompanyId!, data), + onSuccess: (agent) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); + reset(); + closeNewAgent(); + navigate(`/agents/${agent.id}`); + }, + }); + + function reset() { + setName(""); + setTitle(""); + setRole("general"); + setReportsTo(""); + setCapabilities(""); + setAdapterType("claude_local"); + setCwd(""); + setPromptTemplate(""); + setBootstrapPrompt(""); + setModel(""); + setMaxTurnsPerRun(80); + setDangerouslySkipPermissions(true); + setSearch(false); + setDangerouslyBypassSandbox(true); + setCommand(""); + setArgs(""); + setUrl(""); + setHeartbeatEnabled(true); + setIntervalSec(300); + setWakeOnAssignment(true); + setWakeOnOnDemand(true); + setWakeOnAutomation(true); + setCooldownSec(10); + setContextMode("thin"); + setBudgetMonthlyCents(0); + setTimeoutSec(900); + setGraceSec(15); + setExpanded(false); + setAdapterOpen(true); + setHeartbeatOpen(false); + setRuntimeOpen(false); + } + + function buildAdapterConfig() { + const config: Record = {}; + if (cwd) config.cwd = cwd; + if (promptTemplate) config.promptTemplate = promptTemplate; + if (bootstrapPrompt) config.bootstrapPromptTemplate = bootstrapPrompt; + if (model) config.model = model; + config.timeoutSec = timeoutSec; + config.graceSec = graceSec; + + if (adapterType === "claude_local") { + config.maxTurnsPerRun = maxTurnsPerRun; + config.dangerouslySkipPermissions = dangerouslySkipPermissions; + } else if (adapterType === "codex_local") { + config.search = search; + config.dangerouslyBypassApprovalsAndSandbox = dangerouslyBypassSandbox; + } else if (adapterType === "process") { + if (command) config.command = command; + if (args) config.args = args.split(",").map((a) => a.trim()).filter(Boolean); + } else if (adapterType === "http") { + if (url) config.url = url; + } + return config; + } + + function handleSubmit() { + if (!selectedCompanyId || !name.trim()) return; + createAgent.mutate({ + name: name.trim(), + role: effectiveRole, + ...(title.trim() ? { title: title.trim() } : {}), + ...(reportsTo ? { reportsTo } : {}), + ...(capabilities.trim() ? { capabilities: capabilities.trim() } : {}), + adapterType, + adapterConfig: buildAdapterConfig(), + runtimeConfig: { + heartbeat: { + enabled: heartbeatEnabled, + intervalSec, + wakeOnAssignment, + wakeOnOnDemand, + wakeOnAutomation, + cooldownSec, + }, + }, + contextMode, + budgetMonthlyCents, + }); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + } + + const currentAgent = (agents ?? []).find((a) => a.id === reportsTo); + + return ( + { + if (!open) { + reset(); + closeNewAgent(); + } + }} + > + + {/* Header */} +
+
+ {selectedCompany && ( + + {selectedCompany.name.slice(0, 3).toUpperCase()} + + )} + + New agent +
+
+ + +
+
+ +
+ {/* Name */} +
+ setName(e.target.value)} + autoFocus + /> +
+ + {/* Title */} +
+ setTitle(e.target.value)} + /> +
+ + {/* Property chips */} +
+ {/* Role */} + + + + + + {AGENT_ROLES.map((r) => ( + + ))} + + + + {/* Reports To */} + + + + + + + {(agents ?? []).map((a) => ( + + ))} + + + + {/* Adapter type */} + + + + + + {AGENT_ADAPTER_TYPES.map((t) => ( + + ))} + + +
+ + {/* Capabilities */} +
+ setCapabilities(e.target.value)} + /> +
+ + {/* Adapter Config Section */} + setAdapterOpen(!adapterOpen)} + > +
+ + setCwd(e.target.value)} + /> + + +