diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index b120c3ad65c..89fa1fc82ca 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -61,6 +61,7 @@ import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, omitUndefinedEntries, type AgentConfigOverlay } from "../lib/agent-config-patch"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment"; +import { buildAgentConfigChanges, revertAgentConfigChange, type AgentConfigChange } from "../lib/agent-config-changeset"; /* ---- Create mode values ---- */ @@ -75,6 +76,7 @@ import { Badge } from "@/components/ui/badge"; type AgentConfigFormProps = { adapterModels?: AdapterModel[]; onDirtyDetailsChange?: (details: { count: number; sections: string[] }) => void; + onChangesetChange?: (changes: AgentConfigChange[], revert: (key: string) => void) => void; onDirtyChange?: (dirty: boolean) => void; onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; @@ -349,6 +351,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isDirty = !isCreate && isOverlayDirty(overlay); const dirtyDetails = useMemo(() => getAgentConfigDirtyDetails(overlay), [overlay]); + const changes = useMemo( + () => isCreate ? [] : buildAgentConfigChanges(props.agent, overlay), + [isCreate, overlay, !isCreate ? props.agent : undefined], // eslint-disable-line react-hooks/exhaustive-deps + ); + const revertChange = useCallback((key: string) => { + setOverlay((current) => revertAgentConfigChange(current, key)); + }, []); type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; @@ -419,10 +428,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (!isCreate) { props.onDirtyChange?.(isDirty); props.onDirtyDetailsChange?.(dirtyDetails); + props.onChangesetChange?.(changes, revertChange); props.onSaveActionChange?.(handleSave); props.onCancelActionChange?.(handleCancel); } - }, [isCreate, isDirty, dirtyDetails, props.onDirtyChange, props.onDirtyDetailsChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); + }, [isCreate, isDirty, dirtyDetails, changes, revertChange, props.onDirtyChange, props.onDirtyDetailsChange, props.onChangesetChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]); useEffect(() => { if (isCreate) return; @@ -431,8 +441,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { props.onCancelActionChange?.(null); props.onDirtyChange?.(false); props.onDirtyDetailsChange?.({ count: 0, sections: [] }); + props.onChangesetChange?.([], () => undefined); }; - }, [isCreate, props.onDirtyChange, props.onDirtyDetailsChange, props.onSaveActionChange, props.onCancelActionChange]); + }, [isCreate, props.onDirtyChange, props.onDirtyDetailsChange, props.onChangesetChange, props.onSaveActionChange, props.onCancelActionChange]); // ---- Resolve values ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; diff --git a/ui/src/components/AgentConfigHistory.test.tsx b/ui/src/components/AgentConfigHistory.test.tsx new file mode 100644 index 00000000000..c46ce697484 --- /dev/null +++ b/ui/src/components/AgentConfigHistory.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentConfigRevision } from "@paperclipai/shared"; +import { AgentConfigHistory } from "./AgentConfigHistory"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const revision = { + id: "revision-1", companyId: "company-1", agentId: "agent-1", createdByAgentId: null, createdByUserId: "user-1", + source: "patch", rolledBackFromRevisionId: null, changedKeys: ["adapterConfig.model"], + beforeConfig: { adapterConfig: { model: "gpt-5" } }, afterConfig: { adapterConfig: { model: "gpt-5.5" } }, + createdAt: new Date("2026-07-22T12:00:00Z"), +} satisfies AgentConfigRevision; + +describe("AgentConfigHistory", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + document.body.innerHTML = ""; + }); + + it("requires confirmation before restoring a revision", () => { + const onRestore = vi.fn(); + flushSync(() => root.render()); + + const restoreButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Restore"); + flushSync(() => restoreButton?.click()); + expect(document.body.textContent).toContain("Restore this configuration?"); + expect(document.body.textContent).toContain("gpt-5"); + expect(onRestore).not.toHaveBeenCalled(); + + const confirmButton = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Restore configuration"); + flushSync(() => confirmButton?.click()); + expect(onRestore).toHaveBeenCalledWith("revision-1"); + }); +}); diff --git a/ui/src/components/AgentConfigHistory.tsx b/ui/src/components/AgentConfigHistory.tsx new file mode 100644 index 00000000000..43851c34472 --- /dev/null +++ b/ui/src/components/AgentConfigHistory.tsx @@ -0,0 +1,121 @@ +import { useMemo, useState } from "react"; +import type { AgentConfigRevision } from "@paperclipai/shared"; +import { ChevronRight, RotateCcw, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { formatDate } from "../lib/utils"; +import { formatAgentConfigValue } from "../lib/agent-config-changeset"; + +function valueAt(snapshot: Record, key: string): unknown { + return key.split(".").reduce((current, segment) => { + if (!current || typeof current !== "object") return undefined; + return (current as Record)[segment]; + }, snapshot); +} + +export function revisionDiff(revision: AgentConfigRevision) { + return revision.changedKeys.map((key) => ({ + key, + before: valueAt(revision.beforeConfig, key), + after: valueAt(revision.afterConfig, key), + })); +} + +function RevisionDiffRows({ revision }: { revision: AgentConfigRevision }) { + return ( +
+ {revisionDiff(revision).map((entry) => ( +
+
{entry.key}
+
+
{formatAgentConfigValue(entry.before)}
+
{formatAgentConfigValue(entry.after)}
+
+
+ ))} +
+ ); +} + +export function AgentConfigHistory({ + revisions, + onRestore, + restoring, +}: { + revisions: AgentConfigRevision[]; + onRestore: (revisionId: string) => void; + restoring: boolean; +}) { + const [selectedRevisionId, setSelectedRevisionId] = useState(null); + const [restoreRevisionId, setRestoreRevisionId] = useState(null); + const selectedRevision = useMemo(() => revisions.find((revision) => revision.id === selectedRevisionId) ?? null, [revisions, selectedRevisionId]); + const restoreRevision = useMemo(() => revisions.find((revision) => revision.id === restoreRevisionId) ?? null, [revisions, restoreRevisionId]); + + if (revisions.length === 0) return

No configuration revisions yet.

; + + return ( + <> +
+ {revisions.map((revision) => ( +
+
+ +
+ + +
+
+ ))} +
+ + { if (!open) setSelectedRevisionId(null); }}> + + + Configuration changes + {selectedRevision ? `${formatDate(selectedRevision.createdAt)} · ${selectedRevision.source}` : ""} + +
{selectedRevision ? : null}
+
+
+ + { if (!open) setRestoreRevisionId(null); }}> + + + Restore this configuration? + Review the values that will be restored. This applies immediately and creates a new history entry. + +
{restoreRevision ? : null}
+ + Cancel + { if (restoreRevision) onRestore(restoreRevision.id); }}> + {restoring ? "Restoring…" : "Restore configuration"} + + +
+
+ + ); +} diff --git a/ui/src/lib/agent-config-changeset.test.ts b/ui/src/lib/agent-config-changeset.test.ts new file mode 100644 index 00000000000..1b57e2c0ee6 --- /dev/null +++ b/ui/src/lib/agent-config-changeset.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { buildAgentConfigChanges, revertAgentConfigChange } from "./agent-config-changeset"; + +const agent = { adapterType: "codex_local", adapterConfig: { model: "gpt-5", cwd: "/old" }, runtimeConfig: { heartbeat: { intervalSec: 300 } } } as unknown as Agent; + +describe("agent config changeset", () => { + it("builds review rows from overlay keys", () => { + const changes = buildAgentConfigChanges(agent, { identity: {}, adapterConfig: { model: "gpt-5.5", cwd: "/new" }, heartbeat: { intervalSec: 600 }, runtime: {} }); + expect(changes.map((change) => [change.key, change.before, change.after, change.section])).toEqual([ + ["adapterConfig.model", "gpt-5", "gpt-5.5", "Runtime"], + ["adapterConfig.cwd", "/old", "/new", "Danger & Legacy"], + ["heartbeat.intervalSec", 300, 600, "Schedule & Runs"], + ]); + }); + + it("reverts one row without disturbing the rest", () => { + const overlay = { identity: {}, adapterConfig: { model: "gpt-5.5", cwd: "/new" }, heartbeat: {}, runtime: {} }; + expect(revertAgentConfigChange(overlay, "adapterConfig.model")).toEqual({ identity: {}, adapterConfig: { cwd: "/new" }, heartbeat: {}, runtime: {} }); + }); +}); diff --git a/ui/src/lib/agent-config-changeset.ts b/ui/src/lib/agent-config-changeset.ts new file mode 100644 index 00000000000..9180db1136f --- /dev/null +++ b/ui/src/lib/agent-config-changeset.ts @@ -0,0 +1,67 @@ +import type { Agent } from "@paperclipai/shared"; +import type { AgentConfigOverlay } from "./agent-config-patch"; + +export type AgentConfigChange = { + key: string; + label: string; + section: "Runtime" | "Environment" | "Schedule & Runs" | "Danger & Legacy"; + before: unknown; + after: unknown; +}; + +type OverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; + +function labelForKey(key: string): string { + return key.replace(/^.*\./, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (value) => value.toUpperCase()); +} + +function sectionForKey(key: string): AgentConfigChange["section"] { + if (key === "identity.defaultEnvironmentId" || key === "adapterConfig.env") return "Environment"; + if (key.startsWith("heartbeat.")) return "Schedule & Runs"; + if (/cwd|dangerouslySkipPermissions|dangerouslyBypassSandbox|dangerouslyBypassApprovalsAndSandbox/.test(key)) return "Danger & Legacy"; + return "Runtime"; +} + +function originalValue(agent: Agent, group: OverlayGroup, field: string): unknown { + if (group === "identity") return (agent as unknown as Record)[field]; + if (group === "adapterConfig") return (agent.adapterConfig as Record | null | undefined)?.[field]; + const runtimeConfig = (agent.runtimeConfig ?? {}) as Record; + if (group === "heartbeat") return (runtimeConfig.heartbeat as Record | null | undefined)?.[field]; + return runtimeConfig[field]; +} + +export function buildAgentConfigChanges(agent: Agent, overlay: AgentConfigOverlay): AgentConfigChange[] { + const changes: AgentConfigChange[] = []; + const addGroup = (group: OverlayGroup) => { + for (const [field, after] of Object.entries(overlay[group])) { + const key = `${group}.${field}`; + changes.push({ key, label: labelForKey(key), section: sectionForKey(key), before: originalValue(agent, group, field), after }); + } + }; + addGroup("identity"); + if (overlay.adapterType !== undefined) changes.push({ key: "adapterType", label: "Adapter", section: "Runtime", before: agent.adapterType, after: overlay.adapterType }); + addGroup("adapterConfig"); + addGroup("heartbeat"); + addGroup("runtime"); + if (overlay.modelProfiles?.cheap !== undefined) { + const profiles = ((agent.runtimeConfig ?? {}) as Record).modelProfiles as Record | undefined; + changes.push({ key: "modelProfiles.cheap", label: "Cost saver model", section: "Runtime", before: profiles?.cheap, after: overlay.modelProfiles.cheap }); + } + return changes; +} + +export function revertAgentConfigChange(overlay: AgentConfigOverlay, key: string): AgentConfigOverlay { + if (key === "adapterType") return { ...overlay, adapterType: undefined }; + if (key === "modelProfiles.cheap") return { ...overlay, modelProfiles: undefined }; + const [group, field] = key.split(".", 2) as [OverlayGroup, string]; + if (!field || !(group in overlay)) return overlay; + const nextGroup = { ...overlay[group] }; + delete nextGroup[field]; + return { ...overlay, [group]: nextGroup }; +} + +export function formatAgentConfigValue(value: unknown): string { + if (value === undefined || value === null || value === "") return "Not set"; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index d674a02e3a2..cae506cdbe5 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router"; +import { useParams, useNavigate, Link, Navigate, useBeforeUnload, useBlocker, type NavigateFunction } from "@/lib/router"; import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { agentsApi, @@ -26,6 +26,8 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab"; import { AgentConfigForm } from "../components/AgentConfigForm"; +import { AgentConfigHistory } from "../components/AgentConfigHistory"; +import { formatAgentConfigValue, type AgentConfigChange } from "../lib/agent-config-changeset"; import { AgentConfigurationRail, ConfigurationSection, @@ -71,6 +73,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CheckCircle2, XCircle, @@ -713,9 +716,11 @@ export function AgentDetail() { const shouldLoadHeartbeats = needsDashboardData || needsRunData; const [configDirty, setConfigDirty] = useState(false); const [configDirtyDetails, setConfigDirtyDetails] = useState<{ count: number; sections: string[] }>({ count: 0, sections: [] }); + const [configChanges, setConfigChanges] = useState([]); const [configSaving, setConfigSaving] = useState(false); const saveConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null); + const revertConfigChangeRef = useRef<(key: string) => void>(() => undefined); const { isMobile } = useSidebar(); const routeAgentRef = agentId ?? ""; const routeCompanyId = useMemo(() => { @@ -727,6 +732,21 @@ export function AgentDetail() { const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId)); const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); + const setConfigChangeset = useCallback((changes: AgentConfigChange[], revert: (key: string) => void) => { + setConfigChanges(changes); + revertConfigChangeRef.current = revert; + }, []); + useBeforeUnload(useCallback((event) => { + if (!configDirty) return; + event.preventDefault(); + event.returnValue = ""; + }, [configDirty])); + const navigationBlocker = useBlocker(configDirty); + useEffect(() => { + if (navigationBlocker.state !== "blocked") return; + if (window.confirm("Discard your unsaved agent configuration changes?")) navigationBlocker.proceed(); + else navigationBlocker.reset(); + }, [navigationBlocker]); const { data: agent, isLoading, error } = useQuery({ queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], @@ -1058,6 +1078,25 @@ export function AgentDetail() { const isPendingApproval = agent.status === "pending_approval"; const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving); + const reviewChangesPopover = configChanges.length > 0 ? ( + + + +
Changes to save
+
+ {configChanges.map((change) => ( +
+
+
{change.label}{change.section}
+
{formatAgentConfigValue(change.before)} → {formatAgentConfigValue(change.after)}
+
+ +
+ ))} +
+
+
+ ) : null; const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id); const agentMembershipPending = membershipMutation.isPending && @@ -1289,6 +1328,7 @@ export function AgentDetail() {
{configDirtyDetails.count} unsaved {configDirtyDetails.count === 1 ? "change" : "changes"} + {reviewChangesPopover} - {revisionsOpen && ( -
- {(configRevisions ?? []).length === 0 ? ( -

No configuration revisions yet.

- ) : ( -
- {(configRevisions ?? []).slice(0, 10).map((revision) => ( -
-
-
- {revision.id.slice(0, 8)} - · - {formatDate(revision.createdAt)} - · - {revision.source} -
- -
-

- Changed:{" "} - {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"} -

-
- ))} -
- )} -
- )} + rollbackConfig.mutate(revisionId)} restoring={rollbackConfig.isPending} /> : null}
@@ -1868,6 +1865,7 @@ function ConfigurationTab({ companyId, onDirtyChange, onDirtyDetailsChange, + onChangesetChange, onSaveActionChange, onCancelActionChange, onSavingChange, @@ -1880,6 +1878,7 @@ function ConfigurationTab({ companyId?: string; onDirtyChange: (dirty: boolean) => void; onDirtyDetailsChange: (details: { count: number; sections: string[] }) => void; + onChangesetChange: (changes: AgentConfigChange[], revert: (key: string) => void) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; @@ -2086,6 +2085,7 @@ function ConfigurationTab({ adapterModels={adapterModels} onDirtyChange={onDirtyChange} onDirtyDetailsChange={onDirtyDetailsChange} + onChangesetChange={onChangesetChange} onSaveActionChange={onSaveActionChange} onCancelActionChange={onCancelActionChange} hideInlineSave @@ -4278,39 +4278,26 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }

Active Keys

-
- - - - - - - - - - - {activeKeys.map((key: AgentKey) => ( - - - - - - - ))} - -
NameCreatedLast usedActions
{key.name}{formatDate(key.createdAt)} - {key.lastUsedAt ? relativeTime(key.lastUsedAt) : "—"} - - -
+
+ {activeKeys.map((key: AgentKey) => ( +
+
+ {key.name} + + Created {formatDate(key.createdAt)} + +
+ +
+ ))}
)}