From adbc6f745391e36896f003bb9af100588e98db72 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 7 Jun 2026 02:25:31 +0000 Subject: [PATCH] Add Done-reviewing handoff dialog (PAP-10570) - New DoneReviewingDialog: review summary (open comments/suggestions/stale/ orphaned counts), optional overall comment, and notify toggles (comment on linked issue, wake document owner), plus a 'Just close' escape hatch. - Wire the review rail 'Done reviewing' CTA in DocumentDetail to open it. Read-only viewers keep the disabled CTA + tooltip already in the rail. - Submit handoff uses existing primitives (no dedicated review-events endpoint exists, confirmed vs PAP-10523 / document-feedback contract): records the overall note as a document review thread, optionally posts a summary comment on the linked issue (wakes assignee), and fires a request_confirmation targeting the latest revision so the owner picks the review back up. - Unit tests for the dialog + Storybook stories for UX sign-off. Co-Authored-By: Paperclip --- .../documents/DoneReviewingDialog.test.tsx | 151 ++++++++ .../documents/DoneReviewingDialog.tsx | 211 +++++++++++ ui/src/pages/DocumentDetail.tsx | 327 +++++++++++++++--- .../stories/document-review.stories.tsx | 172 +++++++-- 4 files changed, 772 insertions(+), 89 deletions(-) create mode 100644 ui/src/components/documents/DoneReviewingDialog.test.tsx create mode 100644 ui/src/components/documents/DoneReviewingDialog.tsx diff --git a/ui/src/components/documents/DoneReviewingDialog.test.tsx b/ui/src/components/documents/DoneReviewingDialog.test.tsx new file mode 100644 index 00000000000..838af7bb0af --- /dev/null +++ b/ui/src/components/documents/DoneReviewingDialog.test.tsx @@ -0,0 +1,151 @@ +// @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 { DocumentReviewIndexCounts } from "@paperclipai/shared"; +import { DoneReviewingDialog, type DoneReviewingHandoff } from "./DoneReviewingDialog"; + +// 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 counts(overrides: Partial = {}): DocumentReviewIndexCounts { + return { + unresolved: 0, + openAnchoredThreads: 2, + openReviewThreads: 1, + pendingSuggestions: 3, + resolvedAnchoredThreads: 0, + resolvedReviewThreads: 0, + acceptedSuggestions: 0, + rejectedSuggestions: 0, + staleAnchors: 1, + orphanedAnchors: 4, + ...overrides, + }; +} + +// Radix Dialog portals into document.body, so query there rather than `container`. +function q(selector: string): T | null { + return document.body.querySelector(selector); +} + +function setTextarea(el: HTMLTextAreaElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; + setter?.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); +} + +describe("DoneReviewingDialog", () => { + it("renders the review summary counts", async () => { + await act(() => + root.render( + {}} counts={counts()} onSubmit={() => {}} />, + ), + ); + expect(q('[data-testid="done-reviewing-dialog"]')).not.toBeNull(); + // Open comments = anchored(2) + review(1) = 3 + expect(q('[data-testid="done-reviewing-count-open-comments"]')?.textContent).toBe("3"); + expect(q('[data-testid="done-reviewing-count-suggestions"]')?.textContent).toBe("3"); + expect(q('[data-testid="done-reviewing-count-stale"]')?.textContent).toBe("1"); + expect(q('[data-testid="done-reviewing-count-orphaned"]')?.textContent).toBe("4"); + }); + + it("submits with default toggles and the trimmed overall comment", async () => { + const onSubmit = vi.fn<(h: DoneReviewingHandoff) => void>(); + await act(() => + root.render( {}} counts={counts()} onSubmit={onSubmit} />), + ); + + const textarea = q('[data-testid="done-reviewing-overall"]')!; + await act(() => setTextarea(textarea, " ship it ")); + await act(() => q('[data-testid="done-reviewing-submit"]')!.click()); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toEqual({ + overallComment: "ship it", + commentOnLinkedIssue: true, + wakeOwner: true, + }); + }); + + it("'Just close' closes without submitting", async () => { + const onSubmit = vi.fn(); + const onOpenChange = vi.fn(); + await act(() => + root.render( + , + ), + ); + await act(() => q('[data-testid="done-reviewing-just-close"]')!.click()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("disables the owner wake toggle and forces wakeOwner false when no owner", async () => { + const onSubmit = vi.fn<(h: DoneReviewingHandoff) => void>(); + await act(() => + root.render( + {}} + counts={counts()} + canWakeOwner={false} + onSubmit={onSubmit} + />, + ), + ); + const wakeToggle = q('[data-testid="done-reviewing-toggle-wake"]')!; + expect(wakeToggle.disabled).toBe(true); + expect(wakeToggle.getAttribute("aria-checked")).toBe("false"); + + await act(() => q('[data-testid="done-reviewing-submit"]')!.click()); + expect(onSubmit.mock.calls[0][0].wakeOwner).toBe(false); + }); + + it("lets the reviewer turn off the linked-issue comment", async () => { + const onSubmit = vi.fn<(h: DoneReviewingHandoff) => void>(); + await act(() => + root.render( {}} counts={counts()} onSubmit={onSubmit} />), + ); + await act(() => q('[data-testid="done-reviewing-toggle-comment"]')!.click()); + await act(() => q('[data-testid="done-reviewing-submit"]')!.click()); + expect(onSubmit.mock.calls[0][0].commentOnLinkedIssue).toBe(false); + }); + + it("shows the clean-handoff hint when there is no open feedback", async () => { + await act(() => + root.render( + {}} + counts={counts({ openAnchoredThreads: 0, openReviewThreads: 0, pendingSuggestions: 0, staleAnchors: 0, orphanedAnchors: 0 })} + onSubmit={() => {}} + />, + ), + ); + expect(q('[data-testid="done-reviewing-clean"]')).not.toBeNull(); + }); +}); diff --git a/ui/src/components/documents/DoneReviewingDialog.tsx b/ui/src/components/documents/DoneReviewingDialog.tsx new file mode 100644 index 00000000000..5dff1da0953 --- /dev/null +++ b/ui/src/components/documents/DoneReviewingDialog.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import type { DocumentReviewIndexCounts } from "@paperclipai/shared"; +import { CheckCircle2, MessageSquare, Bell } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; + +/** Payload emitted when the reviewer confirms the handoff. */ +export interface DoneReviewingHandoff { + /** Trimmed overall comment (empty string when the reviewer left none). */ + overallComment: string; + /** Post a structured summary comment on the linked issue (wakes the assignee). */ + commentOnLinkedIssue: boolean; + /** Fire a structured owner wake (request_confirmation against the latest revision). */ + wakeOwner: boolean; +} + +export interface DoneReviewingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + counts?: DocumentReviewIndexCounts; + /** Linked issue identifier, used to label the "comment on issue" toggle. */ + issueIdentifier?: string | null; + /** Owner agent display name, used to label the "wake owner" toggle. */ + ownerName?: string | null; + /** When false the owner-wake toggle is disabled (no resolvable owner / revision). */ + canWakeOwner?: boolean; + submitting?: boolean; + onSubmit: (handoff: DoneReviewingHandoff) => Promise | void; +} + +interface SummaryStat { + label: string; + value: number; + tone: "default" | "warn"; +} + +function buildStats(counts: DocumentReviewIndexCounts | undefined): SummaryStat[] { + const c = counts; + return [ + { label: "Open comments", value: (c?.openAnchoredThreads ?? 0) + (c?.openReviewThreads ?? 0), tone: "default" }, + { label: "Suggestions", value: c?.pendingSuggestions ?? 0, tone: "default" }, + { label: "Stale", value: c?.staleAnchors ?? 0, tone: "warn" }, + { label: "Orphaned", value: c?.orphanedAnchors ?? 0, tone: "warn" }, + ]; +} + +export function DoneReviewingDialog({ + open, + onOpenChange, + counts, + issueIdentifier, + ownerName, + canWakeOwner = true, + submitting = false, + onSubmit, +}: DoneReviewingDialogProps) { + const [overall, setOverall] = useState(""); + const [commentOnLinkedIssue, setCommentOnLinkedIssue] = useState(true); + const [wakeOwner, setWakeOwner] = useState(true); + + // Reset the form each time the dialog opens so a prior draft never leaks in. + useEffect(() => { + if (open) { + setOverall(""); + setCommentOnLinkedIssue(true); + setWakeOwner(canWakeOwner); + } + }, [open, canWakeOwner]); + + const stats = buildStats(counts); + const hasUnresolved = stats.some((s) => s.value > 0); + + const submit = () => { + void onSubmit({ + overallComment: overall.trim(), + commentOnLinkedIssue, + wakeOwner: wakeOwner && canWakeOwner, + }); + }; + + return ( + !submitting && onOpenChange(next)}> + + + Done reviewing + + Hand the document back to its owner with an optional note and a real wake. + + + + {/* Review summary */} +
+ {stats.map((stat) => ( +
+
0 + ? "text-lg font-semibold text-amber-600 dark:text-amber-400" + : "text-lg font-semibold text-foreground" + } + data-testid={`done-reviewing-count-${stat.label.toLowerCase().replace(/\s+/g, "-")}`} + > + {stat.value} +
+
{stat.label}
+
+ ))} +
+ {!hasUnresolved ? ( +

+ No open feedback — this is a clean handoff. +

+ ) : null} + + {/* Optional overall comment */} +
+ +