diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 2ab3d21d..23a23b60 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,11 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useEffect } 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 { AGENT_ROLES } from "@paperclip/shared"; import { Dialog, DialogContent, @@ -16,58 +16,19 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip"; import { Minimize2, Maximize2, Shield, User, - ChevronDown, - ChevronRight, - Heart, - HelpCircle, - FolderOpen, } 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", -}; - -/* ---- Help text for (?) tooltips ---- */ -const help: Record = { - name: "Display name for this agent.", - title: "Job title shown in the org chart.", - role: "Organizational role. Determines position and capabilities.", - reportsTo: "The agent this one reports to in the org hierarchy.", - adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.", - cwd: "The working directory where the agent operates. Should be an absolute path on the server.", - promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", - model: "Override the default model used by the adapter.", - dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", - dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", - search: "Enable Codex web search capability during runs.", - bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.", - maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", - command: "The command to execute (e.g. node, python).", - args: "Command-line arguments, comma-separated.", - webhookUrl: "The URL that receives POST requests when the agent is invoked.", - heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.", - intervalSec: "Seconds between automatic heartbeat invocations.", -}; +import { roleLabels } from "./agent-config-primitives"; +import { + AgentConfigForm, + defaultCreateValues, + type CreateConfigValues, +} from "./AgentConfigForm"; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent } = useDialog(); @@ -82,42 +43,12 @@ export function NewAgentDialog() { const [role, setRole] = useState("general"); const [reportsTo, setReportsTo] = useState(""); - // Adapter - const [adapterType, setAdapterType] = useState("claude_local"); - const [cwd, setCwd] = useState(""); - const [promptTemplate, setPromptTemplate] = useState(""); - const [model, setModel] = useState(""); - - // claude_local specific - const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(false); - - // codex_local specific - const [search, setSearch] = useState(false); - const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(false); - - // process specific - const [command, setCommand] = useState(""); - const [args, setArgs] = useState(""); - - // http specific - const [url, setUrl] = useState(""); - - // Advanced adapter fields - const [bootstrapPrompt, setBootstrapPrompt] = useState(""); - const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80); - - // Heartbeat - const [heartbeatEnabled, setHeartbeatEnabled] = useState(false); - const [intervalSec, setIntervalSec] = useState(300); - - // Sections - const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false); - const [heartbeatOpen, setHeartbeatOpen] = useState(false); + // Config values (managed by AgentConfigForm) + const [configValues, setConfigValues] = useState(defaultCreateValues); // Popover states const [roleOpen, setRoleOpen] = useState(false); const [reportsToOpen, setReportsToOpen] = useState(false); - const [modelOpen, setModelOpen] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -126,8 +57,8 @@ export function NewAgentDialog() { }); const { data: adapterModels } = useQuery({ - queryKey: ["adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(adapterType), + queryKey: ["adapter-models", configValues.adapterType], + queryFn: () => agentsApi.adapterModels(configValues.adapterType), enabled: newAgentOpen, }); @@ -158,47 +89,33 @@ export function NewAgentDialog() { setTitle(""); setRole("general"); setReportsTo(""); - setAdapterType("claude_local"); - setCwd(""); - setPromptTemplate(""); - setModel(""); - setDangerouslySkipPermissions(false); - setSearch(false); - setDangerouslyBypassSandbox(false); - setCommand(""); - setArgs(""); - setUrl(""); - setBootstrapPrompt(""); - setMaxTurnsPerRun(80); - setHeartbeatEnabled(false); - setIntervalSec(300); + setConfigValues(defaultCreateValues); setExpanded(true); - setAdapterAdvancedOpen(false); - setHeartbeatOpen(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 = 0; - config.graceSec = 15; + const v = configValues; + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; + if (v.model) ac.model = v.model; + ac.timeoutSec = 0; + ac.graceSec = 15; - 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; + if (v.adapterType === "claude_local") { + ac.maxTurnsPerRun = v.maxTurnsPerRun; + ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; + } else if (v.adapterType === "codex_local") { + ac.search = v.search; + ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox; + } else if (v.adapterType === "process") { + if (v.command) ac.command = v.command; + if (v.args) ac.args = v.args.split(",").map((a) => a.trim()).filter(Boolean); + } else if (v.adapterType === "http") { + if (v.url) ac.url = v.url; } - return config; + return ac; } function handleSubmit() { @@ -208,12 +125,12 @@ export function NewAgentDialog() { role: effectiveRole, ...(title.trim() ? { title: title.trim() } : {}), ...(reportsTo ? { reportsTo } : {}), - adapterType, + adapterType: configValues.adapterType, adapterConfig: buildAdapterConfig(), runtimeConfig: { heartbeat: { - enabled: heartbeatEnabled, - intervalSec, + enabled: configValues.heartbeatEnabled, + intervalSec: configValues.intervalSec, wakeOnAssignment: true, wakeOnOnDemand: true, wakeOnAutomation: true, @@ -233,7 +150,6 @@ export function NewAgentDialog() { } const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); - const selectedModel = (adapterModels ?? []).find((m) => m.id === model); return ( - {/* Adapter type dropdown (above config section) */} -
- - - - - - - {AGENT_ADAPTER_TYPES.map((t) => ( - - ))} - - - -
- - {/* Adapter Configuration (always open) */} -
-
- Adapter Configuration -
-
- {/* Working directory — basic, shown for local adapters */} - {(adapterType === "claude_local" || adapterType === "codex_local") && ( - -
- - setCwd(e.target.value)} - /> - -
-
- )} - - {/* Prompt template — basic, auto-expanding */} - {(adapterType === "claude_local" || adapterType === "codex_local") && ( - - - - )} - - {/* Skip permissions — basic for claude */} - {adapterType === "claude_local" && ( - - )} - - {/* Bypass sandbox + search — basic for codex */} - {adapterType === "codex_local" && ( - <> - - - - )} - - {/* Process-specific fields */} - {adapterType === "process" && ( - <> - - setCommand(e.target.value)} - /> - - - setArgs(e.target.value)} - /> - - - )} - - {/* HTTP-specific fields */} - {adapterType === "http" && ( - - setUrl(e.target.value)} - /> - - )} - - {/* Advanced section for local adapters */} - {(adapterType === "claude_local" || adapterType === "codex_local") && ( - setAdapterAdvancedOpen(!adapterAdvancedOpen)} - > -
- {/* Model dropdown */} - - - - - - - - {(adapterModels ?? []).map((m) => ( - - ))} - - - - - {/* Bootstrap prompt */} - - - - - {/* Max turns — claude only */} - {adapterType === "claude_local" && ( - - setMaxTurnsPerRun(Number(e.target.value))} - /> - - )} -
-
- )} -
-
- - {/* Heartbeat Policy */} - } - open={heartbeatOpen} - onToggle={() => setHeartbeatOpen(!heartbeatOpen)} - bordered - > -
- -
-
+ {/* Shared config form (adapter + heartbeat) */} + setConfigValues((prev) => ({ ...prev, ...patch }))} + adapterModels={adapterModels} + /> {/* Footer */} @@ -620,194 +310,3 @@ export function NewAgentDialog() {
); } - -/* ---- Reusable components ---- */ - -function HintIcon({ text }: { text: string }) { - return ( - - - - - - {text} - - - ); -} - -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
-
- - {hint && } -
- {children} -
- ); -} - -function ToggleField({ - label, - hint, - checked, - onChange, -}: { - label: string; - hint?: string; - checked: boolean; - onChange: (v: boolean) => void; -}) { - return ( -
-
- {label} - {hint && } -
- -
- ); -} - -function ToggleWithNumber({ - label, - hint, - checked, - onCheckedChange, - number, - onNumberChange, - numberLabel, - numberHint, - showNumber, -}: { - label: string; - hint?: string; - checked: boolean; - onCheckedChange: (v: boolean) => void; - number: number; - onNumberChange: (v: number) => void; - numberLabel: string; - numberHint?: string; - showNumber: boolean; -}) { - return ( -
-
-
- {label} - {hint && } -
- -
- {showNumber && ( -
- Run heartbeat every - onNumberChange(Number(e.target.value))} - /> - {numberLabel} - {numberHint && } -
- )} -
- ); -} - -function CollapsibleSection({ - title, - icon, - open, - onToggle, - bordered, - children, -}: { - title: string; - icon?: React.ReactNode; - open: boolean; - onToggle: () => void; - bordered?: boolean; - children: React.ReactNode; -}) { - return ( -
- - {open &&
{children}
} -
- ); -} - -function AutoExpandTextarea({ - value, - onChange, - placeholder, - minRows, -}: { - value: string; - onChange: (v: string) => void; - placeholder?: string; - minRows?: number; -}) { - const textareaRef = useRef(null); - const rows = minRows ?? 3; - const lineHeight = 20; // approx line height in px for text-sm mono - const minHeight = rows * lineHeight; - - const adjustHeight = useCallback(() => { - const el = textareaRef.current; - if (!el) return; - el.style.height = "auto"; - el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`; - }, [minHeight]); - - useEffect(() => { adjustHeight(); }, [value, adjustHeight]); - - return ( -