Files
PaperClipAI/ui/src/components/RoutineHistoryTab.tsx
T
scotttongandClaude Fable 5 c90e66bdd6 feat(ui): design-system component convergence — Card/Badge adoption, multiplicative radius ladder, unified list surfaces (#9240)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its UI is governed by a design system (`DESIGN.md` + the token layer
in `ui/src/index.css`, merged in #9134) whose first principle is "one
way to say each thing" — one Card, one Badge, one nav row
> - After the token extraction landed, ~35 files still hand-rolled card
containers, ~45 files hand-rolled pill spans, the sidebar agents section
duplicated the nav-row chrome, and the inbox and tasks lists rendered
the same task rows two subtly different ways
> - Each divergence is a place where a future design change (radius,
hover language, status vocabulary) silently misses surfaces, defeating
the "edit tokens + run checks" model the design system exists for
> - This pull request converges those surfaces onto the shared
primitives, codifies the radius scale as the modern multiplicative
shadcn ladder, and unifies the row/hover/tree-guide language across the
inbox and tasks lists — every visible delta was human-reviewed
screen-by-screen against a live instance across nine feedback rounds
> - The benefit is that the app's look is now steerable from single
knobs (one `--radius` anchor, one Card, one Badge, one row component),
and the visual regression suite covers the result (514 snapshots
including a new AgentDetail page story)

## Linked Issues or Issue Description

No public GitHub issue exists for this work; describing per the feature
template:

- **Problem**: after the design-token foundation (#9134),
component-level drift remained — hand-rolled cards/pills, duplicated
sidebar row chrome, and two different renderings of task rows (inbox vs
tasks list) meant design changes had to be applied per-surface and
frequently missed spots (e.g. status glyphs rendered 16px in the inbox
but 20px in the tasks list because a slot override silently beat the
component default).
- **Proposed behavior**: all card-shaped containers render via `Card`,
all label pills via `Badge`, sidebar rows via `SidebarNavItem`, and both
task-list surfaces via one `IssueRow` configuration; the radius scale is
a single multiplicative ladder anchored at `--radius: 0.5rem`.
- **Alternatives considered**: converting interactive
`<button>`/`<Link>` cards to `Card` divs (rejected — breaks semantics;
documented inline with `design-allow` comments instead); keeping the
legacy additive radius ladder (rejected in favor of the standard shadcn
multiplicative mapping).

## What Changed

- `Card` adoption across ~35 files (settings pages, auth/board flows,
dashboards, list containers, KPI tiles); non-adoptable sites
(interactive cards, `<li>` rows, class-string props, chart tooltip)
carry documented `design-allow(card-pattern)` comments
- `Card` gains an `interactive` prop — one quiet hover affordance for
clickable cards (cursor, border darken, shadow lift, focus ring),
applied to skills tiles, artifact cards, and the company selector; cards
carry no resting shadow
- `Badge` adoption for 113 hand-rolled pill spans across ~45 files;
`PropertyChip` wraps `Badge` internally; `StatusBadge`, external-object
chips, and match chips stay bespoke by documented decision (WCAG-tuned
status mechanics)
- Radius ladder becomes the multiplicative shadcn mapping
(`sm/md/lg/xl/2xl/3xl/4xl = 0.6/0.8/1.0/1.4/1.8/2.2/2.6 × --radius`,
anchor `0.5rem`); every card surface unifies on `rounded-lg`; the
orphaned 8px literal token is deleted
- Sidebar: agent rows render via `SidebarNavItem` (new additive props:
`iconNode`, `active`, `trailing`, `liveAccessory`); live dots use
`--status-agent-running`; one row rhythm and inset rounded pill
highlight; right-aligned trailing badges; every labeled section is
collapsible
- Inbox + tasks lists unified: md status glyphs, `accent/50` rounded row
hovers, vertical tree guides under parent rows (opaque underlay so
dark-mode translucent borders don't stack), no horizontal dividers under
expanded parents; the swipe-to-archive reveal layer shows only
mid-swipe; board toggle uses the `SquareKanban` glyph
- Kanban: every column carries a status-hued tint; lanes default
expanded (including empty); compact mode collapses empty lanes to
labeled rails (fixes a clipped, label-less empty-column state)
- Storybook: new AgentDetail page story (realistic fixtures, light+dark)
joins the visual suite; suite captures with `reducedMotion: 'reduce'`
and the ux-lab reasoning ticker honors `prefers-reduced-motion`; a stale
lexical alias in `storybook/main.ts` is fixed (build was broken since
the lexical 0.46 bump)
- Keyboard navigation, from live review of the unified lists: inbox
navigation keys work on every tab (archive keys stay scoped to the
archivable tab); keyboard-driven scrolling no longer hands the selection
to whatever row lands under the stationary cursor (hover selects only
after real pointer movement); the tasks list view gains the same j/k /
arrows / Enter selection model as the inbox; and the `g` then `i`
go-to-inbox chord works app-wide instead of only on the issue detail
page
- Token gates restored to 3/3 CLEAN (tokenized a post-#9134 regression
in the recovery card); decisions recorded in
`doc/design/DECISION-SHEET.md` and `doc/design/COMPONENT-INVENTORY.md`
(investigation verdicts: FileTree vs WorkspaceFileBrowser and the four
entity pickers stay separate — evidence included)

## Verification

- `pnpm check:token-gates` → 3/3 CLEAN
- `pnpm typecheck` → green (all packages)
- `cd ui && npx vitest run` → 2106/2106 (assertions updated in lockstep
where they documented superseded decisions; new tests for the global
go-to-inbox chord)
- `pnpm --filter @paperclipai/ui build` → exit 0
- `pnpm build-storybook` → succeeds (also fixes the lexical-alias break
on master)
- Visual regression: 514-snapshot Playwright suite green against the
updated baseline (zero diffs from the keyboard-navigation round — those
changes are purely behavioral). Note: baselines live outside git per the
suite design and the baseline-manifest archive is not yet published, so
CI cannot run this suite — it was run locally throughout; every visible
delta was reviewed screen-by-screen in a live instance across nine
review rounds. Review evidence (before/after triplets) intentionally
kept out of the repo for size; available on request.
- Manual: exercised dashboard, tasks (list + board), inbox, agents,
skills, costs, settings, and artifact surfaces in light and dark themes

## Risks

- Wide but shallow visual surface: most changes are class-string
substitutions with behavior preserved (props, handlers, roles, test
ids). The riskiest areas — dnd-kit card refs (React 19 ref-as-prop),
inbox swipe-to-archive, and sidebar overlays — are covered by existing
unit tests (all green) and were manually exercised.
- Intentional visual deltas (rounded cards, tinted kanban columns, md
status glyphs, unified hovers) are design decisions recorded in
`doc/design/DECISION-SHEET.md`; each maps to a re-baselined snapshot set
locally.
- The visual suite cannot yet run in CI (unpublished baseline archive —
pre-existing condition from #9134); until that lands, snapshot coverage
is local-only.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic
coding with tool use (file editing, test execution, Playwright
screenshot verification); extended thinking enabled.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (will confirm once CI runs)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending first review)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:15:45 -07:00

1300 lines
46 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 {
CompanySecret,
EnvBinding,
EnvSecretRefBinding,
Routine,
RoutineEnvConfig,
RoutineRevision,
RoutineRevisionSnapshotTriggerV1,
RoutineVariable,
SecretVersionSelector,
} 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";
import { Badge } from "@/components/ui/badge";
type AgentLookup = Map<string, { id: string; name: string }>;
type ProjectLookup = Map<string, { id: string; name: string }>;
type SecretLookup = Map<string, CompanySecret>;
type DirtyFieldDescriptor = {
key: string;
label: string;
};
type Props = {
routine: Routine;
isEditDirty: boolean;
dirtyFields: DirtyFieldDescriptor[];
onDiscardEdits: () => void;
onSaveEdits: () => void;
agents: AgentLookup;
projects: ProjectLookup;
secrets?: CompanySecret[];
onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void;
onRestored?: (response: RestoreRoutineRevisionResponse) => void;
};
export function RoutineHistoryTab({
routine,
isEditDirty,
dirtyFields,
onDiscardEdits,
onSaveEdits,
agents,
projects,
secrets,
onRestoreSecretMaterials,
onRestored,
}: Props) {
const secretLookup = useMemo<SecretLookup>(
() => new Map((secrets ?? []).map((secret) => [secret.id, secret])),
[secrets],
);
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-(--gtc-9)">
<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-(--gtc-9)">
<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,
)}
envDiffCounts={summarizeEnvDiffCounts(
currentRevision.snapshot.routine.env ?? null,
selectedRevision.snapshot.routine.env ?? null,
)}
/>
)}
{currentRevision && selectedRevision && (
<RoutineRevisionDiffModal
open={diffOpen}
onOpenChange={setDiffOpen}
revisions={sortedRevisions}
initialOldRevisionId={selectedRevision.id}
initialNewRevisionId={currentRevision.id}
agents={agents}
projects={projects}
secrets={secretLookup}
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-800 dark: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-800 dark: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-(--tracking-caps) text-muted-foreground">
Revisions
</p>
<span className="text-(length:--text-micro) 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 && (
<Badge variant="outline" className="border-border px-1.5 text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Current
</Badge>
)}
{revision.restoredFromRevisionId && (
<Badge variant="outline" className="border-amber-500/40 bg-amber-500/10 px-1.5 text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-amber-800 dark:text-amber-200">
Restored
</Badge>
)}
</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 envSummary = summarizeEnv(snapshot.env ?? null);
const envDiffers = !!currentSnapshot
&& JSON.stringify(normalizeEnv(currentSnapshot.env ?? null))
!== JSON.stringify(normalizeEnv(snapshot.env ?? null));
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,
},
{
key: "env",
label: "Env",
value: envSummary,
differs: envDiffers,
},
];
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-(--tracking-caps) 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-(length:--text-micro) uppercase tracking-wide text-muted-foreground">{row.label}</p>
<p className="text-sm">
{row.value || <span className="text-muted-foreground">—</span>}
{row.differs && (
<Badge variant="outline" className="ml-2 border-amber-500/40 bg-amber-500/10 px-1.5 text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-amber-800 dark:text-amber-200">
differs from current
</Badge>
)}
</p>
</div>
))}
</div>
</div>
<div className={`${cardWrapper} p-3 space-y-2`}>
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) 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-(--tracking-caps) 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">
<Badge variant="outline" className="border-border text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
{trigger.kind}
</Badge>
<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-(--tracking-caps) 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,
envDiffCounts,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
target: RoutineRevision;
currentRevisionNumber: number;
changeSummary: string;
onChangeSummaryChange: (value: string) => void;
onConfirm: () => void;
pending: boolean;
recreatedWebhookLabels: string[];
envDiffCounts: EnvDiffCounts;
}) {
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>
{envDiffCounts.total > 0 && (
<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 secrets will revert: {formatEnvDiffCounts(envDiffCounts)}.
</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-800 dark: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,
secrets,
onRestore,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
revisions: RoutineRevision[];
initialOldRevisionId: string;
initialNewRevisionId: string;
agents: AgentLookup;
projects: ProjectLookup;
secrets: SecretLookup;
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, secrets) : []),
[left, right, agents, projects, secrets],
);
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-(--pct-90) w-full max-h-(--sz-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-(--tracking-caps) 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-700 dark:text-red-300">
{change.oldValue ?? "—"}
</td>
<td className="px-3 py-2 align-top text-xs text-emerald-700 dark:text-emerald-300">
{change.newValue ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="space-y-2">
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) 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-700 dark:text-red-300"
: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300";
return (
<div className="flex items-center gap-2">
<Badge variant="outline"
className={`text-(length:--text-nano) uppercase tracking-wider ${toneClass}`}
>
{label}
</Badge>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-8 min-w-(--sz-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-900 dark:text-red-100",
added: "bg-green-500/10 text-green-900 dark: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-(--gtc-1) border-b border-border/60 bg-muted/30 px-3 py-2 text-(length:--text-micro) 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-(--gtc-1) 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,
secrets: SecretLookup,
): 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),
});
}
compareEnv(oldRoutine.env ?? null, newRoutine.env ?? null, secrets, changes);
compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes);
return changes;
}
function normalizeEnv(env: RoutineEnvConfig | null): Record<string, EnvBinding> {
if (!env) return {};
return env;
}
function envBindingKind(binding: EnvBinding): "plain" | "secret_ref" {
if (typeof binding === "string") return "plain";
if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") {
return "secret_ref";
}
return "plain";
}
function asSecretRef(binding: EnvBinding): EnvSecretRefBinding | null {
if (typeof binding === "string") return null;
if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") {
return binding;
}
return null;
}
function formatVersionSelector(version: SecretVersionSelector | undefined): string {
if (version == null || version === "latest") return "latest";
return `v${version}`;
}
function describeSecretRef(ref: EnvSecretRefBinding, secrets: SecretLookup): string {
const secret = secrets.get(ref.secretId);
const name = secret?.name ?? "<missing-secret>";
return `${name} ${formatVersionSelector(ref.version)}`;
}
function describeEnvBinding(binding: EnvBinding | undefined, secrets: SecretLookup): string {
if (binding === undefined) return "—";
const ref = asSecretRef(binding);
if (ref) return `secret_ref → ${describeSecretRef(ref, secrets)}`;
return "plain (set)";
}
function summarizeEnv(env: RoutineEnvConfig | null): string {
const entries = Object.entries(normalizeEnv(env));
if (entries.length === 0) return "";
const secretCount = entries.filter(([, binding]) => envBindingKind(binding) === "secret_ref").length;
const keyLabel = entries.length === 1 ? "key" : "keys";
if (secretCount === 0) return `${entries.length} ${keyLabel}`;
return `${entries.length} ${keyLabel} (${secretCount} secret ${secretCount === 1 ? "ref" : "refs"})`;
}
type EnvDiffCounts = {
added: number;
removed: number;
changed: number;
total: number;
};
function summarizeEnvDiffCounts(
current: RoutineEnvConfig | null,
target: RoutineEnvConfig | null,
): EnvDiffCounts {
const currentRec = normalizeEnv(current);
const targetRec = normalizeEnv(target);
let added = 0;
let removed = 0;
let changed = 0;
const keys = new Set<string>([...Object.keys(currentRec), ...Object.keys(targetRec)]);
for (const key of keys) {
const inCurrent = key in currentRec;
const inTarget = key in targetRec;
if (inTarget && !inCurrent) {
added += 1;
continue;
}
if (!inTarget && inCurrent) {
removed += 1;
continue;
}
if (JSON.stringify(currentRec[key]) !== JSON.stringify(targetRec[key])) {
changed += 1;
}
}
return { added, removed, changed, total: added + removed + changed };
}
function formatEnvDiffCounts(counts: EnvDiffCounts): string {
const parts: string[] = [];
if (counts.added > 0) parts.push(`${counts.added} ${counts.added === 1 ? "key" : "keys"} added`);
if (counts.removed > 0) parts.push(`${counts.removed} ${counts.removed === 1 ? "key" : "keys"} removed`);
if (counts.changed > 0) parts.push(`${counts.changed} ${counts.changed === 1 ? "key" : "keys"} changed`);
return parts.join(", ");
}
function compareEnv(
oldEnv: RoutineEnvConfig | null,
newEnv: RoutineEnvConfig | null,
secrets: SecretLookup,
changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>,
) {
const oldRec = normalizeEnv(oldEnv);
const newRec = normalizeEnv(newEnv);
const keys = new Set<string>([...Object.keys(oldRec), ...Object.keys(newRec)]);
const sortedKeys = [...keys].sort();
for (const key of sortedKeys) {
const oldBinding = oldRec[key];
const newBinding = newRec[key];
const inOld = key in oldRec;
const inNew = key in newRec;
if (inNew && !inOld) {
changes.push({
field: `Env added (${key})`,
oldValue: "—",
newValue: describeEnvBinding(newBinding, secrets),
});
continue;
}
if (!inNew && inOld) {
changes.push({
field: `Env removed (${key})`,
oldValue: describeEnvBinding(oldBinding, secrets),
newValue: "—",
});
continue;
}
if (JSON.stringify(oldBinding) === JSON.stringify(newBinding)) continue;
const oldKind = envBindingKind(oldBinding);
const newKind = envBindingKind(newBinding);
if (oldKind !== newKind) {
changes.push({
field: `Env ${key} binding kind`,
oldValue: describeEnvBinding(oldBinding, secrets),
newValue: describeEnvBinding(newBinding, secrets),
});
continue;
}
if (newKind === "secret_ref") {
const oldRef = asSecretRef(oldBinding)!;
const newRef = asSecretRef(newBinding)!;
if (oldRef.secretId !== newRef.secretId) {
changes.push({
field: `Env ${key} secret`,
oldValue: describeEnvBinding(oldBinding, secrets),
newValue: describeEnvBinding(newBinding, secrets),
});
continue;
}
changes.push({
field: `Env ${key} version`,
oldValue: describeSecretRef(oldRef, secrets),
newValue: describeSecretRef(newRef, secrets),
});
continue;
}
changes.push({
field: `Env ${key} value`,
oldValue: "plain (set)",
newValue: "plain (changed)",
});
}
}
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;