From d87f22a21757380e205f24a56a22ceac419b9636 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 4 May 2026 18:26:57 -0500 Subject: [PATCH] Fix phantom dirty banner after restoring a routine revision (PAP-3588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful revision restore, RoutineDetail's editor draft was not re-hydrated because routine.id is unchanged and isEditDirty became true relative to the freshly- restored routine, blocking the existing useEffect rehydration. Add an onRestored callback wired from RoutineHistoryTab so the parent can synchronously sync the cached routine, reset the edit draft, and bump the hydration ref the moment the restore POST returns — preventing the phantom "Unsaved routine edits" banner. Co-Authored-By: Paperclip --- ui/src/components/RoutineHistoryTab.test.tsx | 53 ++++++++++++++++++++ ui/src/components/RoutineHistoryTab.tsx | 3 ++ ui/src/pages/RoutineDetail.tsx | 28 ++++++++++- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/ui/src/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx index 66d5264e787..209436cbe16 100644 --- a/ui/src/components/RoutineHistoryTab.test.tsx +++ b/ui/src/components/RoutineHistoryTab.test.tsx @@ -317,4 +317,57 @@ describe("RoutineHistoryTab", () => { ); expect(successCall).toBeTruthy(); }); + + it("invokes onRestored with the restore response so the editor can rehydrate (PAP-3588)", async () => { + const current = createRevision({ id: "revision-2", revisionNumber: 2 }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + snapshot: snapshotV1({ description: "Original description" }), + }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + const restoredRoutine = createRoutine({ + description: "Original description", + latestRevisionId: "revision-3", + latestRevisionNumber: 3, + }); + mockRoutinesApi.restoreRevision.mockResolvedValue({ + routine: restoredRoutine, + revision: createRevision({ + id: "revision-3", + revisionNumber: 3, + restoredFromRevisionId: "revision-1", + }), + restoredFromRevisionId: "revision-1", + restoredFromRevisionNumber: 1, + secretMaterials: [], + }); + const onRestored = vi.fn(); + await render({ onRestored }); + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + await act(async () => { + oldRow?.click(); + }); + await flush(); + const restoreButtons = Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent === "Restore as new revision", + ); + await act(async () => { + restoreButtons[0].click(); + }); + await flush(); + const confirmButtons = Array.from(container.querySelectorAll("button")).filter((b) => + (b.textContent ?? "").includes("Restore as revision 3"), + ); + await act(async () => { + confirmButtons[0].click(); + }); + await flush(); + expect(onRestored).toHaveBeenCalledTimes(1); + const [response] = onRestored.mock.calls[0]; + expect(response.routine).toEqual(restoredRoutine); + expect(response.revision.id).toBe("revision-3"); + }); }); diff --git a/ui/src/components/RoutineHistoryTab.tsx b/ui/src/components/RoutineHistoryTab.tsx index 1e6ec6ec732..9a8ce841fac 100644 --- a/ui/src/components/RoutineHistoryTab.tsx +++ b/ui/src/components/RoutineHistoryTab.tsx @@ -48,6 +48,7 @@ type Props = { agents: AgentLookup; projects: ProjectLookup; onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void; + onRestored?: (response: RestoreRoutineRevisionResponse) => void; }; export function RoutineHistoryTab({ @@ -59,6 +60,7 @@ export function RoutineHistoryTab({ agents, projects, onRestoreSecretMaterials, + onRestored, }: Props) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); @@ -116,6 +118,7 @@ export function RoutineHistoryTab({ tone: "success", }); onRestoreSecretMaterials(data); + onRestored?.(data); setConfirmOpen(false); setRestoreSummary(""); setSelectedRevisionId(data.revision.id); diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index ceedff8a141..047d515f446 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -63,7 +63,7 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; +import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; @@ -1236,6 +1236,32 @@ export function RoutineDetail() { }); } }} + onRestored={(response: RestoreRoutineRevisionResponse) => { + setSaveConflict(false); + queryClient.setQueryData( + queryKeys.routines.detail(routineId!), + (prev) => + prev + ? { + ...prev, + ...response.routine, + latestRevisionId: response.revision.id, + latestRevisionNumber: response.revision.revisionNumber, + } + : prev, + ); + setEditDraft({ + title: response.routine.title, + description: response.routine.description ?? "", + projectId: response.routine.projectId ?? "", + assigneeAgentId: response.routine.assigneeAgentId ?? "", + priority: response.routine.priority, + concurrencyPolicy: response.routine.concurrencyPolicy, + catchUpPolicy: response.routine.catchUpPolicy, + variables: response.routine.variables, + }); + hydratedRoutineIdRef.current = response.routine.id; + }} />