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)} + /> + + +