From 7ec37d5acbcd9d1675b9f93dc58994934c3ca69b Mon Sep 17 00:00:00 2001 From: Forgotten Date: Tue, 17 Feb 2026 20:07:26 -0600 Subject: [PATCH] Extract AgentConfigForm and agent-config-primitives components Shared primitives (Field, ToggleField, ToggleWithNumber, CollapsibleSection, DraftInput, DraftTextarea, DraftNumberInput, HintIcon, help text, adapterLabels, roleLabels) extracted into agent-config-primitives.tsx. AgentConfigForm is a dual-mode form supporting both create (controlled values) and edit (save-on-blur per field) modes, used by NewAgentDialog and AgentDetail. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/AgentConfigForm.tsx | 621 ++++++++++++++++++ ui/src/components/agent-config-primitives.tsx | 370 +++++++++++ 2 files changed, 991 insertions(+) create mode 100644 ui/src/components/AgentConfigForm.tsx create mode 100644 ui/src/components/agent-config-primitives.tsx diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx new file mode 100644 index 00000000..7473ac5c --- /dev/null +++ b/ui/src/components/AgentConfigForm.tsx @@ -0,0 +1,621 @@ +import { useState } from "react"; +import { AGENT_ADAPTER_TYPES } from "@paperclip/shared"; +import type { Agent } from "@paperclip/shared"; +import type { AdapterModel } from "../api/agents"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { FolderOpen, Heart, ChevronDown } from "lucide-react"; +import { cn } from "../lib/utils"; +import { + Field, + ToggleField, + ToggleWithNumber, + CollapsibleSection, + AutoExpandTextarea, + DraftInput, + DraftTextarea, + DraftNumberInput, + HintIcon, + help, + adapterLabels, +} from "./agent-config-primitives"; + +/* ---- Create mode values ---- */ + +export interface CreateConfigValues { + adapterType: string; + cwd: string; + promptTemplate: string; + model: string; + dangerouslySkipPermissions: boolean; + search: boolean; + dangerouslyBypassSandbox: boolean; + command: string; + args: string; + url: string; + bootstrapPrompt: string; + maxTurnsPerRun: number; + heartbeatEnabled: boolean; + intervalSec: number; +} + +export const defaultCreateValues: CreateConfigValues = { + adapterType: "claude_local", + cwd: "", + promptTemplate: "", + model: "", + dangerouslySkipPermissions: false, + search: false, + dangerouslyBypassSandbox: false, + command: "", + args: "", + url: "", + bootstrapPrompt: "", + maxTurnsPerRun: 80, + heartbeatEnabled: false, + intervalSec: 300, +}; + +/* ---- Props ---- */ + +type AgentConfigFormProps = { + adapterModels?: AdapterModel[]; +} & ( + | { + mode: "create"; + values: CreateConfigValues; + onChange: (patch: Partial) => void; + } + | { + mode: "edit"; + agent: Agent; + onSave: (patch: Record) => void; + } +); + +/* ---- Shared input class ---- */ +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +/* ---- Form ---- */ + +export function AgentConfigForm(props: AgentConfigFormProps) { + const { mode, adapterModels } = props; + const isCreate = mode === "create"; + + // Resolve adapter type + config + heartbeat from props + const adapterType = isCreate ? props.values.adapterType : props.agent.adapterType; + const isLocal = adapterType === "claude_local" || adapterType === "codex_local"; + + // Edit mode: extract from agent + const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; + const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; + const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record) : {}; + + // Section toggle state + const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(!isCreate); + const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate); + + // Popover states + const [modelOpen, setModelOpen] = useState(false); + + // Create mode helpers + const val = isCreate ? props.values : null; + const set = isCreate + ? (patch: Partial) => props.onChange(patch) + : null; + + // Edit mode helpers + const saveConfig = !isCreate + ? (field: string, value: unknown) => + props.onSave({ adapterConfig: { ...config, [field]: value } }) + : null; + const saveHeartbeat = !isCreate + ? (field: string, value: unknown) => + props.onSave({ + runtimeConfig: { ...runtimeConfig, heartbeat: { ...heartbeat, [field]: value } }, + }) + : null; + const saveIdentity = !isCreate + ? (field: string, value: unknown) => props.onSave({ [field]: value }) + : null; + + // Current model for display + const currentModelId = isCreate ? val!.model : String(config.model ?? ""); + const selectedModel = (adapterModels ?? []).find((m) => m.id === currentModelId); + + return ( +
+ {/* ---- Identity (edit only) ---- */} + {!isCreate && ( +
+
Identity
+
+ + saveIdentity!("name", v)} + className={inputClass} + placeholder="Agent name" + /> + + + saveIdentity!("title", v || null)} + className={inputClass} + placeholder="e.g. VP of Engineering" + /> + + + saveIdentity!("capabilities", v || null)} + placeholder="Describe what this agent can do..." + minRows={2} + /> + +
+
+ )} + + {/* ---- Adapter type ---- */} +
+ + {isCreate ? ( + set!({ adapterType: t })} /> + ) : ( +
{adapterLabels[adapterType] ?? adapterType}
+ )} +
+
+ + {/* ---- Adapter Configuration ---- */} +
+
+ Adapter Configuration +
+
+ {/* Working directory */} + {isLocal && ( + +
+ + + isCreate ? set!({ cwd: v }) : saveConfig!("cwd", v || undefined) + } + immediate={isCreate} + className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" + placeholder="/path/to/project" + /> + +
+
+ )} + + {/* Prompt template */} + {isLocal && ( + + {isCreate ? ( + set!({ promptTemplate: v })} + minRows={4} + /> + ) : ( + saveConfig!("promptTemplate", v || undefined)} + placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." + minRows={4} + /> + )} + + )} + + {/* Claude-specific: Skip permissions */} + {adapterType === "claude_local" && ( + + isCreate + ? set!({ dangerouslySkipPermissions: v }) + : saveConfig!("dangerouslySkipPermissions", v) + } + /> + )} + + {/* Codex-specific: Bypass sandbox + Search */} + {adapterType === "codex_local" && ( + <> + + isCreate + ? set!({ dangerouslyBypassSandbox: v }) + : saveConfig!("dangerouslyBypassApprovalsAndSandbox", v) + } + /> + + isCreate ? set!({ search: v }) : saveConfig!("search", v) + } + /> + + )} + + {/* Process-specific */} + {adapterType === "process" && ( + <> + + + isCreate ? set!({ command: v }) : saveConfig!("command", v || undefined) + } + immediate={isCreate} + className={inputClass} + placeholder="e.g. node, python" + /> + + + + isCreate + ? set!({ args: v }) + : saveConfig!( + "args", + v + ? v + .split(",") + .map((a) => a.trim()) + .filter(Boolean) + : undefined, + ) + } + immediate={isCreate} + className={inputClass} + placeholder="e.g. script.js, --flag" + /> + + + )} + + {/* HTTP-specific */} + {adapterType === "http" && ( + + + isCreate ? set!({ url: v }) : saveConfig!("url", v || undefined) + } + immediate={isCreate} + className={inputClass} + placeholder="https://..." + /> + + )} + + {/* Advanced adapter section */} + {isLocal && ( + <> + {isCreate ? ( + setAdapterAdvancedOpen(!adapterAdvancedOpen)} + > +
+ set!({ model: v })} + open={modelOpen} + onOpenChange={setModelOpen} + /> + + set!({ bootstrapPrompt: v })} + minRows={2} + /> + + {adapterType === "claude_local" && ( + + set!({ maxTurnsPerRun: Number(e.target.value) })} + /> + + )} +
+
+ ) : ( + /* Edit mode: show advanced fields inline (no collapse) */ +
+
+ Advanced +
+ saveConfig!("model", v || undefined)} + open={modelOpen} + onOpenChange={setModelOpen} + /> + + saveConfig!("bootstrapPromptTemplate", v || undefined)} + placeholder="Optional initial setup prompt for the first run" + minRows={2} + /> + + {adapterType === "claude_local" && ( + + saveConfig!("maxTurnsPerRun", v || 80)} + className={inputClass} + /> + + )} + + saveConfig!("timeoutSec", v)} + className={inputClass} + /> + + + saveConfig!("graceSec", v)} + className={inputClass} + /> + +
+ )} + + )} +
+
+ + {/* ---- Heartbeat Policy ---- */} + {isCreate ? ( + } + open={heartbeatOpen} + onToggle={() => setHeartbeatOpen(!heartbeatOpen)} + bordered + > +
+ set!({ heartbeatEnabled: v })} + number={val!.intervalSec} + onNumberChange={(v) => set!({ intervalSec: v })} + numberLabel="sec" + numberPrefix="Run heartbeat every" + numberHint={help.intervalSec} + showNumber={val!.heartbeatEnabled} + /> +
+
+ ) : ( +
+
+ + Heartbeat Policy +
+
+ saveHeartbeat!("enabled", v)} + number={Number(heartbeat.intervalSec ?? 300)} + onNumberChange={(v) => saveHeartbeat!("intervalSec", v)} + numberLabel="sec" + numberPrefix="Run heartbeat every" + numberHint={help.intervalSec} + showNumber={heartbeat.enabled !== false} + /> + + {/* Edit-only: wake-on-* and cooldown */} +
+
+ Advanced +
+ saveHeartbeat!("wakeOnAssignment", v)} + /> + saveHeartbeat!("wakeOnOnDemand", v)} + /> + saveHeartbeat!("wakeOnAutomation", v)} + /> + + saveHeartbeat!("cooldownSec", v)} + className={inputClass} + /> + +
+
+
+ )} + + {/* ---- Runtime (edit only) ---- */} + {!isCreate && ( +
+
Runtime
+
+ +
+ {props.agent.contextMode} +
+
+ + saveIdentity!("budgetMonthlyCents", v)} + className={inputClass} + /> + +
+
+ )} +
+ ); +} + +/* ---- Internal sub-components ---- */ + +function AdapterTypeDropdown({ + value, + onChange, +}: { + value: string; + onChange: (type: string) => void; +}) { + return ( + + + + + + {AGENT_ADAPTER_TYPES.map((t) => ( + + ))} + + + ); +} + +function ModelDropdown({ + models, + value, + onChange, + open, + onOpenChange, +}: { + models: AdapterModel[]; + value: string; + onChange: (id: string) => void; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const selected = models.find((m) => m.id === value); + + return ( + + + + + + + + {models.map((m) => ( + + ))} + + + + ); +} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx new file mode 100644 index 00000000..dd340f07 --- /dev/null +++ b/ui/src/components/agent-config-primitives.tsx @@ -0,0 +1,370 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react"; +import { cn } from "../lib/utils"; + +/* ---- Help text for (?) tooltips ---- */ +export 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.", + capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", + 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.", + timeoutSec: "Maximum seconds a run can take before being terminated. 0 means no timeout.", + graceSec: "Seconds to wait after sending interrupt before force-killing the process.", + wakeOnAssignment: "Automatically wake this agent when a new issue is assigned to it.", + wakeOnOnDemand: "Allow this agent to be woken on demand via the API or UI.", + wakeOnAutomation: "Allow automated systems to wake this agent.", + cooldownSec: "Minimum seconds between consecutive heartbeat runs.", + contextMode: "How context is managed between runs (thin = fresh context each run).", + budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.", +}; + +export const adapterLabels: Record = { + claude_local: "Claude (local)", + codex_local: "Codex (local)", + process: "Process", + http: "HTTP", +}; + +export 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", +}; + +/* ---- Primitive components ---- */ + +export function HintIcon({ text }: { text: string }) { + return ( + + + + + + {text} + + + ); +} + +export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+
+ + {hint && } +
+ {children} +
+ ); +} + +export function ToggleField({ + label, + hint, + checked, + onChange, +}: { + label: string; + hint?: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+ {label} + {hint && } +
+ +
+ ); +} + +export function ToggleWithNumber({ + label, + hint, + checked, + onCheckedChange, + number, + onNumberChange, + numberLabel, + numberHint, + numberPrefix, + showNumber, +}: { + label: string; + hint?: string; + checked: boolean; + onCheckedChange: (v: boolean) => void; + number: number; + onNumberChange: (v: number) => void; + numberLabel: string; + numberHint?: string; + numberPrefix?: string; + showNumber: boolean; +}) { + return ( +
+
+
+ {label} + {hint && } +
+ +
+ {showNumber && ( +
+ {numberPrefix && {numberPrefix}} + onNumberChange(Number(e.target.value))} + /> + {numberLabel} + {numberHint && } +
+ )} +
+ ); +} + +export 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}
} +
+ ); +} + +export function AutoExpandTextarea({ + value, + onChange, + onBlur, + placeholder, + minRows, +}: { + value: string; + onChange: (v: string) => void; + onBlur?: () => void; + placeholder?: string; + minRows?: number; +}) { + const textareaRef = useRef(null); + const rows = minRows ?? 3; + const lineHeight = 20; + 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 ( +