import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import type { Agent, AdapterEnvironmentTestResult, CompanySecret, EnvBinding, } from "@paperclipai/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; import { secretsApi } from "../api/secrets"; import { assetsApi } from "../api/assets"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; 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 { extractModelName, extractProviderId } from "../lib/model-utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { Field, ToggleField, ToggleWithNumber, CollapsibleSection, DraftInput, DraftNumberInput, help, adapterLabels, } from "./agent-config-primitives"; import { defaultCreateValues } from "./agent-config-defaults"; import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; /* ---- Create mode values ---- */ // Canonical type lives in @paperclipai/adapter-utils; re-exported here // so existing imports from this file keep working. export type { CreateConfigValues } from "@paperclipai/adapter-utils"; import type { CreateConfigValues } from "@paperclipai/adapter-utils"; /* ---- Props ---- */ type AgentConfigFormProps = { adapterModels?: AdapterModel[]; onDirtyChange?: (dirty: boolean) => void; onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; hideInlineSave?: boolean; showAdapterTypeField?: boolean; showAdapterTestEnvironmentButton?: boolean; showCreateRunPolicySection?: boolean; hideInstructionsFile?: boolean; /** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */ sectionLayout?: "inline" | "cards"; } & ( | { 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: {}, }; /** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ const EMPTY_ENV: Record = {}; 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 openCodeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, { id: "max", label: "Max" }, ] as const; const cursorModeOptions = [ { id: "", label: "Auto" }, { id: "plan", label: "Plan" }, { id: "ask", label: "Ask" }, ] as const; const claudeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ] as const; /* ---- Form ---- */ export function AgentConfigForm(props: AgentConfigFormProps) { const { mode, adapterModels: externalModels } = props; const isCreate = mode === "create"; const cards = props.sectionLayout === "cards"; const showAdapterTypeField = props.showAdapterTypeField ?? true; const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const hideInstructionsFile = props.hideInstructionsFile ?? false; 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) }); }, }); const uploadMarkdownImage = useMutation({ mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { if (!selectedCompanyId) throw new Error("Select a company to upload images"); return assetsApi.uploadImage(selectedCompanyId, file, namespace); }, }); // ---- 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 */ const handleCancel = useCallback(() => { setOverlay({ ...emptyOverlay }); }, []); const handleSave = useCallback(() => { 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; // When adapter type changes, send only the new config โ€” don't merge // with old config since old adapter fields are meaningless for the new type patch.adapterConfig = overlay.adapterConfig; } else 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); }, [isCreate, isDirty, overlay, props]); useEffect(() => { if (!isCreate) { props.onDirtyChange?.(isDirty); props.onSaveActionChange?.(handleSave); props.onCancelActionChange?.(handleCancel); } }, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); useEffect(() => { if (isCreate) return; return () => { props.onSaveActionChange?.(null); props.onCancelActionChange?.(null); props.onDirtyChange?.(false); }; }, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]); // ---- 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" || adapterType === "gemini_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type const { data: fetchedModels, error: fetchedModelsError, } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) : ["agents", "none", "adapter-models", adapterType], queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), enabled: Boolean(selectedCompanyId), }); 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, hideInstructionsFile, }; // Section toggle state โ€” advanced always starts collapsed const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false); // 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; function buildAdapterConfigForTest(): Record { if (isCreate) { return uiAdapter.buildAdapterConfig(val!); } const base = config as Record; return { ...base, ...overlay.adapterConfig }; } const testEnvironment = useMutation({ mutationFn: async () => { if (!selectedCompanyId) { throw new Error("Select a company to test adapter environment"); } return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig: buildAdapterConfigForTest(), }); }, }); // Current model for display const currentModelId = isCreate ? val!.model : eff("adapterConfig", "model", String(config.model ?? "")); const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" : adapterType === "opencode_local" ? "variant" : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexThinkingEffortOptions : adapterType === "cursor" ? cursorModeOptions : adapterType === "opencode_local" ? openCodeThinkingEffortOptions : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" ? eff( "adapterConfig", "modelReasoningEffort", String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) : adapterType === "opencode_local" ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); const showThinkingEffort = adapterType !== "gemini_local"; 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 && (
{cards ?

Identity

:
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)} placeholder="Describe what this agent can do..." contentClassName="min-h-[44px] text-sm font-mono" imageUploadHandler={async (file) => { const asset = await uploadMarkdownImage.mutateAsync({ file, namespace: `agents/${props.agent.id}/capabilities`, }); return asset.contentPath; }} /> {isLocal && ( <> mark("adapterConfig", "promptTemplate", v ?? "")} placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." contentClassName="min-h-[88px] text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${props.agent.id}/prompt-template`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} />
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
)}
)} {/* ---- Adapter ---- */}
{cards ?

Adapter

: Adapter } {showAdapterTestEnvironmentButton && ( )}
{showAdapterTypeField && ( { if (isCreate) { // Reset all adapter-specific fields to defaults when switching adapter type const { adapterType: _at, ...defaults } = defaultCreateValues; const nextValues: CreateConfigValues = { ...defaults, adapterType: t }; if (t === "codex_local") { nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; nextValues.dangerouslyBypassSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } else if (t === "gemini_local") { nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { nextValues.model = ""; } set!(nextValues); } else { // Clear all adapter config and explicitly blank out model + effort/mode keys // so the old adapter's values don't bleed through via eff() setOverlay((prev) => ({ ...prev, adapterType: t, adapterConfig: { model: t === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL : t === "gemini_local" ? DEFAULT_GEMINI_LOCAL_MODEL : t === "cursor" ? DEFAULT_CURSOR_LOCAL_MODEL : "", effort: "", modelReasoningEffort: "", variant: "", mode: "", ...(t === "codex_local" ? { dangerouslyBypassApprovalsAndSandbox: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, } : {}), }, })); } }} /> )} {testEnvironment.error && (
{testEnvironment.error instanceof Error ? testEnvironment.error.message : "Environment test failed"}
)} {testEnvironment.data && ( )} {/* 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" />
)} {/* Prompt template (create mode only โ€” edit mode shows this in Identity) */} {isLocal && isCreate && ( <> set!({ promptTemplate: v })} placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." contentClassName="min-h-[88px] text-sm font-mono" imageUploadHandler={async (file) => { const namespace = "agents/drafts/prompt-template"; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} />
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like {"{{ context.* }}"} or {"{{ run.* }}"}; avoid repeating stable instructions here.
)} {/* Adapter-specific fields */}
{/* ---- Permissions & Configuration ---- */} {isLocal && (
{cards ?

Permissions & Configuration

:
Permissions & Configuration
}
isCreate ? set!({ command: v }) : mark("adapterConfig", "command", v || undefined) } immediate className={inputClass} placeholder={ adapterType === "codex_local" ? "codex" : adapterType === "gemini_local" ? "gemini" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" ? "agent" : adapterType === "opencode_local" ? "opencode" : "claude" } /> isCreate ? set!({ model: v }) : mark("adapterConfig", "model", v || undefined) } open={modelOpen} onOpenChange={setModelOpen} allowDefault={adapterType !== "opencode_local"} required={adapterType === "opencode_local"} groupByProvider={adapterType === "opencode_local"} /> {fetchedModelsError && (

{fetchedModelsError instanceof Error ? fetchedModelsError.message : "Failed to load adapter models."}

)} {showThinkingEffort && ( <> 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 }) : mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) } placeholder="Optional initial setup prompt for the first run" contentClassName="min-h-[44px] text-sm font-mono" imageUploadHandler={async (file) => { const namespace = isCreate ? "agents/drafts/bootstrap-prompt" : `agents/${props.agent.id}/bootstrap-prompt`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); return asset.contentPath; }} />
Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it.
{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 ?? EMPTY_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} /> )}
)} {/* ---- Run Policy ---- */} {isCreate && showCreateRunPolicySection ? (
{cards ?

Run Policy

:
Run Policy
}
set!({ heartbeatEnabled: v })} number={val!.intervalSec} onNumberChange={(v) => set!({ intervalSec: v })} numberLabel="sec" numberPrefix="Run heartbeat every" numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} />
) : !isCreate ? (
{cards ?

Run Policy

:
Run 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)} />
setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} >
mark("heartbeat", "wakeOnDemand", v)} /> mark("heartbeat", "cooldownSec", v)} immediate className={inputClass} /> mark("heartbeat", "maxConcurrentRuns", v)} immediate className={inputClass} />
) : null}
); } function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { const statusLabel = result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed"; const statusClass = result.status === "pass" ? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10" : result.status === "warn" ? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10" : "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10"; return (
{statusLabel} {new Date(result.testedAt).toLocaleTimeString()}
{result.checks.map((check, idx) => (
{check.level} ยท {check.message} {check.detail && ({check.detail})} {check.hint && Hint: {check.hint}}
))}
); } /* ---- Internal sub-components ---- */ const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); /** Display list includes all real adapter types plus UI-only coming-soon entries. */ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ ...AGENT_ADAPTER_TYPES.map((t) => ({ value: t, label: adapterLabels[t] ?? t, comingSoon: !ENABLED_ADAPTER_TYPES.has(t), })), ]; function AdapterTypeDropdown({ value, onChange, }: { value: string; onChange: (type: string) => void; }) { return ( {ADAPTER_DISPLAY_LIST.map((item) => ( ))} ); } 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, allowDefault, required, groupByProvider, }: { models: AdapterModel[]; value: string; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; allowDefault: boolean; required: boolean; groupByProvider: boolean; }) { const [modelSearch, setModelSearch] = useState(""); const selected = models.find((m) => m.id === value); const filteredModels = useMemo(() => { return models.filter((m) => { if (!modelSearch.trim()) return true; const q = modelSearch.toLowerCase(); const provider = extractProviderId(m.id) ?? ""; return ( m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q) || provider.toLowerCase().includes(q) ); }); }, [models, modelSearch]); const groupedModels = useMemo(() => { if (!groupByProvider) { return [ { provider: "models", entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), }, ]; } const map = new Map(); for (const model of filteredModels) { const provider = extractProviderId(model.id) ?? "other"; const group = map.get(provider) ?? []; group.push(model); map.set(provider, group); } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, entries]) => ({ provider, entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), })); }, [filteredModels, groupByProvider]); return ( { onOpenChange(nextOpen); if (!nextOpen) setModelSearch(""); }} > setModelSearch(e.target.value)} autoFocus />
{allowDefault && ( )} {groupedModels.map((group) => (
{groupByProvider && (
{group.provider} ({group.entries.length})
)} {group.entries.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) => ( ))} ); }