mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
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:
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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user