Files
paperclip/ui/src/components/AgentConfigForm.tsx
Forgotten adca44849a feat(ui): active agents panel, sidebar context, and page enhancements
Add live ActiveAgentsPanel with real-time transcript feed, SidebarContext
for responsive sidebar state, agent config form with reasoning effort,
improved inbox with failed run alerts, enriched issue detail with project
picker, and various component refinements across pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:32:32 -06:00

1156 lines
41 KiB
TypeScript

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<CreateConfigValues>) => void;
}
| {
mode: "edit";
agent: Agent;
onSave: (patch: Record<string, unknown>) => void;
isSaving?: boolean;
}
);
/* ---- Edit mode overlay (dirty tracking) ---- */
interface Overlay {
identity: Record<string, unknown>;
adapterType?: string;
adapterConfig: Record<string, unknown>;
heartbeat: Record<string, unknown>;
runtime: Record<string, unknown>;
}
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<Overlay>(emptyOverlay);
const agentRef = useRef<Agent | null>(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<T>(group: keyof Omit<Overlay, "adapterType">, 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<Overlay, "adapterType">, 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<string, unknown> = {};
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<string, unknown>;
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
}
if (Object.keys(overlay.heartbeat).length > 0) {
const existingRc = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
const existingHb = (existingRc.heartbeat ?? {}) as Record<string, unknown>;
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<string, unknown>) : {};
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
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<CreateConfigValues>) => props.onChange(patch) : null,
config,
eff: eff as <T>(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<string | null>(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<CreateConfigValues>) => 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 (
<div className="relative">
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
{isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Unsaved changes</span>
<Button
size="sm"
onClick={handleSave}
disabled={!isCreate && props.isSaving}
>
{!isCreate && props.isSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
{/* ---- Identity (edit only) ---- */}
{!isCreate && (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
<div className="px-4 pb-3 space-y-3">
<Field label="Name" hint={help.name}>
<DraftInput
value={eff("identity", "name", props.agent.name)}
onCommit={(v) => mark("identity", "name", v)}
immediate
className={inputClass}
placeholder="Agent name"
/>
</Field>
<Field label="Title" hint={help.title}>
<DraftInput
value={eff("identity", "title", props.agent.title ?? "")}
onCommit={(v) => mark("identity", "title", v || null)}
immediate
className={inputClass}
placeholder="e.g. VP of Engineering"
/>
</Field>
<Field label="Capabilities" hint={help.capabilities}>
<DraftTextarea
value={eff("identity", "capabilities", props.agent.capabilities ?? "")}
onCommit={(v) => mark("identity", "capabilities", v || null)}
immediate
placeholder="Describe what this agent can do..."
minRows={2}
/>
</Field>
</div>
</div>
)}
{/* ---- Adapter type ---- */}
<div className={cn("px-4 py-2.5", isCreate ? "border-t border-border" : "border-b border-border")}>
<Field label="Adapter" hint={help.adapterType}>
<AdapterTypeDropdown
value={adapterType}
onChange={(t) => {
if (isCreate) {
set!({ adapterType: t, model: "", thinkingEffort: "" });
} else {
setOverlay((prev) => ({
...prev,
adapterType: t,
adapterConfig: {}, // clear adapter config when type changes
}));
}
}}
/>
</Field>
</div>
{/* ---- Adapter Configuration ---- */}
<div className={cn(isCreate ? "border-t border-border" : "border-b border-border")}>
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
Adapter Configuration
</div>
<div className="px-4 pb-3 space-y-3">
{/* Working directory */}
{isLocal && (
<Field label="Working directory" hint={help.cwd}>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<DraftInput
value={
isCreate
? val!.cwd
: eff("adapterConfig", "cwd", String(config.cwd ?? ""))
}
onCommit={(v) =>
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"
/>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={async () => {
try {
setCwdPickerNotice(null);
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
const handle = await window.showDirectoryPicker({ mode: "read" });
const absolutePath = extractPickedDirectoryPath(handle);
if (absolutePath) {
if (isCreate) set!({ cwd: absolutePath });
else mark("adapterConfig", "cwd", absolutePath);
return;
}
const selectedName =
typeof handle === "object" &&
handle !== null &&
typeof (handle as { name?: unknown }).name === "string"
? String((handle as { name: string }).name)
: "selected folder";
setCwdPickerNotice(
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
);
} catch {
// user cancelled or API unsupported
}
}}
>
Choose
</button>
</div>
{cwdPickerNotice && (
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
)}
</Field>
)}
{/* Prompt template */}
{isLocal && (
<Field label="Prompt template" hint={help.promptTemplate}>
{isCreate ? (
<AutoExpandTextarea
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
value={val!.promptTemplate}
onChange={(v) => set!({ promptTemplate: v })}
minRows={4}
/>
) : (
<DraftTextarea
value={eff(
"adapterConfig",
"promptTemplate",
String(config.promptTemplate ?? ""),
)}
onCommit={(v) =>
mark("adapterConfig", "promptTemplate", v || undefined)
}
immediate
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
minRows={4}
/>
)}
</Field>
)}
{/* Adapter-specific fields */}
<uiAdapter.ConfigFields {...adapterFieldProps} />
{/* Advanced adapter section — collapsible in both modes */}
{isLocal && (
<CollapsibleSection
title="Advanced Adapter Settings"
open={adapterAdvancedOpen}
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
>
<div className="space-y-3">
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
isCreate
? val!.command
: eff("adapterConfig", "command", String(config.command ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ command: v })
: mark("adapterConfig", "command", v || undefined)
}
immediate
className={inputClass}
placeholder={adapterType === "codex_local" ? "codex" : "claude"}
/>
</Field>
<ModelDropdown
models={models}
value={currentModelId}
onChange={(v) =>
isCreate
? set!({ model: v })
: mark("adapterConfig", "model", v || undefined)
}
open={modelOpen}
onOpenChange={setModelOpen}
/>
<ThinkingEffortDropdown
value={currentThinkingEffort}
options={thinkingEffortOptions}
onChange={(v) =>
isCreate
? set!({ thinkingEffort: v })
: mark("adapterConfig", thinkingEffortKey, v || undefined)
}
open={thinkingEffortOpen}
onOpenChange={setThinkingEffortOpen}
/>
{adapterType === "codex_local" &&
codexSearchEnabled &&
currentThinkingEffort === "minimal" && (
<p className="text-xs text-amber-400">
Codex may reject `minimal` thinking when search is enabled.
</p>
)}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
{isCreate ? (
<AutoExpandTextarea
placeholder="Optional initial setup prompt for the first run"
value={val!.bootstrapPrompt}
onChange={(v) => set!({ bootstrapPrompt: v })}
minRows={2}
/>
) : (
<DraftTextarea
value={eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)}
onCommit={(v) =>
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
}
immediate
placeholder="Optional initial setup prompt for the first run"
minRows={2}
/>
)}
</Field>
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
<DraftInput
value={
isCreate
? val!.extraArgs
: eff("adapterConfig", "extraArgs", formatArgList(config.extraArgs))
}
onCommit={(v) =>
isCreate
? set!({ extraArgs: v })
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined)
}
immediate
className={inputClass}
placeholder="e.g. --verbose, --foo=bar"
/>
</Field>
<Field label="Environment variables" hint={help.envVars}>
<EnvVarEditor
value={
isCreate
? ((val!.envBindings ?? {}) as Record<string, EnvBinding>)
: ((eff("adapterConfig", "env", config.env ?? {}) as Record<string, EnvBinding>)
)
}
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)
}
/>
</Field>
{/* Edit-only: timeout + grace period */}
{!isCreate && (
<>
<Field label="Timeout (sec)" hint={help.timeoutSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"timeoutSec",
Number(config.timeoutSec ?? 0),
)}
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
immediate
className={inputClass}
/>
</Field>
<Field label="Interrupt grace period (sec)" hint={help.graceSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"graceSec",
Number(config.graceSec ?? 15),
)}
onCommit={(v) => mark("adapterConfig", "graceSec", v)}
immediate
className={inputClass}
/>
</Field>
</>
)}
</div>
</CollapsibleSection>
)}
</div>
</div>
{/* ---- Heartbeat Policy ---- */}
{isCreate ? (
<CollapsibleSection
title="Heartbeat Policy"
icon={<Heart className="h-3 w-3" />}
open={heartbeatOpen}
onToggle={() => setHeartbeatOpen(!heartbeatOpen)}
bordered
>
<div className="space-y-3">
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
checked={val!.heartbeatEnabled}
onCheckedChange={(v) => set!({ heartbeatEnabled: v })}
number={val!.intervalSec}
onNumberChange={(v) => set!({ intervalSec: v })}
numberLabel="sec"
numberPrefix="Run heartbeat every"
numberHint={help.intervalSec}
showNumber={val!.heartbeatEnabled}
/>
</div>
</CollapsibleSection>
) : (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
<Heart className="h-3 w-3" />
Heartbeat Policy
</div>
<div className="px-4 pb-3 space-y-3">
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
onCheckedChange={(v) => 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 */}
<div className="space-y-3 pt-2 border-t border-border/50">
<div className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-wider">
Advanced
</div>
<ToggleField
label="Wake on demand"
hint={help.wakeOnDemand}
checked={eff(
"heartbeat",
"wakeOnDemand",
heartbeat.wakeOnDemand !== false,
)}
onChange={(v) => mark("heartbeat", "wakeOnDemand", v)}
/>
<Field label="Cooldown (sec)" hint={help.cooldownSec}>
<DraftNumberInput
value={eff(
"heartbeat",
"cooldownSec",
Number(heartbeat.cooldownSec ?? 10),
)}
onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
immediate
className={inputClass}
/>
</Field>
</div>
</div>
</div>
)}
{/* ---- Runtime (edit only) ---- */}
{!isCreate && (
<div className="border-b border-border">
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Runtime</div>
<div className="px-4 pb-3 space-y-3">
<Field label="Monthly budget (cents)" hint={help.budgetMonthlyCents}>
<DraftNumberInput
value={eff(
"runtime",
"budgetMonthlyCents",
props.agent.budgetMonthlyCents,
)}
onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)}
immediate
className={inputClass}
/>
</Field>
</div>
</div>
)}
</div>
);
}
/* ---- Internal sub-components ---- */
function AdapterTypeDropdown({
value,
onChange,
}: {
value: string;
onChange: (type: string) => void;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span>{adapterLabels[value] ?? value}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
{AGENT_ADAPTER_TYPES.map((t) => (
<button
key={t}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
t === value && "bg-accent",
)}
onClick={() => onChange(t)}
>
{adapterLabels[t] ?? t}
</button>
))}
</PopoverContent>
</Popover>
);
}
function EnvVarEditor({
value,
secrets,
onCreateSecret,
onChange,
}: {
value: Record<string, EnvBinding>;
secrets: CompanySecret[];
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
onChange: (env: Record<string, EnvBinding> | undefined) => void;
}) {
type Row = {
key: string;
source: "plain" | "secret";
plainValue: string;
secretId: string;
};
function toRows(rec: Record<string, EnvBinding> | 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<Row[]>(() => toRows(value));
const [sealError, setSealError] = useState<string | null>(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<string, EnvBinding> = {};
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<Row>) {
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 (
<div className="space-y-1.5">
{rows.map((row, i) => {
const isTrailing =
i === rows.length - 1 &&
!row.key &&
!row.plainValue &&
!row.secretId;
return (
<div key={i} className="flex items-center gap-1.5">
<input
className={cn(inputClass, "flex-[2]")}
placeholder="KEY"
value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })}
/>
<select
className={cn(inputClass, "flex-[1] bg-background")}
value={row.source}
onChange={(e) =>
updateRow(i, {
source: e.target.value === "secret" ? "secret" : "plain",
...(e.target.value === "plain" ? { secretId: "" } : {}),
})
}
>
<option value="plain">Plain</option>
<option value="secret">Secret</option>
</select>
{row.source === "secret" ? (
<>
<select
className={cn(inputClass, "flex-[3] bg-background")}
value={row.secretId}
onChange={(e) => updateRow(i, { secretId: e.target.value })}
>
<option value="">Select secret...</option>
{secrets.map((secret) => (
<option key={secret.id} value={secret.id}>
{secret.name}
</option>
))}
</select>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(i)}
disabled={!row.key.trim() || !row.plainValue}
title="Create secret from current plain value"
>
New
</button>
</>
) : (
<>
<input
className={cn(inputClass, "flex-[3]")}
placeholder="value"
value={row.plainValue}
onChange={(e) => updateRow(i, { plainValue: e.target.value })}
/>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(i)}
disabled={!row.key.trim() || !row.plainValue}
title="Store value as secret and replace with reference"
>
Seal
</button>
</>
)}
{!isTrailing ? (
<button
type="button"
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
onClick={() => removeRow(i)}
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<div className="w-[26px] shrink-0" />
)}
</div>
);
})}
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
<p className="text-[11px] text-muted-foreground/60">
PAPERCLIP_* variables are injected automatically at runtime.
</p>
</div>
);
}
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 (
<Field label="Model" hint={help.model}>
<Popover
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected ? selected.label : value || "Default"}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
)}
onClick={() => {
onChange("");
onOpenChange(false);
}}
>
Default
</button>
{filteredModels.map((m) => (
<button
key={m.id}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
)}
onClick={() => {
onChange(m.id);
onOpenChange(false);
}}
>
<span>{m.label}</span>
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
</button>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
)}
</PopoverContent>
</Popover>
</Field>
);
}
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 (
<Field label="Thinking effort" hint={help.thinkingEffort}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>{selected?.label ?? "Auto"}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
{options.map((option) => (
<button
key={option.id || "auto"}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
option.id === value && "bg-accent",
)}
onClick={() => {
onChange(option.id);
onOpenChange(false);
}}
>
<span>{option.label}</span>
{option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : null}
</button>
))}
</PopoverContent>
</Popover>
</Field>
);
}