Files
paperclip/ui/src/components/RoutineHistoryTab.tsx
T
Dotta 0c6f9bdcf8 Revert "fix(ui): improve routine properties panel and history UX" (#5723)
## Thinking Path

> - Paperclip orchestrates AI agents through visible, governable task
and routine workflows.
> - The routines UI includes the routine detail page, properties panel,
history tab, and shared sidebar components.
> - PR #5703 changed that workflow by widening the routine properties
panel and moving revision inspection/comparison into dialogs.
> - The product direction for that change is being paused for now, so
the safest path is a direct revert instead of partial edits.
> - This pull request reverts merge commit
`74cb560c41305ac3283067d1ec8d3060ffdc28cb` from #5703.
> - The benefit is restoring the prior routines UI behavior while
keeping the revert easy to review and re-apply later if needed.

## What Changed

- Reverted #5703: `fix(ui): improve routine properties panel and history
UX`.
- Restored the previous routine properties panel sizing, panel context
API, routine detail layout, and routine history rendering behavior.
- Removed the reverted sidebar pane test additions and restored the
previous focused routine history test expectations.

## Verification

- `git diff --check origin/master..HEAD`
- `pnpm vitest run ui/src/components/RoutineHistoryTab.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`

### Screenshots

This is a direct revert of #5703. The visual state after this PR
corresponds to the "Before" screenshots from #5703, and the state being
removed corresponds to the "After" screenshots from #5703.

#### Trigger Panel Width

| Before this revert | After this revert |
| --- | --- |
| <img width="1742" height="1288" alt="triggers-before-this-revert"
src="https://github.com/user-attachments/assets/9e818978-283c-49a3-9401-879be550c67b"
/> | <img width="1741" height="1289" alt="triggers-after-this-revert"
src="https://github.com/user-attachments/assets/2a391769-c355-4219-8da3-d1ea18698430"
/> |

#### History Panel

| Before this revert | After this revert |
| --- | --- |
| <img width="1741" height="1290" alt="history-before-this-revert"
src="https://github.com/user-attachments/assets/4c139238-8494-4438-89e1-4277d05bc3aa"
/> | <img width="1739" height="1289" alt="history-after-this-revert"
src="https://github.com/user-attachments/assets/eaea4f3d-bb65-4af6-b67f-3ba3026fe0c9"
/> |

## Risks

- Low technical risk: this is a clean Git revert of a recently merged
UI-only PR.
- Product risk: the routine properties panel and revision history return
to the older, narrower workflow that #5703 was improving.
- Re-application risk: future work that wants the #5703 behavior back
should re-apply it deliberately rather than cherry-picking around this
revert.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled with local shell and
GitHub CLI access. Context window size was not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 13:10:40 -05:00

1101 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { History as HistoryIcon, RotateCcw, Search } 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 { buildLineDiff, type DiffRow } from "../lib/line-diff";
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<string, { id: string; name: string }>;
type ProjectLookup = Map<string, { id: string; name: string }>;
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<string | null>(null);
const [highlightedRevisionId, setHighlightedRevisionId] = useState<string | null>(null);
const [showOlder, setShowOlder] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [diffOpen, setDiffOpen] = 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],
);
useEffect(() => {
if (selectedRevisionId === null && currentRevision) {
setSelectedRevisionId(currentRevision.id);
}
}, [currentRevision, selectedRevisionId]);
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);
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);
};
const handleReturnToCurrent = () => {
if (currentRevision) setSelectedRevisionId(currentRevision.id);
};
const openRestoreConfirm = () => {
if (!selectedRevision || !isHistoricalSelected) return;
setRestoreSummary("");
setConfirmOpen(true);
};
const confirmRestore = () => {
if (!selectedRevision) return;
restoreMutation.mutate({
revisionId: selectedRevision.id,
changeSummary: restoreSummary,
});
};
if (revisionsQuery.isLoading) {
return (
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
<Skeleton className="h-32 w-full" />
</div>
);
}
if (revisionsQuery.error) {
return (
<div className="rounded-md border border-l-2 border-l-destructive border-border p-4 space-y-3">
<div>
<p className="text-sm font-medium">Could not load revisions</p>
<p className="text-xs text-muted-foreground">
{revisionsQuery.error instanceof Error
? revisionsQuery.error.message
: "Unknown error loading revisions."}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => revisionsQuery.refetch()}>
Retry
</Button>
</div>
);
}
const onlyBootstrapRevision = revisions.length <= 1;
return (
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
<RevisionList
revisions={visibleRevisions}
latestRevisionId={routine.latestRevisionId}
selectedRevisionId={selectedRevisionId}
highlightedRevisionId={highlightedRevisionId}
isEditDirty={isEditDirty}
totalRevisions={sortedRevisions.length}
onSelect={handleSelectRevision}
onShowOlder={() => setShowOlder(true)}
showOlder={showOlder}
/>
<div className="space-y-4 min-w-0">
{isEditDirty && (
<ConflictBanner
dirtyFields={dirtyFields}
onDiscard={onDiscardEdits}
onSave={onSaveEdits}
/>
)}
{!isEditDirty && onlyBootstrapRevision ? (
<div className="space-y-2">
<EmptyState
icon={HistoryIcon}
message="No edits yet"
/>
<p className="text-center text-xs text-muted-foreground">
Revision 1 is the only history this routine has. Saving an edit creates the first
additional revision.
</p>
</div>
) : (
selectedRevision && (
<>
{isHistoricalSelected && currentRevision && (
<HistoricalPreviewBanner
revisionNumber={selectedRevision.revisionNumber}
nextRevisionNumber={currentRevision.revisionNumber + 1}
onReturn={handleReturnToCurrent}
onRestore={openRestoreConfirm}
pending={restoreMutation.isPending}
/>
)}
<RevisionPreview
revision={selectedRevision}
currentRevision={currentRevision}
isHistorical={isHistoricalSelected}
agents={agents}
projects={projects}
onCompare={() => setDiffOpen(true)}
onRestore={openRestoreConfirm}
restorePending={restoreMutation.isPending}
highlighted={highlightedRevisionId === selectedRevision.id}
/>
</>
)
)}
</div>
{selectedRevision && currentRevision && (
<RestoreConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
target={selectedRevision}
currentRevisionNumber={currentRevision.revisionNumber}
changeSummary={restoreSummary}
onChangeSummaryChange={setRestoreSummary}
onConfirm={confirmRestore}
pending={restoreMutation.isPending}
recreatedWebhookLabels={collectWebhookTriggerDifferences(
selectedRevision,
currentRevision,
)}
/>
)}
{currentRevision && selectedRevision && (
<RoutineRevisionDiffModal
open={diffOpen}
onOpenChange={setDiffOpen}
revisions={sortedRevisions}
initialOldRevisionId={selectedRevision.id}
initialNewRevisionId={currentRevision.id}
agents={agents}
projects={projects}
onRestore={(rev) => {
setSelectedRevisionId(rev.id);
setDiffOpen(false);
setRestoreSummary("");
setConfirmOpen(true);
}}
/>
)}
</div>
);
}
function HistoricalPreviewBanner({
revisionNumber,
nextRevisionNumber,
onReturn,
onRestore,
pending,
}: {
revisionNumber: number;
nextRevisionNumber: number;
onReturn: () => void;
onRestore: () => void;
pending: boolean;
}) {
return (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-amber-200">
Viewing revision {revisionNumber} (read-only)
</p>
<p className="text-xs text-muted-foreground">
Restoring this revision creates a new revision {nextRevisionNumber} with the same content.
History stays append-only.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onReturn} disabled={pending}>
Return to current
</Button>
<Button size="sm" onClick={onRestore} disabled={pending}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore as new revision
</Button>
</div>
</div>
</div>
);
}
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 (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-amber-200">Unsaved routine edits</p>
<p className="text-xs text-muted-foreground">
You changed {fieldsText} but haven&apos;t saved yet. Save or discard before previewing or
restoring an older revision.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onDiscard}>
Discard changes
</Button>
<Button size="sm" onClick={onSave}>
Save and continue
</Button>
</div>
</div>
{dirtyFields.length > 0 && (
<ul className="mt-3 space-y-1 text-xs text-muted-foreground">
{dirtyFields.map((field) => (
<li key={field.key} className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-amber-400" />
{field.label}
</li>
))}
</ul>
)}
</div>
);
}
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 (
<aside className="space-y-1">
<header className="flex items-center justify-between pb-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Revisions
</p>
<span className="text-[11px] text-muted-foreground">{totalRevisions} total</span>
</header>
{revisions.map((revision) => {
const isSelected = revision.id === selectedRevisionId;
const isCurrent = revision.id === latestRevisionId;
const isHistorical = !isCurrent;
const isHighlighted = revision.id === highlightedRevisionId;
const blockedByEdits = isEditDirty && isHistorical;
const baseClass = "w-full rounded-md border px-3 py-2 text-left transition-colors";
const stateClass = isHighlighted
? "border-emerald-500/40 bg-emerald-500/10"
: isSelected && isHistorical
? "border-amber-500/40 bg-amber-500/10"
: isSelected
? "border-border bg-accent/40"
: blockedByEdits
? "border-amber-500/30 bg-amber-500/5 opacity-70 cursor-not-allowed"
: "border-border/60 hover:bg-accent/40";
return (
<button
key={revision.id}
type="button"
disabled={blockedByEdits}
onClick={() => onSelect(revision.id)}
className={`${baseClass} ${stateClass}`}
data-testid={`revision-row-${revision.revisionNumber}`}
>
<div className="flex items-center gap-2 text-sm font-medium">
<span>rev {revision.revisionNumber}</span>
{isCurrent && (
<span className="rounded-full border border-border px-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
Current
</span>
)}
{revision.restoredFromRevisionId && (
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 text-[10px] uppercase tracking-[0.12em] text-amber-200">
Restored
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">
{relativeTime(revision.createdAt)} {getActorLabel(revision)}
{revision.changeSummary ? `${revision.changeSummary}` : ""}
</div>
</button>
);
})}
{totalRevisions > revisions.length && !showOlder && (
<Button variant="ghost" size="sm" className="w-full" onClick={onShowOlder}>
Show {totalRevisions - revisions.length} older
</Button>
)}
</aside>
);
}
function RevisionPreview({
revision,
currentRevision,
isHistorical,
agents,
projects,
onCompare,
onRestore,
restorePending,
highlighted,
}: {
revision: RoutineRevision;
currentRevision: RoutineRevision | null;
isHistorical: boolean;
agents: AgentLookup;
projects: ProjectLookup;
onCompare: () => void;
onRestore: () => void;
restorePending: boolean;
highlighted: boolean;
}) {
const snapshot = revision.snapshot.routine;
const triggers = revision.snapshot.triggers;
const currentSnapshot = currentRevision?.snapshot.routine ?? null;
const restoreLabel = isHistorical ? "Restore this revision" : "Restore this revision";
const cardWrapper = `rounded-md border transition-colors duration-1000 ${
highlighted ? "border-emerald-500/40 bg-emerald-500/10" : "border-border"
}`;
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,
},
];
return (
<div className="space-y-4">
<header className={`${cardWrapper} p-4 space-y-2`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<p className="text-sm font-medium">rev {revision.revisionNumber}</p>
<p className="text-xs text-muted-foreground truncate">
Saved {relativeTime(revision.createdAt)} by {getActorLabel(revision)}
{revision.changeSummary ? ` · ${revision.changeSummary}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onCompare}>
<Search className="mr-1.5 h-3.5 w-3.5" />
Compare with current
</Button>
<Button
size="sm"
onClick={onRestore}
disabled={!isHistorical || restorePending}
aria-label={restoreLabel}
className={!isHistorical ? "text-muted-foreground/60" : undefined}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{restoreLabel}
</Button>
</div>
</div>
</header>
<div className={`${cardWrapper} p-3`}>
<p className="pb-2 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Structured fields
</p>
<div className="grid gap-3 md:grid-cols-2 divide-y md:divide-y-0 divide-border">
{fieldRows.map((row) => (
<div key={row.key} className="space-y-1 p-2">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{row.label}</p>
<p className="text-sm">
{row.value || <span className="text-muted-foreground"></span>}
{row.differs && (
<span className="ml-2 rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 text-[10px] uppercase tracking-[0.12em] text-amber-200">
differs from current
</span>
)}
</p>
</div>
))}
</div>
</div>
<div className={`${cardWrapper} p-3 space-y-2`}>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Description
</p>
<div className="rounded-md bg-background/40 p-3 text-sm leading-7">
{snapshot.description ? (
<MarkdownBody>{snapshot.description}</MarkdownBody>
) : (
<span className="text-muted-foreground">No description</span>
)}
</div>
</div>
<div className={`${cardWrapper} p-3 space-y-2`}>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Triggers ({triggers.length})
</p>
{triggers.length === 0 ? (
<p className="text-sm text-muted-foreground">No triggers in this revision.</p>
) : (
<ul className="divide-y divide-border">
{triggers.map((trigger) => (
<li key={trigger.id} className="py-2 flex flex-wrap items-center gap-2 text-sm">
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
{trigger.kind}
</span>
<span className="font-medium">{trigger.label ?? trigger.kind}</span>
<span className="text-xs text-muted-foreground">
{summarizeTriggerSnapshot(trigger)}
</span>
<span
className={`ml-auto text-xs ${trigger.enabled ? "text-emerald-400" : "text-muted-foreground"}`}
>
{trigger.enabled ? "enabled" : "disabled"}
</span>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">
Webhook secrets are not stored in revisions. If a restored webhook trigger needs re-creation,
Paperclip mints fresh secret material at restore time.
</p>
</div>
{snapshot.variables.length > 0 && (
<div className={`${cardWrapper} p-3 space-y-2`}>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Variables ({snapshot.variables.length})
</p>
<ul className="divide-y divide-border">
{snapshot.variables.map((variable) => (
<li key={variable.name} className="py-2 flex items-center justify-between text-sm">
<span className="font-mono text-xs">{variable.name}</span>
<span className="text-xs text-muted-foreground">
default: {formatVariableDefault(variable)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Restore revision {target.revisionNumber}?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
Routine field values, variables, and schedule cron will revert.
</li>
<li className="flex items-start gap-2">
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
Previous run history is preserved.
</li>
{recreatedWebhookLabels.map((label) => (
<li key={label} className="flex items-start gap-2 text-amber-200">
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-amber-400" />
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.
</li>
))}
</ul>
<div className="space-y-1.5">
<Label htmlFor="restore-change-summary" className="text-xs">
Change summary (optional)
</Label>
<Input
id="restore-change-summary"
value={changeSummary}
placeholder="Why are you restoring? Visible in history."
onChange={(event) => onChangeSummaryChange(event.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={onConfirm} disabled={pending}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{pending ? "Restoring…" : `Restore as revision ${newRevisionNumber}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function RoutineRevisionDiffModal({
open,
onOpenChange,
revisions,
initialOldRevisionId,
initialNewRevisionId,
agents,
projects,
onRestore,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
revisions: RoutineRevision[];
initialOldRevisionId: string;
initialNewRevisionId: string;
agents: AgentLookup;
projects: ProjectLookup;
onRestore: (revision: RoutineRevision) => void;
}) {
const [leftId, setLeftId] = useState<string>(initialOldRevisionId);
const [rightId, setRightId] = useState<string>(initialNewRevisionId);
useEffect(() => {
if (open) {
setLeftId(initialOldRevisionId);
setRightId(initialNewRevisionId);
}
}, [open, initialOldRevisionId, initialNewRevisionId]);
const left = revisions.find((r) => r.id === leftId) ?? null;
const right = revisions.find((r) => r.id === rightId) ?? null;
const fieldChanges = useMemo(
() => (left && right ? computeFieldChanges(left, right, agents, projects) : []),
[left, right, agents, projects],
);
const descriptionDiff = useMemo<DiffRow[]>(
() => (left && right
? buildLineDiff(left.snapshot.routine.description ?? "", right.snapshot.routine.description ?? "")
: []),
[left, right],
);
const newest = revisions[0] ?? null;
const leftIsHistorical = !!left && !!newest && left.id !== newest.id;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[90%] w-full max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Compare routine revisions</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap items-center gap-3">
<RevisionPicker
label="Old"
value={leftId}
onChange={setLeftId}
revisions={revisions}
tone="red"
/>
<RevisionPicker
label="New"
value={rightId}
onChange={setRightId}
revisions={revisions}
tone="green"
/>
</div>
<div className="overflow-auto flex-1 space-y-4">
<section className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Field changes
</p>
{fieldChanges.length === 0 ? (
<p className="text-sm text-muted-foreground">No structural field changes.</p>
) : (
<table className="w-full text-sm border border-border rounded-md overflow-hidden">
<thead>
<tr className="text-xs uppercase tracking-wide bg-muted/30 text-muted-foreground">
<th className="px-3 py-2 text-left">Field</th>
<th className="px-3 py-2 text-left">Old value</th>
<th className="px-3 py-2 text-left">New value</th>
</tr>
</thead>
<tbody>
{fieldChanges.map((change) => (
<tr key={change.field} className="border-t border-border/60">
<td className="px-3 py-2 align-top text-xs font-medium">{change.field}</td>
<td className="px-3 py-2 align-top text-xs text-red-300">
{change.oldValue ?? "—"}
</td>
<td className="px-3 py-2 align-top text-xs text-emerald-300">
{change.newValue ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Description diff
</p>
<DiffTable rows={descriptionDiff} />
</section>
</div>
<DialogFooter className="justify-between sm:justify-between">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
{leftIsHistorical && left && (
<Button onClick={() => onRestore(left)}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore rev {left.revisionNumber} as new revision
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function RevisionPicker({
label,
value,
onChange,
revisions,
tone,
}: {
label: string;
value: string;
onChange: (id: string) => void;
revisions: RoutineRevision[];
tone: "red" | "green";
}) {
const toneClass = tone === "red"
? "border-red-500/30 bg-red-500/10 text-red-300"
: "border-green-500/30 bg-green-500/10 text-green-300";
return (
<div className="flex items-center gap-2">
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${toneClass}`}
>
{label}
</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-8 min-w-[12rem] rounded-md border border-border/60 bg-background px-2 text-xs"
>
{revisions.map((revision) => (
<option key={revision.id} value={revision.id}>
rev {revision.revisionNumber} {relativeTime(revision.createdAt)}
{revision.changeSummary ? `${revision.changeSummary}` : ""}
</option>
))}
</select>
</div>
);
}
function DiffTable({ rows }: { rows: DiffRow[] }) {
if (rows.length === 0) {
return <p className="text-sm text-muted-foreground">No description on either revision.</p>;
}
if (rows.every((row) => row.kind === "context")) {
return <p className="text-sm text-muted-foreground">Descriptions are identical.</p>;
}
const lineClassesByKind: Record<DiffRow["kind"], string> = {
context: "bg-transparent",
removed: "bg-red-500/10 text-red-100",
added: "bg-green-500/10 text-green-100",
};
const markerByKind: Record<DiffRow["kind"], string> = {
context: " ",
removed: "-",
added: "+",
};
return (
<div className="rounded-md border border-border text-xs font-mono leading-6 overflow-hidden">
<div className="grid grid-cols-[56px_56px_24px_minmax(0,1fr)] border-b border-border/60 bg-muted/30 px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>Old</span>
<span>New</span>
<span />
<span>Content</span>
</div>
{rows.map((row, index) => (
<div
key={`${row.kind}-${index}-${row.oldLineNumber ?? "x"}-${row.newLineNumber ?? "x"}`}
className={`grid grid-cols-[56px_56px_24px_minmax(0,1fr)] gap-0 border-b border-border/30 px-3 ${lineClassesByKind[row.kind]}`}
>
<span className="select-none border-r border-border/30 pr-3 text-right text-muted-foreground">
{row.oldLineNumber ?? ""}
</span>
<span className="select-none border-r border-border/30 px-3 text-right text-muted-foreground">
{row.newLineNumber ?? ""}
</span>
<span className="select-none px-3 text-center text-muted-foreground">
{markerByKind[row.kind]}
</span>
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-0 text-inherit">
{row.text.length > 0 ? row.text : " "}
</pre>
</div>
))}
</div>
);
}
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");
}
function describeSnapshotField(value: unknown): string {
if (value == null) return "—";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
function computeFieldChanges(
left: RoutineRevision,
right: RoutineRevision,
agents: AgentLookup,
projects: ProjectLookup,
): Array<{ field: string; oldValue: string | null; newValue: string | null }> {
const oldRoutine = left.snapshot.routine;
const newRoutine = right.snapshot.routine;
const changes: Array<{ field: string; oldValue: string | null; newValue: string | null }> = [];
const compareScalar = (
_field: string,
label: string,
oldVal: unknown,
newVal: unknown,
transform: (value: unknown) => string = describeSnapshotField,
) => {
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changes.push({ field: label, oldValue: transform(oldVal), newValue: transform(newVal) });
}
};
compareScalar("title", "Title", oldRoutine.title, newRoutine.title);
compareScalar("priority", "Priority", oldRoutine.priority, newRoutine.priority);
compareScalar(
"assigneeAgentId",
"Default agent",
resolveAgentName(oldRoutine.assigneeAgentId, agents),
resolveAgentName(newRoutine.assigneeAgentId, agents),
);
compareScalar(
"projectId",
"Project",
resolveProjectName(oldRoutine.projectId, projects),
resolveProjectName(newRoutine.projectId, projects),
);
compareScalar("concurrencyPolicy", "Concurrency", oldRoutine.concurrencyPolicy, newRoutine.concurrencyPolicy);
compareScalar("catchUpPolicy", "Catch-up", oldRoutine.catchUpPolicy, newRoutine.catchUpPolicy);
compareScalar("status", "Status", oldRoutine.status, newRoutine.status);
if (JSON.stringify(oldRoutine.variables) !== JSON.stringify(newRoutine.variables)) {
changes.push({
field: "Variables",
oldValue: summarizeVariables(oldRoutine.variables),
newValue: summarizeVariables(newRoutine.variables),
});
}
compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes);
return changes;
}
function summarizeVariables(variables: RoutineVariable[]): string {
if (variables.length === 0) return "(none)";
return variables
.map((variable) => `${variable.name}=${formatVariableDefault(variable)}`)
.join(", ");
}
function compareTriggers(
oldTriggers: RoutineRevisionSnapshotTriggerV1[],
newTriggers: RoutineRevisionSnapshotTriggerV1[],
changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>,
) {
const byId = new Map<string, { old?: RoutineRevisionSnapshotTriggerV1; next?: RoutineRevisionSnapshotTriggerV1 }>();
for (const trigger of oldTriggers) byId.set(trigger.id, { old: trigger });
for (const trigger of newTriggers) {
const existing = byId.get(trigger.id) ?? {};
byId.set(trigger.id, { ...existing, next: trigger });
}
for (const [, pair] of byId) {
if (pair.old && !pair.next) {
changes.push({
field: `Trigger removed (${pair.old.label ?? pair.old.kind})`,
oldValue: summarizeTriggerSnapshot(pair.old),
newValue: null,
});
} else if (!pair.old && pair.next) {
changes.push({
field: `Trigger added (${pair.next.label ?? pair.next.kind})`,
oldValue: null,
newValue: summarizeTriggerSnapshot(pair.next),
});
} else if (pair.old && pair.next) {
const oldSummary = summarizeTriggerSnapshot(pair.old);
const newSummary = summarizeTriggerSnapshot(pair.next);
if (oldSummary !== newSummary || pair.old.enabled !== pair.next.enabled) {
changes.push({
field: `Trigger ${pair.next.label ?? pair.next.kind}`,
oldValue: `${oldSummary} (${pair.old.enabled ? "enabled" : "disabled"})`,
newValue: `${newSummary} (${pair.next.enabled ? "enabled" : "disabled"})`,
});
}
}
}
}
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;