diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index bbaa9cf67fe..bed5b412a3c 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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; /** 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(); + let count = 0; + const addGroup = (group: Record, 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) : {}; @@ -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) { mark("heartbeat", "maxTurnContinuation", { @@ -948,10 +991,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} {/* ---- Identity (edit only) ---- */} - {!isCreate && ( + {!isCreate && !props.hideIdentity && (
{cards - ?

Identity

+ ?

{props.identitySectionTitle ?? "Identity"}

:
Identity
}
@@ -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. -
+
{cards ?

Environment

:
Environment
@@ -1055,7 +1098,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
) : showEnvironmentOverrideControl ? ( -
+
{cards ?

Environment

:
Environment
@@ -1086,13 +1129,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
- ) : null} + ) : null)} {/* ---- Adapter ---- */} -
+ {sectionVisible("runtime") &&
{cards - ?

Adapter

+ ?

{configurationShell ? "Runtime" : "Adapter"}

: Adapter } {showInlineAdapterTestEnvironmentButton && ( @@ -1207,13 +1250,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {/* Local adapter-specific fields are rendered inside Permissions & Configuration */}
-
+
} {/* ---- Permissions & Configuration ---- */} - {isLocal && ( + {sectionVisible("runtime") && isLocal && (
{cards - ?

Permissions & Configuration

+ ?

{configurationShell ? "Advanced runtime" : "Permissions & Configuration"}

:
Permissions & Configuration
}
@@ -1451,10 +1494,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} {/* ---- Run Policy ---- */} - {isCreate && showCreateRunPolicySection ? ( -
+ {sectionVisible("schedule") && (isCreate && showCreateRunPolicySection ? ( +
{cards - ?

Run Policy

+ ?

{configurationShell ? "Schedule & Runs" : "Run Policy"}

:
Run Policy
}
@@ -1473,9 +1516,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
) : !isCreate ? ( -
+
{cards - ?

Run Policy

+ ?

{configurationShell ? "Schedule & Runs" : "Run Policy"}

:
Run Policy
}
@@ -1572,7 +1615,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
- ) : null} + ) : null)}
); diff --git a/ui/src/components/agent-configuration-shell.test.ts b/ui/src/components/agent-configuration-shell.test.ts new file mode 100644 index 00000000000..981ad9af922 --- /dev/null +++ b/ui/src/components/agent-configuration-shell.test.ts @@ -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"], + }); + }); +}); diff --git a/ui/src/components/agent-configuration-shell.tsx b/ui/src/components/agent-configuration-shell.tsx new file mode 100644 index 00000000000..5f22d327a55 --- /dev/null +++ b/ui/src/components/agent-configuration-shell.tsx @@ -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 { + 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; + const runtimeConfig = (agent.runtimeConfig ?? {}) as Record; + const heartbeat = (runtimeConfig.heartbeat ?? {}) as Record; + const profiles = (runtimeConfig.modelProfiles ?? {}) as Record; + const cheap = (profiles.cheap ?? {}) as Record; + const cheapConfig = (cheap.adapterConfig ?? {}) as Record; + 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; + dirtySections: ReadonlySet; +}) { + const shown = agentConfigurationSections.filter((section) => visibleSections.has(section.id)); + return ( + <> +
+ +
+ + + ); +} + +export function EffectiveConfigurationStrip({ chips }: { chips: Array<{ label: string; value: string; section: AgentConfigurationSectionId; inherited?: boolean }> }) { + return ( + + ); +} + +export function ConfigurationSection({ id, title, instant, children }: { id: AgentConfigurationSectionId; title: string; instant?: boolean; children: ReactNode }) { + return ( +
+
+

{title}

+ {instant ? applies immediately : null} +
+ {children} +
+ ); +} diff --git a/ui/src/index.css b/ui/src/index.css index d346948b9ad..3cd8cc9e114 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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]). */ diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index e97c56c0719..c34853a0356 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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 && (
+ {configDirtyDetails.count} unsaved {configDirtyDetails.count === 1 ? "change" : "changes"}
)} {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) => 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 ( + 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 ( -
- + +
+ +
+ -
-

API Keys

- -
+ {visibleSections.has("keys") ? ( + +

Long-lived keys for external processes acting as this agent. Runs receive short-lived tokens automatically.

+ +
+ ) : null} + {visibleSections.has("danger") ? ( + +
+ 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. +
+
+ ) : null} {/* Configuration Revisions — collapsible at the bottom */} -
+ {visibleSections.has("history") ?
)} + : null} +
); @@ -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; }) { 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" />

Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session.

+ {visibleSections.has("access") ?
+ : null}
); }