diff --git a/ui/src/components/documents/DocumentReviewRail.tsx b/ui/src/components/documents/DocumentReviewRail.tsx index 03cf254fad8..e1a0449485c 100644 --- a/ui/src/components/documents/DocumentReviewRail.tsx +++ b/ui/src/components/documents/DocumentReviewRail.tsx @@ -3,7 +3,7 @@ import type { DocumentReviewIndex, DocumentSuggestionWithComments, } from "@paperclipai/shared"; -import { CheckCircle2, MessageSquarePlus } from "lucide-react"; +import { CheckCircle2, ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -29,6 +29,20 @@ const FILTERS: { id: ReviewFilter; label: string }[] = [ { id: "orphaned", label: "Orphaned" }, ]; +function threadBucket(thread: RailThread): ReviewFilter { + if (thread.anchorState === "orphaned") return "orphaned"; + if (thread.anchorState === "stale") return "stale"; + if (thread.status === "resolved") return "resolved"; + return "open"; +} + +function suggestionBucket(suggestion: DocumentSuggestionWithComments): ReviewFilter { + if (suggestion.anchorState === "orphaned") return "orphaned"; + if (suggestion.anchorState === "stale") return "stale"; + if (suggestion.status !== "pending") return "resolved"; + return "open"; +} + export interface DocumentReviewRailProps { reviewIndex?: DocumentReviewIndex; loading?: boolean; @@ -46,6 +60,7 @@ export interface DocumentReviewRailProps { onAddOverallComment?: (body: string) => Promise | void; onAcceptSuggestion: (suggestion: DocumentSuggestionWithComments) => Promise | void; onRejectSuggestion: (suggestion: DocumentSuggestionWithComments, reason: string) => Promise | void; + onResolveSuggestion?: (suggestion: DocumentSuggestionWithComments) => Promise | void; onReplySuggestion: (suggestion: DocumentSuggestionWithComments, body: string) => Promise | void; onViewSuggestionDiff?: (suggestion: DocumentSuggestionWithComments) => void; onDoneReviewing?: () => void; @@ -102,6 +117,7 @@ function ReviewRailBody({ onAddOverallComment, onAcceptSuggestion, onRejectSuggestion, + onResolveSuggestion, onReplySuggestion, onViewSuggestionDiff, onDoneReviewing, @@ -145,19 +161,22 @@ function ReviewRailBody({ const activeFilters = filters.size === 0 ? new Set(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 threadMatches = (thread: RailThread): boolean => activeFilters.has(threadBucket(thread)); - 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 suggestionMatches = (suggestion: DocumentSuggestionWithComments): boolean => + activeFilters.has(suggestionBucket(suggestion)); + + // Per-filter counts for the active tab so empty buckets read as empty and the + // stale/orphaned rows become discoverable rather than guess-and-click. + const filterCounts = useMemo(() => { + const counts: Record = { open: 0, resolved: 0, stale: 0, orphaned: 0 }; + if (tab === "comments") { + for (const thread of [...overallThreads, ...anchoredThreads]) counts[threadBucket(thread)] += 1; + } else { + for (const s of reviewIndex?.suggestions ?? []) counts[suggestionBucket(s)] += 1; + } + return counts; + }, [tab, overallThreads, anchoredThreads, reviewIndex?.suggestions]); const visibleOverall = overallThreads.filter(threadMatches); const visibleAnchored = anchoredThreads.filter(threadMatches); @@ -220,6 +239,9 @@ function ReviewRailBody({ )} > {filter.label} + {filterCounts[filter.id] > 0 ? ( + {filterCounts[filter.id]} + ) : null} ))} @@ -332,6 +354,7 @@ function ReviewRailBody({ authorMaps={authorMaps} onAccept={onAcceptSuggestion} onReject={onRejectSuggestion} + onResolve={onResolveSuggestion} onReply={onReplySuggestion} onViewDiff={onViewSuggestionDiff} /> @@ -397,12 +420,16 @@ function OrphanGroup({ count, children }: { count: number; children: React.React
{open ?
{children}
: null}
diff --git a/ui/src/components/documents/DocumentThreadCard.tsx b/ui/src/components/documents/DocumentThreadCard.tsx index f6bb1dfc373..28d302c54a7 100644 --- a/ui/src/components/documents/DocumentThreadCard.tsx +++ b/ui/src/components/documents/DocumentThreadCard.tsx @@ -51,9 +51,11 @@ function stateBadge(thread: RailThread): { label: string; className: string; ico }; } if (thread.status === "resolved") { + // Spec §5.4: badge color matches the body highlight. The resolved highlight + // is a muted/low-sat yellow, so the badge is muted yellow (not green). return { label: "Resolved", - className: "border-transparent bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", + className: "border-transparent bg-yellow-50 text-yellow-700/80 dark:bg-yellow-950/40 dark:text-yellow-200/70", }; } return { @@ -174,21 +176,25 @@ export function DocumentThreadCard({ className="min-h-[2.25rem] resize-none text-sm" />
+ {reply.trim() ? ( + + ) : ( + + )} - diff --git a/ui/src/components/documents/SuggestionCard.test.tsx b/ui/src/components/documents/SuggestionCard.test.tsx index 7292fd9e0a7..dd55f1c1380 100644 --- a/ui/src/components/documents/SuggestionCard.test.tsx +++ b/ui/src/components/documents/SuggestionCard.test.tsx @@ -161,6 +161,43 @@ describe("SuggestionCard", () => { expect(onReject).toHaveBeenCalledWith(expect.objectContaining({ id: "sug-1" }), "Not needed"); }); + it("resolves a pending suggestion without a reason when onResolve is provided", async () => { + const onResolve = vi.fn(); + await act(() => + root.render( + , + ), + ); + const resolve = container.querySelector('[data-testid="suggestion-resolve-sug-1"]'); + expect(resolve).not.toBeNull(); + await act(() => resolve!.click()); + expect(onResolve).toHaveBeenCalledWith(expect.objectContaining({ id: "sug-1" })); + }); + + it("omits Resolve when no handler is supplied", async () => { + await act(() => + root.render( + , + ), + ); + expect(container.querySelector('[data-testid="suggestion-resolve-sug-1"]')).toBeNull(); + }); + it("hides review actions for read-only viewers", async () => { await act(() => root.render( diff --git a/ui/src/components/documents/SuggestionCard.tsx b/ui/src/components/documents/SuggestionCard.tsx index 97ebf2094ba..9d45feae5af 100644 --- a/ui/src/components/documents/SuggestionCard.tsx +++ b/ui/src/components/documents/SuggestionCard.tsx @@ -3,7 +3,7 @@ import type { DocumentSuggestionKind, DocumentSuggestionWithComments, } from "@paperclipai/shared"; -import { AlertTriangle, Check, MoreHorizontal, X } from "lucide-react"; +import { AlertTriangle, Check, CheckCheck, MoreHorizontal, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; @@ -26,9 +26,14 @@ const KIND_LABEL: Record = { substitution: "Replace", }; +// Insert/delete badges read the same §5.4 tokens the inline diff body uses, so a +// future token change propagates to both. Substitution has no dedicated token +// (it composes insert + delete), so it keeps the amber pairing. const KIND_BADGE_CLASS: Record = { - 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", + insertion: + "border-transparent bg-[var(--paperclip-doc-suggestion-insert-bg)] text-[var(--paperclip-doc-suggestion-insert-fg)]", + deletion: + "border-transparent bg-[var(--paperclip-doc-suggestion-delete-bg)] text-[var(--paperclip-doc-suggestion-delete-fg)]", substitution: "border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", }; @@ -41,6 +46,12 @@ export interface SuggestionCardProps { authorMaps?: ReviewAuthorMaps; onAccept: (suggestion: DocumentSuggestionWithComments) => Promise | void; onReject: (suggestion: DocumentSuggestionWithComments, reason: string) => Promise | void; + /** + * Resolve without accepting or rejecting — "handled outside / no longer + * applies". The safety valve when Accept is gated on a rebase and Reject would + * falsely imply disagreement. No reason required. + */ + onResolve?: (suggestion: DocumentSuggestionWithComments) => Promise | void; onReply: (suggestion: DocumentSuggestionWithComments, body: string) => Promise | void; /** Opens the diff modal for the revision this suggestion would produce. */ onViewDiff?: (suggestion: DocumentSuggestionWithComments) => void; @@ -62,6 +73,7 @@ export function SuggestionCard({ authorMaps, onAccept, onReject, + onResolve, onReply, onViewDiff, className, @@ -69,7 +81,7 @@ export function SuggestionCard({ const [reply, setReply] = useState(""); const [rejecting, setRejecting] = useState(false); const [reason, setReason] = useState(""); - const [pending, setPending] = useState(null); + const [pending, setPending] = useState(null); const author = resolveReviewAuthor(suggestion, authorMaps ?? {}); const kindLabel = KIND_LABEL[suggestion.kind]; @@ -108,6 +120,16 @@ export function SuggestionCard({ } }; + const handleResolve = async () => { + if (!onResolve) return; + setPending("resolve"); + try { + await onResolve(suggestion); + } finally { + setPending(null); + } + }; + const handleReply = async () => { const trimmed = reply.trim(); if (!trimmed) return; @@ -224,16 +246,20 @@ export function SuggestionCard({ className="min-h-[2.25rem] resize-none text-sm" />
- + {reply.trim() ? ( + + ) : ( + + )} {isPending ? (
{rejecting ? null : ( @@ -273,6 +299,20 @@ export function SuggestionCard({
) : null}
diff --git a/ui/src/pages/DocumentDetail.test.tsx b/ui/src/pages/DocumentDetail.test.tsx index c2d3810c438..95eb2358d71 100644 --- a/ui/src/pages/DocumentDetail.test.tsx +++ b/ui/src/pages/DocumentDetail.test.tsx @@ -21,7 +21,10 @@ const mocks = vi.hoisted(() => ({ params: { value: { documentId: "doc-1" } as { documentId?: string } }, })); -vi.mock("react-router-dom", () => ({ useParams: () => mocks.params.value })); +vi.mock("react-router-dom", () => ({ + useParams: () => mocks.params.value, + useSearchParams: () => [new URLSearchParams(), () => {}], +})); vi.mock("@/lib/router", () => ({ Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, })); diff --git a/ui/storybook/stories/document-review.stories.tsx b/ui/storybook/stories/document-review.stories.tsx index 7c347687190..84eb425962c 100644 --- a/ui/storybook/stories/document-review.stories.tsx +++ b/ui/storybook/stories/document-review.stories.tsx @@ -242,6 +242,7 @@ const railHandlers = { onAddOverallComment: asyncNoop, onAcceptSuggestion: asyncNoop, onRejectSuggestion: asyncNoop, + onResolveSuggestion: asyncNoop, onReplySuggestion: asyncNoop, onViewSuggestionDiff: noop, }; @@ -408,10 +409,10 @@ export const RailMobileSheet: Story = { export const Suggestions: Story = { render: () => (
- - - - + + + +
), };