mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
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:
1 parent
2f23e4f1ca
commit
02012b84d7
15 files changed
+3281
-14
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user