diff --git a/ui/src/App.tsx b/ui/src/App.tsx index dd0bbff61df..8baf908d725 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/document-reviews.ts b/ui/src/api/document-reviews.ts new file mode 100644 index 00000000000..d51831215b0 --- /dev/null +++ b/ui/src/api/document-reviews.ts @@ -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( + `/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( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments`, + data, + ), + addReviewComment: ( + issueId: string, + key: string, + threadId: string, + data: CreateDocumentReviewCommentRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments/${threadId}/comments`, + data, + ), + updateReviewThreadStatus: ( + issueId: string, + key: string, + threadId: string, + status: DocumentReviewThreadStatus, + ) => + api.patch( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/review-comments/${threadId}`, + { status }, + ), + + // --- Suggested edits ------------------------------------------------------- + createSuggestion: (issueId: string, key: string, data: CreateDocumentSuggestionRequest) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions`, + data, + ), + addSuggestionComment: ( + issueId: string, + key: string, + suggestionId: string, + data: CreateDocumentSuggestionCommentRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/comments`, + data, + ), + acceptSuggestion: ( + issueId: string, + key: string, + suggestionId: string, + data: AcceptDocumentSuggestionRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/accept`, + data, + ), + rejectSuggestion: ( + issueId: string, + key: string, + suggestionId: string, + data: RejectDocumentSuggestionRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/reject`, + data, + ), +}; diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 6859922ab60..7a6567591a7 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -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([]); const [toolbarPosition, setToolbarPosition] = useState(null); @@ -492,20 +505,28 @@ export function DocumentAnnotationLayer({ style={{ top: toolbarPosition.top, left: toolbarPosition.left }} onMouseDown={(event) => event.preventDefault()} > - + {renderSelectionToolbar ? ( + renderSelectionToolbar({ + anchor: pendingAnchor, + addComment: handleAddComment, + clear: () => onPendingAnchorChange(null), + }) + ) : ( + + )} ) : null} diff --git a/ui/src/components/documents/DocumentReviewRail.test.tsx b/ui/src/components/documents/DocumentReviewRail.test.tsx new file mode 100644 index 00000000000..96b4ce0e356 --- /dev/null +++ b/ui/src/components/documents/DocumentReviewRail.test.tsx @@ -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 }) =>

{children}

, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function makeSuggestion(overrides: Partial = {}): 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 { + 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( + , + ), + ); + 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( + , + ), + ); + 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( + , + ), + ); + // Enable the orphaned filter chip. + await act(() => container.querySelector('[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( + , + ), + ); + const done = container.querySelector('[data-testid="rail-done-reviewing"]'); + expect(done?.disabled).toBe(true); + }); +}); diff --git a/ui/src/components/documents/DocumentReviewRail.tsx b/ui/src/components/documents/DocumentReviewRail.tsx new file mode 100644 index 00000000000..03cf254fad8 --- /dev/null +++ b/ui/src/components/documents/DocumentReviewRail.tsx @@ -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; + onToggleThreadResolved: (thread: RailThread, resolved: boolean) => Promise | void; + onAddOverallComment?: (body: string) => Promise | void; + onAcceptSuggestion: (suggestion: DocumentSuggestionWithComments) => Promise | void; + onRejectSuggestion: (suggestion: DocumentSuggestionWithComments, reason: string) => Promise | void; + onReplySuggestion: (suggestion: DocumentSuggestionWithComments, body: string) => Promise | 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 ( + {})}> + + Document review +