Add document detail / review view UI (PAP-10568)

Builds the full document detail/review surface at /:co/documents/:id, the
second slice of the Documents UI (PAP-10524). Reuses MarkdownBody,
MarkdownEditor, DocumentAnnotationLayer and DocumentDiffModal; adds the
review rail and the net-new SuggestionCard / SelectionToolbar.

- DocumentDetail page + route: header (breadcrumb, title, status pill, type,
  owner chip, action row Edit/Suggest/History/Lock/Copy link/overflow,
  backlinks chips, rev/meta row), read body with annotation layer, edit body
  with MarkdownEditor, conflict banner on stale baseRevisionId, read-only /
  permission-denied / lock (self + other) states, desktop rail + mobile FAB
  and Sheet.
- DocumentReviewRail: two tabs (Comments / Suggestions) with counts, Open/
  Resolved/Stale/Orphaned chip filters, document-level overall comment card,
  collapsible orphan pin group, bottom Done reviewing CTA (gated; dialog is
  PAP-10570).
- SuggestionCard (NEW): insert/delete/replace with CriticMarkup-style diff,
  discussion + reply, Accept/Reject(reason), Needs-rebase gate on stale
  baseRevisionId / drifted anchor.
- SelectionToolbar (NEW): Comment / Suggest edit (Replace/Delete/Insert
  before/after) injected into DocumentAnnotationLayer via a new
  renderSelectionToolbar slot so caret positioning is reused.
- documentReviewsApi client: review-index, review-comments, suggestions
  (create/comment/accept/reject). Suggestion treatment CSS tokens (§5.4).
- Tests for SuggestionCard, SelectionToolbar, DocumentReviewRail, and the
  page (permission-denied, header/body/rail, read-only, lock). Storybook
  stories for UX sign-off.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-07 02:00:31 +00:00
1 parent 2f23e4f1ca
commit 02012b84d7
15 files changed
+3281 -14

No files matched your search

