import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { History as HistoryIcon, RotateCcw } from "lucide-react"; import type { Routine, RoutineRevision, RoutineRevisionSnapshotTriggerV1, RoutineVariable, } from "@paperclipai/shared"; import { routinesApi, type RestoreRoutineRevisionResponse, } from "../api/routines"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import { relativeTime } from "../lib/utils"; import { useToastActions } from "../context/ToastContext"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "./EmptyState"; import { MarkdownBody } from "./MarkdownBody"; type AgentLookup = Map; type ProjectLookup = Map; type DirtyFieldDescriptor = { key: string; label: string; }; type Props = { routine: Routine; isEditDirty: boolean; dirtyFields: DirtyFieldDescriptor[]; onDiscardEdits: () => void; onSaveEdits: () => void; agents: AgentLookup; projects: ProjectLookup; onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void; onRestored?: (response: RestoreRoutineRevisionResponse) => void; }; export function RoutineHistoryTab({ routine, isEditDirty, dirtyFields, onDiscardEdits, onSaveEdits, agents, projects, onRestoreSecretMaterials, onRestored, }: Props) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const [selectedRevisionId, setSelectedRevisionId] = useState(null); const [snapshotOpen, setSnapshotOpen] = useState(false); const [compareOn, setCompareOn] = useState(false); const [highlightedRevisionId, setHighlightedRevisionId] = useState(null); const [showOlder, setShowOlder] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [restoreSummary, setRestoreSummary] = useState(""); const revisionsQuery = useQuery({ queryKey: queryKeys.routines.revisions(routine.id), queryFn: () => routinesApi.listRevisions(routine.id), }); const revisions = useMemo(() => revisionsQuery.data ?? [], [revisionsQuery.data]); const sortedRevisions = useMemo( () => [...revisions].sort((a, b) => b.revisionNumber - a.revisionNumber), [revisions], ); const currentRevision = useMemo( () => sortedRevisions.find((r) => r.id === routine.latestRevisionId) ?? sortedRevisions[0] ?? null, [sortedRevisions, routine.latestRevisionId], ); const selectedRevision = useMemo( () => sortedRevisions.find((r) => r.id === selectedRevisionId) ?? null, [sortedRevisions, selectedRevisionId], ); const isHistoricalSelected = !!selectedRevision && selectedRevision.id !== routine.latestRevisionId; const visibleRevisions = useMemo(() => { if (showOlder || sortedRevisions.length <= 8) return sortedRevisions; return sortedRevisions.slice(0, 8); }, [sortedRevisions, showOlder]); const restoreMutation = useMutation({ mutationFn: (input: { revisionId: string; changeSummary: string }) => routinesApi.restoreRevision(routine.id, input.revisionId, { changeSummary: input.changeSummary.trim() || null, }), onSuccess: async (data) => { const restoredFromNumber = data.restoredFromRevisionNumber; const newNumber = data.revision.revisionNumber; pushToast({ title: `Restored revision ${restoredFromNumber} as revision ${newNumber}`, body: data.secretMaterials.length > 0 ? "Trigger enabled state was restored from the snapshot. New webhook secrets are available in the banner above." : "Trigger enabled state was restored from the snapshot.", tone: "success", }); onRestoreSecretMaterials(data); onRestored?.(data); setConfirmOpen(false); setSnapshotOpen(false); setCompareOn(false); setRestoreSummary(""); setSelectedRevisionId(data.revision.id); setHighlightedRevisionId(data.revision.id); window.setTimeout(() => { setHighlightedRevisionId((current) => current === data.revision.id ? null : current, ); }, 3000); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routine.id) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routine.id) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(routine.companyId, routine.id), }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(routine.companyId) }), queryClient.invalidateQueries({ queryKey: queryKeys.routines.revisions(routine.id) }), ]); }, onError: (error) => { pushToast({ title: "Failed to restore revision", body: error instanceof Error ? error.message : "Paperclip could not restore the revision.", tone: "error", }); }, }); const handleSelectRevision = (revisionId: string) => { if (isEditDirty) return; setSelectedRevisionId(revisionId); setCompareOn(false); setSnapshotOpen(true); }; const openRestoreConfirm = () => { if (!selectedRevision || !isHistoricalSelected) return; setRestoreSummary(""); setSnapshotOpen(false); setCompareOn(false); setConfirmOpen(true); }; const confirmRestore = () => { if (!selectedRevision) return; restoreMutation.mutate({ revisionId: selectedRevision.id, changeSummary: restoreSummary, }); }; if (revisionsQuery.isLoading) { return (
{Array.from({ length: 5 }).map((_, idx) => ( ))}
); } if (revisionsQuery.error) { return (

Could not load revisions

{revisionsQuery.error instanceof Error ? revisionsQuery.error.message : "Unknown error loading revisions."}

); } const onlyBootstrapRevision = revisions.length <= 1; return (
{isEditDirty && ( )} {!isEditDirty && onlyBootstrapRevision ? (

Revision 1 is the only history this routine has. Saving an edit creates the first additional revision.

) : ( setShowOlder(true)} showOlder={showOlder} /> )} {selectedRevision && ( { setSnapshotOpen(next); if (!next) setCompareOn(false); }} revision={selectedRevision} currentRevision={currentRevision} isHistorical={isHistoricalSelected} compareOn={compareOn} onCompareToggle={setCompareOn} agents={agents} projects={projects} onRestore={openRestoreConfirm} restorePending={restoreMutation.isPending} highlighted={highlightedRevisionId === selectedRevision.id} /> )} {selectedRevision && currentRevision && ( )}
); } function RevisionSnapshotDialog({ open, onOpenChange, revision, currentRevision, isHistorical, compareOn, onCompareToggle, agents, projects, onRestore, restorePending, highlighted, }: { open: boolean; onOpenChange: (open: boolean) => void; revision: RoutineRevision; currentRevision: RoutineRevision | null; isHistorical: boolean; compareOn: boolean; onCompareToggle: (next: boolean) => void; agents: AgentLookup; projects: ProjectLookup; onRestore: () => void; restorePending: boolean; highlighted: boolean; }) { const showCompare = compareOn && !!currentRevision && isHistorical; return (
{isHistorical ? `Viewing revision ${revision.revisionNumber} (read-only)` : `Revision ${revision.revisionNumber} (current)`} {isHistorical && currentRevision && ( )}
{isHistorical && currentRevision && ( Restoring this revision creates a new revision {currentRevision.revisionNumber + 1}{" "} with the same content. History stays append-only. )}
{showCompare && currentRevision ? (
) : ( )}
{isHistorical && ( )}
); } function DiffPill({ kind }: { kind: "differs" | "only-here" }) { const label = kind === "differs" ? "differs" : "only here"; return ( {label} ); } function ColumnLabel({ tone, title }: { tone: "amber" | "emerald"; title: string }) { const cls = tone === "amber" ? "border-amber-400 bg-amber-300 text-amber-950" : "border-emerald-400 bg-emerald-300 text-emerald-950"; return (
{title}
); } function ConflictBanner({ dirtyFields, onDiscard, onSave, }: { dirtyFields: DirtyFieldDescriptor[]; onDiscard: () => void; onSave: () => void; }) { const labels = dirtyFields.length > 0 ? dirtyFields.map((field) => field.label) : ["the routine"]; const fieldsText = formatDirtyFieldList(labels); return (

Unsaved routine edits

You changed {fieldsText} but haven't saved yet. Save or discard before previewing or restoring an older revision.

{dirtyFields.length > 0 && (
    {dirtyFields.map((field) => (
  • {field.label}
  • ))}
)}
); } function RevisionList({ revisions, latestRevisionId, selectedRevisionId, highlightedRevisionId, isEditDirty, totalRevisions, onSelect, onShowOlder, showOlder, }: { revisions: RoutineRevision[]; latestRevisionId: string | null; selectedRevisionId: string | null; highlightedRevisionId: string | null; isEditDirty: boolean; totalRevisions: number; onSelect: (revisionId: string) => void; onShowOlder: () => void; showOlder: boolean; }) { return ( ); } function RevisionPreview({ revision, currentRevision, agents, projects, highlighted, }: { revision: RoutineRevision; currentRevision: RoutineRevision | null; agents: AgentLookup; projects: ProjectLookup; highlighted: boolean; }) { const snapshot = revision.snapshot.routine; const triggers = revision.snapshot.triggers; const currentSnapshot = currentRevision?.snapshot.routine ?? null; const otherTriggers = currentRevision?.snapshot.triggers ?? []; const otherTriggerById = new Map(otherTriggers.map((t) => [t.id, t])); const otherVariableByName = new Map( (currentSnapshot?.variables ?? []).map((v) => [v.name, v]), ); const cardWrapper = `rounded-md border transition-colors duration-1000 ${ highlighted ? "border-emerald-500/40 bg-emerald-500/10" : "border-border" }`; const descriptionDiffers = !!currentSnapshot && (currentSnapshot.description ?? "") !== (snapshot.description ?? ""); const fieldRows: Array<{ key: string; label: string; value: string; differs: boolean }> = [ { key: "title", label: "Title", value: snapshot.title, differs: !!currentSnapshot && currentSnapshot.title !== snapshot.title, }, { key: "priority", label: "Priority", value: snapshot.priority, differs: !!currentSnapshot && currentSnapshot.priority !== snapshot.priority, }, { key: "status", label: "Status", value: snapshot.status, differs: !!currentSnapshot && currentSnapshot.status !== snapshot.status, }, { key: "assigneeAgentId", label: "Default agent", value: resolveAgentName(snapshot.assigneeAgentId, agents), differs: !!currentSnapshot && currentSnapshot.assigneeAgentId !== snapshot.assigneeAgentId, }, { key: "projectId", label: "Project", value: resolveProjectName(snapshot.projectId, projects), differs: !!currentSnapshot && currentSnapshot.projectId !== snapshot.projectId, }, { key: "concurrencyPolicy", label: "Concurrency", value: snapshot.concurrencyPolicy.replaceAll("_", " "), differs: !!currentSnapshot && currentSnapshot.concurrencyPolicy !== snapshot.concurrencyPolicy, }, { key: "catchUpPolicy", label: "Catch-up", value: snapshot.catchUpPolicy.replaceAll("_", " "), differs: !!currentSnapshot && currentSnapshot.catchUpPolicy !== snapshot.catchUpPolicy, }, ]; const triggerStatus = (trigger: RoutineRevisionSnapshotTriggerV1): "same" | "differs" | "only-here" => { if (!currentRevision) return "same"; const other = otherTriggerById.get(trigger.id); if (!other) return "only-here"; return JSON.stringify(other) === JSON.stringify(trigger) ? "same" : "differs"; }; const variableStatus = (variable: RoutineVariable): "same" | "differs" | "only-here" => { if (!currentRevision) return "same"; const other = otherVariableByName.get(variable.name); if (!other) return "only-here"; return JSON.stringify(other) === JSON.stringify(variable) ? "same" : "differs"; }; return (

rev {revision.revisionNumber}

Saved {relativeTime(revision.createdAt)} by {getActorLabel(revision)} {revision.changeSummary ? ` · ${revision.changeSummary}` : ""}

Structured fields

{fieldRows.map((row) => (

{row.label}

{row.value || } {row.differs && }

))}

Description

{descriptionDiffers && }
{snapshot.description ? ( {snapshot.description} ) : ( No description )}

Triggers ({triggers.length})

{triggers.length === 0 ? (

No triggers in this revision.

) : (
    {triggers.map((trigger) => { const status = triggerStatus(trigger); return (
  • {trigger.kind} {trigger.label ?? trigger.kind} {summarizeTriggerSnapshot(trigger)} {status !== "same" && } {trigger.enabled ? "enabled" : "disabled"}
  • ); })}
)}

Webhook secrets are not stored in revisions. If a restored webhook trigger needs re-creation, Paperclip mints fresh secret material at restore time.

{snapshot.variables.length > 0 && (

Variables ({snapshot.variables.length})

    {snapshot.variables.map((variable) => { const status = variableStatus(variable); return (
  • {variable.name} default: {formatVariableDefault(variable)} {status !== "same" && }
  • ); })}
)}
); } function RestoreConfirmDialog({ open, onOpenChange, target, currentRevisionNumber, changeSummary, onChangeSummaryChange, onConfirm, pending, recreatedWebhookLabels, }: { open: boolean; onOpenChange: (open: boolean) => void; target: RoutineRevision; currentRevisionNumber: number; changeSummary: string; onChangeSummaryChange: (value: string) => void; onConfirm: () => void; pending: boolean; recreatedWebhookLabels: string[]; }) { const newRevisionNumber = currentRevisionNumber + 1; return ( Restore revision {target.revisionNumber}? This creates a new revision {newRevisionNumber} with the same content as revision{" "} {target.revisionNumber}. Revisions {target.revisionNumber}–{currentRevisionNumber} stay in history and are not modified.
  • Routine field values, variables, and schedule cron will revert.
  • Previous run history is preserved.
  • {recreatedWebhookLabels.map((label) => (
  • The webhook trigger {label} will be recreated with a new URL and secret. Paperclip will show the secret once after restore — copy it before closing.
  • ))}
onChangeSummaryChange(event.target.value)} />
); } function getActorLabel(revision: RoutineRevision): string { if (revision.createdByUserId) return "board"; if (revision.createdByAgentId) return "agent"; return "system"; } function resolveAgentName(agentId: string | null, lookup: AgentLookup) { if (!agentId) return "Unassigned"; return lookup.get(agentId)?.name ?? agentId; } function resolveProjectName(projectId: string | null, lookup: ProjectLookup) { if (!projectId) return "No project"; return lookup.get(projectId)?.name ?? projectId; } function summarizeTriggerSnapshot(trigger: RoutineRevisionSnapshotTriggerV1): string { if (trigger.kind === "schedule") { return [trigger.cronExpression, trigger.timezone].filter(Boolean).join(" · "); } if (trigger.kind === "webhook") { const replay = trigger.replayWindowSec != null ? `replay ${trigger.replayWindowSec}s` : ""; return [trigger.signingMode, replay].filter(Boolean).join(" · "); } return "API"; } function formatVariableDefault(variable: RoutineVariable): string { if (variable.defaultValue == null) return "—"; return String(variable.defaultValue); } function formatDirtyFieldList(labels: string[]): string { if (labels.length === 0) return "the routine"; if (labels.length === 1) return labels[0]; if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`; } function collectWebhookTriggerDifferences( target: RoutineRevision, current: RoutineRevision, ): string[] { const currentIds = new Set(current.snapshot.triggers.map((t) => t.id)); return target.snapshot.triggers .filter((trigger) => trigger.kind === "webhook" && !currentIds.has(trigger.id)) .map((trigger) => trigger.label ?? "webhook"); } export function isUpdateConflictError(error: unknown): error is ApiError { return error instanceof ApiError && error.status === 409; } export type RoutineHistoryDirtyFieldDescriptor = DirtyFieldDescriptor; export type RoutineHistoryAgentLookup = AgentLookup; export type RoutineHistoryProjectLookup = ProjectLookup;