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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-07 02:25:31 +00:00
1 parent c295c19985
commit adbc6f7453
4 files changed
+772 -89

No files matched your search

@@ -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<void>) {
let result: void | Promise<void> = 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> = {}): 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<T extends Element = HTMLElement>(selector: string): T | null {
return document.body.querySelector<T>(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(
<DoneReviewingDialog open onOpenChange={() => {}} 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(<DoneReviewingDialog open onOpenChange={() => {}} counts={counts()} onSubmit={onSubmit} />),
);
const textarea = q<HTMLTextAreaElement>('[data-testid="done-reviewing-overall"]')!;
await act(() => setTextarea(textarea, " ship it "));
await act(() => q<HTMLButtonElement>('[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(
<DoneReviewingDialog open onOpenChange={onOpenChange} counts={counts()} onSubmit={onSubmit} />,
),
);
await act(() => q<HTMLButtonElement>('[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(
<DoneReviewingDialog
open
onOpenChange={() => {}}
counts={counts()}
canWakeOwner={false}
onSubmit={onSubmit}
/>,
),
);
const wakeToggle = q<HTMLButtonElement>('[data-testid="done-reviewing-toggle-wake"]')!;
expect(wakeToggle.disabled).toBe(true);
expect(wakeToggle.getAttribute("aria-checked")).toBe("false");
await act(() => q<HTMLButtonElement>('[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(<DoneReviewingDialog open onOpenChange={() => {}} counts={counts()} onSubmit={onSubmit} />),
);
await act(() => q<HTMLButtonElement>('[data-testid="done-reviewing-toggle-comment"]')!.click());
await act(() => q<HTMLButtonElement>('[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(
<DoneReviewingDialog
open
onOpenChange={() => {}}
counts={counts({ openAnchoredThreads: 0, openReviewThreads: 0, pendingSuggestions: 0, staleAnchors: 0, orphanedAnchors: 0 })}
onSubmit={() => {}}
/>,
),
);
expect(q('[data-testid="done-reviewing-clean"]')).not.toBeNull();
});
});
@@ -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> | 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 (
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
<DialogContent className="sm:max-w-md" data-testid="done-reviewing-dialog">
<DialogHeader>
<DialogTitle>Done reviewing</DialogTitle>
<DialogDescription>
Hand the document back to its owner with an optional note and a real wake.
</DialogDescription>
</DialogHeader>
{/* Review summary */}
<div className="grid grid-cols-4 gap-2" data-testid="done-reviewing-summary">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-md border border-border bg-muted/30 px-2 py-1.5 text-center"
>
<div
className={
stat.tone === "warn" && stat.value > 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}
</div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{stat.label}</div>
</div>
))}
</div>
{!hasUnresolved ? (
<p className="text-xs text-muted-foreground" data-testid="done-reviewing-clean">
No open feedback — this is a clean handoff.
</p>
) : null}
{/* Optional overall comment */}
<div className="space-y-1.5">
<label htmlFor="done-reviewing-overall" className="text-xs font-medium text-foreground">
Overall comment <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<Textarea
id="done-reviewing-overall"
data-testid="done-reviewing-overall"
value={overall}
onChange={(event) => setOverall(event.currentTarget.value)}
placeholder="Summarize your review for the document owner…"
rows={3}
className="resize-none text-sm"
/>
</div>
{/* Notify toggles */}
<div className="space-y-2.5 rounded-md border border-border p-3">
<ToggleRow
icon={<MessageSquare className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
title={issueIdentifier ? `Comment on ${issueIdentifier}` : "Comment on linked issue"}
description="Post a review summary on the linked issue thread."
checked={commentOnLinkedIssue}
onCheckedChange={setCommentOnLinkedIssue}
testId="done-reviewing-toggle-comment"
/>
<ToggleRow
icon={<Bell className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
title={ownerName ? `Wake ${ownerName}` : "Wake document owner"}
description={
canWakeOwner
? "Send a confirmation request so the owner picks the review back up."
: "No owner is assigned to wake."
}
checked={wakeOwner && canWakeOwner}
onCheckedChange={setWakeOwner}
disabled={!canWakeOwner}
testId="done-reviewing-toggle-wake"
/>
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
data-testid="done-reviewing-just-close"
>
Just close
</Button>
<Button type="button" onClick={submit} disabled={submitting} data-testid="done-reviewing-submit">
<CheckCircle2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
Done reviewing
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface ToggleRowProps {
icon: React.ReactNode;
title: string;
description: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
testId: string;
}
function ToggleRow({ icon, title, description, checked, onCheckedChange, disabled, testId }: ToggleRowProps) {
return (
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<span className="mt-0.5">{icon}</span>
<div>
<div className="text-sm font-medium text-foreground">{title}</div>
<div className="text-[11px] text-muted-foreground">{description}</div>
</div>
</div>
<ToggleSwitch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} data-testid={testId} />
</div>
);
}
+273 -54
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import type {
Agent,
CompanyDocument,
DocumentReviewIndexCounts,
DocumentSuggestionKind,
DocumentSuggestionWithComments,
DocumentType,
@@ -12,6 +13,7 @@ import {
AlertTriangle,
BarChart3,
BookOpen,
Check,
ClipboardList,
FileText,
History,
@@ -38,6 +40,8 @@ import { cn, relativeTime } from "@/lib/utils";
import { EmptyState } from "@/components/EmptyState";
import { StatusBadge } from "@/components/StatusBadge";
import { Identity } from "@/components/Identity";
import { InlineEditor } from "@/components/InlineEditor";
import { useOptionalToastActions } from "@/context/ToastContext";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@@ -70,10 +74,14 @@ import {
} from "@/components/DocumentAnnotationLayer";
import { DocumentDiffModal } from "@/components/DocumentDiffModal";
import { DocumentReviewRail } from "@/components/documents/DocumentReviewRail";
import {
DoneReviewingDialog,
type DoneReviewingHandoff,
} from "@/components/documents/DoneReviewingDialog";
import { SelectionToolbar, type SuggestionDraftIntent } from "@/components/documents/SelectionToolbar";
import type { RailThread } from "@/components/documents/DocumentThreadCard";
const DOC_TYPE_ICON: Record<DocumentType, typeof FileText> = {
export const DOC_TYPE_ICON: Record<DocumentType, typeof FileText> = {
plan: ListTodo,
spec: BookOpen,
brief: ClipboardList,
@@ -104,8 +112,10 @@ function resolveIssueBinding(doc: CompanyDocument | undefined): IssueBinding | n
export function DocumentDetail() {
const { documentId } = useParams<{ documentId: string }>();
const [searchParams] = useSearchParams();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const toast = useOptionalToastActions();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
@@ -113,6 +123,7 @@ export function DocumentDetail() {
const [baseRevisionId, setBaseRevisionId] = useState<string | null>(null);
const [conflict, setConflict] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const [handoffOpen, setHandoffOpen] = useState(false);
const [railOpen, setRailOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [commentDraft, setCommentDraft] = useState<{ anchor: PendingAnchor } | null>(null);
@@ -120,6 +131,7 @@ export function DocumentDetail() {
const [suggestDraft, setSuggestDraft] = useState<{ anchor: PendingAnchor; intent: SuggestionDraftIntent } | null>(null);
const [proposed, setProposed] = useState("");
const containerRef = useRef<HTMLElement | null>(null);
const finishAfterSaveRef = useRef(false);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
@@ -181,12 +193,25 @@ export function DocumentDetail() {
? userProfileMap.get(doc.lockedByUserId)?.label ?? "a board member"
: null;
// `from=issue:<identifier>` deep links the breadcrumb back to the source issue
// so reviewers who opened the doc from an issue land back where they came from.
const fromParam = searchParams.get("from");
const fromIssueIdentifier = fromParam?.startsWith("issue:") ? fromParam.slice("issue:".length) : null;
useEffect(() => {
setBreadcrumbs([
{ label: "Documents", href: "/documents" },
{ label: doc?.title ?? "Document" },
]);
}, [setBreadcrumbs, doc?.title]);
const title = doc?.title ?? "Document";
if (fromIssueIdentifier) {
setBreadcrumbs([
{ label: "Issues", href: "/issues" },
{ label: fromIssueIdentifier, href: `/issues/${fromIssueIdentifier}` },
{ label: title },
]);
} else {
setBreadcrumbs([
{ label: "Documents", href: "/documents" },
{ label: title },
]);
}
}, [setBreadcrumbs, doc?.title, fromIssueIdentifier]);
const invalidateReview = useCallback(() => {
if (binding) {
@@ -240,6 +265,12 @@ export function DocumentDetail() {
onSuccess: invalidateDoc,
});
const titleMutation = useMutation({
mutationFn: (title: string) =>
documentsApi.updateMetadata(selectedCompanyId!, documentId!, { title: title || null }),
onSuccess: invalidateDoc,
});
const saveMutation = useMutation({
mutationFn: (body: string) =>
issuesApi.upsertDocument(binding!.issueId, binding!.key, {
@@ -252,12 +283,69 @@ export function DocumentDetail() {
setConflict(false);
invalidateDoc();
invalidateReview();
if (finishAfterSaveRef.current) {
finishAfterSaveRef.current = false;
setHandoffOpen(true);
}
},
onError: (error) => {
finishAfterSaveRef.current = false;
if (error instanceof ApiError && error.status === 409) setConflict(true);
},
});
// "Done reviewing" handoff. There is no dedicated review-events endpoint
// (confirmed against PAP-10523 / the document-feedback contract): the handoff
// is the issue thread. We record the overall note as a document review thread,
// optionally post a summary comment on the linked issue (wakes the assignee),
// and optionally fire a request_confirmation against the latest revision so the
// owner picks the review back up.
const reviewHandoff = useMutation({
mutationFn: async (handoff: DoneReviewingHandoff) => {
if (!binding || !doc) return;
const counts = reviewIndex?.counts;
const summary = buildHandoffComment(doc, counts, handoff.overallComment);
if (handoff.overallComment) {
await documentReviewsApi.createReviewThread(binding.issueId, binding.key, {
body: handoff.overallComment,
});
}
if (handoff.commentOnLinkedIssue) {
await issuesApi.addComment(binding.issueId, summary);
}
if (handoff.wakeOwner && doc.latestRevisionId) {
await issuesApi.createInteraction(binding.issueId, {
kind: "request_confirmation",
idempotencyKey: `confirmation:${binding.issueId}:doc-review:${binding.key}:${doc.latestRevisionId}`,
title: `Review complete: ${doc.title ?? "document"}`.slice(0, 240),
summary: summary.slice(0, 1000),
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: "A reviewer finished reviewing this document. Pick the feedback back up when ready.",
acceptLabel: "Got it",
rejectLabel: "Dismiss",
target: {
type: "issue_document",
issueId: binding.issueId,
key: binding.key,
revisionId: doc.latestRevisionId,
},
},
});
}
},
onSuccess: () => {
setHandoffOpen(false);
invalidateReview();
toast?.pushToast({ title: "Review handed off", tone: "success" });
},
onError: () => {
toast?.pushToast({ title: "Couldn't complete the handoff", tone: "error" });
},
});
// Rail handlers
const replyThread = useCallback(
async (thread: RailThread, body: string) => {
@@ -325,6 +413,21 @@ export function DocumentDetail() {
[binding, invalidateReview],
);
// "Resolve" = handled outside / no longer applies. The suggestion data model
// only has pending/accepted/rejected today, so we record it as a reject with a
// clear non-disagreement reason for the audit trail. A first-class `resolved`
// status is tracked as a follow-up (child of PAP-10524).
const resolveSuggestion = useCallback(
async (suggestion: DocumentSuggestionWithComments) => {
if (!binding) return;
await documentReviewsApi.rejectSuggestion(binding.issueId, binding.key, suggestion.id, {
reason: "Resolved — handled outside review / no longer applies.",
});
invalidateReview();
},
[binding, invalidateReview],
);
const startEdit = useCallback(() => {
if (!doc) return;
setDraft(doc.body);
@@ -377,15 +480,26 @@ export function DocumentDetail() {
doc={doc}
TypeIcon={TypeIcon}
ownerName={ownerAgent?.name ?? null}
ownerAgentId={doc.ownerAgentId}
canEdit={canEdit}
canReview={canReview}
isBoard={isBoard}
editMode={editMode}
lockedByMe={lockedByMe}
lockedByOther={lockedByOther}
lockHolderName={lockHolderName}
onEdit={startEdit}
onSuggestEdit={() => {
setRailOpen(true);
toast?.pushToast({
title: "Select text in the document, then choose Suggest edit",
tone: "info",
});
}}
onSaveTitle={(title) => titleMutation.mutateAsync(title)}
onHistory={() => setDiffOpen(true)}
onToggleLock={() => lockMutation.mutate(!lockedByMe)}
onCopyLink={() => toast?.pushToast({ title: "Link copied", tone: "success" })}
lockPending={lockMutation.isPending}
/>
@@ -393,37 +507,41 @@ export function DocumentDetail() {
<div
role="alert"
data-testid="document-conflict-banner"
className="mx-4 mb-3 flex flex-wrap items-center gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
className="mx-4 mb-3 flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-amber-800 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
>
<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="flex-1">Someone updated this document while you were editing.</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setDiffOpen(true)}>
View their changes
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => {
setBaseRevisionId(doc.latestRevisionId);
setConflict(false);
}}
>
Rebase my draft
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => {
setDraft(doc.body);
setBaseRevisionId(doc.latestRevisionId);
setConflict(false);
setEditMode(false);
}}
>
Discard mine
</Button>
<div className="flex min-w-0 items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>Someone updated this document while you were editing.</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={() => setDiffOpen(true)}>
View their changes
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => {
setBaseRevisionId(doc.latestRevisionId);
setConflict(false);
}}
>
Rebase my draft
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-destructive hover:text-destructive"
onClick={() => {
setDraft(doc.body);
setBaseRevisionId(doc.latestRevisionId);
setConflict(false);
setEditMode(false);
}}
>
Discard mine
</Button>
</div>
</div>
) : null}
@@ -438,11 +556,27 @@ export function DocumentDetail() {
</Button>
<Button
size="sm"
variant="outline"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(draft)}
onClick={() => {
finishAfterSaveRef.current = false;
saveMutation.mutate(draft);
}}
data-testid="save-draft"
>
Save draft
</Button>
<Button
size="sm"
disabled={saveMutation.isPending}
onClick={() => {
finishAfterSaveRef.current = true;
saveMutation.mutate(draft);
}}
data-testid="save-finish-review"
>
Save + finish review
</Button>
</div>
</div>
) : (
@@ -517,8 +651,10 @@ export function DocumentDetail() {
onAddOverallComment={canReview ? addOverallComment : undefined}
onAcceptSuggestion={acceptSuggestion}
onRejectSuggestion={rejectSuggestion}
onResolveSuggestion={resolveSuggestion}
onReplySuggestion={replySuggestion}
onViewSuggestionDiff={() => setDiffOpen(true)}
onDoneReviewing={() => setHandoffOpen(true)}
/>
</div>
) : null}
@@ -552,8 +688,13 @@ export function DocumentDetail() {
onAddOverallComment={canReview ? addOverallComment : undefined}
onAcceptSuggestion={acceptSuggestion}
onRejectSuggestion={rejectSuggestion}
onResolveSuggestion={resolveSuggestion}
onReplySuggestion={replySuggestion}
onViewSuggestionDiff={() => setDiffOpen(true)}
onDoneReviewing={() => {
setRailOpen(false);
setHandoffOpen(true);
}}
/>
</>
) : null}
@@ -626,6 +767,17 @@ export function DocumentDetail() {
</DialogContent>
</Dialog>
<DoneReviewingDialog
open={handoffOpen}
onOpenChange={setHandoffOpen}
counts={reviewIndex?.counts}
issueIdentifier={doc.backlinks.find((b) => b.identifier && b.targetType === "issue")?.identifier ?? null}
ownerName={ownerAgent?.name ?? null}
canWakeOwner={!!doc.ownerAgentId && !!doc.latestRevisionId}
submitting={reviewHandoff.isPending}
onSubmit={(handoff) => reviewHandoff.mutate(handoff)}
/>
{binding ? (
<DocumentDiffModal
issueId={binding.issueId}
@@ -645,37 +797,71 @@ function suggestionDialogTitle(kind?: DocumentSuggestionKind): string {
return "Replace selection";
}
interface DocumentHeaderProps {
/** Markdown summary posted to the linked issue / interaction on review completion. */
function buildHandoffComment(
doc: CompanyDocument,
counts: DocumentReviewIndexCounts | undefined,
overallComment: string,
): string {
const openComments = (counts?.openAnchoredThreads ?? 0) + (counts?.openReviewThreads ?? 0);
const lines = [
`## Review complete: ${doc.title ?? "document"}`,
"",
`Reviewed **rev ${doc.latestRevisionNumber}**.`,
"",
`- Open comments: ${openComments}`,
`- Pending suggestions: ${counts?.pendingSuggestions ?? 0}`,
`- Stale anchors: ${counts?.staleAnchors ?? 0}`,
`- Orphaned anchors: ${counts?.orphanedAnchors ?? 0}`,
];
if (overallComment) {
lines.push("", `**Overall:** ${overallComment}`);
}
return lines.join("\n");
}
export interface DocumentHeaderProps {
doc: CompanyDocument;
TypeIcon: typeof FileText;
ownerName: string | null;
ownerAgentId: string | null;
canEdit: boolean;
canReview: boolean;
isBoard: boolean;
editMode: boolean;
lockedByMe: boolean;
lockedByOther: boolean;
lockHolderName: string | null;
onEdit: () => void;
onSuggestEdit: () => void;
onSaveTitle: (title: string) => Promise<unknown>;
onHistory: () => void;
onToggleLock: () => void;
onCopyLink: () => void;
lockPending: boolean;
}
function DocumentHeader({
export function DocumentHeader({
doc,
TypeIcon,
ownerName,
ownerAgentId,
canEdit,
canReview,
isBoard,
editMode,
lockedByMe,
lockedByOther,
lockHolderName,
onEdit,
onSuggestEdit,
onSaveTitle,
onHistory,
onToggleLock,
onCopyLink,
lockPending,
}: DocumentHeaderProps) {
const [copied, setCopied] = useState(false);
const backlinks = doc.backlinks.filter((b) => b.identifier);
const shownBacklinks = backlinks.slice(0, 5);
const extraBacklinks = backlinks.length - shownBacklinks.length;
@@ -684,21 +870,45 @@ function DocumentHeader({
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(window.location.href).catch(() => {});
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
onCopyLink();
};
return (
<header className="px-4 pt-4">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold text-foreground">{doc.title ?? "Untitled document"}</h1>
{canEdit ? (
<InlineEditor
value={doc.title ?? ""}
onSave={(next) => onSaveTitle(next.trim())}
as="h1"
nullable
placeholder="Untitled document"
className="text-xl font-bold text-foreground"
/>
) : (
<h1 className="text-xl font-bold text-foreground">{doc.title ?? "Untitled document"}</h1>
)}
<StatusBadge status={doc.status} />
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<TypeIcon className="h-3.5 w-3.5" aria-hidden="true" />
{DOC_TYPE_LABEL[doc.documentType]}
</span>
{ownerName ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Identity name={ownerName} size="sm" />
</span>
ownerAgentId ? (
<Link
to={`/documents?owner=${ownerAgentId}`}
className="inline-flex items-center gap-1 rounded text-xs text-muted-foreground hover:text-foreground"
title={`Filter documents owned by ${ownerName}`}
>
<Identity name={ownerName} size="sm" />
</Link>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Identity name={ownerName} size="sm" />
</span>
)
) : null}
{!isBoard ? (
<Badge className="border-transparent bg-muted px-1.5 py-0 text-[10px] text-muted-foreground" data-testid="readonly-badge">
@@ -740,6 +950,12 @@ function DocumentHeader({
</TooltipContent>
) : null}
</Tooltip>
{canReview ? (
<Button size="sm" variant="ghost" className="h-8" onClick={onSuggestEdit} data-testid="action-suggest-edit">
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
Suggest edit
</Button>
) : null}
<Button size="sm" variant="ghost" className="h-8" onClick={onHistory} data-testid="action-history">
<History className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
History
@@ -767,8 +983,12 @@ function DocumentHeader({
</Button>
) : null}
<Button size="sm" variant="ghost" className="h-8" onClick={copyLink} data-testid="action-copy-link">
<Link2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
Copy link
{copied ? (
<Check className="mr-1 h-3.5 w-3.5 text-green-600 dark:text-green-400" aria-hidden="true" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
)}
{copied ? "Copied" : "Copy link"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -804,15 +1024,14 @@ function DocumentHeader({
{/* Meta row */}
<div className="mb-3 flex items-center gap-2 border-b border-border pb-3 text-[11px] text-muted-foreground">
<button
type="button"
onClick={onHistory}
className="rounded border border-border px-1.5 py-0.5 font-mono hover:bg-accent/50"
data-testid="meta-rev"
>
Rev {doc.latestRevisionNumber}
</button>
<Badge asChild variant="outline" className="cursor-pointer px-1.5 py-0 font-mono hover:bg-accent/50">
<button type="button" onClick={onHistory} data-testid="meta-rev">
Rev {doc.latestRevisionNumber}
</button>
</Badge>
<span>updated {relativeTime(doc.updatedAt)}</span>
{/* Autosave indicator slot (§5.2) — wired when autosave lands. */}
<span data-testid="autosave-slot" className="ml-auto empty:hidden" aria-live="polite" />
</div>
</header>
);
+137 -35
View File
@@ -1,14 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AlertTriangle, BookOpen, History, Link2, Lock, Pencil } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import type {
CompanyDocument,
DocumentReviewIndex,
DocumentSuggestionWithComments,
} from "@paperclipai/shared";
import { DocumentReviewRail } from "@/components/documents/DocumentReviewRail";
import { DoneReviewingDialog } from "@/components/documents/DoneReviewingDialog";
import { SuggestionCard } from "@/components/documents/SuggestionCard";
import { SelectionToolbar } from "@/components/documents/SelectionToolbar";
import { StatusBadge } from "@/components/StatusBadge";
import { Badge } from "@/components/ui/badge";
import { DocumentHeader, DOC_TYPE_ICON } from "@/pages/DocumentDetail";
import { Button } from "@/components/ui/button";
import { MarkdownBody } from "@/components/MarkdownBody";
@@ -263,34 +264,73 @@ export default meta;
type Story = StoryObj;
// Real CompanyDocument so the story mounts the production `DocumentHeader` (not a
// hand-rolled copy) — this is what the visual-truth gate requires.
const sampleDoc: CompanyDocument = {
id: "doc-spec",
companyId: "c",
title: "Paperclip Documents review flow",
format: "markdown",
status: "in_review",
documentType: "spec",
summary: null,
ownerAgentId: "agent-claude",
ownerUserId: null,
latestRevisionId: "rev-12",
latestRevisionNumber: 12,
createdByAgentId: "agent-claude",
createdByUserId: null,
updatedByAgentId: "agent-claude",
updatedByUserId: null,
lockedAt: null,
lockedByAgentId: null,
lockedByUserId: null,
sourceTrust: null,
archivedAt: null,
archivedByAgentId: null,
archivedByUserId: null,
createdAt: new Date(now - 48 * HOUR),
updatedAt: new Date(now - 2 * HOUR),
backlinks: [
{ id: "l1", companyId: "c", documentId: "doc-spec", targetType: "issue", targetId: "i1", relationship: "source", issueDocumentId: "id1", issueDocumentKey: "spec", title: "Design Paperclip Documents UX", identifier: "PAP-10520", createdAt: new Date(now), updatedAt: new Date(now) },
{ id: "l2", companyId: "c", documentId: "doc-spec", targetType: "issue", targetId: "i2", relationship: "related", issueDocumentId: null, issueDocumentKey: null, title: "Document review backend", identifier: "PAP-10522", createdAt: new Date(now), updatedAt: new Date(now) },
],
feedbackCounts: {
openComments: 2,
resolvedComments: 1,
openReviewThreads: 1,
resolvedReviewThreads: 0,
pendingSuggestions: 3,
acceptedSuggestions: 0,
rejectedSuggestions: 1,
staleAnchors: 1,
orphanedAnchors: 1,
},
body: "",
};
function DetailHeader() {
return (
<header className="px-4 pt-4">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold text-foreground">Paperclip Documents review flow</h1>
<StatusBadge status="in_review" />
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<BookOpen className="h-3.5 w-3.5" /> Spec
</span>
<span className="text-xs text-muted-foreground">Owner: ClaudeCoder</span>
</div>
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<Button size="sm" variant="outline" className="h-8"><Pencil className="mr-1 h-3.5 w-3.5" />Edit</Button>
<Button size="sm" variant="ghost" className="h-8"><Pencil className="mr-1 h-3.5 w-3.5" />Suggest edit</Button>
<Button size="sm" variant="ghost" className="h-8"><History className="mr-1 h-3.5 w-3.5" />History</Button>
<Button size="sm" variant="ghost" className="h-8"><Lock className="mr-1 h-3.5 w-3.5" />Lock</Button>
<Button size="sm" variant="ghost" className="h-8"><Link2 className="mr-1 h-3.5 w-3.5" />Copy link</Button>
</div>
<div className="mb-1.5 flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-muted-foreground">Backlinks:</span>
<Badge variant="outline" className="px-1.5 py-0 text-[10px] font-mono">PAP-10520</Badge>
<Badge variant="outline" className="px-1.5 py-0 text-[10px] font-mono">PAP-10522</Badge>
</div>
<div className="mb-3 flex items-center gap-2 border-b border-border pb-3 text-[11px] text-muted-foreground">
<span className="rounded border border-border px-1.5 py-0.5 font-mono">Rev 12</span>
<span>updated 2h ago by ClaudeCoder</span>
</div>
</header>
<DocumentHeader
doc={sampleDoc}
TypeIcon={DOC_TYPE_ICON[sampleDoc.documentType]}
ownerName="ClaudeCoder"
ownerAgentId="agent-claude"
canEdit
canReview
isBoard
editMode={false}
lockedByMe={false}
lockedByOther={false}
lockHolderName={null}
onEdit={noop}
onSuggestEdit={noop}
onSaveTitle={async () => {}}
onHistory={noop}
onToggleLock={noop}
onCopyLink={noop}
lockPending={false}
/>
);
}
@@ -332,17 +372,39 @@ export const ConflictBanner: Story = {
render: () => (
<div
role="alert"
className="m-4 flex max-w-2xl flex-wrap items-center gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
className="m-4 flex max-w-3xl flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-amber-800 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="flex-1">Someone updated this document while you were editing.</span>
<Button size="sm" variant="outline" className="h-7 text-xs">View their changes</Button>
<Button size="sm" variant="outline" className="h-7 text-xs">Rebase my draft</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs text-destructive hover:text-destructive">Discard mine</Button>
<div className="flex min-w-0 items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>Someone updated this document while you were editing.</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button size="sm" variant="outline" className="h-8 text-xs">View their changes</Button>
<Button size="sm" variant="outline" className="h-8 text-xs">Rebase my draft</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs text-destructive hover:text-destructive">Discard mine</Button>
</div>
</div>
),
};
/** Mobile review surface — the rail rendered inside its bottom Sheet (≤640px). */
export const RailMobileSheet: Story = {
name: "Rail — mobile sheet",
render: () => (
<DocumentReviewRail
isMobile
open
onOpenChange={noop}
reviewIndex={reviewIndex}
canReview
canFinishReview
latestRevisionId="rev-12"
authorMaps={{ agentMap }}
{...railHandlers}
/>
),
};
export const Suggestions: Story = {
render: () => (
<div className="w-[340px] space-y-3 p-4">
@@ -364,3 +426,43 @@ export const SelectionToolbarStory: Story = {
</div>
),
};
/** PAP-10570 — the "Done reviewing" handoff dialog opened from the rail CTA. */
export const DoneReviewing: Story = {
name: "Done-reviewing handoff",
render: () => (
<div className="h-[560px] w-full">
<DoneReviewingDialog
open
onOpenChange={noop}
counts={reviewIndex.counts}
issueIdentifier="PAP-10520"
ownerName="ClaudeCoder"
onSubmit={noop}
/>
</div>
),
};
export const DoneReviewingClean: Story = {
name: "Done-reviewing handoff (clean)",
render: () => (
<div className="h-[560px] w-full">
<DoneReviewingDialog
open
onOpenChange={noop}
counts={{
...reviewIndex.counts,
openAnchoredThreads: 0,
openReviewThreads: 0,
pendingSuggestions: 0,
staleAnchors: 0,
orphanedAnchors: 0,
}}
issueIdentifier="PAP-10520"
ownerName="ClaudeCoder"
onSubmit={noop}
/>
</div>
),
};