Add agent config review and history

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 18:32:29 +00:00
1 parent 8b288dbc1e
commit 2d1118db48
6 files changed
+339 -85

No files matched your search

+13 -2
View File
@@ -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<string, unknown>) : {};
@@ -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(<AgentConfigHistory revisions={[revision]} onRestore={onRestore} restoring={false} />));
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");
});
});
+121
View File
@@ -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<string, unknown>, key: string): unknown {
return key.split(".").reduce<unknown>((current, segment) => {
if (!current || typeof current !== "object") return undefined;
return (current as Record<string, unknown>)[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 (
<div className="divide-y divide-border">
{revisionDiff(revision).map((entry) => (
<div key={entry.key} className="space-y-2 py-3">
<div className="font-mono text-xs text-muted-foreground">{entry.key}</div>
<div className="grid gap-2 text-xs sm:grid-cols-2">
<div className="min-w-0 rounded-md bg-muted/40 p-2 font-mono break-words">{formatAgentConfigValue(entry.before)}</div>
<div className="min-w-0 rounded-md bg-muted/40 p-2 font-mono break-words">{formatAgentConfigValue(entry.after)}</div>
</div>
</div>
))}
</div>
);
}
export function AgentConfigHistory({
revisions,
onRestore,
restoring,
}: {
revisions: AgentConfigRevision[];
onRestore: (revisionId: string) => void;
restoring: boolean;
}) {
const [selectedRevisionId, setSelectedRevisionId] = useState<string | null>(null);
const [restoreRevisionId, setRestoreRevisionId] = useState<string | null>(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 <p className="text-sm text-muted-foreground">No configuration revisions yet.</p>;
return (
<>
<div className="divide-y divide-border">
{revisions.map((revision) => (
<div key={revision.id} className="flex items-start gap-3 py-3">
<div className="mt-1 h-2 w-2 shrink-0 rounded-full bg-muted-foreground/50" />
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setSelectedRevisionId(revision.id)}>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{revision.source === "rollback" ? "Configuration restored" : "Configuration saved"}</span>
{revision.source === "rollback" ? <Zap className="h-3.5 w-3.5 text-amber-500" aria-label="Instant apply" /> : null}
<span className="text-xs text-muted-foreground">{formatDate(revision.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{revision.createdByUserId ? "Board" : revision.createdByAgentId ? `Agent ${revision.createdByAgentId.slice(0, 8)}` : "System"} · {revision.source}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{revision.changedKeys.map((key) => <Badge key={key} variant="secondary" className="font-mono text-xs">{key}</Badge>)}
</div>
</button>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setRestoreRevisionId(revision.id)}><RotateCcw className="h-3.5 w-3.5" />Restore</Button>
<Button variant="ghost" size="icon" aria-label="View revision diff" onClick={() => setSelectedRevisionId(revision.id)}><ChevronRight className="h-4 w-4" /></Button>
</div>
</div>
))}
</div>
<Sheet open={Boolean(selectedRevision)} onOpenChange={(open) => { if (!open) setSelectedRevisionId(null); }}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>Configuration changes</SheetTitle>
<SheetDescription>{selectedRevision ? `${formatDate(selectedRevision.createdAt)} · ${selectedRevision.source}` : ""}</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto px-4 pb-4">{selectedRevision ? <RevisionDiffRows revision={selectedRevision} /> : null}</div>
</SheetContent>
</Sheet>
<AlertDialog open={Boolean(restoreRevision)} onOpenChange={(open) => { if (!open) setRestoreRevisionId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore this configuration?</AlertDialogTitle>
<AlertDialogDescription>Review the values that will be restored. This applies immediately and creates a new history entry.</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-64 overflow-y-auto">{restoreRevision ? <RevisionDiffRows revision={restoreRevision} /> : null}</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={restoring}>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={restoring} onClick={() => { if (restoreRevision) onRestore(restoreRevision.id); }}>
{restoring ? "Restoring…" : "Restore configuration"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+21
View File
@@ -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: {} });
});
});
+67
View File
@@ -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<string, unknown>)[field];
if (group === "adapterConfig") return (agent.adapterConfig as Record<string, unknown> | null | undefined)?.[field];
const runtimeConfig = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
if (group === "heartbeat") return (runtimeConfig.heartbeat as Record<string, unknown> | 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<string, unknown>).modelProfiles as Record<string, unknown> | 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);
}
+70 -83
View File
@@ -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<AgentConfigChange[]>([]);
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<AgentDetailRecord>({
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 ? (
<Popover>
<PopoverTrigger asChild><Button variant="ghost" size="sm">Review changes</Button></PopoverTrigger>
<PopoverContent align="end" className="w-96 max-w-(--pct-90)">
<div className="mb-2 text-sm font-medium">Changes to save</div>
<div className="max-h-80 divide-y divide-border overflow-y-auto">
{configChanges.map((change) => (
<div key={change.key} className="grid grid-cols-(--gtc-agent-config-review) gap-3 py-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2"><span className="text-sm font-medium">{change.label}</span><span className="text-xs text-muted-foreground">{change.section}</span></div>
<div className="truncate font-mono text-xs text-muted-foreground">{formatAgentConfigValue(change.before)} → {formatAgentConfigValue(change.after)}</div>
</div>
<Button variant="ghost" size="sm" onClick={() => revertConfigChangeRef.current(change.key)}>Revert</Button>
</div>
))}
</div>
</PopoverContent>
</Popover>
) : null;
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
const agentMembershipPending =
membershipMutation.isPending &&
@@ -1289,6 +1328,7 @@ export function AgentDetail() {
<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>
{reviewChangesPopover}
<Button
variant="ghost"
size="sm"
@@ -1316,6 +1356,7 @@ export function AgentDetail() {
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>
{reviewChangesPopover}
<Button
variant="ghost"
size="sm"
@@ -1368,6 +1409,7 @@ export function AgentDetail() {
companyId={resolvedCompanyId ?? undefined}
onDirtyChange={setConfigDirty}
onDirtyDetailsChange={setConfigDirtyDetails}
onChangesetChange={setConfigChangeset}
onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving}
@@ -1728,6 +1770,7 @@ function AgentConfigurePage({
companyId,
onDirtyChange,
onDirtyDetailsChange,
onChangesetChange,
onSaveActionChange,
onCancelActionChange,
onSavingChange,
@@ -1738,6 +1781,7 @@ function AgentConfigurePage({
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;
@@ -1746,7 +1790,6 @@ function AgentConfigurePage({
const queryClient = useQueryClient();
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]);
@@ -1796,6 +1839,7 @@ function AgentConfigurePage({
agent={agent}
onDirtyChange={onDirtyChange}
onDirtyDetailsChange={handleDirtyDetailsChange}
onChangesetChange={onChangesetChange}
onSaveActionChange={onSaveActionChange}
onCancelActionChange={onCancelActionChange}
onSavingChange={onSavingChange}
@@ -1805,55 +1849,8 @@ function AgentConfigurePage({
hideInstructionsFile
visibleSections={visibleSections}
/>
{/* Configuration Revisions — collapsible at the bottom */}
{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)}
>
{revisionsOpen
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
}
Configuration Revisions
<span className="text-xs font-normal text-muted-foreground">{configRevisions?.length ?? 0}</span>
</button>
{revisionsOpen && (
<div className="mt-3">
{(configRevisions ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No configuration revisions yet.</p>
) : (
<div className="space-y-2">
{(configRevisions ?? []).slice(0, 10).map((revision) => (
<div key={revision.id} className="border border-border/70 rounded-md p-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-muted-foreground">
<span className="font-mono">{revision.id.slice(0, 8)}</span>
<span className="mx-1">·</span>
<span>{formatDate(revision.createdAt)}</span>
<span className="mx-1">·</span>
<span>{revision.source}</span>
</div>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
onClick={() => rollbackConfig.mutate(revision.id)}
disabled={rollbackConfig.isPending}
>
Restore
</Button>
</div>
<p className="text-xs text-muted-foreground">
Changed:{" "}
{revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"}
</p>
</div>
))}
</div>
)}
</div>
)}
<AgentConfigHistory revisions={configRevisions ?? []} onRestore={(revisionId) => rollbackConfig.mutate(revisionId)} restoring={rollbackConfig.isPending} />
</ConfigurationSection> : null}
</div>
</div>
@@ -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 }
<h3 className="text-xs font-medium text-muted-foreground mb-2">
Active Keys
</h3>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left">
<thead className="border-b border-border text-xs font-medium text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Created</th>
<th className="px-4 py-2 font-medium">Last used</th>
<th className="px-4 py-2"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{activeKeys.map((key: AgentKey) => (
<tr key={key.id}>
<td className="whitespace-nowrap px-4 py-2.5 text-sm font-medium">{key.name}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-xs text-muted-foreground">{formatDate(key.createdAt)}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-xs font-mono text-muted-foreground" title={key.lastUsedAt ? formatDate(key.lastUsedAt) : undefined}>
{key.lastUsedAt ? relativeTime(key.lastUsedAt) : "—"}
</td>
<td className="px-4 py-2.5 text-right">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive text-xs"
onClick={() => revokeKey.mutate(key.id)}
disabled={revokeKey.isPending}
>
Revoke
</Button>
</td>
</tr>
))}
</tbody>
</table>
<div className="border border-border rounded-lg divide-y divide-border">
{activeKeys.map((key: AgentKey) => (
<div key={key.id} className="flex items-center justify-between px-4 py-2.5">
<div>
<span className="text-sm font-medium">{key.name}</span>
<span className="text-xs text-muted-foreground ml-3">
Created {formatDate(key.createdAt)}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive text-xs"
onClick={() => revokeKey.mutate(key.id)}
disabled={revokeKey.isPending}
>
Revoke
</Button>
</div>
))}
</div>
</div>
)}