mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Redesign agent configuration shell
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d31a28828b
commit
8818747b3a
5 files changed
+370
-28
No files matched your search
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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]). */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user