UI: secrets-aware env editor, issue hiding, and adapter env bindings

Replace plain text env var editor with structured EnvVarEditor
supporting plain values and secret references with inline "Seal"
to convert a value to a managed secret. Add issue hide action via
popover menu on issue detail. Adapters now emit envBindings with
typed plain/secret_ref entries instead of raw KEY=VALUE strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 15:44:05 -06:00
parent f1b558dcfb
commit 5c259a9470
8 changed files with 365 additions and 48 deletions

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclip/shared";
import type { Agent } 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,
@@ -12,6 +13,8 @@ import {
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,
@@ -46,6 +49,7 @@ export const defaultCreateValues: CreateConfigValues = {
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "",
bootstrapPrompt: "",
maxTurnsPerRun: 80,
@@ -134,6 +138,25 @@ function extractPickedDirectoryPath(handle: unknown): string | null {
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);
@@ -510,19 +533,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
<Field label="Environment variables" hint={help.envVars}>
{isCreate ? (
<AutoExpandTextarea
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
value={val!.envVars}
onChange={(v) => set!({ envVars: v })}
minRows={3}
/>
) : (
<EnvVarEditor
value={(eff("adapterConfig", "env", config.env ?? {}) as Record<string, string>)}
onChange={(env) => mark("adapterConfig", "env", env)}
/>
)}
<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 */}
@@ -697,20 +725,75 @@ function AdapterTypeDropdown({
function EnvVarEditor({
value,
secrets,
onCreateSecret,
onChange,
}: {
value: Record<string, string>;
onChange: (env: Record<string, string> | undefined) => void;
value: Record<string, EnvBinding>;
secrets: CompanySecret[];
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
onChange: (env: Record<string, EnvBinding> | undefined) => void;
}) {
type Row = { key: string; value: string };
type Row = {
key: string;
source: "plain" | "secret";
plainValue: string;
secretId: string;
};
function toRows(rec: Record<string, string> | null | undefined): Row[] {
if (!rec || typeof rec !== "object") return [{ key: "", value: "" }];
const entries = Object.entries(rec).map(([k, v]) => ({ key: k, value: String(v) }));
return [...entries, { key: "", value: "" }];
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)
@@ -722,50 +805,151 @@ function EnvVarEditor({
}, [value]);
function emit(nextRows: Row[]) {
const rec: Record<string, string> = {};
const rec: Record<string, EnvBinding> = {};
for (const row of nextRows) {
const k = row.key.trim();
if (k) rec[k] = row.value;
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, field: "key" | "value", v: string) {
const next = rows.map((r, idx) => (idx === i ? { ...r, [field]: v } : r));
if (next[next.length - 1].key || next[next.length - 1].value) {
next.push({ key: "", value: "" });
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 removeRow(i: number) {
const next = rows.filter((_, idx) => idx !== i);
if (next.length === 0 || next[next.length - 1].key || next[next.length - 1].value) {
next.push({ key: "", value: "" });
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");
}
setRows(next);
emit(next);
}
return (
<div className="space-y-1.5">
{rows.map((row, i) => {
const isTrailing = i === rows.length - 1 && !row.key && !row.value;
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)}
/>
<input
className={cn(inputClass, "flex-[3]")}
placeholder="value"
value={row.value}
onChange={(e) => updateRow(i, "value", e.target.value)}
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"
@@ -780,6 +964,7 @@ function EnvVarEditor({
</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>