From 13ada98e78567d84a4b6125fbbeac02a99da544e Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 08:11:50 -0500 Subject: [PATCH] feat(ui): add document revision diff viewer Add a "View diff" option to the document three-dot menu (visible when revision > 1) that opens a modal showing side-by-side changes between revisions using react-diff-viewer-continued. Defaults to comparing the current revision with its predecessor, with dropdowns to select any two revisions. Co-Authored-By: Paperclip --- ui/package.json | 5 +- ui/src/components/DocumentDiffModal.tsx | 169 ++++++++++++++++++++ ui/src/components/IssueDocumentsSection.tsx | 24 ++- 3 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/DocumentDiffModal.tsx diff --git a/ui/package.json b/ui/package.json index d344f67a..c8374200 100644 --- a/ui/package.json +++ b/ui/package.json @@ -30,7 +30,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lexical/link": "0.35.0", - "lexical": "0.35.0", "@mdxeditor/editor": "^3.52.4", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", @@ -41,17 +40,19 @@ "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", - "hermes-paperclip-adapter": "^0.2.0", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "hermes-paperclip-adapter": "^0.2.0", + "lexical": "0.35.0", "lucide-react": "^0.574.0", "mermaid": "^11.12.0", "radix-ui": "^1.4.3", "react": "^19.0.0", + "react-diff-viewer-continued": "^4.2.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.1.5", diff --git a/ui/src/components/DocumentDiffModal.tsx b/ui/src/components/DocumentDiffModal.tsx new file mode 100644 index 00000000..b85defa5 --- /dev/null +++ b/ui/src/components/DocumentDiffModal.tsx @@ -0,0 +1,169 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { DocumentRevision } from "@paperclipai/shared"; +import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +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(null); + const [rightRevisionId, setRightRevisionId] = useState(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 ?? ""; + + return ( + + + + + Diff — {documentKey} + + + +
+
+ Left: + +
+
+ Right: + +
+
+ +
+ {!revisions ? ( +
Loading revisions...
+ ) : !leftRevision || !rightRevision ? ( +
Select two revisions to compare.
+ ) : leftRevision.id === rightRevision.id ? ( +
Both sides are the same revision.
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 31a6dd46..26db7266 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -29,7 +29,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react"; +import { DocumentDiffModal } from "./DocumentDiffModal"; type DraftState = { key: string; @@ -162,6 +163,7 @@ export function IssueDocumentsSection({ const [highlightDocumentKey, setHighlightDocumentKey] = useState(null); const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState(null); const [selectedRevisionIds, setSelectedRevisionIds] = useState>({}); + const [diffViewKey, setDiffViewKey] = useState(null); const autosaveDebounceRef = useRef | null>(null); const copiedDocumentTimerRef = useRef | null>(null); const hasScrolledToHashRef = useRef(false); @@ -929,6 +931,12 @@ export function IssueDocumentsSection({ Download document + {doc.latestRevisionNumber > 1 ? ( + setDiffViewKey(doc.key)}> + + View diff + + ) : null} {canDeleteDocuments ? : null} {canDeleteDocuments ? ( + + {diffViewKey && (() => { + const diffDoc = sortedDocuments.find((d) => d.key === diffViewKey); + if (!diffDoc) return null; + return ( + { if (!open) setDiffViewKey(null); }} + /> + ); + })()} ); }