Redesign agent configuration shell

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 15:25:45 +00:00
1 parent d31a28828b
commit 8818747b3a
5 files changed
+370 -28

No files matched your search

+62 -19
View File
@@ -74,6 +74,7 @@ import { Badge } from "@/components/ui/badge";
type AgentConfigFormProps = {
adapterModels?: AdapterModel[];
onDirtyDetailsChange?: (details: { count: number; sections: string[] }) => void;
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
@@ -88,6 +89,11 @@ type AgentConfigFormProps = {
showAdapterTestEnvironmentButton?: boolean;
showCreateRunPolicySection?: boolean;
hideInstructionsFile?: boolean;
hideIdentity?: boolean;
identityOnly?: boolean;
identitySectionTitle?: string;
configurationShell?: boolean;
visibleConfigurationSections?: ReadonlySet<string>;
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
hidePromptTemplate?: boolean;
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
@@ -133,6 +139,37 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean {
);
}
export function getAgentConfigDirtyDetails(o: AgentConfigOverlay): { count: number; sections: string[] } {
const sections = new Set<string>();
let count = 0;
const addGroup = (group: Record<string, unknown>, defaultSection: string) => {
for (const key of Object.keys(group)) {
count += 1;
if (key === "defaultEnvironmentId" || key === "env") sections.add("environment");
else if (
key === "cwd" ||
key === "dangerouslySkipPermissions" ||
key === "dangerouslyBypassSandbox" ||
key === "dangerouslyBypassApprovalsAndSandbox"
) sections.add("danger");
else sections.add(defaultSection);
}
};
addGroup(o.identity, "runtime");
if (o.adapterType !== undefined) {
count += 1;
sections.add("runtime");
}
addGroup(o.adapterConfig, "runtime");
addGroup(o.heartbeat, "schedule");
addGroup(o.runtime, "runtime");
if (o.modelProfiles?.cheap !== undefined) {
count += 1;
sections.add("runtime");
}
return { count, sections: [...sections] };
}
/* ---- 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";
@@ -305,6 +342,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
const isDirty = !isCreate && isOverlayDirty(overlay);
const dirtyDetails = useMemo(() => getAgentConfigDirtyDetails(overlay), [overlay]);
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime";
@@ -374,10 +412,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
useEffect(() => {
if (!isCreate) {
props.onDirtyChange?.(isDirty);
props.onDirtyDetailsChange?.(dirtyDetails);
props.onSaveActionChange?.(handleSave);
props.onCancelActionChange?.(handleCancel);
}
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
}, [isCreate, isDirty, dirtyDetails, props.onDirtyChange, props.onDirtyDetailsChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
useEffect(() => {
if (isCreate) return;
@@ -385,8 +424,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
props.onDirtyDetailsChange?.({ count: 0, sections: [] });
};
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
}, [isCreate, props.onDirtyChange, props.onDirtyDetailsChange, props.onSaveActionChange, props.onCancelActionChange]);
// ---- Resolve values ----
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
@@ -921,6 +961,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
0,
MAX_TURN_CONTINUATION_MAX_DELAY_SEC,
);
const configurationShell = props.configurationShell === true;
const sectionVisible = (section: string) =>
!props.identityOnly && (!props.visibleConfigurationSections || props.visibleConfigurationSections.has(section));
function updateMaxTurnContinuation(patch: Record<string, unknown>) {
mark("heartbeat", "maxTurnContinuation", {
@@ -948,10 +991,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* ---- Identity (edit only) ---- */}
{!isCreate && (
{!isCreate && !props.hideIdentity && (
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Identity</h3>
? <h3 className="text-sm font-medium mb-3">{props.identitySectionTitle ?? "Identity"}</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
@@ -1026,11 +1069,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* ---- Execution ---- */}
{forcedKubernetes ? (
{sectionVisible("environment") && (forcedKubernetes ? (
// Instance execution policy forces the managed Kubernetes sandbox
// (executionMode=kubernetes): never offer local / non-Kubernetes targets.
// Render the environment read-only instead of the selectable picker.
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
<div id={configurationShell ? "config-environment" : undefined} className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"), configurationShell && "scroll-mt-24")}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
@@ -1055,7 +1098,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
</div>
) : showEnvironmentOverrideControl ? (
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
<div id={configurationShell ? "config-environment" : undefined} className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"), configurationShell && "scroll-mt-24")}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
@@ -1086,13 +1129,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
</div>
</div>
) : null}
) : null)}
{/* ---- Adapter ---- */}
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
{sectionVisible("runtime") && <div id={configurationShell ? "config-runtime" : undefined} className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"), configurationShell && "scroll-mt-24")}>
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
{cards
? <h3 className="text-sm font-medium">Adapter</h3>
? <h3 className="text-sm font-medium">{configurationShell ? "Runtime" : "Adapter"}</h3>
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
}
{showInlineAdapterTestEnvironmentButton && (
@@ -1207,13 +1250,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{/* Local adapter-specific fields are rendered inside Permissions & Configuration */}
</div>
</div>
</div>}
{/* ---- Permissions & Configuration ---- */}
{isLocal && (
{sectionVisible("runtime") && isLocal && (
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Permissions &amp; Configuration</h3>
? <h3 className="text-sm font-medium mb-3">{configurationShell ? "Advanced runtime" : "Permissions & Configuration"}</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions &amp; Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
@@ -1451,10 +1494,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* ---- Run Policy ---- */}
{isCreate && showCreateRunPolicySection ? (
<div className={cn(!cards && "border-b border-border")}>
{sectionVisible("schedule") && (isCreate && showCreateRunPolicySection ? (
<div id={configurationShell ? "config-schedule" : undefined} className={cn(!cards && "border-b border-border", configurationShell && "scroll-mt-24")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> {configurationShell ? "Schedule & Runs" : "Run Policy"}</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
@@ -1473,9 +1516,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
</div>
) : !isCreate ? (
<div className={cn(!cards && "border-b border-border")}>
<div id={configurationShell ? "config-schedule" : undefined} className={cn(!cards && "border-b border-border", configurationShell && "scroll-mt-24")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> {configurationShell ? "Schedule & Runs" : "Run Policy"}</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg overflow-hidden" : "")}>
@@ -1572,7 +1615,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</CollapsibleSection>
</div>
</div>
) : null}
) : null)}
</div>
);
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@paperclipai/shared";
import { getAgentConfigDirtyDetails } from "./AgentConfigForm";
import {
filterAgentConfigurationSections,
resolveEffectiveConfiguration,
} from "./agent-configuration-shell";
describe("agent configuration shell", () => {
it("matches both section names and field labels", () => {
expect([...filterAgentConfigurationSections("heartbeat")]).toEqual(["schedule"]);
expect([...filterAgentConfigurationSections("API Keys")]).toEqual(["keys"]);
expect([...filterAgentConfigurationSections("sandbox")]).toEqual(["danger"]);
});
it("resolves adapter defaults to a concrete model label", () => {
const agent = {
adapterType: "claude_local",
adapterConfig: { modelReasoningEffort: "high" },
runtimeConfig: { heartbeat: { enabled: true, intervalSec: 300 } },
permissions: { trustPreset: "standard" },
defaultEnvironmentId: null,
} as unknown as Agent;
expect(resolveEffectiveConfiguration(agent, [{ id: "claude-fable-5", label: "Claude Fable 5" }], 2)).toMatchObject({
model: "Claude Fable 5 · high",
modelInherited: true,
environmentInherited: true,
apiKeyCount: 2,
});
});
it("counts draft fields and assigns dirty section dots", () => {
expect(getAgentConfigDirtyDetails({
identity: { defaultEnvironmentId: "env-1" },
adapterConfig: { model: "claude-fable-5", dangerouslySkipPermissions: true },
heartbeat: { intervalSec: 300 },
runtime: {},
})).toEqual({
count: 4,
sections: ["environment", "runtime", "danger", "schedule"],
});
});
});
@@ -0,0 +1,146 @@
import type { ReactNode } from "react";
import { Search, Zap } from "lucide-react";
import type { Agent, AgentEnvConfig } from "@paperclipai/shared";
import type { AdapterModel } from "../api/agents";
import { adapterLabels } from "./agent-config-primitives";
import { Badge } from "@/components/ui/badge";
import { cn } from "../lib/utils";
export type AgentConfigurationSectionId =
| "runtime"
| "environment"
| "schedule"
| "access"
| "keys"
| "danger"
| "history";
export const agentConfigurationSections: ReadonlyArray<{
id: AgentConfigurationSectionId;
label: string;
instant?: boolean;
fields: string[];
}> = [
{ id: "runtime", label: "Runtime", fields: ["adapter", "model", "effort", "turns", "command", "arguments", "engine", "timeout", "chrome"] },
{ id: "environment", label: "Environment", fields: ["execution environment", "environment override", "variables", "secrets"] },
{ id: "schedule", label: "Schedule & Runs", fields: ["heartbeat", "wake on demand", "cooldown", "concurrent runs", "continuation"] },
{ id: "access", label: "Access & Governance", instant: true, fields: ["trust preset", "boundary", "create agents", "create skills", "assign tasks"] },
{ id: "keys", label: "API Keys", instant: true, fields: ["credentials", "create key", "revoke key"] },
{ id: "danger", label: "Danger & Legacy", fields: ["skip permissions", "bypass sandbox", "working directory", "deprecated"] },
{ id: "history", label: "History", instant: true, fields: ["configuration revisions", "restore"] },
];
export function filterAgentConfigurationSections(query: string): Set<AgentConfigurationSectionId> {
const normalized = query.trim().toLowerCase();
if (!normalized) return new Set(agentConfigurationSections.map((section) => section.id));
return new Set(
agentConfigurationSections
.filter((section) => section.label.toLowerCase().includes(normalized) || section.fields.some((field) => field.includes(normalized)))
.map((section) => section.id),
);
}
function readString(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function resolveEffectiveConfiguration(agent: Agent, adapterModels: AdapterModel[] = [], apiKeyCount = 0) {
const adapterConfig = (agent.adapterConfig ?? {}) as Record<string, unknown>;
const runtimeConfig = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
const heartbeat = (runtimeConfig.heartbeat ?? {}) as Record<string, unknown>;
const profiles = (runtimeConfig.modelProfiles ?? {}) as Record<string, unknown>;
const cheap = (profiles.cheap ?? {}) as Record<string, unknown>;
const cheapConfig = (cheap.adapterConfig ?? {}) as Record<string, unknown>;
const explicitModel = readString(adapterConfig.model);
const resolvedModel = explicitModel || adapterModels[0]?.label || adapterModels[0]?.id || "Adapter default";
const effort = readString(adapterConfig.modelReasoningEffort || adapterConfig.reasoningEffort || adapterConfig.effort || adapterConfig.variant || adapterConfig.mode);
const env = (adapterConfig.env ?? {}) as AgentEnvConfig;
return {
adapter: adapterLabels[agent.adapterType] ?? agent.adapterType,
model: `${resolvedModel}${effort ? ` · ${effort}` : ""}`,
modelInherited: !explicitModel,
cheapModel: readString(cheapConfig.model) || "Primary model",
cheapInherited: !readString(cheapConfig.model),
environment: agent.defaultEnvironmentId ? "Override" : "Company default",
environmentInherited: !agent.defaultEnvironmentId,
cadence: heartbeat.enabled === true ? `Every ${Number(heartbeat.intervalSec ?? 300)} sec` : "On demand",
trust: agent.permissions?.trustPreset === "low_trust_review" ? "Low-trust review" : "Standard",
apiKeyCount,
environmentVariableCount: Object.keys(env).length,
};
}
export function AgentConfigurationRail({
query,
onQueryChange,
visibleSections,
dirtySections,
}: {
query: string;
onQueryChange: (query: string) => void;
visibleSections: Set<AgentConfigurationSectionId>;
dirtySections: ReadonlySet<string>;
}) {
const shown = agentConfigurationSections.filter((section) => visibleSections.has(section.id));
return (
<>
<div className="sticky top-0 z-20 bg-background py-2 md:hidden">
<select
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
aria-label="Configuration section"
onChange={(event) => document.getElementById(`config-${event.target.value}`)?.scrollIntoView({ behavior: "smooth" })}
>
{shown.map((section) => <option key={section.id} value={section.id}>{section.label}</option>)}
</select>
</div>
<aside className="sticky top-4 hidden self-start md:block">
<label className="flex items-center gap-2 rounded-md border border-border px-2.5 py-2">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Find a setting"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</label>
<nav className="mt-3 space-y-1" aria-label="Configuration sections">
{shown.map((section) => (
<a key={section.id} href={`#config-${section.id}`} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground">
<span className="flex-1">{section.label}</span>
{dirtySections.has(section.id) ? <span className="text-primary" aria-label="Unsaved changes">●</span> : null}
{section.instant ? <Zap className="h-3 w-3" aria-label="Applies immediately" /> : null}
</a>
))}
</nav>
</aside>
</>
);
}
export function EffectiveConfigurationStrip({ chips }: { chips: Array<{ label: string; value: string; section: AgentConfigurationSectionId; inherited?: boolean }> }) {
return (
<div className="flex gap-2 overflow-x-auto border-b border-border pb-3" aria-label="Effective configuration">
{chips.map((chip) => (
<a key={chip.label} href={`#config-${chip.section}`} className="shrink-0">
<Badge variant="outline" className={cn("gap-1.5 py-1", chip.inherited && "text-muted-foreground")}>
<span>{chip.label}</span>
<span className="font-mono">{chip.value}</span>
{chip.inherited ? <span>inherited</span> : null}
</Badge>
</a>
))}
</div>
);
}
export function ConfigurationSection({ id, title, instant, children }: { id: AgentConfigurationSectionId; title: string; instant?: boolean; children: ReactNode }) {
return (
<section id={`config-${id}`} className="scroll-mt-24 space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{instant ? <Badge variant="outline" className="gap-1"><Zap className="h-3 w-3" /> applies immediately</Badge> : null}
</div>
{children}
</section>
);
}
+1
View File
@@ -1790,6 +1790,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--gtc-17: 1fr auto; /* Extracted from ui/src/components/ui/card.tsx (grid-cols-[1fr_auto]). */
--gtc-18: auto minmax(0,1fr) minmax(12rem,0.65fr); /* Extracted from ui/src/pages/AgentDetail.tsx (grid-cols-[auto_minmax(0,1fr)_minmax(12rem,0.65fr)]). */
--gtc-19: 260px minmax(0,1fr); /* Extracted from ui/src/pages/AgentDetail.tsx (grid-cols-[260px_minmax(0,1fr)]). */
--gtc-agent-configuration: 14rem minmax(0,1fr);
--gtc-20: 7rem 8rem 1fr; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[7rem_8rem_1fr]). */
--gtc-21: 1.25rem 12rem 1fr; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[1.25rem_12rem_1fr]). */
--gtc-22: 8rem 1fr 1fr 8rem; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[8rem_1fr_1fr_8rem]). */
+117 -9
View File
@@ -26,6 +26,14 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab";
import { AgentConfigForm } from "../components/AgentConfigForm";
import {
AgentConfigurationRail,
ConfigurationSection,
EffectiveConfigurationStrip,
filterAgentConfigurationSections,
resolveEffectiveConfiguration,
type AgentConfigurationSectionId,
} from "../components/agent-configuration-shell";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@@ -703,6 +711,7 @@ export function AgentDetail() {
const needsRunData = activeView === "runs" || Boolean(urlRunId);
const shouldLoadHeartbeats = needsDashboardData || needsRunData;
const [configDirty, setConfigDirty] = useState(false);
const [configDirtyDetails, setConfigDirtyDetails] = useState<{ count: number; sections: string[] }>({ count: 0, sections: [] });
const [configSaving, setConfigSaving] = useState(false);
const saveConfigActionRef = useRef<(() => void) | null>(null);
const cancelConfigActionRef = useRef<(() => void) | null>(null);
@@ -1278,13 +1287,14 @@ export function AgentDetail() {
{!isMobile && showConfigActionBar && (
<div className="fixed bottom-6 right-6 z-30">
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
<span className="text-xs text-muted-foreground">{configDirtyDetails.count} unsaved {configDirtyDetails.count === 1 ? "change" : "changes"}</span>
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
Cancel
Discard
</Button>
<Button
size="sm"
@@ -1304,13 +1314,14 @@ export function AgentDetail() {
className="flex items-center justify-end gap-2 px-3 py-2"
style={{ paddingBottom: "max(env(safe-area-inset-bottom), 0.5rem)" }}
>
<span className="mr-auto text-xs text-muted-foreground">{configDirtyDetails.count} unsaved {configDirtyDetails.count === 1 ? "change" : "changes"}</span>
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
Cancel
Discard
</Button>
<Button
size="sm"
@@ -1325,6 +1336,8 @@ export function AgentDetail() {
{/* View content */}
{activeView === "dashboard" && (
<div className="space-y-8">
<AgentProfileCard agent={agent} companyId={resolvedCompanyId ?? undefined} />
<AgentOverview
agent={agent}
runs={heartbeats ?? []}
@@ -1333,6 +1346,7 @@ export function AgentDetail() {
agentId={agent.id}
agentRouteId={canonicalAgentRef}
/>
</div>
)}
{activeView === "instructions" && (
@@ -1352,6 +1366,7 @@ export function AgentDetail() {
agentId={agent.id}
companyId={resolvedCompanyId ?? undefined}
onDirtyChange={setConfigDirty}
onDirtyDetailsChange={setConfigDirtyDetails}
onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving}
@@ -1496,6 +1511,35 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
/* ---- Agent Overview (main single-page view) ---- */
function AgentProfileCard({ agent, companyId }: { agent: AgentDetailRecord; companyId?: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToastActions();
const updateAgent = useMutation({
mutationFn: (patch: Record<string, unknown>) => agentsApi.update(agent.id, patch, companyId),
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, "dashboard")) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
}
pushToast({ title: "Profile saved", tone: "success" });
},
});
return (
<AgentConfigForm
mode="edit"
agent={agent}
onSave={(patch) => updateAgent.mutateAsync(patch)}
isSaving={updateAgent.isPending}
identityOnly
identitySectionTitle="Profile"
hidePromptTemplate
sectionLayout="cards"
/>
);
}
function AgentOverview({
agent,
runs,
@@ -1682,6 +1726,7 @@ function AgentConfigurePage({
agentId,
companyId,
onDirtyChange,
onDirtyDetailsChange,
onSaveActionChange,
onCancelActionChange,
onSavingChange,
@@ -1691,6 +1736,7 @@ function AgentConfigurePage({
agentId: string;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onDirtyDetailsChange: (details: { count: number; sections: string[] }) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void;
@@ -1700,11 +1746,31 @@ function AgentConfigurePage({
const navigate = useNavigate();
const { tab: urlTab } = useParams<{ tab?: string }>();
const [revisionsOpen, setRevisionsOpen] = useState(false);
const [filterQuery, setFilterQuery] = useState("");
const [dirtyDetails, setDirtyDetails] = useState<{ count: number; sections: string[] }>({ count: 0, sections: [] });
const visibleSections = useMemo(() => filterAgentConfigurationSections(filterQuery), [filterQuery]);
const { data: configRevisions } = useQuery({
queryKey: queryKeys.agents.configRevisions(agent.id),
queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId),
});
const { data: keys = [] } = useQuery({
queryKey: queryKeys.agents.keys(agentId),
queryFn: () => agentsApi.listKeys(agentId, companyId),
});
const { data: adapterModels = [] } = useQuery({
queryKey: companyId ? queryKeys.agents.adapterModels(companyId, agent.adapterType) : ["agents", "none", "adapter-models", agent.adapterType],
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType),
enabled: Boolean(companyId),
});
const effectiveConfig = useMemo(
() => resolveEffectiveConfiguration(agent, adapterModels, keys.filter((key) => !key.revokedAt).length),
[adapterModels, agent, keys],
);
const handleDirtyDetailsChange = useCallback((details: { count: number; sections: string[] }) => {
setDirtyDetails(details);
onDirtyDetailsChange(details);
}, [onDirtyDetailsChange]);
const rollbackConfig = useMutation({
mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId),
@@ -1717,11 +1783,31 @@ function AgentConfigurePage({
},
});
const effectiveChips: Array<{ label: string; value: string; section: AgentConfigurationSectionId; inherited?: boolean }> = [
{ label: "Adapter", value: effectiveConfig.adapter, section: "runtime" },
{ label: "Model", value: effectiveConfig.model, section: "runtime", inherited: effectiveConfig.modelInherited },
{ label: "Cost saver", value: effectiveConfig.cheapModel, section: "runtime", inherited: effectiveConfig.cheapInherited },
{ label: "Environment", value: effectiveConfig.environment, section: "environment", inherited: effectiveConfig.environmentInherited },
{ label: "Heartbeat", value: effectiveConfig.cadence, section: "schedule" },
{ label: "Trust", value: effectiveConfig.trust, section: "access" },
{ label: "API keys", value: String(effectiveConfig.apiKeyCount), section: "keys" },
];
return (
<div className="max-w-3xl space-y-6">
<ConfigurationTab
<div className="space-y-5">
<EffectiveConfigurationStrip chips={effectiveChips} />
<div className="grid gap-6 md:grid-cols-(--gtc-agent-configuration)">
<AgentConfigurationRail
query={filterQuery}
onQueryChange={setFilterQuery}
visibleSections={visibleSections}
dirtySections={new Set(dirtyDetails.sections)}
/>
<div className="max-w-3xl space-y-8">
<ConfigurationTab
agent={agent}
onDirtyChange={onDirtyChange}
onDirtyDetailsChange={handleDirtyDetailsChange}
onSaveActionChange={onSaveActionChange}
onCancelActionChange={onCancelActionChange}
onSavingChange={onSavingChange}
@@ -1729,14 +1815,24 @@ function AgentConfigurePage({
companyId={companyId}
hidePromptTemplate
hideInstructionsFile
visibleSections={visibleSections}
/>
<div>
<h3 className="text-sm font-medium mb-3">API Keys</h3>
<KeysTab agentId={agentId} companyId={companyId} />
</div>
{visibleSections.has("keys") ? (
<ConfigurationSection id="keys" title="API Keys" instant>
<p className="text-xs text-muted-foreground">Long-lived keys for external processes acting as this agent. Runs receive short-lived tokens automatically.</p>
<KeysTab agentId={agentId} companyId={companyId} />
</ConfigurationSection>
) : null}
{visibleSections.has("danger") ? (
<ConfigurationSection id="danger" title="Danger & Legacy">
<div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">
Adapter-specific sandbox bypass, permission skipping, and deprecated values remain draftable in the runtime form. They are grouped here for navigation while the field internals stay unchanged in P1.
</div>
</ConfigurationSection>
) : null}
{/* Configuration Revisions — collapsible at the bottom */}
<div>
{visibleSections.has("history") ? <ConfigurationSection id="history" title="History" instant>
<button
className="flex items-center gap-2 text-sm font-medium hover:text-foreground transition-colors"
onClick={() => setRevisionsOpen((v) => !v)}
@@ -1784,6 +1880,8 @@ function AgentConfigurePage({
)}
</div>
)}
</ConfigurationSection> : null}
</div>
</div>
</div>
);
@@ -1795,22 +1893,26 @@ function ConfigurationTab({
agent,
companyId,
onDirtyChange,
onDirtyDetailsChange,
onSaveActionChange,
onCancelActionChange,
onSavingChange,
updatePermissions,
hidePromptTemplate,
hideInstructionsFile,
visibleSections,
}: {
agent: AgentDetailRecord;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onDirtyDetailsChange: (details: { count: number; sections: string[] }) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
hidePromptTemplate?: boolean;
hideInstructionsFile?: boolean;
visibleSections: Set<AgentConfigurationSectionId>;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -1907,17 +2009,22 @@ function ConfigurationTab({
isSaving={isConfigSaving}
adapterModels={adapterModels}
onDirtyChange={onDirtyChange}
onDirtyDetailsChange={onDirtyDetailsChange}
onSaveActionChange={onSaveActionChange}
onCancelActionChange={onCancelActionChange}
hideInlineSave
hidePromptTemplate={hidePromptTemplate}
hideInstructionsFile={hideInstructionsFile}
hideIdentity
configurationShell
visibleConfigurationSections={visibleSections}
sectionLayout="cards"
/>
<p className="text-xs text-muted-foreground">
Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session.
</p>
{visibleSections.has("access") ? <ConfigurationSection id="access" title="Access & Governance" instant>
<TrustPresetSection
permissions={agent.permissions}
disabled={updatePermissions.isPending}
@@ -2003,6 +2110,7 @@ function ConfigurationTab({
</div>
</div>
</div>
</ConfigurationSection> : null}
</div>
);
}