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>
1156 lines
41 KiB
TypeScript
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>
|
|
);
|
|
}
|