Files
paperclip/ui/src/components/DocumentDiffModal.tsx
T
Dotta d6d7a7cea6 Add routine revision history and restore flow (#5285)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - Routines are the scheduled/recurring work surface that keeps a
company operating without manual kicks.
> - Operators need routine edits to be auditable and recoverable,
especially when routines control assignments, prompts, triggers, and
webhook secrets.
> - Documents already have revision-style safety, but routines did not
have equivalent history or restore semantics.
> - This pull request adds append-only routine revisions across the
database, shared contracts, server routes, and board UI.
> - The benefit is safer routine iteration: users can inspect history,
compare changes, restore older definitions, and avoid overwriting newer
edits.

## What Changed

- Added `routine_revisions` storage, latest revision pointers on
routines, shared types, validators, and API docs for routine revision
history.
- Added server service/route support for listing routine revisions,
conflict-aware routine saves, and append-only restore operations.
- Added a History tab on routine detail with revision preview,
structured change summaries, description line diffs, dirty-edit
blocking, restore confirmation, and restored webhook secret surfacing.
- Extracted the line diff helper from `DocumentDiffModal` into
`ui/src/lib/line-diff.ts` for reuse.
- Rebased the branch onto current `public-gh/master` and renumbered the
routine revision migration to `0077_unusual_karnak` after upstream
`0076_useful_elektra`.
- Made the `0077` routine revision migration idempotent so installs that
already applied the branch-local `0076_unusual_karnak` can safely
advance.
- Updated the plugin SDK test harness routine fixture with the new
revision fields required by the shared `Routine` contract.

## Verification

- `pnpm --filter @paperclipai/db run check:migrations` passed.
- `pnpm exec vitest run --project @paperclipai/shared
packages/shared/src/validators/routine.test.ts` passed.
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/lib/line-diff.test.ts
ui/src/components/RoutineHistoryTab.test.tsx
ui/src/lib/workspace-routines.test.ts ui/src/pages/Routines.test.tsx`
passed.
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/routines-service.test.ts --pool=forks
--poolOptions.forks.isolate=true` passed.
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/routines-routes.test.ts --pool=forks
--poolOptions.forks.isolate=true` passed.
- `pnpm --filter @paperclipai/plugin-sdk typecheck` passed after
updating the SDK test harness fixture.
- `pnpm --filter @paperclipai/plugin-sdk build` passed; this refreshed
local generated SDK output needed by plugin example typechecks.
- `pnpm -r typecheck` passed.

## Risks

- Medium migration risk: this adds routine revision storage and
backfills existing routines. The migration is ordered after upstream
`0076` and uses `IF NOT EXISTS` / duplicate-object guards to tolerate
earlier branch-local migration application.
- Restore behavior intentionally appends a new revision instead of
mutating history; callers expecting an in-place rollback need to follow
the new latest revision pointer.
- Restoring webhook triggers recreates webhook secret material, so users
must copy newly surfaced secrets after restore.
- Conflict-aware saves now reject stale routine edits when the client
sends an older `baseRevisionId`.

> 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-based coding agent, with shell/tool use in a local
git worktree. Exact context-window size is not exposed in this runtime.

## 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

Screenshots: not attached in this draft PR; the new UI flow is covered
by component tests listed above.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-05 11:54:52 -05:00

177 lines
7.0 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DocumentRevision } from "@paperclipai/shared";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { buildLineDiff, type DiffRow } from "../lib/line-diff";
import { relativeTime } from "../lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function getRevisionLabel(revision: DocumentRevision) {
const actor = revision.createdByUserId
? "board"
: revision.createdByAgentId
? "agent"
: "system";
return `rev ${revision.revisionNumber}${relativeTime(revision.createdAt)}${actor}`;
}
export function DocumentDiffModal({
issueId,
documentKey,
latestRevisionNumber,
open,
onOpenChange,
}: {
issueId: string;
documentKey: string;
latestRevisionNumber: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { data: revisions } = useQuery({
queryKey: queryKeys.issues.documentRevisions(issueId, documentKey),
queryFn: () => issuesApi.listDocumentRevisions(issueId, documentKey),
enabled: open,
});
const sortedRevisions = useMemo(() => {
if (!revisions) return [];
return [...revisions].sort((a, b) => b.revisionNumber - a.revisionNumber);
}, [revisions]);
// Default: compare previous (latestRevisionNumber - 1) with current (latestRevisionNumber)
const [leftRevisionId, setLeftRevisionId] = useState<string | null>(null);
const [rightRevisionId, setRightRevisionId] = useState<string | null>(null);
const effectiveLeftId = leftRevisionId ?? sortedRevisions.find(
(r) => r.revisionNumber === latestRevisionNumber - 1,
)?.id ?? null;
const effectiveRightId = rightRevisionId ?? sortedRevisions.find(
(r) => r.revisionNumber === latestRevisionNumber,
)?.id ?? null;
const leftRevision = sortedRevisions.find((r) => r.id === effectiveLeftId) ?? null;
const rightRevision = sortedRevisions.find((r) => r.id === effectiveRightId) ?? null;
const leftBody = leftRevision?.body ?? "";
const rightBody = rightRevision?.body ?? "";
const diffRows = useMemo(() => buildLineDiff(leftBody, rightBody), [leftBody, rightBody]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[90%] w-full max-h-[85vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between gap-4">
<DialogHeader className="shrink-0">
<DialogTitle>
Diff <span className="font-mono text-sm">{documentKey}</span>
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-2">
<span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-red-400">Old</span>
<Select
value={effectiveLeftId ?? ""}
onValueChange={(value) => setLeftRevisionId(value)}
>
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
<SelectValue placeholder="Select revision" />
</SelectTrigger>
<SelectContent>
{sortedRevisions.map((revision) => (
<SelectItem key={revision.id} value={revision.id} className="text-xs">
{getRevisionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-green-400">New</span>
<Select
value={effectiveRightId ?? ""}
onValueChange={(value) => setRightRevisionId(value)}
>
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
<SelectValue placeholder="Select revision" />
</SelectTrigger>
<SelectContent>
{sortedRevisions.map((revision) => (
<SelectItem key={revision.id} value={revision.id} className="text-xs">
{getRevisionLabel(revision)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="overflow-auto flex-1 rounded-md border border-border text-xs">
{!revisions ? (
<div className="p-6 text-center text-muted-foreground text-sm">Loading revisions...</div>
) : !leftRevision || !rightRevision ? (
<div className="p-6 text-center text-muted-foreground text-sm">Select two revisions to compare.</div>
) : leftRevision.id === rightRevision.id ? (
<div className="p-6 text-center text-muted-foreground text-sm">Both sides are the same revision.</div>
) : (
<div className="font-mono text-[12px] leading-6">
<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>
{diffRows.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>
)}
</div>
</DialogContent>
</Dialog>
);
}