From 6aa50ea1fc4bee142bd0d7387ff480bddd7f119e Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 19:12:07 +0000 Subject: [PATCH] Fix nested agent config history diffs Co-Authored-By: Paperclip --- ui/src/components/AgentConfigHistory.test.tsx | 32 ++++++++++++++++++- ui/src/components/AgentConfigHistory.tsx | 30 +++++++++++++---- ui/src/lib/agent-config-changeset.ts | 4 +-- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/ui/src/components/AgentConfigHistory.test.tsx b/ui/src/components/AgentConfigHistory.test.tsx index c46ce697484..d31ff5b51e9 100644 --- a/ui/src/components/AgentConfigHistory.test.tsx +++ b/ui/src/components/AgentConfigHistory.test.tsx @@ -4,7 +4,7 @@ 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"; +import { AgentConfigHistory, revisionDiff } from "./AgentConfigHistory"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -44,4 +44,34 @@ describe("AgentConfigHistory", () => { flushSync(() => confirmButton?.click()); expect(onRestore).toHaveBeenCalledWith("revision-1"); }); + + it("expands legacy whole-object changes into field-level rows", () => { + const legacyRevision = { + ...revision, + changedKeys: ["adapterConfig"], + beforeConfig: { adapterConfig: { effort: "high", graceSec: 15, headers: { version: "1" } } }, + afterConfig: { adapterConfig: { effort: "high", graceSec: 42, headers: { version: "2" } } }, + } satisfies AgentConfigRevision; + + expect(revisionDiff(legacyRevision)).toEqual([ + { key: "adapterConfig.graceSec", before: 15, after: 42 }, + { key: "adapterConfig.headers.version", before: "1", after: "2" }, + ]); + }); + + it("uses the same human-readable labels as the review changes popover", () => { + const labelRevision = { + ...revision, + changedKeys: ["adapterConfig.graceSec"], + beforeConfig: { adapterConfig: { graceSec: 15 } }, + afterConfig: { adapterConfig: { graceSec: 42 } }, + } satisfies AgentConfigRevision; + flushSync(() => root.render()); + + const viewDiffButton = container.querySelector('button[aria-label="View revision diff"]'); + flushSync(() => viewDiffButton?.click()); + + expect(document.body.textContent).toContain("Grace Sec"); + expect(document.body.textContent).toContain("Grace Sec1542"); + }); }); diff --git a/ui/src/components/AgentConfigHistory.tsx b/ui/src/components/AgentConfigHistory.tsx index 16b74644274..455df8a39dc 100644 --- a/ui/src/components/AgentConfigHistory.tsx +++ b/ui/src/components/AgentConfigHistory.tsx @@ -15,7 +15,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { formatDate } from "../lib/utils"; -import { formatAgentConfigValue } from "../lib/agent-config-changeset"; +import { formatAgentConfigValue, labelForAgentConfigKey } from "../lib/agent-config-changeset"; function valueAt(snapshot: Record, key: string): unknown { if (Object.prototype.hasOwnProperty.call(snapshot, key)) return snapshot[key]; @@ -29,11 +29,27 @@ function valueAt(snapshot: Record, key: string): unknown { } export function revisionDiff(revision: AgentConfigRevision) { - return revision.changedKeys.map((key) => ({ - key, - before: valueAt(revision.beforeConfig, key), - after: valueAt(revision.afterConfig, key), - })); + return revision.changedKeys.flatMap((key) => expandRevisionDiff(key, valueAt(revision.beforeConfig, key), valueAt(revision.afterConfig, key))); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function expandRevisionDiff(key: string, before: unknown, after: unknown): Array<{ key: string; before: unknown; after: unknown }> { + if (!isRecord(before) && !isRecord(after)) return [{ key, before, after }]; + if (before !== undefined && !isRecord(before)) return [{ key, before, after }]; + if (after !== undefined && !isRecord(after)) return [{ key, before, after }]; + + const beforeRecord = isRecord(before) ? before : {}; + const afterRecord = isRecord(after) ? after : {}; + const fields = [...new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)])].sort(); + return fields.flatMap((field) => { + const fieldBefore = beforeRecord[field]; + const fieldAfter = afterRecord[field]; + if (JSON.stringify(fieldBefore) === JSON.stringify(fieldAfter)) return []; + return expandRevisionDiff(key + "." + field, fieldBefore, fieldAfter); + }); } function RevisionDiffRows({ revision }: { revision: AgentConfigRevision }) { @@ -41,7 +57,7 @@ function RevisionDiffRows({ revision }: { revision: AgentConfigRevision }) {
{revisionDiff(revision).map((entry) => (
-
{entry.key}
+
{labelForAgentConfigKey(entry.key)}
{formatAgentConfigValue(entry.before)}
{formatAgentConfigValue(entry.after)}
diff --git a/ui/src/lib/agent-config-changeset.ts b/ui/src/lib/agent-config-changeset.ts index e5cd3968a44..3bd4eebe493 100644 --- a/ui/src/lib/agent-config-changeset.ts +++ b/ui/src/lib/agent-config-changeset.ts @@ -11,7 +11,7 @@ export type AgentConfigChange = { type OverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; -function labelForKey(key: string): string { +export function labelForAgentConfigKey(key: string): string { return key.replace(/^.*\./, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (value) => value.toUpperCase()); } @@ -41,7 +41,7 @@ export function buildAgentConfigChanges(agent: Agent, overlay: AgentConfigOverla : group === "runtime" ? field : `adapterConfig.${field}`; - changes.push({ key, label: labelForKey(key), section: sectionForKey(key), before: originalValue(agent, group, field), after }); + changes.push({ key, label: labelForAgentConfigKey(key), section: sectionForKey(key), before: originalValue(agent, group, field), after }); } }; addGroup("identity");