From 02012b84d7e02b52017e31a1fbd30793df99dc94 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 7 Jun 2026 02:00:31 +0000 Subject: [PATCH] Add document detail / review view UI (PAP-10568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the full document detail/review surface at /:co/documents/:id, the second slice of the Documents UI (PAP-10524). Reuses MarkdownBody, MarkdownEditor, DocumentAnnotationLayer and DocumentDiffModal; adds the review rail and the net-new SuggestionCard / SelectionToolbar. - DocumentDetail page + route: header (breadcrumb, title, status pill, type, owner chip, action row Edit/Suggest/History/Lock/Copy link/overflow, backlinks chips, rev/meta row), read body with annotation layer, edit body with MarkdownEditor, conflict banner on stale baseRevisionId, read-only / permission-denied / lock (self + other) states, desktop rail + mobile FAB and Sheet. - DocumentReviewRail: two tabs (Comments / Suggestions) with counts, Open/ Resolved/Stale/Orphaned chip filters, document-level overall comment card, collapsible orphan pin group, bottom Done reviewing CTA (gated; dialog is PAP-10570). - SuggestionCard (NEW): insert/delete/replace with CriticMarkup-style diff, discussion + reply, Accept/Reject(reason), Needs-rebase gate on stale baseRevisionId / drifted anchor. - SelectionToolbar (NEW): Comment / Suggest edit (Replace/Delete/Insert before/after) injected into DocumentAnnotationLayer via a new renderSelectionToolbar slot so caret positioning is reused. - documentReviewsApi client: review-index, review-comments, suggestions (create/comment/accept/reject). Suggestion treatment CSS tokens (§5.4). - Tests for SuggestionCard, SelectionToolbar, DocumentReviewRail, and the page (permission-denied, header/body/rail, read-only, lock). Storybook stories for UX sign-off. Co-Authored-By: Paperclip --- ui/src/App.tsx | 2 + ui/src/api/document-reviews.ts | 115 +++ ui/src/components/DocumentAnnotationLayer.tsx | 49 +- .../documents/DocumentReviewRail.test.tsx | 264 ++++++ .../documents/DocumentReviewRail.tsx | 429 +++++++++ .../documents/DocumentThreadCard.tsx | 203 +++++ .../documents/SelectionToolbar.test.tsx | 67 ++ .../components/documents/SelectionToolbar.tsx | 124 +++ .../documents/SuggestionCard.test.tsx | 180 ++++ .../components/documents/SuggestionCard.tsx | 391 ++++++++ ui/src/index.css | 12 + ui/src/lib/document-review-authors.ts | 41 + ui/src/pages/DocumentDetail.test.tsx | 219 +++++ ui/src/pages/DocumentDetail.tsx | 833 ++++++++++++++++++ .../stories/document-review.stories.tsx | 366 ++++++++ 15 files changed, 3281 insertions(+), 14 deletions(-) create mode 100644 ui/src/api/document-reviews.ts create mode 100644 ui/src/components/documents/DocumentReviewRail.test.tsx create mode 100644 ui/src/components/documents/DocumentReviewRail.tsx create mode 100644 ui/src/components/documents/DocumentThreadCard.tsx create mode 100644 ui/src/components/documents/SelectionToolbar.test.tsx create mode 100644 ui/src/components/documents/SelectionToolbar.tsx create mode 100644 ui/src/components/documents/SuggestionCard.test.tsx create mode 100644 ui/src/components/documents/SuggestionCard.tsx create mode 100644 ui/src/lib/document-review-authors.ts create mode 100644 ui/src/pages/DocumentDetail.test.tsx create mode 100644 ui/src/pages/DocumentDetail.tsx create mode 100644 ui/storybook/stories/document-review.stories.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index dd0bbff61df..8baf908d725 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,7 @@ import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; import { Artifacts } from "./pages/Artifacts"; import { Documents } from "./pages/Documents"; +import { DocumentDetail } from "./pages/DocumentDetail"; import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; @@ -130,6 +131,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/document-reviews.ts b/ui/src/api/document-reviews.ts new file mode 100644 index 00000000000..d51831215b0 --- /dev/null +++ b/ui/src/api/document-reviews.ts @@ -0,0 +1,115 @@ +import type { + AcceptDocumentSuggestionRequest, + CompanyDocument, + CreateDocumentReviewCommentRequest, + CreateDocumentReviewThreadRequest, + CreateDocumentSuggestionCommentRequest, + CreateDocumentSuggestionRequest, + DocumentReviewComment, + DocumentReviewIndex, + DocumentReviewThreadStatus, + DocumentReviewThreadWithComments, + DocumentSuggestion, + DocumentSuggestionComment, + RejectDocumentSuggestionRequest, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export type DocumentReviewIndexStatus = "open" | "all"; + +/** + * The accept endpoint returns the resulting revision + the updated document so + * the caller can refresh the body and open a diff. We keep the shape loose for + * the fields the detail view actually consumes. + */ +export interface AcceptDocumentSuggestionResult { + suggestion: DocumentSuggestion; + document: CompanyDocument; + revision: { id: string; revisionNumber: number }; +} + +/** + * Document review surface (anchored comments live in `document-annotations`; + * this module covers the *review-index*, document-level review threads, and + * suggested edits — the pieces the detail/review view layers on top). + */ +export const documentReviewsApi = { + reviewIndex: ( + issueId: string, + key: string, + options: { rev?: number; status?: DocumentReviewIndexStatus; includeComments?: boolean } = {}, + ) => { + const params = new URLSearchParams(); + if (options.rev !== undefined) params.set("rev", String(options.rev)); + if (options.status) params.set("status", options.status); + if (options.includeComments) params.set("includeComments", "true"); + const qs = params.toString(); + return api.get( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-index${qs ? `?${qs}` : ""}`, + ); + }, + + // --- Document-level / overall review threads (anchor: null) ---------------- + createReviewThread: (issueId: string, key: string, data: CreateDocumentReviewThreadRequest) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments`, + data, + ), + addReviewComment: ( + issueId: string, + key: string, + threadId: string, + data: CreateDocumentReviewCommentRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments/${threadId}/comments`, + data, + ), + updateReviewThreadStatus: ( + issueId: string, + key: string, + threadId: string, + status: DocumentReviewThreadStatus, + ) => + api.patch( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments/${threadId}`, + { status }, + ), + + // --- Suggested edits ------------------------------------------------------- + createSuggestion: (issueId: string, key: string, data: CreateDocumentSuggestionRequest) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions`, + data, + ), + addSuggestionComment: ( + issueId: string, + key: string, + suggestionId: string, + data: CreateDocumentSuggestionCommentRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/comments`, + data, + ), + acceptSuggestion: ( + issueId: string, + key: string, + suggestionId: string, + data: AcceptDocumentSuggestionRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/accept`, + data, + ), + rejectSuggestion: ( + issueId: string, + key: string, + suggestionId: string, + data: RejectDocumentSuggestionRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/reject`, + data, + ), +}; diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 6859922ab60..7a6567591a7 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -48,6 +48,18 @@ export interface AnnotationLayerProps { * pending anchor for the keyboard shortcut path. */ captureSelectionRequestId?: number; + /** + * Optional override for the floating selection toolbar contents. When provided, + * it replaces the built-in "Comment" button so callers (e.g. the document + * detail view) can offer richer actions like "Suggest edit" while reusing the + * layer's caret-anchored positioning. `addComment` runs the same flow the + * default button does; `clear` drops the pending selection. + */ + renderSelectionToolbar?: (args: { + anchor: PendingAnchor; + addComment: () => void; + clear: () => void; + }) => React.ReactNode; } interface HighlightRect { @@ -203,6 +215,7 @@ export function DocumentAnnotationLayer({ newCommentDisabledReason = null, hideResolved = true, captureSelectionRequestId, + renderSelectionToolbar, }: AnnotationLayerProps) { const [highlightRects, setHighlightRects] = useState([]); const [toolbarPosition, setToolbarPosition] = useState(null); @@ -492,20 +505,28 @@ export function DocumentAnnotationLayer({ style={{ top: toolbarPosition.top, left: toolbarPosition.left }} onMouseDown={(event) => event.preventDefault()} > - + {renderSelectionToolbar ? ( + renderSelectionToolbar({ + anchor: pendingAnchor, + addComment: handleAddComment, + clear: () => onPendingAnchorChange(null), + }) + ) : ( + + )} ) : null} diff --git a/ui/src/components/documents/DocumentReviewRail.test.tsx b/ui/src/components/documents/DocumentReviewRail.test.tsx new file mode 100644 index 00000000000..96b4ce0e356 --- /dev/null +++ b/ui/src/components/documents/DocumentReviewRail.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + DocumentReviewIndex, + DocumentSuggestionWithComments, +} from "@paperclipai/shared"; +import { DocumentReviewRail } from "./DocumentReviewRail"; + +vi.mock("@/components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>

{children}

, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function makeSuggestion(overrides: Partial = {}): DocumentSuggestionWithComments { + return { + id: "sug-1", + companyId: "c1", + issueId: "i1", + documentId: "d1", + documentKey: "spec", + kind: "insertion", + status: "pending", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: "rev-1", + originalRevisionNumber: 1, + currentRevisionId: "rev-1", + currentRevisionNumber: 1, + selectedText: "anchor", + proposedText: "added text", + insertionPosition: "after", + prefixText: "", + suffixText: "", + normalizedStart: 0, + normalizedEnd: 6, + markdownStart: 0, + markdownEnd: 6, + anchorSelector: { quote: { exact: "anchor", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 6, markdownStart: 0, markdownEnd: 6 } }, + createdByAgentId: "agent-1", + createdByUserId: null, + acceptedByAgentId: null, + acceptedByUserId: null, + acceptedAt: null, + acceptedRevisionId: null, + rejectedByAgentId: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + comments: [], + ...overrides, + }; +} + +function makeIndex(overrides: Partial = {}): DocumentReviewIndex { + return { + issueId: "i1", + documentId: "d1", + documentKey: "spec", + latestRevisionId: "rev-1", + latestRevisionNumber: 1, + counts: { + unresolved: 1, + openAnchoredThreads: 1, + openReviewThreads: 1, + pendingSuggestions: 1, + resolvedAnchoredThreads: 0, + resolvedReviewThreads: 0, + acceptedSuggestions: 0, + rejectedSuggestions: 0, + staleAnchors: 0, + orphanedAnchors: 0, + }, + annotationThreads: [ + { + id: "thread-1", + companyId: "c1", + issueId: "i1", + documentId: "d1", + documentKey: "spec", + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: "rev-1", + originalRevisionNumber: 1, + currentRevisionId: "rev-1", + currentRevisionNumber: 1, + selectedText: "anchored phrase", + prefixText: "", + suffixText: "", + normalizedStart: 0, + normalizedEnd: 5, + markdownStart: 0, + markdownEnd: 5, + anchorSelector: { quote: { exact: "anchored phrase", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 5, markdownStart: 0, markdownEnd: 5 } }, + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + comments: [ + { + id: "c1", + companyId: "c1", + threadId: "thread-1", + issueId: "i1", + documentId: "d1", + body: "Worth being explicit here?", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + createdByRunId: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + }, + ], + }, + ], + reviewThreads: [ + { + id: "overall-1", + companyId: "c1", + issueId: "i1", + documentId: "d1", + documentKey: "spec", + status: "open", + createdByAgentId: null, + createdByUserId: "user-1", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + comments: [ + { + id: "rc1", + companyId: "c1", + threadId: "overall-1", + issueId: "i1", + documentId: "d1", + body: "Overall this is close.", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + createdByRunId: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + }, + ], + }, + ], + suggestions: [makeSuggestion()], + ...overrides, + }; +} + +const baseHandlers = { + onReplyThread: vi.fn(), + onToggleThreadResolved: vi.fn(), + onAcceptSuggestion: vi.fn(), + onRejectSuggestion: vi.fn(), + onReplySuggestion: vi.fn(), +}; + +describe("DocumentReviewRail", () => { + it("renders two tabs with counts and the overall + anchored comments", async () => { + await act(() => + root.render( + , + ), + ); + const commentsTab = container.querySelector('[data-testid="rail-tab-comments"]'); + const suggestionsTab = container.querySelector('[data-testid="rail-tab-suggestions"]'); + expect(commentsTab?.textContent).toContain("Comments (2)"); + expect(suggestionsTab?.textContent).toContain("Suggestions (1)"); + // Comments tab is default — shows overall + anchored. + expect(container.textContent).toContain("Overall feedback"); + expect(container.textContent).toContain("anchored phrase"); + }); + + it("renders a SuggestionCard on the Suggestions tab", async () => { + await act(() => + root.render( + , + ), + ); + expect(container.querySelector('[data-testid="suggestion-card-sug-1"]')).not.toBeNull(); + expect(container.textContent).toContain("added text"); + }); + + it("pins orphaned anchors into a collapsible group when the orphan filter is on", async () => { + const index = makeIndex({ + annotationThreads: [ + { + ...makeIndex().annotationThreads[0], + id: "thread-orphan", + anchorState: "orphaned", + selectedText: "lost anchor", + }, + ], + }); + await act(() => + root.render( + , + ), + ); + // Enable the orphaned filter chip. + await act(() => container.querySelector('[data-testid="rail-filter-orphaned"]')!.click()); + expect(container.querySelector('[data-testid="rail-orphan-group"]')).not.toBeNull(); + expect(container.textContent).toContain("Orphaned feedback (1)"); + }); + + it("disables Done reviewing for read-only viewers", async () => { + await act(() => + root.render( + , + ), + ); + const done = container.querySelector('[data-testid="rail-done-reviewing"]'); + expect(done?.disabled).toBe(true); + }); +}); diff --git a/ui/src/components/documents/DocumentReviewRail.tsx b/ui/src/components/documents/DocumentReviewRail.tsx new file mode 100644 index 00000000000..03cf254fad8 --- /dev/null +++ b/ui/src/components/documents/DocumentReviewRail.tsx @@ -0,0 +1,429 @@ +import { useMemo, useState } from "react"; +import type { + DocumentReviewIndex, + DocumentSuggestionWithComments, +} from "@paperclipai/shared"; +import { CheckCircle2, MessageSquarePlus } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { ReviewAuthorMaps } from "@/lib/document-review-authors"; +import { DocumentThreadCard, type RailThread } from "./DocumentThreadCard"; +import { SuggestionCard } from "./SuggestionCard"; + +type ReviewFilter = "open" | "resolved" | "stale" | "orphaned"; + +const FILTERS: { id: ReviewFilter; label: string }[] = [ + { id: "open", label: "Open" }, + { id: "resolved", label: "Resolved" }, + { id: "stale", label: "Stale" }, + { id: "orphaned", label: "Orphaned" }, +]; + +export interface DocumentReviewRailProps { + reviewIndex?: DocumentReviewIndex; + loading?: boolean; + /** Viewer can reply/resolve/accept/reject. */ + canReview: boolean; + /** Viewer has edit rights (gates the Done reviewing CTA). */ + canFinishReview?: boolean; + doneReviewingDisabledReason?: string | null; + latestRevisionId: string | null; + authorMaps?: ReviewAuthorMaps; + focusedThreadId?: string | null; + onFocusThread?: (threadId: string) => void; + onReplyThread: (thread: RailThread, body: string) => Promise | void; + onToggleThreadResolved: (thread: RailThread, resolved: boolean) => Promise | void; + onAddOverallComment?: (body: string) => Promise | void; + onAcceptSuggestion: (suggestion: DocumentSuggestionWithComments) => Promise | void; + onRejectSuggestion: (suggestion: DocumentSuggestionWithComments, reason: string) => Promise | void; + onReplySuggestion: (suggestion: DocumentSuggestionWithComments, body: string) => Promise | void; + onViewSuggestionDiff?: (suggestion: DocumentSuggestionWithComments) => void; + onDoneReviewing?: () => void; + /** Which tab to open first (e.g. when a deep link targets a suggestion). */ + initialTab?: "comments" | "suggestions"; + /** Mobile: render inside a bottom Sheet. */ + isMobile?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + className?: string; +} + +export function DocumentReviewRail(props: DocumentReviewRailProps) { + if (props.isMobile) { + return ( + {})}> + + Document review +