Refactor NewAgentDialog and AgentDetail to use AgentConfigForm
NewAgentDialog now delegates adapter/heartbeat config to AgentConfigForm in create mode. AgentDetail replaces its inline ConfigSection/ConfigField/ConfigBool components with AgentConfigForm in edit mode, cutting ~250 lines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AGENT_ROLES, AGENT_ADAPTER_TYPES } from "@paperclip/shared";
|
import { AGENT_ROLES } from "@paperclip/shared";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -16,58 +16,19 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipTrigger,
|
|
||||||
TooltipContent,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import {
|
import {
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Shield,
|
Shield,
|
||||||
User,
|
User,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Heart,
|
|
||||||
HelpCircle,
|
|
||||||
FolderOpen,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { roleLabels } from "./agent-config-primitives";
|
||||||
const roleLabels: Record<string, string> = {
|
import {
|
||||||
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
|
AgentConfigForm,
|
||||||
engineer: "Engineer", designer: "Designer", pm: "PM",
|
defaultCreateValues,
|
||||||
qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
|
type CreateConfigValues,
|
||||||
};
|
} from "./AgentConfigForm";
|
||||||
|
|
||||||
const adapterLabels: Record<string, string> = {
|
|
||||||
claude_local: "Claude (local)",
|
|
||||||
codex_local: "Codex (local)",
|
|
||||||
process: "Process",
|
|
||||||
http: "HTTP",
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---- Help text for (?) tooltips ---- */
|
|
||||||
const help: Record<string, string> = {
|
|
||||||
name: "Display name for this agent.",
|
|
||||||
title: "Job title shown in the org chart.",
|
|
||||||
role: "Organizational role. Determines position and capabilities.",
|
|
||||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
|
||||||
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
|
|
||||||
cwd: "The working directory where the agent operates. Should be an absolute path on the server.",
|
|
||||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
|
||||||
model: "Override the default model used by the adapter.",
|
|
||||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
|
||||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
|
||||||
search: "Enable Codex web search capability during runs.",
|
|
||||||
bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.",
|
|
||||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
|
||||||
command: "The command to execute (e.g. node, python).",
|
|
||||||
args: "Command-line arguments, comma-separated.",
|
|
||||||
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
|
||||||
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
|
||||||
intervalSec: "Seconds between automatic heartbeat invocations.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NewAgentDialog() {
|
export function NewAgentDialog() {
|
||||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||||
@@ -82,42 +43,12 @@ export function NewAgentDialog() {
|
|||||||
const [role, setRole] = useState("general");
|
const [role, setRole] = useState("general");
|
||||||
const [reportsTo, setReportsTo] = useState("");
|
const [reportsTo, setReportsTo] = useState("");
|
||||||
|
|
||||||
// Adapter
|
// Config values (managed by AgentConfigForm)
|
||||||
const [adapterType, setAdapterType] = useState<string>("claude_local");
|
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
|
||||||
const [cwd, setCwd] = useState("");
|
|
||||||
const [promptTemplate, setPromptTemplate] = useState("");
|
|
||||||
const [model, setModel] = useState("");
|
|
||||||
|
|
||||||
// claude_local specific
|
|
||||||
const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(false);
|
|
||||||
|
|
||||||
// codex_local specific
|
|
||||||
const [search, setSearch] = useState(false);
|
|
||||||
const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(false);
|
|
||||||
|
|
||||||
// process specific
|
|
||||||
const [command, setCommand] = useState("");
|
|
||||||
const [args, setArgs] = useState("");
|
|
||||||
|
|
||||||
// http specific
|
|
||||||
const [url, setUrl] = useState("");
|
|
||||||
|
|
||||||
// Advanced adapter fields
|
|
||||||
const [bootstrapPrompt, setBootstrapPrompt] = useState("");
|
|
||||||
const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80);
|
|
||||||
|
|
||||||
// Heartbeat
|
|
||||||
const [heartbeatEnabled, setHeartbeatEnabled] = useState(false);
|
|
||||||
const [intervalSec, setIntervalSec] = useState(300);
|
|
||||||
|
|
||||||
// Sections
|
|
||||||
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
|
|
||||||
const [heartbeatOpen, setHeartbeatOpen] = useState(false);
|
|
||||||
|
|
||||||
// Popover states
|
// Popover states
|
||||||
const [roleOpen, setRoleOpen] = useState(false);
|
const [roleOpen, setRoleOpen] = useState(false);
|
||||||
const [reportsToOpen, setReportsToOpen] = useState(false);
|
const [reportsToOpen, setReportsToOpen] = useState(false);
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -126,8 +57,8 @@ export function NewAgentDialog() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
queryKey: ["adapter-models", adapterType],
|
queryKey: ["adapter-models", configValues.adapterType],
|
||||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
queryFn: () => agentsApi.adapterModels(configValues.adapterType),
|
||||||
enabled: newAgentOpen,
|
enabled: newAgentOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,47 +89,33 @@ export function NewAgentDialog() {
|
|||||||
setTitle("");
|
setTitle("");
|
||||||
setRole("general");
|
setRole("general");
|
||||||
setReportsTo("");
|
setReportsTo("");
|
||||||
setAdapterType("claude_local");
|
setConfigValues(defaultCreateValues);
|
||||||
setCwd("");
|
|
||||||
setPromptTemplate("");
|
|
||||||
setModel("");
|
|
||||||
setDangerouslySkipPermissions(false);
|
|
||||||
setSearch(false);
|
|
||||||
setDangerouslyBypassSandbox(false);
|
|
||||||
setCommand("");
|
|
||||||
setArgs("");
|
|
||||||
setUrl("");
|
|
||||||
setBootstrapPrompt("");
|
|
||||||
setMaxTurnsPerRun(80);
|
|
||||||
setHeartbeatEnabled(false);
|
|
||||||
setIntervalSec(300);
|
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
setAdapterAdvancedOpen(false);
|
|
||||||
setHeartbeatOpen(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdapterConfig() {
|
function buildAdapterConfig() {
|
||||||
const config: Record<string, unknown> = {};
|
const v = configValues;
|
||||||
if (cwd) config.cwd = cwd;
|
const ac: Record<string, unknown> = {};
|
||||||
if (promptTemplate) config.promptTemplate = promptTemplate;
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
if (bootstrapPrompt) config.bootstrapPromptTemplate = bootstrapPrompt;
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
if (model) config.model = model;
|
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||||
config.timeoutSec = 0;
|
if (v.model) ac.model = v.model;
|
||||||
config.graceSec = 15;
|
ac.timeoutSec = 0;
|
||||||
|
ac.graceSec = 15;
|
||||||
|
|
||||||
if (adapterType === "claude_local") {
|
if (v.adapterType === "claude_local") {
|
||||||
config.maxTurnsPerRun = maxTurnsPerRun;
|
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||||
config.dangerouslySkipPermissions = dangerouslySkipPermissions;
|
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||||
} else if (adapterType === "codex_local") {
|
} else if (v.adapterType === "codex_local") {
|
||||||
config.search = search;
|
ac.search = v.search;
|
||||||
config.dangerouslyBypassApprovalsAndSandbox = dangerouslyBypassSandbox;
|
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
||||||
} else if (adapterType === "process") {
|
} else if (v.adapterType === "process") {
|
||||||
if (command) config.command = command;
|
if (v.command) ac.command = v.command;
|
||||||
if (args) config.args = args.split(",").map((a) => a.trim()).filter(Boolean);
|
if (v.args) ac.args = v.args.split(",").map((a) => a.trim()).filter(Boolean);
|
||||||
} else if (adapterType === "http") {
|
} else if (v.adapterType === "http") {
|
||||||
if (url) config.url = url;
|
if (v.url) ac.url = v.url;
|
||||||
}
|
}
|
||||||
return config;
|
return ac;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
@@ -208,12 +125,12 @@ export function NewAgentDialog() {
|
|||||||
role: effectiveRole,
|
role: effectiveRole,
|
||||||
...(title.trim() ? { title: title.trim() } : {}),
|
...(title.trim() ? { title: title.trim() } : {}),
|
||||||
...(reportsTo ? { reportsTo } : {}),
|
...(reportsTo ? { reportsTo } : {}),
|
||||||
adapterType,
|
adapterType: configValues.adapterType,
|
||||||
adapterConfig: buildAdapterConfig(),
|
adapterConfig: buildAdapterConfig(),
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
enabled: heartbeatEnabled,
|
enabled: configValues.heartbeatEnabled,
|
||||||
intervalSec,
|
intervalSec: configValues.intervalSec,
|
||||||
wakeOnAssignment: true,
|
wakeOnAssignment: true,
|
||||||
wakeOnOnDemand: true,
|
wakeOnOnDemand: true,
|
||||||
wakeOnAutomation: true,
|
wakeOnAutomation: true,
|
||||||
@@ -233,7 +150,6 @@ export function NewAgentDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
|
const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
|
||||||
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -368,239 +284,13 @@ export function NewAgentDialog() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Adapter type dropdown (above config section) */}
|
{/* Shared config form (adapter + heartbeat) */}
|
||||||
<div className="px-4 py-2.5 border-t border-border">
|
<AgentConfigForm
|
||||||
<Field label="Adapter" hint={help.adapterType}>
|
mode="create"
|
||||||
<Popover>
|
values={configValues}
|
||||||
<PopoverTrigger asChild>
|
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
|
||||||
<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">
|
adapterModels={adapterModels}
|
||||||
<span>{adapterLabels[adapterType] ?? adapterType}</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 === adapterType && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => setAdapterType(t)}
|
|
||||||
>
|
|
||||||
{adapterLabels[t] ?? t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Adapter Configuration (always open) */}
|
|
||||||
<div className="border-t 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 — basic, shown for local adapters */}
|
|
||||||
{(adapterType === "claude_local" || adapterType === "codex_local") && (
|
|
||||||
<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" />
|
|
||||||
<input
|
|
||||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
|
||||||
placeholder="/path/to/project"
|
|
||||||
value={cwd}
|
|
||||||
onChange={(e) => setCwd(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={async () => {
|
|
||||||
try {
|
|
||||||
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
|
||||||
const handle = await window.showDirectoryPicker({ mode: "read" });
|
|
||||||
setCwd(handle.name);
|
|
||||||
} catch {
|
|
||||||
// user cancelled or API unsupported
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Choose
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Prompt template — basic, auto-expanding */}
|
|
||||||
{(adapterType === "claude_local" || adapterType === "codex_local") && (
|
|
||||||
<Field label="Prompt template" hint={help.promptTemplate}>
|
|
||||||
<AutoExpandTextarea
|
|
||||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
|
||||||
value={promptTemplate}
|
|
||||||
onChange={setPromptTemplate}
|
|
||||||
minRows={4}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Skip permissions — basic for claude */}
|
|
||||||
{adapterType === "claude_local" && (
|
|
||||||
<ToggleField
|
|
||||||
label="Skip permissions"
|
|
||||||
hint={help.dangerouslySkipPermissions}
|
|
||||||
checked={dangerouslySkipPermissions}
|
|
||||||
onChange={setDangerouslySkipPermissions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bypass sandbox + search — basic for codex */}
|
|
||||||
{adapterType === "codex_local" && (
|
|
||||||
<>
|
|
||||||
<ToggleField
|
|
||||||
label="Bypass sandbox"
|
|
||||||
hint={help.dangerouslyBypassSandbox}
|
|
||||||
checked={dangerouslyBypassSandbox}
|
|
||||||
onChange={setDangerouslyBypassSandbox}
|
|
||||||
/>
|
|
||||||
<ToggleField
|
|
||||||
label="Enable search"
|
|
||||||
hint={help.search}
|
|
||||||
checked={search}
|
|
||||||
onChange={setSearch}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Process-specific fields */}
|
|
||||||
{adapterType === "process" && (
|
|
||||||
<>
|
|
||||||
<Field label="Command" hint={help.command}>
|
|
||||||
<input
|
|
||||||
className="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"
|
|
||||||
placeholder="e.g. node, python"
|
|
||||||
value={command}
|
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Args (comma-separated)" hint={help.args}>
|
|
||||||
<input
|
|
||||||
className="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"
|
|
||||||
placeholder="e.g. script.js, --flag"
|
|
||||||
value={args}
|
|
||||||
onChange={(e) => setArgs(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HTTP-specific fields */}
|
|
||||||
{adapterType === "http" && (
|
|
||||||
<Field label="Webhook URL" hint={help.webhookUrl}>
|
|
||||||
<input
|
|
||||||
className="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"
|
|
||||||
placeholder="https://..."
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Advanced section for local adapters */}
|
|
||||||
{(adapterType === "claude_local" || adapterType === "codex_local") && (
|
|
||||||
<CollapsibleSection
|
|
||||||
title="Advanced Adapter Configuration"
|
|
||||||
open={adapterAdvancedOpen}
|
|
||||||
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Model dropdown */}
|
|
||||||
<Field label="Model" hint={help.model}>
|
|
||||||
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
|
||||||
<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(!model && "text-muted-foreground")}>
|
|
||||||
{selectedModel ? selectedModel.label : model || "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">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
||||||
!model && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setModel(""); setModelOpen(false); }}
|
|
||||||
>
|
|
||||||
Default
|
|
||||||
</button>
|
|
||||||
{(adapterModels ?? []).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 === model && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setModel(m.id); setModelOpen(false); }}
|
|
||||||
>
|
|
||||||
<span>{m.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Bootstrap prompt */}
|
|
||||||
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
|
|
||||||
<AutoExpandTextarea
|
|
||||||
placeholder="Optional initial setup prompt for the first run"
|
|
||||||
value={bootstrapPrompt}
|
|
||||||
onChange={setBootstrapPrompt}
|
|
||||||
minRows={2}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Max turns — claude only */}
|
|
||||||
{adapterType === "claude_local" && (
|
|
||||||
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono"
|
|
||||||
value={maxTurnsPerRun}
|
|
||||||
onChange={(e) => setMaxTurnsPerRun(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Heartbeat Policy */}
|
|
||||||
<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={heartbeatEnabled}
|
|
||||||
onCheckedChange={setHeartbeatEnabled}
|
|
||||||
number={intervalSec}
|
|
||||||
onNumberChange={setIntervalSec}
|
|
||||||
numberLabel="sec"
|
|
||||||
numberHint={help.intervalSec}
|
|
||||||
showNumber={heartbeatEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -620,194 +310,3 @@ export function NewAgentDialog() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Reusable components ---- */
|
|
||||||
|
|
||||||
function HintIcon({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button type="button" className="inline-flex text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
|
||||||
<HelpCircle className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
|
||||||
{text}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
|
||||||
<label className="text-xs text-muted-foreground">{label}</label>
|
|
||||||
{hint && <HintIcon text={hint} />}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleField({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
|
||||||
{hint && <HintIcon text={hint} />}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
||||||
checked ? "bg-green-600" : "bg-muted"
|
|
||||||
)}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
checked ? "translate-x-4.5" : "translate-x-0.5"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleWithNumber({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
checked,
|
|
||||||
onCheckedChange,
|
|
||||||
number,
|
|
||||||
onNumberChange,
|
|
||||||
numberLabel,
|
|
||||||
numberHint,
|
|
||||||
showNumber,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
checked: boolean;
|
|
||||||
onCheckedChange: (v: boolean) => void;
|
|
||||||
number: number;
|
|
||||||
onNumberChange: (v: number) => void;
|
|
||||||
numberLabel: string;
|
|
||||||
numberHint?: string;
|
|
||||||
showNumber: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
|
||||||
{hint && <HintIcon text={hint} />}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
|
|
||||||
checked ? "bg-green-600" : "bg-muted"
|
|
||||||
)}
|
|
||||||
onClick={() => onCheckedChange(!checked)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
checked ? "translate-x-4.5" : "translate-x-0.5"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showNumber && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<span>Run heartbeat every</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="w-16 rounded-md border border-border px-2 py-0.5 bg-transparent outline-none text-xs font-mono text-center"
|
|
||||||
value={number}
|
|
||||||
onChange={(e) => onNumberChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<span>{numberLabel}</span>
|
|
||||||
{numberHint && <HintIcon text={numberHint} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CollapsibleSection({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
open,
|
|
||||||
onToggle,
|
|
||||||
bordered,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
open: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
bordered?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn(bordered && "border-t border-border")}>
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors"
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
||||||
{icon}
|
|
||||||
{title}
|
|
||||||
</button>
|
|
||||||
{open && <div className="px-4 pb-3">{children}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutoExpandTextarea({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
minRows,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
minRows?: number;
|
|
||||||
}) {
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const rows = minRows ?? 3;
|
|
||||||
const lineHeight = 20; // approx line height in px for text-sm mono
|
|
||||||
const minHeight = rows * lineHeight;
|
|
||||||
|
|
||||||
const adjustHeight = useCallback(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.style.height = "auto";
|
|
||||||
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
|
||||||
}, [minHeight]);
|
|
||||||
|
|
||||||
useEffect(() => { adjustHeight(); }, [value, adjustHeight]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
className="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 resize-none overflow-hidden"
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
style={{ minHeight }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AgentProperties } from "../components/AgentProperties";
|
import { AgentProperties } from "../components/AgentProperties";
|
||||||
|
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||||
|
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||||
@@ -35,22 +38,10 @@ import {
|
|||||||
Slash,
|
Slash,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
||||||
|
|
||||||
const adapterLabels: Record<string, string> = {
|
|
||||||
claude_local: "Claude (local)",
|
|
||||||
codex_local: "Codex (local)",
|
|
||||||
process: "Process",
|
|
||||||
http: "HTTP",
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleLabels: Record<string, string> = {
|
|
||||||
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
|
|
||||||
engineer: "Engineer", designer: "Designer", pm: "PM",
|
|
||||||
qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
|
|
||||||
};
|
|
||||||
|
|
||||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||||
succeeded: { icon: CheckCircle2, color: "text-green-400" },
|
succeeded: { icon: CheckCircle2, color: "text-green-400" },
|
||||||
failed: { icon: XCircle, color: "text-red-400" },
|
failed: { icon: XCircle, color: "text-red-400" },
|
||||||
@@ -71,6 +62,7 @@ export function AgentDetail() {
|
|||||||
const { agentId } = useParams<{ agentId: string }>();
|
const { agentId } = useParams<{ agentId: string }>();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openPanel, closePanel } = usePanel();
|
const { openPanel, closePanel } = usePanel();
|
||||||
|
const { openNewIssue } = useDialog();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -174,6 +166,14 @@ export function AgentDetail() {
|
|||||||
<Play className="h-3.5 w-3.5 mr-1" />
|
<Play className="h-3.5 w-3.5 mr-1" />
|
||||||
Invoke
|
Invoke
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openNewIssue({ assigneeAgentId: agentId })}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Assign Task
|
||||||
|
</Button>
|
||||||
{agent.status === "active" || agent.status === "running" ? (
|
{agent.status === "active" || agent.status === "running" ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -389,6 +389,12 @@ function SummaryRow({ label, children }: { label: string; children: React.ReactN
|
|||||||
|
|
||||||
function ConfigurationTab({ agent }: { agent: Agent }) {
|
function ConfigurationTab({ agent }: { agent: Agent }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: adapterModels } = useQuery({
|
||||||
|
queryKey: ["adapter-models", agent.adapterType],
|
||||||
|
queryFn: () => agentsApi.adapterModels(agent.adapterType),
|
||||||
|
});
|
||||||
|
|
||||||
const updateAgent = useMutation({
|
const updateAgent = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data),
|
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -396,228 +402,14 @@ function ConfigurationTab({ agent }: { agent: Agent }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
|
||||||
const heartbeat = ((agent.runtimeConfig ?? {}) as Record<string, unknown>).heartbeat as Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="max-w-2xl border border-border rounded-lg overflow-hidden">
|
||||||
{/* Identity */}
|
<AgentConfigForm
|
||||||
<ConfigSection title="Identity">
|
mode="edit"
|
||||||
<ConfigField label="Name" value={agent.name} onSave={(v) => updateAgent.mutate({ name: v })} />
|
agent={agent}
|
||||||
<ConfigField label="Title" value={agent.title ?? ""} onSave={(v) => updateAgent.mutate({ title: v || null })} />
|
onSave={(patch) => updateAgent.mutate(patch)}
|
||||||
<ConfigField label="Role" value={roleLabels[agent.role] ?? agent.role} readOnly />
|
adapterModels={adapterModels}
|
||||||
<ConfigField label="Capabilities" value={agent.capabilities ?? ""} onSave={(v) => updateAgent.mutate({ capabilities: v || null })} />
|
/>
|
||||||
</ConfigSection>
|
|
||||||
|
|
||||||
{/* Adapter Config */}
|
|
||||||
<ConfigSection title="Adapter Configuration">
|
|
||||||
<ConfigField label="Type" value={adapterLabels[agent.adapterType] ?? agent.adapterType} readOnly />
|
|
||||||
<ConfigField
|
|
||||||
label="Working directory"
|
|
||||||
value={String(config.cwd ?? "")}
|
|
||||||
mono
|
|
||||||
onSave={(v) => updateAgent.mutate({ adapterConfig: { ...config, cwd: v || undefined } })}
|
|
||||||
/>
|
|
||||||
<ConfigField
|
|
||||||
label="Prompt template"
|
|
||||||
value={String(config.promptTemplate ?? "")}
|
|
||||||
mono
|
|
||||||
multiline
|
|
||||||
onSave={(v) => updateAgent.mutate({ adapterConfig: { ...config, promptTemplate: v || undefined } })}
|
|
||||||
/>
|
|
||||||
<ConfigField
|
|
||||||
label="Bootstrap prompt"
|
|
||||||
value={String(config.bootstrapPromptTemplate ?? "")}
|
|
||||||
mono
|
|
||||||
multiline
|
|
||||||
onSave={(v) => updateAgent.mutate({ adapterConfig: { ...config, bootstrapPromptTemplate: v || undefined } })}
|
|
||||||
/>
|
|
||||||
<ConfigField
|
|
||||||
label="Model"
|
|
||||||
value={String(config.model ?? "")}
|
|
||||||
mono
|
|
||||||
onSave={(v) => updateAgent.mutate({ adapterConfig: { ...config, model: v || undefined } })}
|
|
||||||
/>
|
|
||||||
<ConfigField label="Timeout (sec)" value={String(config.timeoutSec ?? 900)} mono
|
|
||||||
onSave={(v) => updateAgent.mutate({ adapterConfig: { ...config, timeoutSec: Number(v) || 900 } })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{agent.adapterType === "claude_local" && (
|
|
||||||
<>
|
|
||||||
<ConfigField label="Max turns per run" value={String(config.maxTurnsPerRun ?? 80)} mono
|
|
||||||
onSave={(v) => updateAgent.mutate({ adapterConfig: { ...config, maxTurnsPerRun: Number(v) || 80 } })}
|
|
||||||
/>
|
|
||||||
<ConfigBool label="Skip permissions" value={config.dangerouslySkipPermissions !== false}
|
|
||||||
onToggle={(v) => updateAgent.mutate({ adapterConfig: { ...config, dangerouslySkipPermissions: v } })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{agent.adapterType === "codex_local" && (
|
|
||||||
<>
|
|
||||||
<ConfigBool label="Search" value={!!config.search}
|
|
||||||
onToggle={(v) => updateAgent.mutate({ adapterConfig: { ...config, search: v } })}
|
|
||||||
/>
|
|
||||||
<ConfigBool label="Bypass sandbox" value={config.dangerouslyBypassApprovalsAndSandbox !== false}
|
|
||||||
onToggle={(v) => updateAgent.mutate({ adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: v } })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ConfigSection>
|
|
||||||
|
|
||||||
{/* Heartbeat Policy */}
|
|
||||||
<ConfigSection title="Heartbeat Policy">
|
|
||||||
<ConfigBool label="Enabled" value={heartbeat?.enabled !== false}
|
|
||||||
onToggle={(v) => updateAgent.mutate({ runtimeConfig: { ...agent.runtimeConfig as object, heartbeat: { ...heartbeat, enabled: v } } })}
|
|
||||||
/>
|
|
||||||
<ConfigField label="Interval (sec)" value={String(heartbeat?.intervalSec ?? 300)} mono
|
|
||||||
onSave={(v) => updateAgent.mutate({ runtimeConfig: { ...agent.runtimeConfig as object, heartbeat: { ...heartbeat, intervalSec: Number(v) || 300 } } })}
|
|
||||||
/>
|
|
||||||
<ConfigBool label="Wake on assignment" value={heartbeat?.wakeOnAssignment !== false}
|
|
||||||
onToggle={(v) => updateAgent.mutate({ runtimeConfig: { ...agent.runtimeConfig as object, heartbeat: { ...heartbeat, wakeOnAssignment: v } } })}
|
|
||||||
/>
|
|
||||||
<ConfigBool label="Wake on on-demand" value={heartbeat?.wakeOnOnDemand !== false}
|
|
||||||
onToggle={(v) => updateAgent.mutate({ runtimeConfig: { ...agent.runtimeConfig as object, heartbeat: { ...heartbeat, wakeOnOnDemand: v } } })}
|
|
||||||
/>
|
|
||||||
<ConfigBool label="Wake on automation" value={heartbeat?.wakeOnAutomation !== false}
|
|
||||||
onToggle={(v) => updateAgent.mutate({ runtimeConfig: { ...agent.runtimeConfig as object, heartbeat: { ...heartbeat, wakeOnAutomation: v } } })}
|
|
||||||
/>
|
|
||||||
<ConfigField label="Cooldown (sec)" value={String(heartbeat?.cooldownSec ?? 10)} mono
|
|
||||||
onSave={(v) => updateAgent.mutate({ runtimeConfig: { ...agent.runtimeConfig as object, heartbeat: { ...heartbeat, cooldownSec: Number(v) || 10 } } })}
|
|
||||||
/>
|
|
||||||
</ConfigSection>
|
|
||||||
|
|
||||||
{/* Runtime */}
|
|
||||||
<ConfigSection title="Runtime">
|
|
||||||
<ConfigField label="Context mode" value={agent.contextMode} readOnly />
|
|
||||||
<ConfigField label="Monthly budget (cents)" value={String(agent.budgetMonthlyCents)} mono
|
|
||||||
onSave={(v) => updateAgent.mutate({ budgetMonthlyCents: Number(v) || 0 })}
|
|
||||||
/>
|
|
||||||
</ConfigSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigSection({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium mb-3">{title}</h3>
|
|
||||||
<div className="border border-border rounded-lg divide-y divide-border">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigField({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
mono,
|
|
||||||
multiline,
|
|
||||||
readOnly,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
mono?: boolean;
|
|
||||||
multiline?: boolean;
|
|
||||||
readOnly?: boolean;
|
|
||||||
onSave?: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [draft, setDraft] = useState(value);
|
|
||||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => { setDraft(value); }, [value]);
|
|
||||||
|
|
||||||
function handleSave() {
|
|
||||||
if (draft !== value && onSave) {
|
|
||||||
onSave(draft);
|
|
||||||
}
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
|
||||||
if (e.key === "Enter" && !multiline) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setDraft(value);
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-4 px-4 py-2.5">
|
|
||||||
<span className="text-xs text-muted-foreground w-40 shrink-0 pt-0.5">{label}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{editing && !readOnly ? (
|
|
||||||
multiline ? (
|
|
||||||
<textarea
|
|
||||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
|
||||||
className={cn("w-full bg-accent/30 rounded px-2 py-1 text-sm outline-none resize-none min-h-[60px]", mono && "font-mono")}
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onBlur={handleSave}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
|
||||||
className={cn("w-full bg-accent/30 rounded px-2 py-1 text-sm outline-none", mono && "font-mono")}
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onBlur={handleSave}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"text-sm text-left w-full truncate",
|
|
||||||
mono && "font-mono",
|
|
||||||
!readOnly && "hover:bg-accent/30 rounded px-2 py-0.5 -mx-2 -my-0.5 cursor-text",
|
|
||||||
!value && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
onClick={() => !readOnly && setEditing(true)}
|
|
||||||
disabled={readOnly}
|
|
||||||
>
|
|
||||||
{value || (readOnly ? "-" : "Click to edit")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigBool({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: boolean;
|
|
||||||
onToggle: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-4 px-4 py-2.5">
|
|
||||||
<span className="text-xs text-muted-foreground w-40 shrink-0">{label}</span>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
||||||
value ? "bg-green-600" : "bg-muted"
|
|
||||||
)}
|
|
||||||
onClick={() => onToggle(!value)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
value ? "translate-x-4.5" : "translate-x-0.5"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user