+2
View File
@@ -24,6 +24,7 @@ import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals";
import { Artifacts } from "./pages/Artifacts";
import { Documents } from "./pages/Documents";
import { DocumentDetail } from "./pages/DocumentDetail";
import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
import { ApprovalDetail } from "./pages/ApprovalDetail";
@@ -130,6 +131,7 @@ function boardRoutes() {
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="documents" element={<Documents />} />
<Route path="documents/:documentId" element={<DocumentDetail />} />
<Route path="artifacts" element={<Artifacts />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
<Route path="approvals/pending" element={<Approvals />} />
+115
View File
@@ -0,0 +1,115 @@
import type {
AcceptDocumentSuggestionRequest,
CompanyDocument,
CreateDocumentReviewCommentRequest,
CreateDocumentReviewThreadRequest,
CreateDocumentSuggestionCommentRequest,
CreateDocumentSuggestionRequest,
DocumentReviewComment,
DocumentReviewIndex,
DocumentReviewThreadStatus,
DocumentReviewThreadWithComments,
DocumentSuggestion,
DocumentSuggestionComment,
RejectDocumentSuggestionRequest,
} from "@paperclipai/shared";
import { api } from "./client";
export type DocumentReviewIndexStatus = "open" | "all";
/**
* The accept endpoint returns the resulting revision + the updated document so
* the caller can refresh the body and open a diff. We keep the shape loose for
* the fields the detail view actually consumes.
*/
export interface AcceptDocumentSuggestionResult {
suggestion: DocumentSuggestion;
document: CompanyDocument;
revision: { id: string; revisionNumber: number };
}
/**
* Document review surface (anchored comments live in `document-annotations`;
* this module covers the *review-index*, document-level review threads, and
* suggested edits — the pieces the detail/review view layers on top).
*/
export const documentReviewsApi = {
reviewIndex: (
issueId: string,
key: string,
options: { rev?: number; status?: DocumentReviewIndexStatus; includeComments?: boolean } = {},
) => {
const params = new URLSearchParams();
if (options.rev !== undefined) params.set("rev", String(options.rev));
if (options.status) params.set("status", options.status);
if (options.includeComments) params.set("includeComments", "true");
const qs = params.toString();
return api.get<DocumentReviewIndex>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/review-index${qs ? `?${qs}` : ""}`,
);
},
// --- Document-level / overall review threads (anchor: null) ----------------
createReviewThread: (issueId: string, key: string, data: CreateDocumentReviewThreadRequest) =>
api.post<DocumentReviewThreadWithComments>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments`,
data,
),
addReviewComment: (
issueId: string,
key: string,
threadId: string,
data: CreateDocumentReviewCommentRequest,
) =>
api.post<DocumentReviewComment>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments/${threadId}/comments`,
data,
),
updateReviewThreadStatus: (
issueId: string,
key: string,
threadId: string,
status: DocumentReviewThreadStatus,
) =>
api.patch<DocumentReviewThreadWithComments>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments/${threadId}`,
{ status },
),
// --- Suggested edits -------------------------------------------------------
createSuggestion: (issueId: string, key: string, data: CreateDocumentSuggestionRequest) =>
api.post<DocumentSuggestion>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions`,
data,
),
addSuggestionComment: (
issueId: string,
key: string,
suggestionId: string,
data: CreateDocumentSuggestionCommentRequest,
) =>
api.post<DocumentSuggestionComment>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/comments`,
data,
),
acceptSuggestion: (
issueId: string,
key: string,
suggestionId: string,
data: AcceptDocumentSuggestionRequest,
) =>
api.post<AcceptDocumentSuggestionResult>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/accept`,
data,
),
rejectSuggestion: (
issueId: string,
key: string,
suggestionId: string,
data: RejectDocumentSuggestionRequest,
) =>
api.post<DocumentSuggestion>(
`/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/reject`,
data,
),
};
+35 -14
View File
@@ -48,6 +48,18 @@ export interface AnnotationLayerProps {
* pending anchor for the keyboard shortcut path.
*/
captureSelectionRequestId?: number;
/**
* Optional override for the floating selection toolbar contents. When provided,
* it replaces the built-in "Comment" button so callers (e.g. the document
* detail view) can offer richer actions like "Suggest edit" while reusing the
* layer's caret-anchored positioning. `addComment` runs the same flow the
* default button does; `clear` drops the pending selection.
*/
renderSelectionToolbar?: (args: {
anchor: PendingAnchor;
addComment: () => void;
clear: () => void;
}) => React.ReactNode;
}
interface HighlightRect {
@@ -203,6 +215,7 @@ export function DocumentAnnotationLayer({
newCommentDisabledReason = null,
hideResolved = true,
captureSelectionRequestId,
renderSelectionToolbar,
}: AnnotationLayerProps) {
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
@@ -492,20 +505,28 @@ export function DocumentAnnotationLayer({
style={{ top: toolbarPosition.top, left: toolbarPosition.left }}
onMouseDown={(event) => event.preventDefault()}
>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={handleAddComment}
disabled={newCommentDisabled}
title={newCommentDisabled
? newCommentDisabledReason ?? undefined
: "Add comment on selection (⌘⇧M)"}
>
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
Comment
</Button>
{renderSelectionToolbar ? (
renderSelectionToolbar({
anchor: pendingAnchor,
addComment: handleAddComment,
clear: () => onPendingAnchorChange(null),
})
) : (
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={handleAddComment}
disabled={newCommentDisabled}
title={newCommentDisabled
? newCommentDisabledReason ?? undefined
: "Add comment on selection (⌘⇧M)"}
>
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
Comment
</Button>
)}
</div>
) : null}
</div>
@@ -0,0 +1,264 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
DocumentReviewIndex,
DocumentSuggestionWithComments,
} from "@paperclipai/shared";
import { DocumentReviewRail } from "./DocumentReviewRail";
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <p>{children}</p>,
}));
// 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 makeSuggestion(overrides: Partial<DocumentSuggestionWithComments> = {}): DocumentSuggestionWithComments {
return {
id: "sug-1",
companyId: "c1",
issueId: "i1",
documentId: "d1",
documentKey: "spec",
kind: "insertion",
status: "pending",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: "rev-1",
originalRevisionNumber: 1,
currentRevisionId: "rev-1",
currentRevisionNumber: 1,
selectedText: "anchor",
proposedText: "added text",
insertionPosition: "after",
prefixText: "",
suffixText: "",
normalizedStart: 0,
normalizedEnd: 6,
markdownStart: 0,
markdownEnd: 6,
anchorSelector: { quote: { exact: "anchor", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 6, markdownStart: 0, markdownEnd: 6 } },
createdByAgentId: "agent-1",
createdByUserId: null,
acceptedByAgentId: null,
acceptedByUserId: null,
acceptedAt: null,
acceptedRevisionId: null,
rejectedByAgentId: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
comments: [],
...overrides,
};
}
function makeIndex(overrides: Partial<DocumentReviewIndex> = {}): DocumentReviewIndex {
return {
issueId: "i1",
documentId: "d1",
documentKey: "spec",
latestRevisionId: "rev-1",
latestRevisionNumber: 1,
counts: {
unresolved: 1,
openAnchoredThreads: 1,
openReviewThreads: 1,
pendingSuggestions: 1,
resolvedAnchoredThreads: 0,
resolvedReviewThreads: 0,
acceptedSuggestions: 0,
rejectedSuggestions: 0,
staleAnchors: 0,
orphanedAnchors: 0,
},
annotationThreads: [
{
id: "thread-1",
companyId: "c1",
issueId: "i1",
documentId: "d1",
documentKey: "spec",
status: "open",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: "rev-1",
originalRevisionNumber: 1,
currentRevisionId: "rev-1",
currentRevisionNumber: 1,
selectedText: "anchored phrase",
prefixText: "",
suffixText: "",
normalizedStart: 0,
normalizedEnd: 5,
markdownStart: 0,
markdownEnd: 5,
anchorSelector: { quote: { exact: "anchored phrase", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 5, markdownStart: 0, markdownEnd: 5 } },
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
comments: [
{
id: "c1",
companyId: "c1",
threadId: "thread-1",
issueId: "i1",
documentId: "d1",
body: "Worth being explicit here?",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
createdByRunId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
},
],
},
],
reviewThreads: [
{
id: "overall-1",
companyId: "c1",
issueId: "i1",
documentId: "d1",
documentKey: "spec",
status: "open",
createdByAgentId: null,
createdByUserId: "user-1",
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
comments: [
{
id: "rc1",
companyId: "c1",
threadId: "overall-1",
issueId: "i1",
documentId: "d1",
body: "Overall this is close.",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
createdByRunId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
},
],
},
],
suggestions: [makeSuggestion()],
...overrides,
};
}
const baseHandlers = {
onReplyThread: vi.fn(),
onToggleThreadResolved: vi.fn(),
onAcceptSuggestion: vi.fn(),
onRejectSuggestion: vi.fn(),
onReplySuggestion: vi.fn(),
};
describe("DocumentReviewRail", () => {
it("renders two tabs with counts and the overall + anchored comments", async () => {
await act(() =>
root.render(
<DocumentReviewRail reviewIndex={makeIndex()} canReview canFinishReview latestRevisionId="rev-1" {...baseHandlers} />,
),
);
const commentsTab = container.querySelector('[data-testid="rail-tab-comments"]');
const suggestionsTab = container.querySelector('[data-testid="rail-tab-suggestions"]');
expect(commentsTab?.textContent).toContain("Comments (2)");
expect(suggestionsTab?.textContent).toContain("Suggestions (1)");
// Comments tab is default — shows overall + anchored.
expect(container.textContent).toContain("Overall feedback");
expect(container.textContent).toContain("anchored phrase");
});
it("renders a SuggestionCard on the Suggestions tab", async () => {
await act(() =>
root.render(
<DocumentReviewRail
reviewIndex={makeIndex()}
canReview
canFinishReview
latestRevisionId="rev-1"
initialTab="suggestions"
{...baseHandlers}
/>,
),
);
expect(container.querySelector('[data-testid="suggestion-card-sug-1"]')).not.toBeNull();
expect(container.textContent).toContain("added text");
});
it("pins orphaned anchors into a collapsible group when the orphan filter is on", async () => {
const index = makeIndex({
annotationThreads: [
{
...makeIndex().annotationThreads[0],
id: "thread-orphan",
anchorState: "orphaned",
selectedText: "lost anchor",
},
],
});
await act(() =>
root.render(
<DocumentReviewRail reviewIndex={index} canReview canFinishReview latestRevisionId="rev-1" {...baseHandlers} />,
),
);
// Enable the orphaned filter chip.
await act(() => container.querySelector<HTMLButtonElement>('[data-testid="rail-filter-orphaned"]')!.click());
expect(container.querySelector('[data-testid="rail-orphan-group"]')).not.toBeNull();
expect(container.textContent).toContain("Orphaned feedback (1)");
});
it("disables Done reviewing for read-only viewers", async () => {
await act(() =>
root.render(
<DocumentReviewRail
reviewIndex={makeIndex()}
canReview={false}
canFinishReview={false}
latestRevisionId="rev-1"
{...baseHandlers}
/>,
),
);
const done = container.querySelector<HTMLButtonElement>('[data-testid="rail-done-reviewing"]');
expect(done?.disabled).toBe(true);
});
});
@@ -0,0 +1,429 @@
import { useMemo, useState } from "react";
import type {
DocumentReviewIndex,
DocumentSuggestionWithComments,
} from "@paperclipai/shared";
import { CheckCircle2, MessageSquarePlus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ReviewAuthorMaps } from "@/lib/document-review-authors";
import { DocumentThreadCard, type RailThread } from "./DocumentThreadCard";
import { SuggestionCard } from "./SuggestionCard";
type ReviewFilter = "open" | "resolved" | "stale" | "orphaned";
const FILTERS: { id: ReviewFilter; label: string }[] = [
{ id: "open", label: "Open" },
{ id: "resolved", label: "Resolved" },
{ id: "stale", label: "Stale" },
{ id: "orphaned", label: "Orphaned" },
];
export interface DocumentReviewRailProps {
reviewIndex?: DocumentReviewIndex;
loading?: boolean;
/** Viewer can reply/resolve/accept/reject. */
canReview: boolean;
/** Viewer has edit rights (gates the Done reviewing CTA). */
canFinishReview?: boolean;
doneReviewingDisabledReason?: string | null;
latestRevisionId: string | null;
authorMaps?: ReviewAuthorMaps;
focusedThreadId?: string | null;
onFocusThread?: (threadId: string) => void;
onReplyThread: (thread: RailThread, body: string) => Promise<void> | void;
onToggleThreadResolved: (thread: RailThread, resolved: boolean) => Promise<void> | void;
onAddOverallComment?: (body: string) => Promise<void> | void;
onAcceptSuggestion: (suggestion: DocumentSuggestionWithComments) => Promise<void> | void;
onRejectSuggestion: (suggestion: DocumentSuggestionWithComments, reason: string) => Promise<void> | void;
onReplySuggestion: (suggestion: DocumentSuggestionWithComments, body: string) => Promise<void> | void;
onViewSuggestionDiff?: (suggestion: DocumentSuggestionWithComments) => void;
onDoneReviewing?: () => void;
/** Which tab to open first (e.g. when a deep link targets a suggestion). */
initialTab?: "comments" | "suggestions";
/** Mobile: render inside a bottom Sheet. */
isMobile?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
export function DocumentReviewRail(props: DocumentReviewRailProps) {
if (props.isMobile) {
return (
<Sheet open={props.open ?? false} onOpenChange={props.onOpenChange ?? (() => {})}>
<SheetContent
side="bottom"
showCloseButton={false}
className="z-[60] flex max-h-[88vh] flex-col rounded-t-lg border-t border-border bg-popover p-0 text-popover-foreground shadow-2xl"
>
<SheetTitle className="sr-only">Document review</SheetTitle>
<div className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted-foreground/30" aria-hidden="true" />
<ReviewRailBody {...props} />
</SheetContent>
</Sheet>
);
}
return (
<aside
role="complementary"
aria-label="Document review"
data-testid="document-review-rail"
className={cn("flex h-full w-full flex-col overflow-hidden border-l border-border bg-card", props.className)}
>
<ReviewRailBody {...props} />
</aside>
);
}
function ReviewRailBody({
reviewIndex,
loading,
canReview,
canFinishReview,
doneReviewingDisabledReason,
latestRevisionId,
authorMaps,
focusedThreadId,
onFocusThread,
onReplyThread,
onToggleThreadResolved,
onAddOverallComment,
onAcceptSuggestion,
onRejectSuggestion,
onReplySuggestion,
onViewSuggestionDiff,
onDoneReviewing,
initialTab = "comments",
}: DocumentReviewRailProps) {
const [tab, setTab] = useState<"comments" | "suggestions">(initialTab);
const [filters, setFilters] = useState<Set<ReviewFilter>>(new Set(["open"]));
const [composeOverall, setComposeOverall] = useState(false);
const [overall, setOverall] = useState("");
const [overallPending, setOverallPending] = useState(false);
const counts = reviewIndex?.counts;
const commentCount = (counts?.openAnchoredThreads ?? 0) + (counts?.openReviewThreads ?? 0);
const suggestionCount = counts?.pendingSuggestions ?? 0;
const overallThreads = useMemo<RailThread[]>(
() =>
(reviewIndex?.reviewThreads ?? []).map((thread) => ({
id: thread.id,
kind: "overall" as const,
status: thread.status,
anchorState: null,
selectedText: null,
comments: thread.comments,
})),
[reviewIndex?.reviewThreads],
);
const anchoredThreads = useMemo<RailThread[]>(
() =>
(reviewIndex?.annotationThreads ?? []).map((thread) => ({
id: thread.id,
kind: "anchored" as const,
status: thread.status,
anchorState: thread.anchorState,
selectedText: thread.selectedText,
comments: thread.comments,
})),
[reviewIndex?.annotationThreads],
);
const activeFilters = filters.size === 0 ? new Set<ReviewFilter>(FILTERS.map((f) => f.id)) : filters;
const threadMatches = (thread: RailThread): boolean => {
if (thread.anchorState === "orphaned") return activeFilters.has("orphaned");
if (thread.anchorState === "stale") return activeFilters.has("stale");
if (thread.status === "resolved") return activeFilters.has("resolved");
return activeFilters.has("open");
};
const suggestionMatches = (suggestion: DocumentSuggestionWithComments): boolean => {
if (suggestion.anchorState === "orphaned") return activeFilters.has("orphaned");
if (suggestion.anchorState === "stale") return activeFilters.has("stale");
if (suggestion.status !== "pending") return activeFilters.has("resolved");
return activeFilters.has("open");
};
const visibleOverall = overallThreads.filter(threadMatches);
const visibleAnchored = anchoredThreads.filter(threadMatches);
const orphanedThreads = visibleAnchored.filter((t) => t.anchorState === "orphaned");
const nonOrphanThreads = visibleAnchored.filter((t) => t.anchorState !== "orphaned");
const suggestions = reviewIndex?.suggestions ?? [];
const visibleSuggestions = suggestions.filter(suggestionMatches);
const orphanedSuggestions = visibleSuggestions.filter((s) => s.anchorState === "orphaned");
const nonOrphanSuggestions = visibleSuggestions.filter((s) => s.anchorState !== "orphaned");
const toggleFilter = (id: ReviewFilter) => {
setFilters((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddOverall = async () => {
const trimmed = overall.trim();
if (!trimmed || !onAddOverallComment) return;
setOverallPending(true);
try {
await onAddOverallComment(trimmed);
setOverall("");
setComposeOverall(false);
} finally {
setOverallPending(false);
}
};
return (
<TooltipProvider delayDuration={200}>
<div className="flex h-full min-h-0 flex-col">
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)} className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-border px-2 pt-2">
<TabsList variant="line" className="w-full">
<TabsTrigger value="comments" className="flex-1" data-testid="rail-tab-comments">
Comments ({commentCount})
</TabsTrigger>
<TabsTrigger value="suggestions" className="flex-1" data-testid="rail-tab-suggestions">
Suggestions ({suggestionCount})
</TabsTrigger>
</TabsList>
<div className="flex flex-wrap items-center gap-1 py-2" role="group" aria-label="Review filters">
{FILTERS.map((filter) => (
<button
key={filter.id}
type="button"
data-testid={`rail-filter-${filter.id}`}
data-active={filters.has(filter.id) || undefined}
onClick={() => toggleFilter(filter.id)}
className={cn(
"rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors",
filters.has(filter.id)
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-accent/50",
)}
>
{filter.label}
</button>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loading ? (
<RailSkeleton />
) : (
<>
<TabsContent value="comments" className="mt-0 space-y-2">
{onAddOverallComment ? (
composeOverall ? (
<div className="space-y-2 rounded-md border border-border bg-card p-2">
<Textarea
data-testid="rail-overall-composer"
value={overall}
onChange={(event) => setOverall(event.currentTarget.value)}
placeholder="Leave overall feedback on the whole document…"
rows={3}
className="resize-none text-sm"
/>
<div className="flex items-center justify-end gap-1.5">
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => setComposeOverall(false)}>
Cancel
</Button>
<Button
type="button"
size="sm"
className="h-7 px-2 text-xs"
disabled={!overall.trim() || overallPending}
onClick={handleAddOverall}
>
Comment
</Button>
</div>
</div>
) : (
<Button
type="button"
size="sm"
variant="outline"
className="h-8 w-full justify-start gap-1.5 text-xs"
onClick={() => setComposeOverall(true)}
data-testid="rail-add-overall"
>
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
Add overall comment
</Button>
)
) : null}
{visibleOverall.map((thread) => (
<DocumentThreadCard
key={thread.id}
thread={thread}
canReview={canReview}
authorMaps={authorMaps}
focused={focusedThreadId === thread.id}
onFocus={onFocusThread}
onReply={onReplyThread}
onToggleResolved={onToggleThreadResolved}
/>
))}
{orphanedThreads.length > 0 ? (
<OrphanGroup count={orphanedThreads.length}>
{orphanedThreads.map((thread) => (
<DocumentThreadCard
key={thread.id}
thread={thread}
canReview={canReview}
authorMaps={authorMaps}
focused={focusedThreadId === thread.id}
onFocus={onFocusThread}
onReply={onReplyThread}
onToggleResolved={onToggleThreadResolved}
/>
))}
</OrphanGroup>
) : null}
{nonOrphanThreads.map((thread) => (
<DocumentThreadCard
key={thread.id}
thread={thread}
canReview={canReview}
authorMaps={authorMaps}
focused={focusedThreadId === thread.id}
onFocus={onFocusThread}
onReply={onReplyThread}
onToggleResolved={onToggleThreadResolved}
/>
))}
{visibleOverall.length === 0 && visibleAnchored.length === 0 ? (
<RailEmpty message="No feedback yet. Highlight text to comment or suggest an edit." />
) : null}
</TabsContent>
<TabsContent value="suggestions" className="mt-0 space-y-2">
{orphanedSuggestions.length > 0 ? (
<OrphanGroup count={orphanedSuggestions.length}>
{orphanedSuggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
latestRevisionId={latestRevisionId}
canReview={canReview}
authorMaps={authorMaps}
onAccept={onAcceptSuggestion}
onReject={onRejectSuggestion}
onReply={onReplySuggestion}
onViewDiff={onViewSuggestionDiff}
/>
))}
</OrphanGroup>
) : null}
{nonOrphanSuggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
latestRevisionId={latestRevisionId}
canReview={canReview}
authorMaps={authorMaps}
onAccept={onAcceptSuggestion}
onReject={onRejectSuggestion}
onReply={onReplySuggestion}
onViewDiff={onViewSuggestionDiff}
/>
))}
{visibleSuggestions.length === 0 ? (
<RailEmpty message="No suggested edits. Select text and choose “Suggest edit”." />
) : null}
</TabsContent>
</>
)}
</div>
</Tabs>
<div className="border-t border-border p-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
<Button
type="button"
className="w-full"
size="sm"
disabled={!canFinishReview}
onClick={onDoneReviewing}
data-testid="rail-done-reviewing"
>
<CheckCircle2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
Done reviewing
</Button>
</span>
</TooltipTrigger>
{!canFinishReview ? (
<TooltipContent>
{doneReviewingDisabledReason ?? "Read-only documents can't be marked reviewed"}
</TooltipContent>
) : null}
</Tooltip>
</div>
</div>
</TooltipProvider>
);
}
function OrphanGroup({ count, children }: { count: number; children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<div className="rounded-md border border-dashed border-border bg-muted/30 p-1.5" data-testid="rail-orphan-group">
<button
type="button"
className="flex w-full items-center justify-between px-1 py-0.5 text-[11px] font-medium text-muted-foreground"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<span>Orphaned feedback ({count})</span>
<span aria-hidden="true">{open ? "−" : "+"}</span>
</button>
{open ? <div className="mt-1.5 space-y-2">{children}</div> : null}
</div>
);
}
function RailEmpty({ message }: { message: string }) {
return (
<div className="px-2 py-8 text-center text-xs text-muted-foreground" data-testid="rail-empty">
<p>{message}</p>
<p className="mt-1 text-[11px] opacity-70">⌘⇧M to comment from a selection</p>
</div>
);
}
function RailSkeleton() {
return (
<div className="space-y-2" data-testid="rail-skeleton">
{[0, 1, 2].map((index) => (
<div key={index} className="h-20 animate-pulse rounded-md border border-border bg-muted/40" />
))}
</div>
);
}
@@ -0,0 +1,203 @@
import { useState } from "react";
import { AlertTriangle, Unlink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownBody } from "@/components/MarkdownBody";
import { cn, relativeTime } from "@/lib/utils";
import {
resolveReviewAuthor,
type ReviewAuthorMaps,
type ReviewAuthorRef,
} from "@/lib/document-review-authors";
export type RailThreadKind = "anchored" | "overall";
export type RailAnchorState = "active" | "stale" | "orphaned" | null;
export interface RailComment extends ReviewAuthorRef {
id: string;
body: string;
createdAt: Date | string;
}
export interface RailThread {
id: string;
kind: RailThreadKind;
status: "open" | "resolved";
anchorState: RailAnchorState;
selectedText: string | null;
comments: RailComment[];
}
function stateBadge(thread: RailThread): { label: string; className: string; icon?: typeof AlertTriangle } {
if (thread.kind === "overall") {
return {
label: "Overall feedback",
className: "border-transparent bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300",
};
}
if (thread.anchorState === "orphaned") {
return {
label: "Orphaned",
className: "border-transparent bg-muted text-muted-foreground",
icon: Unlink,
};
}
if (thread.anchorState === "stale") {
return {
label: "Stale",
className: "border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
icon: AlertTriangle,
};
}
if (thread.status === "resolved") {
return {
label: "Resolved",
className: "border-transparent bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
};
}
return {
label: "Open",
className: "border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300",
};
}
export interface DocumentThreadCardProps {
thread: RailThread;
focused?: boolean;
canReview: boolean;
authorMaps?: ReviewAuthorMaps;
onFocus?: (threadId: string) => void;
onReply: (thread: RailThread, body: string) => Promise<void> | void;
onToggleResolved: (thread: RailThread, resolved: boolean) => Promise<void> | void;
}
/**
* Review-rail card for an anchored comment thread, a document-level overall
* comment, stale/orphaned anchors. Renders the status badge, anchored snippet,
* the comment thread, a reply composer, and a Resolve/Reopen toggle.
*/
export function DocumentThreadCard({
thread,
focused,
canReview,
authorMaps,
onFocus,
onReply,
onToggleResolved,
}: DocumentThreadCardProps) {
const [reply, setReply] = useState("");
const [pending, setPending] = useState<null | "reply" | "resolve">(null);
const badge = stateBadge(thread);
const BadgeIcon = badge.icon;
const handleReply = async () => {
const trimmed = reply.trim();
if (!trimmed) return;
setPending("reply");
try {
await onReply(thread, trimmed);
setReply("");
} finally {
setPending(null);
}
};
const handleToggle = async () => {
setPending("resolve");
try {
await onToggleResolved(thread, thread.status !== "resolved");
} finally {
setPending(null);
}
};
return (
<article
role="article"
data-testid={`thread-card-${thread.id}`}
data-status={thread.status}
data-anchor-state={thread.anchorState ?? undefined}
data-focused={focused || undefined}
className={cn(
"rounded-md border border-border bg-card p-2.5 text-card-foreground shadow-sm",
focused && "border-l-2 border-l-primary",
)}
onClick={() => onFocus?.(thread.id)}
>
<header className="mb-1.5 flex items-center justify-between gap-2">
<Badge className={cn("gap-1 px-1.5 py-0 text-[10px] font-medium", badge.className)}>
{BadgeIcon ? <BadgeIcon className="h-3 w-3" aria-hidden="true" /> : null}
{badge.label}
</Badge>
</header>
{thread.kind === "anchored" && thread.selectedText ? (
<p
id={`thread-quote-${thread.id}`}
className={cn(
"mb-2 border-l-2 border-border pl-2 text-[11px] italic text-muted-foreground",
thread.anchorState === "stale" && "border-amber-400",
thread.anchorState === "orphaned" && "border-dashed",
)}
>
“{thread.selectedText}”
</p>
) : null}
<div className="space-y-1.5">
{thread.comments.map((comment) => {
const author = resolveReviewAuthor(comment, authorMaps ?? {});
return (
<div key={comment.id} className="rounded border border-border bg-background px-2 py-1">
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">{author.name}</span>
{author.role === "agent" ? <span className="ml-1 text-muted-foreground">· agent</span> : null}
</span>
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
</div>
);
})}
</div>
{canReview ? (
<div className="mt-2 space-y-2">
<Textarea
data-testid={`thread-reply-${thread.id}`}
value={reply}
onChange={(event) => setReply(event.currentTarget.value)}
placeholder="Reply…"
rows={2}
className="min-h-[2.25rem] resize-none text-sm"
/>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!reply.trim() || pending != null}
onClick={handleReply}
>
Reply
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={pending != null}
onClick={handleToggle}
data-testid={`thread-resolve-${thread.id}`}
>
{thread.status === "resolved" ? "Reopen" : "Resolve"}
</Button>
</div>
</div>
) : null}
</article>
);
}
@@ -0,0 +1,67 @@
// @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 { SelectionToolbar } from "./SelectionToolbar";
// 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();
});
describe("SelectionToolbar", () => {
it("invokes onComment when Comment is clicked", async () => {
const onComment = vi.fn();
await act(() =>
root.render(<SelectionToolbar onComment={onComment} onSuggest={vi.fn()} onCopyLink={vi.fn()} />),
);
await act(() => container.querySelector<HTMLButtonElement>('[data-testid="selection-toolbar-comment"]')!.click());
expect(onComment).toHaveBeenCalledTimes(1);
});
it("renders the Suggest edit trigger and copy-link button", async () => {
await act(() =>
root.render(<SelectionToolbar onComment={vi.fn()} onSuggest={vi.fn()} onCopyLink={vi.fn()} />),
);
expect(container.querySelector('[data-testid="selection-toolbar-suggest"]')).not.toBeNull();
expect(container.querySelector('[data-testid="selection-toolbar-copy"]')).not.toBeNull();
});
it("disables actions when comment/suggest are disabled", async () => {
await act(() =>
root.render(
<SelectionToolbar
onComment={vi.fn()}
onSuggest={vi.fn()}
commentDisabled
suggestDisabled
/>,
),
);
expect(container.querySelector<HTMLButtonElement>('[data-testid="selection-toolbar-comment"]')!.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>('[data-testid="selection-toolbar-suggest"]')!.disabled).toBe(true);
// No copy-link handler → button omitted.
expect(container.querySelector('[data-testid="selection-toolbar-copy"]')).toBeNull();
});
});
@@ -0,0 +1,124 @@
import type { DocumentSuggestionKind, DocumentSuggestionInsertPosition } from "@paperclipai/shared";
import { Link2, MessageSquarePlus, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export interface SuggestionDraftIntent {
kind: DocumentSuggestionKind;
insertionPosition?: DocumentSuggestionInsertPosition;
}
const SUGGEST_OPTIONS: { label: string; intent: SuggestionDraftIntent }[] = [
{ label: "Replace selection…", intent: { kind: "substitution" } },
{ label: "Delete selection", intent: { kind: "deletion" } },
{ label: "Insert before…", intent: { kind: "insertion", insertionPosition: "before" } },
{ label: "Insert after…", intent: { kind: "insertion", insertionPosition: "after" } },
];
export interface SelectionToolbarProps {
/** Add an anchored comment on the current selection. */
onComment: () => void;
/** Start a suggested-edit draft of a given kind on the current selection. */
onSuggest: (intent: SuggestionDraftIntent) => void;
/** Copy a deep link to the current selection. Omitted hides the button. */
onCopyLink?: () => void;
commentDisabled?: boolean;
commentDisabledReason?: string | null;
suggestDisabled?: boolean;
suggestDisabledReason?: string | null;
className?: string;
}
/**
* NEW floating selection toolbar shown when the reader selects body text. Offers
* "Comment" and a "Suggest edit ▾" menu (Replace / Delete / Insert before /
* Insert after — the four Roughdraft primitives) plus an optional copy-link. The
* caret-anchored positioning is owned by the body annotation layer; this is the
* presentational action cluster injected into it (and reused standalone in
* Storybook/tests).
*/
export function SelectionToolbar({
onComment,
onSuggest,
onCopyLink,
commentDisabled,
commentDisabledReason,
suggestDisabled,
suggestDisabledReason,
className,
}: SelectionToolbarProps) {
return (
<div
data-testid="document-selection-toolbar"
role="toolbar"
aria-label="Selection actions"
className={cn("flex items-center gap-0.5", className)}
onMouseDown={(event) => event.preventDefault()}
>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={onComment}
disabled={commentDisabled}
title={commentDisabled ? commentDisabledReason ?? undefined : "Add comment on selection (⌘⇧M)"}
data-testid="selection-toolbar-comment"
>
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
Comment
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
disabled={suggestDisabled}
title={suggestDisabled ? suggestDisabledReason ?? undefined : "Suggest an edit on selection"}
data-testid="selection-toolbar-suggest"
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
Suggest edit
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{SUGGEST_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.label}
onSelect={() => onSuggest(option.intent)}
data-testid={`selection-toolbar-suggest-${option.intent.kind}${
option.intent.insertionPosition ? `-${option.intent.insertionPosition}` : ""
}`}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{onCopyLink ? (
<Button
type="button"
size="icon-xs"
variant="ghost"
className="h-7 w-7"
onClick={onCopyLink}
aria-label="Copy link to selection"
title="Copy link to selection"
data-testid="selection-toolbar-copy"
>
<Link2 className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,180 @@
// @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 { DocumentSuggestionWithComments } from "@paperclipai/shared";
import { SuggestionCard } from "./SuggestionCard";
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => <p>{children}</p>,
}));
// 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 makeSuggestion(overrides: Partial<DocumentSuggestionWithComments> = {}): DocumentSuggestionWithComments {
return {
id: "sug-1",
companyId: "c1",
issueId: "i1",
documentId: "d1",
documentKey: "spec",
kind: "substitution",
status: "pending",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: "rev-1",
originalRevisionNumber: 12,
currentRevisionId: "rev-1",
currentRevisionNumber: 12,
selectedText: "old text",
proposedText: "new text",
insertionPosition: null,
prefixText: "",
suffixText: "",
normalizedStart: 0,
normalizedEnd: 8,
markdownStart: 0,
markdownEnd: 8,
anchorSelector: { quote: { exact: "old text", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 8, markdownStart: 0, markdownEnd: 8 } },
createdByAgentId: "agent-1",
createdByUserId: null,
acceptedByAgentId: null,
acceptedByUserId: null,
acceptedAt: null,
acceptedRevisionId: null,
rejectedByAgentId: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
comments: [],
...overrides,
};
}
describe("SuggestionCard", () => {
it("renders the kind label and source/proposed diff", async () => {
await act(() =>
root.render(
<SuggestionCard
suggestion={makeSuggestion()}
latestRevisionId="rev-1"
canReview
onAccept={vi.fn()}
onReject={vi.fn()}
onReply={vi.fn()}
/>,
),
);
expect(container.textContent).toContain("Replace");
expect(container.textContent).toContain("old text");
expect(container.textContent).toContain("new text");
});
it("accepts a pending suggestion via the Accept button", async () => {
const onAccept = vi.fn();
await act(() =>
root.render(
<SuggestionCard
suggestion={makeSuggestion()}
latestRevisionId="rev-1"
canReview
onAccept={onAccept}
onReject={vi.fn()}
onReply={vi.fn()}
/>,
),
);
const accept = container.querySelector<HTMLButtonElement>('[data-testid="suggestion-accept-sug-1"]');
expect(accept).not.toBeNull();
expect(accept?.disabled).toBe(false);
await act(() => accept!.click());
expect(onAccept).toHaveBeenCalledTimes(1);
});
it("gates Accept behind Needs rebase when the anchor revision lags the document", async () => {
await act(() =>
root.render(
<SuggestionCard
suggestion={makeSuggestion({ currentRevisionId: "rev-1" })}
latestRevisionId="rev-2"
canReview
onAccept={vi.fn()}
onReject={vi.fn()}
onReply={vi.fn()}
/>,
),
);
expect(container.querySelector('[data-testid="suggestion-needs-rebase-sug-1"]')).not.toBeNull();
const accept = container.querySelector<HTMLButtonElement>('[data-testid="suggestion-accept-sug-1"]');
expect(accept?.disabled).toBe(true);
});
it("requires a reason to reject", async () => {
const onReject = vi.fn();
await act(() =>
root.render(
<SuggestionCard
suggestion={makeSuggestion()}
latestRevisionId="rev-1"
canReview
onAccept={vi.fn()}
onReject={onReject}
onReply={vi.fn()}
/>,
),
);
await act(() => container.querySelector<HTMLButtonElement>('[data-testid="suggestion-reject-toggle-sug-1"]')!.click());
const confirm = container.querySelector<HTMLButtonElement>('[data-testid="suggestion-reject-confirm-sug-1"]');
expect(confirm?.disabled).toBe(true); // no reason yet
const reason = container.querySelector<HTMLTextAreaElement>('[data-testid="suggestion-reject-reason-sug-1"]')!;
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!;
await act(() => {
setter.call(reason, "Not needed");
reason.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(() => container.querySelector<HTMLButtonElement>('[data-testid="suggestion-reject-confirm-sug-1"]')!.click());
expect(onReject).toHaveBeenCalledWith(expect.objectContaining({ id: "sug-1" }), "Not needed");
});
it("hides review actions for read-only viewers", async () => {
await act(() =>
root.render(
<SuggestionCard
suggestion={makeSuggestion()}
latestRevisionId="rev-1"
canReview={false}
onAccept={vi.fn()}
onReject={vi.fn()}
onReply={vi.fn()}
/>,
),
);
expect(container.querySelector('[data-testid="suggestion-accept-sug-1"]')).toBeNull();
expect(container.querySelector('[data-testid="suggestion-reply-sug-1"]')).toBeNull();
});
});
@@ -0,0 +1,391 @@
import { useState } from "react";
import type {
DocumentSuggestionKind,
DocumentSuggestionWithComments,
} from "@paperclipai/shared";
import { AlertTriangle, Check, MoreHorizontal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MarkdownBody } from "@/components/MarkdownBody";
import { cn, relativeTime } from "@/lib/utils";
import {
resolveReviewAuthor,
type ReviewAuthorMaps,
} from "@/lib/document-review-authors";
const KIND_LABEL: Record<DocumentSuggestionKind, string> = {
insertion: "Insert",
deletion: "Delete",
substitution: "Replace",
};
const KIND_BADGE_CLASS: Record<DocumentSuggestionKind, string> = {
insertion: "border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
deletion: "border-transparent bg-rose-100 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300",
substitution: "border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
};
export interface SuggestionCardProps {
suggestion: DocumentSuggestionWithComments;
/** Latest revision of the document; drives the "Needs rebase" gate. */
latestRevisionId: string | null;
/** Whether the viewer can accept/reject (board reviewer with edit rights). */
canReview: boolean;
authorMaps?: ReviewAuthorMaps;
onAccept: (suggestion: DocumentSuggestionWithComments) => Promise<void> | void;
onReject: (suggestion: DocumentSuggestionWithComments, reason: string) => Promise<void> | void;
onReply: (suggestion: DocumentSuggestionWithComments, body: string) => Promise<void> | void;
/** Opens the diff modal for the revision this suggestion would produce. */
onViewDiff?: (suggestion: DocumentSuggestionWithComments) => void;
className?: string;
}
/**
* NEW review-rail card for a suggested edit (insert / delete / replace).
* Composes Badge + author chip + an inline CriticMarkup-style source/proposed
* diff, a discussion thread, a reply composer, and Accept/Reject actions.
*
* Accept disables on a stale `baseRevisionId` (the suggestion was anchored to an
* older revision) or a drifted/lost anchor — surfaced as a "Needs rebase" badge.
*/
export function SuggestionCard({
suggestion,
latestRevisionId,
canReview,
authorMaps,
onAccept,
onReject,
onReply,
onViewDiff,
className,
}: SuggestionCardProps) {
const [reply, setReply] = useState("");
const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState("");
const [pending, setPending] = useState<null | "accept" | "reject" | "reply">(null);
const author = resolveReviewAuthor(suggestion, authorMaps ?? {});
const kindLabel = KIND_LABEL[suggestion.kind];
const isPending = suggestion.status === "pending";
// Needs rebase when the suggestion's anchor revision lags the document, or the
// anchor drifted/was lost during a remap. Accepting then would apply against a
// mismatched base, so the backend rejects it — gate the button instead.
const anchorDrifted = suggestion.anchorState !== "active";
const revisionDrifted =
latestRevisionId != null && suggestion.currentRevisionId != null
? suggestion.currentRevisionId !== latestRevisionId
: false;
const needsRebase = isPending && (anchorDrifted || revisionDrifted);
const acceptDisabled = pending != null || needsRebase;
const handleAccept = async () => {
setPending("accept");
try {
await onAccept(suggestion);
} finally {
setPending(null);
}
};
const handleReject = async () => {
const trimmed = reason.trim();
if (!trimmed) return;
setPending("reject");
try {
await onReject(suggestion, trimmed);
setRejecting(false);
setReason("");
} finally {
setPending(null);
}
};
const handleReply = async () => {
const trimmed = reply.trim();
if (!trimmed) return;
setPending("reply");
try {
await onReply(suggestion, trimmed);
setReply("");
} finally {
setPending(null);
}
};
return (
<TooltipProvider delayDuration={200}>
<article
data-testid={`suggestion-card-${suggestion.id}`}
data-kind={suggestion.kind}
data-status={suggestion.status}
className={cn(
"rounded-md border border-border bg-card p-2.5 text-card-foreground shadow-sm",
suggestion.status === "rejected" && "opacity-70",
className,
)}
>
<header className="mb-1.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Badge className={cn("px-1.5 py-0 text-[10px] font-semibold uppercase", KIND_BADGE_CLASS[suggestion.kind])}>
{kindLabel}
</Badge>
{needsRebase ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge
data-testid={`suggestion-needs-rebase-${suggestion.id}`}
className="gap-1 border-transparent bg-amber-100 px-1.5 py-0 text-[10px] font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
>
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
Needs rebase
</Badge>
</TooltipTrigger>
<TooltipContent>
Document changed since this was suggested. Reload and review again, or rebase the
suggestion onto the latest revision.
</TooltipContent>
</Tooltip>
) : null}
{suggestion.status === "accepted" ? (
<Badge className="border-transparent bg-green-100 px-1.5 py-0 text-[10px] text-green-700 dark:bg-green-900/50 dark:text-green-300">
Accepted
</Badge>
) : null}
{suggestion.status === "rejected" ? (
<Badge className="border-transparent bg-muted px-1.5 py-0 text-[10px] text-muted-foreground">
Rejected
</Badge>
) : null}
</div>
{onViewDiff ? (
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label="View suggestion diff"
title="View diff"
onClick={() => onViewDiff(suggestion)}
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
</header>
<p className="mb-2 text-[11px] text-muted-foreground">
Suggested by <span className="font-medium text-foreground">{author.name}</span>
{author.role === "agent" ? " · agent" : null} · {relativeTime(suggestion.createdAt)}
{suggestion.originalRevisionNumber ? ` · rev ${suggestion.originalRevisionNumber}` : null}
</p>
<SuggestionDiff suggestion={suggestion} />
{suggestion.status === "rejected" && rejectionReason(suggestion) ? (
<p className="mt-2 rounded bg-muted/60 px-2 py-1 text-[11px] text-muted-foreground">
Rejected: {rejectionReason(suggestion)}
</p>
) : null}
{suggestion.comments.length > 0 ? (
<div className="mt-2 space-y-1.5">
<p className="text-[11px] font-medium text-muted-foreground">
Discussion ({suggestion.comments.length})
</p>
{suggestion.comments.map((comment) => {
const commentAuthor = resolveReviewAuthor(comment, authorMaps ?? {});
return (
<div key={comment.id} className="rounded border border-border bg-background px-2 py-1">
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
<span className="font-medium text-foreground">{commentAuthor.name}</span>
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
</div>
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
</div>
);
})}
</div>
) : null}
{canReview ? (
<div className="mt-2 space-y-2">
<Textarea
data-testid={`suggestion-reply-${suggestion.id}`}
value={reply}
onChange={(event) => setReply(event.currentTarget.value)}
placeholder="Reply…"
rows={2}
className="min-h-[2.25rem] resize-none text-sm"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!reply.trim() || pending != null}
onClick={handleReply}
>
Reply
</Button>
{isPending ? (
<div className="flex items-center gap-1.5">
{rejecting ? null : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
disabled={acceptDisabled}
onClick={handleAccept}
data-testid={`suggestion-accept-${suggestion.id}`}
>
<Check className="h-3.5 w-3.5" aria-hidden="true" />
Accept
</Button>
</span>
</TooltipTrigger>
{needsRebase ? (
<TooltipContent>
Document changed since this was suggested. Reload and review again, or
rebase the suggestion onto the latest revision.
</TooltipContent>
) : null}
</Tooltip>
)}
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => setRejecting((value) => !value)}
data-testid={`suggestion-reject-toggle-${suggestion.id}`}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
Reject
</Button>
</div>
) : null}
</div>
{rejecting ? (
<div className="space-y-1.5 rounded border border-border bg-muted/40 p-2">
<label className="text-[11px] font-medium text-muted-foreground" htmlFor={`reject-reason-${suggestion.id}`}>
Reason for rejecting (kept for the audit trail)
</label>
<Textarea
id={`reject-reason-${suggestion.id}`}
data-testid={`suggestion-reject-reason-${suggestion.id}`}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
placeholder="Why is this being rejected?"
rows={2}
className="min-h-[2.25rem] resize-none text-sm"
/>
<div className="flex items-center justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => {
setRejecting(false);
setReason("");
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
variant="destructive"
className="h-7 px-2 text-xs"
disabled={!reason.trim() || pending != null}
onClick={handleReject}
data-testid={`suggestion-reject-confirm-${suggestion.id}`}
>
Reject suggestion
</Button>
</div>
</div>
) : null}
</div>
) : null}
</article>
</TooltipProvider>
);
}
function rejectionReason(suggestion: DocumentSuggestionWithComments): string | null {
// The reject reason is persisted as a suggestion comment on the server; the
// last comment authored at rejection time carries it. Fall back to null.
if (suggestion.status !== "rejected") return null;
const last = suggestion.comments[suggestion.comments.length - 1];
return last?.body ?? null;
}
function SuggestionDiff({ suggestion }: { suggestion: DocumentSuggestionWithComments }) {
const { kind, selectedText, proposedText, insertionPosition } = suggestion;
if (kind === "insertion") {
return (
<div className="space-y-1 text-sm">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
Insert {insertionPosition ? `${insertionPosition} ` : ""}selection
</p>
<SuggestionText variant="add">{proposedText ?? ""}</SuggestionText>
{selectedText ? (
<p className="truncate text-[11px] text-muted-foreground">near “{selectedText}”</p>
) : null}
</div>
);
}
if (kind === "deletion") {
return (
<div className="space-y-1 text-sm">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Delete</p>
<SuggestionText variant="remove">{selectedText}</SuggestionText>
</div>
);
}
return (
<div className="space-y-1 text-sm">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Source</p>
<SuggestionText variant="remove">{selectedText}</SuggestionText>
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Proposed</p>
<SuggestionText variant="add">{proposedText ?? ""}</SuggestionText>
</div>
);
}
function SuggestionText({
variant,
children,
}: {
variant: "add" | "remove";
children: string;
}) {
return (
<p
data-variant={variant}
className={cn(
"whitespace-pre-wrap break-words rounded px-1.5 py-1 text-sm",
variant === "add"
? "bg-[var(--paperclip-doc-suggestion-insert-bg)] text-[var(--paperclip-doc-suggestion-insert-fg)]"
: "bg-[var(--paperclip-doc-suggestion-delete-bg)] text-[var(--paperclip-doc-suggestion-delete-fg)] line-through",
)}
>
<span aria-hidden="true" className="mr-1 font-bold">
{variant === "add" ? "+" : "−"}
</span>
{children}
</p>
);
}
+12
View File
@@ -93,6 +93,12 @@
--paperclip-doc-annotation-highlight-focused: #fde047;
--paperclip-doc-annotation-highlight-stale: #fef08a;
--paperclip-doc-annotation-highlight-resolved: #fef9c3;
/* Suggested-edit (CriticMarkup-style) treatments — see PAP-10520 ux-spec §5.4. */
--paperclip-doc-suggestion-insert-bg: oklch(0.93 0.08 145);
--paperclip-doc-suggestion-insert-fg: oklch(0.3 0.12 145);
--paperclip-doc-suggestion-delete-bg: oklch(0.93 0.08 27);
--paperclip-doc-suggestion-delete-fg: oklch(0.3 0.2 27);
--paperclip-doc-annotation-stale-underline: oklch(0.55 0.18 50);
}
.dark {
@@ -144,6 +150,12 @@
--paperclip-doc-annotation-highlight-focused: #ca8a04;
--paperclip-doc-annotation-highlight-stale: #854d0e;
--paperclip-doc-annotation-highlight-resolved: #713f12;
/* Dark-mode suggestion treatments: drop L to ~0.30, keep chroma. */
--paperclip-doc-suggestion-insert-bg: oklch(0.3 0.08 145);
--paperclip-doc-suggestion-insert-fg: oklch(0.9 0.1 145);
--paperclip-doc-suggestion-delete-bg: oklch(0.3 0.09 27);
--paperclip-doc-suggestion-delete-fg: oklch(0.9 0.12 27);
--paperclip-doc-annotation-stale-underline: oklch(0.7 0.16 50);
}
::highlight(paperclip-doc-annotation-open) {
+41
View File
@@ -0,0 +1,41 @@
import type { Agent, IssueCommentAuthorType } from "@paperclipai/shared";
import type { CompanyUserProfile } from "@/lib/company-members";
export interface ReviewAuthorMaps {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}
export interface ReviewAuthorRef {
authorType?: IssueCommentAuthorType;
authorAgentId?: string | null;
authorUserId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
}
export interface ResolvedReviewAuthor {
name: string;
role: "board" | "agent";
}
/**
* Resolve the display name + role for any document-review entity (annotation
* comment, review comment, suggestion, suggestion comment). Falls back to a
* truncated id, then to a generic "Agent"/"Board" label. Mirrors the resolver
* used by `DocumentAnnotationPanel` so author chips read consistently.
*/
export function resolveReviewAuthor(ref: ReviewAuthorRef, maps: ReviewAuthorMaps): ResolvedReviewAuthor {
const agentId = ref.authorAgentId ?? ref.createdByAgentId ?? null;
const userId = ref.authorUserId ?? ref.createdByUserId ?? null;
if (agentId) {
const agent = maps.agentMap?.get(agentId);
return { name: agent?.name ?? agentId.slice(0, 8), role: "agent" };
}
if (userId) {
const profile = maps.userProfileMap?.get(userId);
return { name: profile?.label ?? userId.slice(0, 8), role: "board" };
}
const isAgent = ref.authorType === "agent";
return { name: isAgent ? "Agent" : "Board", role: isAgent ? "agent" : "board" };
}
+219
View File
@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyDocument } from "@paperclipai/shared";
import { ApiError } from "@/api/client";
import { DocumentDetail } from "./DocumentDetail";
const mocks = vi.hoisted(() => ({
documentsApi: { get: vi.fn() },
documentReviewsApi: { reviewIndex: vi.fn() },
documentAnnotationsApi: {},
issuesApi: {},
agentsApi: { list: vi.fn() },
accessApi: { listUserDirectory: vi.fn() },
authApi: { getSession: vi.fn() },
setBreadcrumbs: vi.fn(),
params: { value: { documentId: "doc-1" } as { documentId?: string } },
}));
vi.mock("react-router-dom", () => ({ useParams: () => mocks.params.value }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) }));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mocks.setBreadcrumbs }) }));
vi.mock("@/api/documents", () => ({ documentsApi: mocks.documentsApi }));
vi.mock("@/api/document-reviews", () => ({ documentReviewsApi: mocks.documentReviewsApi }));
vi.mock("@/api/document-annotations", () => ({ documentAnnotationsApi: mocks.documentAnnotationsApi }));
vi.mock("@/api/issues", () => ({ issuesApi: mocks.issuesApi }));
vi.mock("@/api/agents", () => ({ agentsApi: mocks.agentsApi }));
vi.mock("@/api/access", () => ({ accessApi: mocks.accessApi }));
vi.mock("@/api/auth", () => ({ authApi: mocks.authApi }));
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <div>{children}</div> }));
vi.mock("@/components/MarkdownEditor", () => ({ MarkdownEditor: () => <textarea data-testid="md-editor" /> }));
vi.mock("@/components/DocumentAnnotationLayer", () => ({ DocumentAnnotationLayer: () => null }));
vi.mock("@/components/DocumentDiffModal", () => ({ DocumentDiffModal: () => null }));
// 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;
}
async function waitFor(predicate: () => boolean, label: string) {
for (let i = 0; i < 50; i += 1) {
if (predicate()) return;
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
throw new Error(`waitFor timed out: ${label}`);
}
function makeDoc(overrides: Partial<CompanyDocument> = {}): CompanyDocument {
return {
id: "doc-1",
companyId: "company-1",
title: "Paperclip Documents review flow",
format: "markdown",
status: "in_review",
documentType: "spec",
summary: null,
ownerAgentId: "agent-1",
ownerUserId: null,
latestRevisionId: "rev-12",
latestRevisionNumber: 12,
createdByAgentId: "agent-1",
createdByUserId: null,
updatedByAgentId: "agent-1",
updatedByUserId: null,
lockedAt: null,
lockedByAgentId: null,
lockedByUserId: null,
sourceTrust: null,
archivedAt: null,
archivedByAgentId: null,
archivedByUserId: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-06T00:00:00Z"),
backlinks: [
{
id: "link-1",
companyId: "company-1",
documentId: "doc-1",
targetType: "issue",
targetId: "issue-1",
relationship: "source",
issueDocumentId: "idoc-1",
issueDocumentKey: "spec",
title: "Design Paperclip Documents UX",
identifier: "PAP-10520",
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
},
],
feedbackCounts: {
openComments: 1,
resolvedComments: 0,
openReviewThreads: 0,
resolvedReviewThreads: 0,
pendingSuggestions: 0,
acceptedSuggestions: 0,
rejectedSuggestions: 0,
staleAnchors: 0,
orphanedAnchors: 0,
},
body: "# Heading\n\nBody text here.",
...overrides,
};
}
const emptyIndex = {
issueId: "issue-1",
documentId: "doc-1",
documentKey: "spec",
latestRevisionId: "rev-12",
latestRevisionNumber: 12,
counts: {
unresolved: 0,
openAnchoredThreads: 0,
openReviewThreads: 0,
pendingSuggestions: 0,
resolvedAnchoredThreads: 0,
resolvedReviewThreads: 0,
acceptedSuggestions: 0,
rejectedSuggestions: 0,
staleAnchors: 0,
orphanedAnchors: 0,
},
annotationThreads: [],
reviewThreads: [],
suggestions: [],
};
let container: HTMLDivElement;
let root: Root;
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return act(() =>
root.render(
<QueryClientProvider client={client}>
<DocumentDetail />
</QueryClientProvider>,
),
);
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mocks.params.value = { documentId: "doc-1" };
mocks.agentsApi.list.mockResolvedValue([{ id: "agent-1", name: "ClaudeCoder" }]);
mocks.accessApi.listUserDirectory.mockResolvedValue({ users: [] });
mocks.authApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mocks.documentReviewsApi.reviewIndex.mockResolvedValue(emptyIndex);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
describe("DocumentDetail", () => {
it("shows a permission-denied state without leaking the title", async () => {
mocks.documentsApi.get.mockRejectedValue(new ApiError("Forbidden", 403, null));
await renderPage();
await waitFor(() => container.textContent?.includes("don't have access") ?? false, "permission denied");
expect(container.textContent).toContain("You don't have access to this document.");
expect(container.textContent).not.toContain("Paperclip Documents review flow");
});
it("renders the header, body, and review rail for a board user", async () => {
mocks.documentsApi.get.mockResolvedValue(makeDoc());
await renderPage();
await waitFor(() => container.textContent?.includes("Paperclip Documents review flow") ?? false, "title");
// Header pieces
expect(container.textContent).toContain("Spec");
expect(container.textContent).toContain("Rev 12");
expect(container.querySelector('[data-testid="action-edit"]')).not.toBeNull();
expect(container.querySelector('[data-testid="action-history"]')).not.toBeNull();
// Backlink chip
expect(container.textContent).toContain("PAP-10520");
// Body
expect(container.textContent).toContain("Body text here.");
// Rail
expect(container.querySelector('[data-testid="document-review-rail"]')).not.toBeNull();
});
it("treats signed-out viewers as read-only", async () => {
mocks.authApi.getSession.mockResolvedValue(null);
mocks.documentsApi.get.mockResolvedValue(makeDoc());
await renderPage();
await waitFor(() => container.textContent?.includes("Paperclip Documents review flow") ?? false, "title");
expect(container.querySelector('[data-testid="readonly-badge"]')).not.toBeNull();
const edit = container.querySelector<HTMLButtonElement>('[data-testid="action-edit"]');
expect(edit?.disabled).toBe(true);
});
it("shows a locked-by-other badge and disables Edit", async () => {
mocks.documentsApi.get.mockResolvedValue(makeDoc({ lockedByAgentId: "agent-1", lockedAt: new Date() }));
await renderPage();
await waitFor(() => container.textContent?.includes("Paperclip Documents review flow") ?? false, "title");
expect(container.querySelector('[data-testid="locked-by-other"]')).not.toBeNull();
expect(container.querySelector<HTMLButtonElement>('[data-testid="action-edit"]')?.disabled).toBe(true);
});
});
+833
View File
@@ -0,0 +1,833 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import type {
Agent,
CompanyDocument,
DocumentSuggestionKind,
DocumentSuggestionWithComments,
DocumentType,
} from "@paperclipai/shared";
import {
AlertTriangle,
BarChart3,
BookOpen,
ClipboardList,
FileText,
History,
Link2,
ListTodo,
Lock,
LockOpen,
MessageSquare,
Pencil,
} from "lucide-react";
import { ApiError } from "@/api/client";
import { documentsApi } from "@/api/documents";
import { documentReviewsApi } from "@/api/document-reviews";
import { documentAnnotationsApi } from "@/api/document-annotations";
import { issuesApi } from "@/api/issues";
import { agentsApi } from "@/api/agents";
import { accessApi } from "@/api/access";
import { authApi } from "@/api/auth";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { queryKeys } from "@/lib/queryKeys";
import { buildCompanyUserProfileMap } from "@/lib/company-members";
import { cn, relativeTime } from "@/lib/utils";
import { EmptyState } from "@/components/EmptyState";
import { StatusBadge } from "@/components/StatusBadge";
import { Identity } from "@/components/Identity";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Link } from "@/lib/router";
import { MarkdownBody } from "@/components/MarkdownBody";
import { MarkdownEditor } from "@/components/MarkdownEditor";
import {
DocumentAnnotationLayer,
type AnnotationOverlayThread,
type PendingAnchor,
} from "@/components/DocumentAnnotationLayer";
import { DocumentDiffModal } from "@/components/DocumentDiffModal";
import { DocumentReviewRail } from "@/components/documents/DocumentReviewRail";
import { SelectionToolbar, type SuggestionDraftIntent } from "@/components/documents/SelectionToolbar";
import type { RailThread } from "@/components/documents/DocumentThreadCard";
const DOC_TYPE_ICON: Record<DocumentType, typeof FileText> = {
plan: ListTodo,
spec: BookOpen,
brief: ClipboardList,
report: BarChart3,
other: FileText,
};
const DOC_TYPE_LABEL: Record<DocumentType, string> = {
plan: "Plan",
spec: "Spec",
brief: "Brief",
report: "Report",
other: "Document",
};
interface IssueBinding {
issueId: string;
key: string;
}
/** Resolve the issue + document key the review index is keyed by. */
function resolveIssueBinding(doc: CompanyDocument | undefined): IssueBinding | null {
if (!doc) return null;
const link = doc.backlinks.find((b) => b.issueDocumentKey && b.targetType === "issue");
if (!link?.issueDocumentKey) return null;
return { issueId: link.targetId, key: link.issueDocumentKey };
}
export function DocumentDetail() {
const { documentId } = useParams<{ documentId: string }>();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState("");
const [baseRevisionId, setBaseRevisionId] = useState<string | null>(null);
const [conflict, setConflict] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const [railOpen, setRailOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [commentDraft, setCommentDraft] = useState<{ anchor: PendingAnchor } | null>(null);
const [commentBody, setCommentBody] = useState("");
const [suggestDraft, setSuggestDraft] = useState<{ anchor: PendingAnchor; intent: SuggestionDraftIntent } | null>(null);
const [proposed, setProposed] = useState("");
const containerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mq = window.matchMedia("(max-width: 1023px)");
const handler = () => setIsMobile(mq.matches);
handler();
mq.addEventListener?.("change", handler);
return () => mq.removeEventListener?.("change", handler);
}, []);
const documentQuery = useQuery({
queryKey: queryKeys.documents.detail(selectedCompanyId ?? "", documentId ?? ""),
queryFn: () => documentsApi.get(selectedCompanyId!, documentId!),
enabled: !!selectedCompanyId && !!documentId,
retry: (count, error) => !(error instanceof ApiError && [403, 404].includes(error.status)) && count < 2,
});
const doc = documentQuery.data;
const binding = useMemo(() => resolveIssueBinding(doc), [doc]);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? ""),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: members } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? ""),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() });
const reviewIndexQuery = useQuery({
queryKey: binding ? queryKeys.documents.reviewIndex(binding.issueId, binding.key) : ["documents", "review-index", "none"],
queryFn: () => documentReviewsApi.reviewIndex(binding!.issueId, binding!.key, { status: "all", includeComments: true }),
enabled: !!binding,
});
const reviewIndex = reviewIndexQuery.data;
const agentMap = useMemo(() => {
const map = new Map<string, Pick<Agent, "id" | "name">>();
for (const agent of agents ?? []) map.set(agent.id, agent);
return map;
}, [agents]);
const userProfileMap = useMemo(() => buildCompanyUserProfileMap(members?.users), [members?.users]);
const authorMaps = useMemo(() => ({ agentMap, userProfileMap }), [agentMap, userProfileMap]);
const currentUserId = session?.user?.id ?? null;
const isBoard = !!currentUserId;
const lockedByMe = !!doc?.lockedByUserId && doc.lockedByUserId === currentUserId;
const lockedByOther = !!(doc?.lockedByAgentId || doc?.lockedByUserId) && !lockedByMe;
const isArchived = doc?.status === "archived";
const canReview = isBoard;
const canEdit = isBoard && !lockedByOther && !isArchived;
const ownerAgent = doc?.ownerAgentId ? agentMap.get(doc.ownerAgentId) : undefined;
const lockHolderName = doc?.lockedByAgentId
? agentMap.get(doc.lockedByAgentId)?.name ?? "an agent"
: doc?.lockedByUserId
? userProfileMap.get(doc.lockedByUserId)?.label ?? "a board member"
: null;
useEffect(() => {
setBreadcrumbs([
{ label: "Documents", href: "/documents" },
{ label: doc?.title ?? "Document" },
]);
}, [setBreadcrumbs, doc?.title]);
const invalidateReview = useCallback(() => {
if (binding) {
queryClient.invalidateQueries({ queryKey: queryKeys.documents.reviewIndex(binding.issueId, binding.key) });
}
}, [binding, queryClient]);
const invalidateDoc = useCallback(() => {
queryClient.invalidateQueries({
queryKey: queryKeys.documents.detail(selectedCompanyId ?? "", documentId ?? ""),
});
}, [queryClient, selectedCompanyId, documentId]);
// --- Mutations -------------------------------------------------------------
const createComment = useMutation({
mutationFn: (input: { anchor: PendingAnchor; body: string }) =>
documentAnnotationsApi.create(binding!.issueId, binding!.key, {
baseRevisionId: doc!.latestRevisionId!,
baseRevisionNumber: doc!.latestRevisionNumber,
selector: input.anchor.selector,
body: input.body,
}),
onSuccess: () => {
setCommentDraft(null);
setCommentBody("");
invalidateReview();
},
});
const createSuggestion = useMutation({
mutationFn: (input: { anchor: PendingAnchor; intent: SuggestionDraftIntent; proposedText: string | null }) =>
documentReviewsApi.createSuggestion(binding!.issueId, binding!.key, {
baseRevisionId: doc!.latestRevisionId!,
baseRevisionNumber: doc!.latestRevisionNumber,
kind: input.intent.kind,
selector: input.anchor.selector,
proposedText: input.proposedText,
insertionPosition: input.intent.insertionPosition ?? null,
}),
onSuccess: () => {
setSuggestDraft(null);
setProposed("");
invalidateReview();
},
});
const lockMutation = useMutation({
mutationFn: (lock: boolean) =>
lock
? issuesApi.lockDocument(binding!.issueId, binding!.key)
: issuesApi.unlockDocument(binding!.issueId, binding!.key),
onSuccess: invalidateDoc,
});
const saveMutation = useMutation({
mutationFn: (body: string) =>
issuesApi.upsertDocument(binding!.issueId, binding!.key, {
format: "markdown",
body,
baseRevisionId,
}),
onSuccess: () => {
setEditMode(false);
setConflict(false);
invalidateDoc();
invalidateReview();
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409) setConflict(true);
},
});
// Rail handlers
const replyThread = useCallback(
async (thread: RailThread, body: string) => {
if (!binding) return;
if (thread.kind === "overall") {
await documentReviewsApi.addReviewComment(binding.issueId, binding.key, thread.id, { body });
} else {
await documentAnnotationsApi.addComment(binding.issueId, binding.key, thread.id, { body });
}
invalidateReview();
},
[binding, invalidateReview],
);
const toggleThreadResolved = useCallback(
async (thread: RailThread, resolved: boolean) => {
if (!binding) return;
const status = resolved ? "resolved" : "open";
if (thread.kind === "overall") {
await documentReviewsApi.updateReviewThreadStatus(binding.issueId, binding.key, thread.id, status);
} else {
await documentAnnotationsApi.updateStatus(binding.issueId, binding.key, thread.id, status);
}
invalidateReview();
},
[binding, invalidateReview],
);
const addOverallComment = useCallback(
async (body: string) => {
if (!binding) return;
await documentReviewsApi.createReviewThread(binding.issueId, binding.key, { body });
invalidateReview();
},
[binding, invalidateReview],
);
const acceptSuggestion = useCallback(
async (suggestion: DocumentSuggestionWithComments) => {
if (!binding || !doc?.latestRevisionId) return;
await documentReviewsApi.acceptSuggestion(binding.issueId, binding.key, suggestion.id, {
baseRevisionId: doc.latestRevisionId,
});
invalidateReview();
invalidateDoc();
},
[binding, doc?.latestRevisionId, invalidateReview, invalidateDoc],
);
const rejectSuggestion = useCallback(
async (suggestion: DocumentSuggestionWithComments, reason: string) => {
if (!binding) return;
await documentReviewsApi.rejectSuggestion(binding.issueId, binding.key, suggestion.id, { reason });
invalidateReview();
},
[binding, invalidateReview],
);
const replySuggestion = useCallback(
async (suggestion: DocumentSuggestionWithComments, body: string) => {
if (!binding) return;
await documentReviewsApi.addSuggestionComment(binding.issueId, binding.key, suggestion.id, { body });
invalidateReview();
},
[binding, invalidateReview],
);
const startEdit = useCallback(() => {
if (!doc) return;
setDraft(doc.body);
setBaseRevisionId(doc.latestRevisionId);
setConflict(false);
setEditMode(true);
}, [doc]);
const overlayThreads = useMemo<AnnotationOverlayThread[]>(
() =>
(reviewIndex?.annotationThreads ?? []).map((thread) => ({
id: thread.id,
selectedText: thread.selectedText,
status: thread.status,
anchorState: thread.anchorState,
})),
[reviewIndex?.annotationThreads],
);
// --- Render guards ---------------------------------------------------------
if (!selectedCompanyId) {
return <EmptyState icon={FileText} message="Select a company to view this document." />;
}
if (documentQuery.error instanceof ApiError && [403, 404].includes(documentQuery.error.status)) {
return (
<EmptyState
icon={Lock}
message="You don't have access to this document."
action="Back to your documents"
onAction={() => {
window.location.href = window.location.pathname.replace(/\/documents\/.*/, "/documents");
}}
/>
);
}
if (documentQuery.isLoading || !doc) {
return <DocumentDetailSkeleton />;
}
const TypeIcon = DOC_TYPE_ICON[doc.documentType] ?? FileText;
const staleBase = !editMode && baseRevisionId != null && baseRevisionId !== doc.latestRevisionId;
const showConflictBanner = conflict || staleBase;
return (
<TooltipProvider delayDuration={200}>
<div className="flex h-full min-h-0 w-full flex-col lg:flex-row">
{/* Center column */}
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<DocumentHeader
doc={doc}
TypeIcon={TypeIcon}
ownerName={ownerAgent?.name ?? null}
canEdit={canEdit}
isBoard={isBoard}
editMode={editMode}
lockedByMe={lockedByMe}
lockedByOther={lockedByOther}
lockHolderName={lockHolderName}
onEdit={startEdit}
onHistory={() => setDiffOpen(true)}
onToggleLock={() => lockMutation.mutate(!lockedByMe)}
lockPending={lockMutation.isPending}
/>
{showConflictBanner ? (
<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"
>
<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>
) : null}
<div className="px-4 pb-24 lg:pb-8">
<div className="mx-auto w-full max-w-[78ch]">
{editMode ? (
<div className="space-y-3">
<MarkdownEditor value={draft} onChange={setDraft} />
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setEditMode(false)}>
Cancel
</Button>
<Button
size="sm"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(draft)}
>
Save draft
</Button>
</div>
</div>
) : (
<section
ref={(el) => {
containerRef.current = el;
}}
className="relative min-w-0"
data-testid="document-detail-body"
>
<div className="relative z-[1]">
<MarkdownBody>{doc.body}</MarkdownBody>
</div>
{doc.latestRevisionId && binding ? (
<DocumentAnnotationLayer
containerRef={containerRef}
markdown={doc.body}
threads={overlayThreads}
focusedThreadId={null}
onThreadFocus={() => setRailOpen(true)}
pendingAnchor={null}
onPendingAnchorChange={() => {}}
onRequestComment={(anchor) => {
setCommentDraft({ anchor });
setCommentBody("");
}}
newCommentDisabled={!canReview}
newCommentDisabledReason={canReview ? null : "Sign in to leave feedback."}
hideResolved
renderSelectionToolbar={({ anchor, clear }) => (
<SelectionToolbar
onComment={() => {
setCommentDraft({ anchor });
setCommentBody("");
clear();
}}
onSuggest={(intent) => {
if (intent.kind === "deletion") {
createSuggestion.mutate({ anchor, intent, proposedText: null });
} else {
setSuggestDraft({ anchor, intent });
setProposed("");
}
clear();
}}
commentDisabled={!canReview}
commentDisabledReason="Sign in to leave feedback."
suggestDisabled={!canReview}
suggestDisabledReason="Sign in to suggest edits."
/>
)}
/>
) : null}
</section>
)}
</div>
</div>
</div>
{/* Desktop rail */}
{!isMobile ? (
<div className="hidden w-[360px] shrink-0 lg:block">
<DocumentReviewRail
reviewIndex={reviewIndex}
loading={reviewIndexQuery.isLoading}
canReview={canReview}
canFinishReview={canEdit}
latestRevisionId={doc.latestRevisionId}
authorMaps={authorMaps}
onReplyThread={replyThread}
onToggleThreadResolved={toggleThreadResolved}
onAddOverallComment={canReview ? addOverallComment : undefined}
onAcceptSuggestion={acceptSuggestion}
onRejectSuggestion={rejectSuggestion}
onReplySuggestion={replySuggestion}
onViewSuggestionDiff={() => setDiffOpen(true)}
/>
</div>
) : null}
{/* Mobile FAB + sheet */}
{isMobile ? (
<>
<button
type="button"
data-testid="document-rail-fab"
onClick={() => setRailOpen(true)}
className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-popover px-4 py-2 text-sm shadow-lg"
>
<MessageSquare className="h-4 w-4" aria-hidden="true" />
{(reviewIndex?.counts.openAnchoredThreads ?? 0) + (reviewIndex?.counts.openReviewThreads ?? 0)}
<Pencil className="h-4 w-4" aria-hidden="true" />
{reviewIndex?.counts.pendingSuggestions ?? 0}
</button>
<DocumentReviewRail
isMobile
open={railOpen}
onOpenChange={setRailOpen}
reviewIndex={reviewIndex}
loading={reviewIndexQuery.isLoading}
canReview={canReview}
canFinishReview={canEdit}
latestRevisionId={doc.latestRevisionId}
authorMaps={authorMaps}
onReplyThread={replyThread}
onToggleThreadResolved={toggleThreadResolved}
onAddOverallComment={canReview ? addOverallComment : undefined}
onAcceptSuggestion={acceptSuggestion}
onRejectSuggestion={rejectSuggestion}
onReplySuggestion={replySuggestion}
onViewSuggestionDiff={() => setDiffOpen(true)}
/>
</>
) : null}
</div>
{/* Comment composer */}
<Dialog open={!!commentDraft} onOpenChange={(open) => !open && setCommentDraft(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Comment on selection</DialogTitle>
</DialogHeader>
{commentDraft ? (
<p className="mb-2 border-l-2 border-border pl-2 text-xs italic text-muted-foreground">
“{commentDraft.anchor.selectedText}”
</p>
) : null}
<Textarea
data-testid="comment-composer"
value={commentBody}
onChange={(e) => setCommentBody(e.currentTarget.value)}
placeholder="Add your comment…"
rows={4}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setCommentDraft(null)}>
Cancel
</Button>
<Button
disabled={!commentBody.trim() || createComment.isPending}
onClick={() => commentDraft && createComment.mutate({ anchor: commentDraft.anchor, body: commentBody.trim() })}
>
Comment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Suggestion composer */}
<Dialog open={!!suggestDraft} onOpenChange={(open) => !open && setSuggestDraft(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{suggestionDialogTitle(suggestDraft?.intent.kind)}</DialogTitle>
</DialogHeader>
{suggestDraft ? (
<p className="mb-2 border-l-2 border-border pl-2 text-xs italic text-muted-foreground">
“{suggestDraft.anchor.selectedText}”
</p>
) : null}
<Textarea
data-testid="suggestion-composer"
value={proposed}
onChange={(e) => setProposed(e.currentTarget.value)}
placeholder="Proposed text…"
rows={4}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setSuggestDraft(null)}>
Cancel
</Button>
<Button
disabled={!proposed.trim() || createSuggestion.isPending}
onClick={() =>
suggestDraft &&
createSuggestion.mutate({ anchor: suggestDraft.anchor, intent: suggestDraft.intent, proposedText: proposed.trim() })
}
>
Suggest edit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{binding ? (
<DocumentDiffModal
issueId={binding.issueId}
documentKey={binding.key}
latestRevisionNumber={doc.latestRevisionNumber}
open={diffOpen}
onOpenChange={setDiffOpen}
/>
) : null}
</TooltipProvider>
);
}
function suggestionDialogTitle(kind?: DocumentSuggestionKind): string {
if (kind === "insertion") return "Insert text";
if (kind === "deletion") return "Delete selection";
return "Replace selection";
}
interface DocumentHeaderProps {
doc: CompanyDocument;
TypeIcon: typeof FileText;
ownerName: string | null;
canEdit: boolean;
isBoard: boolean;
editMode: boolean;
lockedByMe: boolean;
lockedByOther: boolean;
lockHolderName: string | null;
onEdit: () => void;
onHistory: () => void;
onToggleLock: () => void;
lockPending: boolean;
}
function DocumentHeader({
doc,
TypeIcon,
ownerName,
canEdit,
isBoard,
editMode,
lockedByMe,
lockedByOther,
lockHolderName,
onEdit,
onHistory,
onToggleLock,
lockPending,
}: DocumentHeaderProps) {
const backlinks = doc.backlinks.filter((b) => b.identifier);
const shownBacklinks = backlinks.slice(0, 5);
const extraBacklinks = backlinks.length - shownBacklinks.length;
const copyLink = () => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(window.location.href).catch(() => {});
}
};
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>
<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>
) : null}
{!isBoard ? (
<Badge className="border-transparent bg-muted px-1.5 py-0 text-[10px] text-muted-foreground" data-testid="readonly-badge">
Read-only
</Badge>
) : null}
{lockedByOther ? (
<Badge
data-testid="locked-by-other"
className="gap-1 border-transparent bg-amber-100 px-1.5 py-0 text-[10px] text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
>
<Lock className="h-3 w-3" aria-hidden="true" />
Locked by {lockHolderName}
</Badge>
) : null}
{lockedByMe ? (
<Badge className="gap-1 border-transparent bg-muted px-1.5 py-0 text-[10px] text-muted-foreground">
<Lock className="h-3 w-3" aria-hidden="true" />
Locked by you
</Badge>
) : null}
</div>
{/* Action row */}
{!editMode ? (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button size="sm" variant="outline" className="h-8" disabled={!canEdit} onClick={onEdit} data-testid="action-edit">
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
Edit
</Button>
</span>
</TooltipTrigger>
{!canEdit ? (
<TooltipContent>
{lockedByOther ? `Locked by ${lockHolderName}` : "You don't have edit access."}
</TooltipContent>
) : null}
</Tooltip>
<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
</Button>
{isBoard ? (
<Button
size="sm"
variant="ghost"
className="h-8"
disabled={lockedByOther || lockPending}
onClick={onToggleLock}
data-testid="action-lock"
>
{lockedByMe ? (
<>
<LockOpen className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
Unlock
</>
) : (
<>
<Lock className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
Lock
</>
)}
</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
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" className="h-8 w-8" aria-label="More actions" data-testid="action-overflow">
<span aria-hidden="true">⋯</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled>Archive</DropdownMenuItem>
<DropdownMenuItem disabled>Manage links…</DropdownMenuItem>
<DropdownMenuItem disabled>Export</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
{/* Backlinks */}
{shownBacklinks.length > 0 ? (
<div className="mb-1.5 flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-muted-foreground">Backlinks:</span>
{shownBacklinks.map((link) => (
<Link key={link.id} to={`/issues/${link.identifier}`}>
<Badge variant="outline" className="px-1.5 py-0 text-[10px] font-mono">
{link.identifier}
</Badge>
</Link>
))}
{extraBacklinks > 0 ? (
<span className="text-[11px] text-muted-foreground">+{extraBacklinks} more</span>
) : null}
</div>
) : null}
{/* 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>
<span>updated {relativeTime(doc.updatedAt)}</span>
</div>
</header>
);
}
function DocumentDetailSkeleton() {
return (
<div className="w-full px-4 py-4" data-testid="document-detail-skeleton">
<div className="mb-3 h-7 w-1/2 animate-pulse rounded bg-muted" />
<div className="mb-2 h-8 w-72 animate-pulse rounded bg-muted" />
<div className="space-y-2">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="h-4 w-full animate-pulse rounded bg-muted/60" />
))}
</div>
</div>
);
}
@@ -0,0 +1,366 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AlertTriangle, BookOpen, History, Link2, Lock, Pencil } from "lucide-react";
import type {
DocumentReviewIndex,
DocumentSuggestionWithComments,
} from "@paperclipai/shared";
import { DocumentReviewRail } from "@/components/documents/DocumentReviewRail";
import { SuggestionCard } from "@/components/documents/SuggestionCard";
import { SelectionToolbar } from "@/components/documents/SelectionToolbar";
import { StatusBadge } from "@/components/StatusBadge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MarkdownBody } from "@/components/MarkdownBody";
/**
* Storybook coverage for the document detail / review surface (PAP-10568).
*
* Renders the real review rail, suggestion cards, and selection toolbar with
* fixture data so UX/QA can capture desktop screenshots for sign-off without a
* live backend. Behaviour is unit-tested alongside each component.
*/
const HOUR = 1000 * 60 * 60;
const now = new Date("2026-06-07T12:00:00Z").getTime();
const agentMap = new Map([
["agent-claude", { id: "agent-claude", name: "ClaudeCoder" }],
["agent-cto", { id: "agent-cto", name: "CTO" }],
]);
function suggestion(overrides: Partial<DocumentSuggestionWithComments>): DocumentSuggestionWithComments {
return {
id: "sug",
companyId: "c",
issueId: "i",
documentId: "d",
documentKey: "spec",
kind: "substitution",
status: "pending",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: "rev-12",
originalRevisionNumber: 12,
currentRevisionId: "rev-12",
currentRevisionNumber: 12,
selectedText: "review state and suggested edits.",
proposedText: "review state, suggested edits, and orphan reconciliation.",
insertionPosition: null,
prefixText: "",
suffixText: "",
normalizedStart: 0,
normalizedEnd: 10,
markdownStart: 0,
markdownEnd: 10,
anchorSelector: {
quote: { exact: "review state and suggested edits.", prefix: "", suffix: "" },
position: { normalizedStart: 0, normalizedEnd: 10, markdownStart: 0, markdownEnd: 10 },
},
createdByAgentId: "agent-claude",
createdByUserId: null,
acceptedByAgentId: null,
acceptedByUserId: null,
acceptedAt: null,
acceptedRevisionId: null,
rejectedByAgentId: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date(now - 2 * HOUR),
updatedAt: new Date(now - 2 * HOUR),
comments: [
{
id: "sc1",
companyId: "c",
suggestionId: "sug",
issueId: "i",
documentId: "d",
body: "Looks right. Ship.",
authorType: "agent",
authorAgentId: "agent-cto",
authorUserId: null,
createdByRunId: null,
createdAt: new Date(now - HOUR),
updatedAt: new Date(now - HOUR),
},
],
...overrides,
};
}
const reviewIndex: DocumentReviewIndex = {
issueId: "i",
documentId: "d",
documentKey: "spec",
latestRevisionId: "rev-12",
latestRevisionNumber: 12,
counts: {
unresolved: 4,
openAnchoredThreads: 2,
openReviewThreads: 1,
pendingSuggestions: 3,
resolvedAnchoredThreads: 1,
resolvedReviewThreads: 0,
acceptedSuggestions: 0,
rejectedSuggestions: 1,
staleAnchors: 1,
orphanedAnchors: 1,
},
annotationThreads: [
{
id: "t-open",
companyId: "c",
issueId: "i",
documentId: "d",
documentKey: "spec",
status: "open",
anchorState: "active",
anchorConfidence: "exact",
originalRevisionId: "rev-12",
originalRevisionNumber: 12,
currentRevisionId: "rev-12",
currentRevisionNumber: 12,
selectedText: "the orchestrator should never proceed",
prefixText: "",
suffixText: "",
normalizedStart: 0,
normalizedEnd: 10,
markdownStart: 0,
markdownEnd: 10,
anchorSelector: { quote: { exact: "the orchestrator should never proceed", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 10, markdownStart: 0, markdownEnd: 10 } },
createdByAgentId: "agent-claude",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date(now - 2 * HOUR),
updatedAt: new Date(now - 2 * HOUR),
comments: [
{ id: "tc1", companyId: "c", threadId: "t-open", issueId: "i", documentId: "d", body: "Worth being more explicit here?", authorType: "agent", authorAgentId: "agent-claude", authorUserId: null, createdByRunId: null, createdAt: new Date(now - 2 * HOUR), updatedAt: new Date(now - 2 * HOUR) },
{ id: "tc2", companyId: "c", threadId: "t-open", issueId: "i", documentId: "d", body: "Agreed. Add an example for the \"no, never proceed\" path.", authorType: "agent", authorAgentId: "agent-cto", authorUserId: null, createdByRunId: null, createdAt: new Date(now - HOUR), updatedAt: new Date(now - HOUR) },
],
},
{
id: "t-stale",
companyId: "c",
issueId: "i",
documentId: "d",
documentKey: "spec",
status: "open",
anchorState: "stale",
anchorConfidence: "fuzzy",
originalRevisionId: "rev-11",
originalRevisionNumber: 11,
currentRevisionId: "rev-12",
currentRevisionNumber: 12,
selectedText: "anchored text that drifted",
prefixText: "",
suffixText: "",
normalizedStart: 0,
normalizedEnd: 10,
markdownStart: 0,
markdownEnd: 10,
anchorSelector: { quote: { exact: "anchored text that drifted", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 10, markdownStart: 0, markdownEnd: 10 } },
createdByAgentId: "agent-cto",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date(now - 5 * HOUR),
updatedAt: new Date(now - 5 * HOUR),
comments: [
{ id: "tc3", companyId: "c", threadId: "t-stale", issueId: "i", documentId: "d", body: "This moved when the intro was rewritten.", authorType: "agent", authorAgentId: "agent-cto", authorUserId: null, createdByRunId: null, createdAt: new Date(now - 5 * HOUR), updatedAt: new Date(now - 5 * HOUR) },
],
},
{
id: "t-orphan",
companyId: "c",
issueId: "i",
documentId: "d",
documentKey: "spec",
status: "open",
anchorState: "orphaned",
anchorConfidence: "missing",
originalRevisionId: "rev-9",
originalRevisionNumber: 9,
currentRevisionId: "rev-12",
currentRevisionNumber: 12,
selectedText: "a paragraph that was deleted entirely",
prefixText: "",
suffixText: "",
normalizedStart: 0,
normalizedEnd: 10,
markdownStart: 0,
markdownEnd: 10,
anchorSelector: { quote: { exact: "a paragraph that was deleted entirely", prefix: "", suffix: "" }, position: { normalizedStart: 0, normalizedEnd: 10, markdownStart: 0, markdownEnd: 10 } },
createdByAgentId: "agent-claude",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date(now - 8 * HOUR),
updatedAt: new Date(now - 8 * HOUR),
comments: [
{ id: "tc4", companyId: "c", threadId: "t-orphan", issueId: "i", documentId: "d", body: "Where did this section go?", authorType: "agent", authorAgentId: "agent-claude", authorUserId: null, createdByRunId: null, createdAt: new Date(now - 8 * HOUR), updatedAt: new Date(now - 8 * HOUR) },
],
},
],
reviewThreads: [
{
id: "overall",
companyId: "c",
issueId: "i",
documentId: "d",
documentKey: "spec",
status: "open",
createdByAgentId: "agent-cto",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
resolvedAt: null,
createdAt: new Date(now - 3 * HOUR),
updatedAt: new Date(now - 3 * HOUR),
comments: [
{ id: "oc1", companyId: "c", threadId: "overall", issueId: "i", documentId: "d", body: "Suggestions look right — accept the two pending edits and we can ship.", authorType: "agent", authorAgentId: "agent-cto", authorUserId: null, createdByRunId: null, createdAt: new Date(now - 3 * HOUR), updatedAt: new Date(now - 3 * HOUR) },
],
},
],
suggestions: [
suggestion({ id: "sug-replace", kind: "substitution" }),
suggestion({ id: "sug-insert", kind: "insertion", insertionPosition: "after", selectedText: "review flow", proposedText: "Also note the orphan-reconciliation pass.", comments: [] }),
suggestion({ id: "sug-delete", kind: "deletion", selectedText: "This sentence is redundant and should go.", proposedText: null, comments: [] }),
suggestion({ id: "sug-rebase", kind: "substitution", currentRevisionId: "rev-11", selectedText: "stale base", proposedText: "needs rebase onto rev 12", comments: [] }),
],
};
const noop = () => {};
const asyncNoop = async () => {};
const railHandlers = {
onReplyThread: asyncNoop,
onToggleThreadResolved: asyncNoop,
onAddOverallComment: asyncNoop,
onAcceptSuggestion: asyncNoop,
onRejectSuggestion: asyncNoop,
onReplySuggestion: asyncNoop,
onViewSuggestionDiff: noop,
};
const SAMPLE_BODY = `# Paperclip Documents review flow
The orchestrator should never proceed when a reviewer has left blocking
feedback. Review state and suggested edits are tracked as discrete rows so an
agent and a human read the same index.
## Anchored feedback
Highlighted spans carry comments and suggestions. When the body changes, anchors
remap; drifted anchors are flagged **stale** and lost ones become **orphaned**.`;
const meta: Meta = {
title: "Documents/Review",
};
export default meta;
type Story = StoryObj;
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>
);
}
export const DetailDesktop: Story = {
render: () => (
<div className="flex h-[680px] w-full overflow-hidden rounded-md border border-border bg-background">
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<DetailHeader />
<div className="px-4 pb-8">
<div className="mx-auto w-full max-w-[78ch]">
<MarkdownBody>{SAMPLE_BODY}</MarkdownBody>
</div>
</div>
</div>
<div className="w-[360px] shrink-0">
<DocumentReviewRail reviewIndex={reviewIndex} canReview canFinishReview latestRevisionId="rev-12" authorMaps={{ agentMap }} {...railHandlers} />
</div>
</div>
),
};
export const RailComments: Story = {
render: () => (
<div className="h-[680px] w-[360px] overflow-hidden rounded-md border border-border">
<DocumentReviewRail reviewIndex={reviewIndex} canReview canFinishReview latestRevisionId="rev-12" authorMaps={{ agentMap }} {...railHandlers} />
</div>
),
};
export const RailSuggestions: Story = {
render: () => (
<div className="h-[680px] w-[360px] overflow-hidden rounded-md border border-border">
<DocumentReviewRail reviewIndex={reviewIndex} canReview canFinishReview latestRevisionId="rev-12" initialTab="suggestions" authorMaps={{ agentMap }} {...railHandlers} />
</div>
),
};
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"
>
<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>
),
};
export const Suggestions: Story = {
render: () => (
<div className="w-[340px] space-y-3 p-4">
<SuggestionCard suggestion={suggestion({ id: "s-replace", kind: "substitution" })} latestRevisionId="rev-12" canReview authorMaps={{ agentMap }} onAccept={asyncNoop} onReject={asyncNoop} onReply={asyncNoop} />
<SuggestionCard suggestion={suggestion({ id: "s-insert", kind: "insertion", insertionPosition: "after", selectedText: "review flow", proposedText: "Also note the orphan-reconciliation pass.", comments: [] })} latestRevisionId="rev-12" canReview authorMaps={{ agentMap }} onAccept={asyncNoop} onReject={asyncNoop} onReply={asyncNoop} />
<SuggestionCard suggestion={suggestion({ id: "s-delete", kind: "deletion", selectedText: "This sentence is redundant.", proposedText: null, comments: [] })} latestRevisionId="rev-12" canReview authorMaps={{ agentMap }} onAccept={asyncNoop} onReject={asyncNoop} onReply={asyncNoop} />
<SuggestionCard suggestion={suggestion({ id: "s-rebase", kind: "substitution", currentRevisionId: "rev-11", selectedText: "stale base", proposedText: "needs rebase", comments: [] })} latestRevisionId="rev-12" canReview authorMaps={{ agentMap }} onAccept={asyncNoop} onReject={asyncNoop} onReply={asyncNoop} />
</div>
),
};
export const SelectionToolbarStory: Story = {
name: "Selection toolbar",
render: () => (
<div className="p-8">
<div className="inline-flex items-center gap-1 rounded-md border border-border bg-popover px-1 py-1 shadow-md">
<SelectionToolbar onComment={noop} onSuggest={noop} onCopyLink={noop} />
</div>
</div>
),
};