mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Add agent config review and history
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8b288dbc1e
commit
2d1118db48
6 files changed
+339
-85
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user