import { useState, useEffect, useRef, useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclip/shared"; import type { Agent, CompanySecret, EnvBinding } from "@paperclip/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; import { secretsApi } from "../api/secrets"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; import { cn } from "../lib/utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { Field, ToggleField, ToggleWithNumber, CollapsibleSection, AutoExpandTextarea, DraftInput, DraftTextarea, DraftNumberInput, help, adapterLabels, } from "./agent-config-primitives"; import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; /* ---- Create mode values ---- */ // Canonical type lives in @paperclip/adapter-utils; re-exported here // so existing imports from this file keep working. export type { CreateConfigValues } from "@paperclip/adapter-utils"; import type { CreateConfigValues } from "@paperclip/adapter-utils"; export const defaultCreateValues: CreateConfigValues = { adapterType: "claude_local", cwd: "", promptTemplate: "", model: "", thinkingEffort: "", dangerouslySkipPermissions: false, search: false, dangerouslyBypassSandbox: false, command: "", args: "", extraArgs: "", envVars: "", envBindings: {}, url: "", bootstrapPrompt: "", maxTurnsPerRun: 80, heartbeatEnabled: false, intervalSec: 300, }; /* ---- Props ---- */ type AgentConfigFormProps = { adapterModels?: AdapterModel[]; onDirtyChange?: (dirty: boolean) => void; onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; hideInlineSave?: boolean; } & ( | { mode: "create"; values: CreateConfigValues; onChange: (patch: Partial) => void; } | { mode: "edit"; agent: Agent; onSave: (patch: Record) => void; isSaving?: boolean; } ); /* ---- Edit mode overlay (dirty tracking) ---- */ interface Overlay { identity: Record; adapterType?: string; adapterConfig: Record; heartbeat: Record; runtime: Record; } const emptyOverlay: Overlay = { identity: {}, adapterConfig: {}, heartbeat: {}, runtime: {}, }; function isOverlayDirty(o: Overlay): boolean { return ( Object.keys(o.identity).length > 0 || o.adapterType !== undefined || Object.keys(o.adapterConfig).length > 0 || Object.keys(o.heartbeat).length > 0 || Object.keys(o.runtime).length > 0 ); } /* ---- 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"; function parseCommaArgs(value: string): string[] { return value .split(",") .map((item) => item.trim()) .filter(Boolean); } function formatArgList(value: unknown): string { if (Array.isArray(value)) { return value .filter((item): item is string => typeof item === "string") .join(", "); } return typeof value === "string" ? value : ""; } const codexThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ] as const; const claudeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ] as const; function extractPickedDirectoryPath(handle: unknown): string | null { if (typeof handle !== "object" || handle === null) return null; const maybePath = (handle as { path?: unknown }).path; return typeof maybePath === "string" && maybePath.length > 0 ? maybePath : null; } /* ---- Form ---- */ export function AgentConfigForm(props: AgentConfigFormProps) { const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); const createSecret = useMutation({ mutationFn: (input: { name: string; value: string }) => { if (!selectedCompanyId) throw new Error("Select a company to create secrets"); return secretsApi.create(selectedCompanyId, input); }, onSuccess: () => { if (!selectedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); }, }); // ---- Edit mode: overlay for dirty tracking ---- const [overlay, setOverlay] = useState(emptyOverlay); const agentRef = useRef(null); // Clear overlay when agent data refreshes (after save) useEffect(() => { if (!isCreate) { if (agentRef.current !== null && props.agent !== agentRef.current) { setOverlay({ ...emptyOverlay }); } agentRef.current = props.agent; } }, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps const isDirty = !isCreate && isOverlayDirty(overlay); /** Read effective value: overlay if dirty, else original */ function eff(group: keyof Omit, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ function mark(group: keyof Omit, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, })); } /** Build accumulated patch and send to parent */ function handleSave() { if (isCreate || !isDirty) return; const agent = props.agent; const patch: Record = {}; if (Object.keys(overlay.identity).length > 0) { Object.assign(patch, overlay.identity); } if (overlay.adapterType !== undefined) { patch.adapterType = overlay.adapterType; } if (Object.keys(overlay.adapterConfig).length > 0) { const existing = (agent.adapterConfig ?? {}) as Record; patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; } if (Object.keys(overlay.heartbeat).length > 0) { const existingRc = (agent.runtimeConfig ?? {}) as Record; const existingHb = (existingRc.heartbeat ?? {}) as Record; patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; } if (Object.keys(overlay.runtime).length > 0) { Object.assign(patch, overlay.runtime); } props.onSave(patch); } useEffect(() => { if (!isCreate) { props.onDirtyChange?.(isDirty); props.onSaveActionChange?.(() => handleSave()); props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay })); return () => { props.onSaveActionChange?.(null); props.onCancelActionChange?.(null); props.onDirtyChange?.(false); }; } return; }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record) : {}; const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; const isLocal = adapterType === "claude_local" || adapterType === "codex_local"; const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type const { data: fetchedModels } = useQuery({ queryKey: ["adapter-models", adapterType], queryFn: () => agentsApi.adapterModels(adapterType), }); const models = fetchedModels ?? externalModels ?? []; /** Props passed to adapter-specific config field components */ const adapterFieldProps = { mode, isCreate, adapterType, values: isCreate ? props.values : null, set: isCreate ? (patch: Partial) => props.onChange(patch) : null, config, eff: eff as (group: "adapterConfig", field: string, original: T) => T, mark: mark as (group: "adapterConfig", field: string, value: unknown) => void, models, }; // Section toggle state — advanced always starts collapsed const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false); const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate); const [cwdPickerNotice, setCwdPickerNotice] = useState(null); // Popover states const [modelOpen, setModelOpen] = useState(false); const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); // Create mode helpers const val = isCreate ? props.values : null; const set = isCreate ? (patch: Partial) => props.onChange(patch) : null; // Current model for display const currentModelId = isCreate ? val!.model : eff("adapterConfig", "model", String(config.model ?? "")); const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexThinkingEffortOptions : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" ? eff( "adapterConfig", "modelReasoningEffort", String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), ) : eff("adapterConfig", "effort", String(config.effort ?? "")); const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; return (
{/* ---- Floating Save button (edit mode, when dirty) ---- */} {isDirty && !props.hideInlineSave && (
Unsaved changes
)} {/* ---- Identity (edit only) ---- */} {!isCreate && (
Identity
mark("identity", "name", v)} immediate className={inputClass} placeholder="Agent name" /> mark("identity", "title", v || null)} immediate className={inputClass} placeholder="e.g. VP of Engineering" /> mark("identity", "capabilities", v || null)} immediate placeholder="Describe what this agent can do..." minRows={2} />
)} {/* ---- Adapter type ---- */}
{ if (isCreate) { set!({ adapterType: t, model: "", thinkingEffort: "" }); } else { setOverlay((prev) => ({ ...prev, adapterType: t, adapterConfig: {}, // clear adapter config when type changes })); } }} />
{/* ---- Adapter Configuration ---- */}
Adapter Configuration
{/* Working directory */} {isLocal && (
isCreate ? set!({ cwd: v }) : mark("adapterConfig", "cwd", v || undefined) } immediate className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" placeholder="/path/to/project" />
{cwdPickerNotice && (

{cwdPickerNotice}

)}
)} {/* Prompt template */} {isLocal && ( {isCreate ? ( set!({ promptTemplate: v })} minRows={4} /> ) : ( mark("adapterConfig", "promptTemplate", v || undefined) } immediate placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." minRows={4} /> )} )} {/* Adapter-specific fields */} {/* Advanced adapter section — collapsible in both modes */} {isLocal && ( setAdapterAdvancedOpen(!adapterAdvancedOpen)} >
isCreate ? set!({ command: v }) : mark("adapterConfig", "command", v || undefined) } immediate className={inputClass} placeholder={adapterType === "codex_local" ? "codex" : "claude"} /> isCreate ? set!({ model: v }) : mark("adapterConfig", "model", v || undefined) } open={modelOpen} onOpenChange={setModelOpen} /> isCreate ? set!({ thinkingEffort: v }) : mark("adapterConfig", thinkingEffortKey, v || undefined) } open={thinkingEffortOpen} onOpenChange={setThinkingEffortOpen} /> {adapterType === "codex_local" && codexSearchEnabled && currentThinkingEffort === "minimal" && (

Codex may reject `minimal` thinking when search is enabled.

)} {isCreate ? ( set!({ bootstrapPrompt: v })} minRows={2} /> ) : ( mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) } immediate placeholder="Optional initial setup prompt for the first run" minRows={2} /> )} {adapterType === "claude_local" && ( )} isCreate ? set!({ extraArgs: v }) : mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined) } immediate className={inputClass} placeholder="e.g. --verbose, --foo=bar" /> ) : ((eff("adapterConfig", "env", config.env ?? {}) as Record) ) } secrets={availableSecrets} onCreateSecret={async (name, value) => { const created = await createSecret.mutateAsync({ name, value }); return created; }} onChange={(env) => isCreate ? set!({ envBindings: env ?? {}, envVars: "" }) : mark("adapterConfig", "env", env) } /> {/* Edit-only: timeout + grace period */} {!isCreate && ( <> mark("adapterConfig", "timeoutSec", v)} immediate className={inputClass} /> mark("adapterConfig", "graceSec", v)} immediate 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
mark("heartbeat", "enabled", v)} number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))} onNumberChange={(v) => mark("heartbeat", "intervalSec", v)} numberLabel="sec" numberPrefix="Run heartbeat every" numberHint={help.intervalSec} showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} /> {/* Edit-only: wake-on-* and cooldown */}
Advanced
mark("heartbeat", "wakeOnDemand", v)} /> mark("heartbeat", "cooldownSec", v)} immediate className={inputClass} />
)}
); } /* ---- Internal sub-components ---- */ function AdapterTypeDropdown({ value, onChange, }: { value: string; onChange: (type: string) => void; }) { return ( {AGENT_ADAPTER_TYPES.map((t) => ( ))} ); } function EnvVarEditor({ value, secrets, onCreateSecret, onChange, }: { value: Record; secrets: CompanySecret[]; onCreateSecret: (name: string, value: string) => Promise; onChange: (env: Record | undefined) => void; }) { type Row = { key: string; source: "plain" | "secret"; plainValue: string; secretId: string; }; function toRows(rec: Record | null | undefined): Row[] { if (!rec || typeof rec !== "object") { return [{ key: "", source: "plain", plainValue: "", secretId: "" }]; } const entries = Object.entries(rec).map(([k, binding]) => { if (typeof binding === "string") { return { key: k, source: "plain" as const, plainValue: binding, secretId: "", }; } if ( typeof binding === "object" && binding !== null && "type" in binding && (binding as { type?: unknown }).type === "secret_ref" ) { const recBinding = binding as { secretId?: unknown }; return { key: k, source: "secret" as const, plainValue: "", secretId: typeof recBinding.secretId === "string" ? recBinding.secretId : "", }; } if ( typeof binding === "object" && binding !== null && "type" in binding && (binding as { type?: unknown }).type === "plain" ) { const recBinding = binding as { value?: unknown }; return { key: k, source: "plain" as const, plainValue: typeof recBinding.value === "string" ? recBinding.value : "", secretId: "", }; } return { key: k, source: "plain" as const, plainValue: "", secretId: "", }; }); return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }]; } const [rows, setRows] = useState(() => toRows(value)); const [sealError, setSealError] = useState(null); const valueRef = useRef(value); // Sync when value identity changes (overlay reset after save) useEffect(() => { if (value !== valueRef.current) { valueRef.current = value; setRows(toRows(value)); } }, [value]); function emit(nextRows: Row[]) { const rec: Record = {}; for (const row of nextRows) { const k = row.key.trim(); if (!k) continue; if (row.source === "secret") { if (!row.secretId) continue; rec[k] = { type: "secret_ref", secretId: row.secretId, version: "latest" }; } else { rec[k] = { type: "plain", value: row.plainValue }; } } onChange(Object.keys(rec).length > 0 ? rec : undefined); } function updateRow(i: number, patch: Partial) { const withPatch = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)); if ( withPatch[withPatch.length - 1].key || withPatch[withPatch.length - 1].plainValue || withPatch[withPatch.length - 1].secretId ) { withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" }); } setRows(withPatch); emit(withPatch); } function removeRow(i: number) { const next = rows.filter((_, idx) => idx !== i); if ( next.length === 0 || next[next.length - 1].key || next[next.length - 1].plainValue || next[next.length - 1].secretId ) { next.push({ key: "", source: "plain", plainValue: "", secretId: "" }); } setRows(next); emit(next); } function defaultSecretName(key: string): string { return key .trim() .toLowerCase() .replace(/[^a-z0-9_]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 64); } async function sealRow(i: number) { const row = rows[i]; if (!row) return; const key = row.key.trim(); const plain = row.plainValue; if (!key || plain.length === 0) return; const suggested = defaultSecretName(key) || "secret"; const name = window.prompt("Secret name", suggested)?.trim(); if (!name) return; try { setSealError(null); const created = await onCreateSecret(name, plain); updateRow(i, { source: "secret", secretId: created.id, }); } catch (err) { setSealError(err instanceof Error ? err.message : "Failed to create secret"); } } return (
{rows.map((row, i) => { const isTrailing = i === rows.length - 1 && !row.key && !row.plainValue && !row.secretId; return (
updateRow(i, { key: e.target.value })} /> {row.source === "secret" ? ( <> ) : ( <> updateRow(i, { plainValue: e.target.value })} /> )} {!isTrailing ? ( ) : (
)}
); })} {sealError &&

{sealError}

}

PAPERCLIP_* variables are injected automatically at runtime.

); } function ModelDropdown({ models, value, onChange, open, onOpenChange, }: { models: AdapterModel[]; value: string; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; }) { const [modelSearch, setModelSearch] = useState(""); const selected = models.find((m) => m.id === value); const filteredModels = models.filter((m) => { if (!modelSearch.trim()) return true; const q = modelSearch.toLowerCase(); return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q); }); return ( { onOpenChange(nextOpen); if (!nextOpen) setModelSearch(""); }} > setModelSearch(e.target.value)} autoFocus /> {filteredModels.map((m) => ( ))} {filteredModels.length === 0 && (

No models found.

)}
); } function ThinkingEffortDropdown({ value, options, onChange, open, onOpenChange, }: { value: string; options: ReadonlyArray<{ id: string; label: string }>; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; }) { const selected = options.find((option) => option.id === value) ?? options[0]; return ( {options.map((option) => ( ))} ); }