From 0f0bb8e107e6a4e9e458ab96ad03de0b9c80953f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 7 Jun 2026 02:47:20 +0000 Subject: [PATCH] Add first-class resolved suggestion status + doc-review P3 polish (PAP-10574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferred polish from the PAP-10568 UX review. First-class `resolved` suggestion status (backend): - Add `resolved` to DOCUMENT_SUGGESTION_STATUSES + resolved-by/at audit columns (schema + migration 0101) and a `resolvedSuggestions` count. - New POST .../suggestions/:id/resolve endpoint + service method that sets status without editing the document, distinct from reject (disagreement). - Wire the rail's Resolve action to the new endpoint; show a Resolved badge. Mobile FAB safe-area inset (§5.8): lift the review FAB above the iOS home indicator with bottom-[max(env(safe-area-inset-bottom),1rem)]. Platform-aware hotkey hint: RailEmpty shows Ctrl+Shift+M on Windows/Linux and ⌘⇧M on macOS/iOS via a new isApplePlatform() helper. (Autosave indicator wiring is left for when document autosave lands — the header slot already exists.) Tests: resolveSuggestion service test (terminal status, no revision created), SuggestionCard resolved-badge test, platform-aware empty-state hint test. Co-Authored-By: Claude Opus 4.8 --- ...01_document_suggestion_resolved_status.sql | 12 +++++ packages/db/src/migrations/meta/_journal.json | 7 +++ .../db/src/schema/document_suggestions.ts | 3 ++ packages/shared/src/constants.ts | 4 +- packages/shared/src/index.ts | 3 ++ packages/shared/src/types/document-review.ts | 9 ++++ packages/shared/src/types/index.ts | 1 + .../shared/src/validators/document-review.ts | 5 ++ packages/shared/src/validators/index.ts | 2 + .../__tests__/document-review-service.test.ts | 41 ++++++++++++++++ server/src/routes/issues.ts | 47 +++++++++++++++++++ server/src/services/document-review.ts | 33 +++++++++++++ ui/src/api/document-reviews.ts | 13 +++++ .../documents/DocumentReviewRail.test.tsx | 34 ++++++++++++++ .../documents/DoneReviewingDialog.test.tsx | 1 + .../documents/SuggestionCard.test.tsx | 24 ++++++++++ .../components/documents/SuggestionCard.tsx | 11 ++++- ui/src/lib/utils.ts | 12 +++++ ui/src/pages/DocumentDetail.test.tsx | 1 + ui/src/pages/DocumentDetail.tsx | 14 +++--- .../stories/document-review.stories.tsx | 4 ++ 21 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 packages/db/src/migrations/0101_document_suggestion_resolved_status.sql diff --git a/packages/db/src/migrations/0101_document_suggestion_resolved_status.sql b/packages/db/src/migrations/0101_document_suggestion_resolved_status.sql new file mode 100644 index 00000000000..d1f68730a35 --- /dev/null +++ b/packages/db/src/migrations/0101_document_suggestion_resolved_status.sql @@ -0,0 +1,12 @@ +-- First-class `resolved` suggestion status (PAP-10574). "Resolved" = handled +-- outside review / no longer applies, distinct from "rejected" (disagreement). +-- `status` is a plain text column with no enum/check constraint, so the new +-- value needs no constraint change — only the audit columns below. +ALTER TABLE "document_suggestions" ADD COLUMN IF NOT EXISTS "resolved_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "document_suggestions" ADD COLUMN IF NOT EXISTS "resolved_by_user_id" text;--> statement-breakpoint +ALTER TABLE "document_suggestions" ADD COLUMN IF NOT EXISTS "resolved_at" timestamp with time zone;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 48af8b9598d..b53d000276e 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -708,6 +708,13 @@ "when": 1780794300000, "tag": "0100_document_review_backend", "breakpoints": true + }, + { + "idx": 101, + "version": "7", + "when": 1780794600000, + "tag": "0101_document_suggestion_resolved_status", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/document_suggestions.ts b/packages/db/src/schema/document_suggestions.ts index 2401df9b39a..1d55c37c3e2 100644 --- a/packages/db/src/schema/document_suggestions.ts +++ b/packages/db/src/schema/document_suggestions.ts @@ -48,6 +48,9 @@ export const documentSuggestions = pgTable( rejectedByAgentId: uuid("rejected_by_agent_id").references(() => agents.id, { onDelete: "set null" }), rejectedByUserId: text("rejected_by_user_id"), rejectedAt: timestamp("rejected_at", { withTimezone: true }), + resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + resolvedByUserId: text("resolved_by_user_id"), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 223fd575151..958acdc10a6 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -360,7 +360,9 @@ export type DocumentSuggestionKind = (typeof DOCUMENT_SUGGESTION_KINDS)[number]; export const DOCUMENT_SUGGESTION_INSERT_POSITIONS = ["before", "after"] as const; export type DocumentSuggestionInsertPosition = (typeof DOCUMENT_SUGGESTION_INSERT_POSITIONS)[number]; -export const DOCUMENT_SUGGESTION_STATUSES = ["pending", "accepted", "rejected"] as const; +// `resolved` = handled outside review / no longer applies — distinct from +// `rejected` (an explicit disagreement) so the audit trail keeps the two apart. +export const DOCUMENT_SUGGESTION_STATUSES = ["pending", "accepted", "rejected", "resolved"] as const; export type DocumentSuggestionStatus = (typeof DOCUMENT_SUGGESTION_STATUSES)[number]; export const DOCUMENT_STATUSES = ["draft", "in_review", "approved", "archived"] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d1504a2e9fd..d7eb01fd9a2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -522,6 +522,7 @@ export type { DocumentSuggestionComment, DocumentSuggestionWithComments, RejectDocumentSuggestionRequest, + ResolveDocumentSuggestionRequest, UpdateDocumentAnnotationThreadRequest, UpdateDocumentReviewThreadRequest, AttachmentArtifactWorkProductMetadata, @@ -1011,6 +1012,7 @@ export { documentSuggestionKindSchema, documentSuggestionStatusSchema, rejectDocumentSuggestionSchema, + resolveDocumentSuggestionSchema, updateDocumentReviewThreadSchema, type AcceptDocumentSuggestion, type CreateDocumentReviewComment, @@ -1018,6 +1020,7 @@ export { type CreateDocumentSuggestion, type CreateDocumentSuggestionComment, type RejectDocumentSuggestion, + type ResolveDocumentSuggestion, type UpdateDocumentReviewThread, companySearchQuerySchema, COMPANY_SEARCH_DEFAULT_LIMIT, diff --git a/packages/shared/src/types/document-review.ts b/packages/shared/src/types/document-review.ts index f9dc0ad00e5..050154a650e 100644 --- a/packages/shared/src/types/document-review.ts +++ b/packages/shared/src/types/document-review.ts @@ -83,6 +83,9 @@ export interface DocumentSuggestion { rejectedByAgentId: string | null; rejectedByUserId: string | null; rejectedAt: Date | null; + resolvedByAgentId: string | null; + resolvedByUserId: string | null; + resolvedAt: Date | null; createdAt: Date; updatedAt: Date; } @@ -133,6 +136,7 @@ export interface DocumentReviewIndexCounts { resolvedReviewThreads: number; acceptedSuggestions: number; rejectedSuggestions: number; + resolvedSuggestions: number; staleAnchors: number; orphanedAnchors: number; } @@ -187,3 +191,8 @@ export interface AcceptDocumentSuggestionRequest { export interface RejectDocumentSuggestionRequest { reason?: string | null; } + +export interface ResolveDocumentSuggestionRequest { + /** Optional note kept for the audit trail (e.g. where it was handled). */ + note?: string | null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 065dc982163..189ec4a1fab 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -191,6 +191,7 @@ export type { DocumentSuggestionComment, DocumentSuggestionWithComments, RejectDocumentSuggestionRequest, + ResolveDocumentSuggestionRequest, UpdateDocumentReviewThreadRequest, } from "./document-review.js"; export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; diff --git a/packages/shared/src/validators/document-review.ts b/packages/shared/src/validators/document-review.ts index 53fe18d34de..ddbd605fbf7 100644 --- a/packages/shared/src/validators/document-review.ts +++ b/packages/shared/src/validators/document-review.ts @@ -80,6 +80,10 @@ export const rejectDocumentSuggestionSchema = z.object({ reason: z.string().trim().min(1).max(2_000).nullable().optional(), }).strict(); +export const resolveDocumentSuggestionSchema = z.object({ + note: z.string().trim().min(1).max(2_000).nullable().optional(), +}).strict(); + export type CreateDocumentReviewThread = z.infer; export type CreateDocumentReviewComment = z.infer; export type UpdateDocumentReviewThread = z.infer; @@ -87,3 +91,4 @@ export type CreateDocumentSuggestion = z.infer; export type AcceptDocumentSuggestion = z.infer; export type RejectDocumentSuggestion = z.infer; +export type ResolveDocumentSuggestion = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 994758bd65a..11a54adc89f 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -240,6 +240,7 @@ export { documentSuggestionKindSchema, documentSuggestionStatusSchema, rejectDocumentSuggestionSchema, + resolveDocumentSuggestionSchema, updateDocumentReviewThreadSchema, type AcceptDocumentSuggestion, type CreateDocumentReviewComment, @@ -247,6 +248,7 @@ export { type CreateDocumentSuggestion, type CreateDocumentSuggestionComment, type RejectDocumentSuggestion, + type ResolveDocumentSuggestion, type UpdateDocumentReviewThread, } from "./document-review.js"; diff --git a/server/src/__tests__/document-review-service.test.ts b/server/src/__tests__/document-review-service.test.ts index 33f3a61ebea..f6053b4c2aa 100644 --- a/server/src/__tests__/document-review-service.test.ts +++ b/server/src/__tests__/document-review-service.test.ts @@ -170,6 +170,47 @@ describeEmbeddedPostgres("documentReviewService", () => { expect(index.counts.acceptedSuggestions).toBe(1); }); + it("resolves a suggestion without editing the document, distinct from rejection", async () => { + const { issueId, document } = await createIssueWithDocument(); + const suggestion = await review.createSuggestion( + issueId, + "plan", + { + baseRevisionId: document.latestRevisionId!, + baseRevisionNumber: document.latestRevisionNumber, + kind: "substitution", + selector: selectedTextSelector, + proposedText: "better text", + }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + + const resolved = await review.resolveSuggestion( + issueId, + "plan", + suggestion.id, + { note: "Handled in a follow-up edit." }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + + expect(resolved.status).toBe("resolved"); + expect(resolved.resolvedByUserId).toBe("board-user"); + expect(resolved.resolvedAt).toBeInstanceOf(Date); + // Resolving must not mutate the document body or create a revision. + const revisions = await docs.listIssueDocumentRevisions(issueId, "plan"); + expect(revisions).toHaveLength(1); + + const index = await review.getReviewIndex(issueId, "plan", { status: "all" }); + expect(index.counts.pendingSuggestions).toBe(0); + expect(index.counts.resolvedSuggestions).toBe(1); + expect(index.counts.rejectedSuggestions).toBe(0); + + // A resolved suggestion is terminal — it can't be resolved or rejected again. + await expect( + review.resolveSuggestion(issueId, "plan", suggestion.id, {}, { actorType: "user", actorId: "board-user", userId: "board-user" }), + ).rejects.toThrow(/Only pending suggestions can be resolved/); + }); + it("remaps pending suggestions across document revisions before acceptance", async () => { const { issueId, document } = await createIssueWithDocument(); const suggestion = await review.createSuggestion( diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 248968ad64c..3956863b1a5 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -49,6 +49,7 @@ import { issueDocumentKeySchema, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, rejectDocumentSuggestionSchema, + resolveDocumentSuggestionSchema, rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, respondIssueThreadInteractionSchema, @@ -3579,6 +3580,52 @@ export function issueRoutes( }, ); + router.post( + "/issues/:id/documents/:key/suggestions/:suggestionId/resolve", + validate(resolveDocumentSuggestionSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const suggestion = await documentReviewsSvc.resolveSuggestion( + issue.id, + keyParsed.data, + req.params.suggestionId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_resolved", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: suggestion.documentKey, + documentId: suggestion.documentId, + suggestionId: suggestion.id, + kind: suggestion.kind, + note: req.body.note ?? null, + }, + }); + res.json(suggestion); + }, + ); + router.post( "/issues/:id/documents/:key/suggestions/:suggestionId/accept", validate(acceptDocumentSuggestionSchema), diff --git a/server/src/services/document-review.ts b/server/src/services/document-review.ts index 68f7af82816..0623bb07aa3 100644 --- a/server/src/services/document-review.ts +++ b/server/src/services/document-review.ts @@ -32,6 +32,7 @@ import { type DocumentSuggestion, type DocumentSuggestionComment, type RejectDocumentSuggestion, + type ResolveDocumentSuggestion, type UpdateDocumentReviewThread, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; @@ -167,6 +168,9 @@ const suggestionSelect = { rejectedByAgentId: documentSuggestions.rejectedByAgentId, rejectedByUserId: documentSuggestions.rejectedByUserId, rejectedAt: documentSuggestions.rejectedAt, + resolvedByAgentId: documentSuggestions.resolvedByAgentId, + resolvedByUserId: documentSuggestions.resolvedByUserId, + resolvedAt: documentSuggestions.resolvedAt, createdAt: documentSuggestions.createdAt, updatedAt: documentSuggestions.updatedAt, }; @@ -365,6 +369,7 @@ export function documentReviewService(db: Db) { pendingSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number), acceptedSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number), rejectedSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number), + resolvedSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'resolved')`.mapWith(Number), staleAnchors: sql`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'stale')`.mapWith(Number), orphanedAnchors: sql`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'orphaned')`.mapWith(Number), }).from(documentSuggestions).where(and( @@ -387,6 +392,7 @@ export function documentReviewService(db: Db) { pendingSuggestions: 0, acceptedSuggestions: 0, rejectedSuggestions: 0, + resolvedSuggestions: 0, staleAnchors: 0, orphanedAnchors: 0, }; @@ -398,6 +404,7 @@ export function documentReviewService(db: Db) { pendingSuggestions: suggestionCounts.pendingSuggestions, acceptedSuggestions: suggestionCounts.acceptedSuggestions, rejectedSuggestions: suggestionCounts.rejectedSuggestions, + resolvedSuggestions: suggestionCounts.resolvedSuggestions, staleAnchors: annotationCounts.staleAnchors + suggestionCounts.staleAnchors, orphanedAnchors: annotationCounts.orphanedAnchors + suggestionCounts.orphanedAnchors, }; @@ -675,6 +682,32 @@ export function documentReviewService(db: Db) { return updated; }), + // "Resolve" = handled outside review / no longer applies. Distinct from + // reject (disagreement) so the audit trail keeps the two apart. Like reject, + // only a pending suggestion can be resolved. + resolveSuggestion: async ( + issueId: string, + key: string, + suggestionId: string, + _input: ResolveDocumentSuggestion, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const suggestion = await getSuggestionForIssue(issueId, key, suggestionId, tx); + if (!suggestion) throw notFound("Suggestion not found"); + if (suggestion.status !== "pending") { + throw conflict("Only pending suggestions can be resolved", { status: suggestion.status }); + } + const now = new Date(); + const [updated] = await tx.update(documentSuggestions).set({ + status: "resolved", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }).where(eq(documentSuggestions.id, suggestion.id)).returning(suggestionSelect); + return updated; + }), + acceptSuggestion: async ( issueId: string, key: string, diff --git a/ui/src/api/document-reviews.ts b/ui/src/api/document-reviews.ts index d51831215b0..9ef86dd1ec9 100644 --- a/ui/src/api/document-reviews.ts +++ b/ui/src/api/document-reviews.ts @@ -12,6 +12,7 @@ import type { DocumentSuggestion, DocumentSuggestionComment, RejectDocumentSuggestionRequest, + ResolveDocumentSuggestionRequest, } from "@paperclipai/shared"; import { api } from "./client"; @@ -112,4 +113,16 @@ export const documentReviewsApi = { `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/reject`, data, ), + // "Resolve" = handled outside review / no longer applies — a first-class status + // distinct from reject (disagreement) so the audit trail keeps the two apart. + resolveSuggestion: ( + issueId: string, + key: string, + suggestionId: string, + data: ResolveDocumentSuggestionRequest = {}, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/resolve`, + data, + ), }; diff --git a/ui/src/components/documents/DocumentReviewRail.test.tsx b/ui/src/components/documents/DocumentReviewRail.test.tsx index befc591e4a8..e489d0a64da 100644 --- a/ui/src/components/documents/DocumentReviewRail.test.tsx +++ b/ui/src/components/documents/DocumentReviewRail.test.tsx @@ -72,6 +72,9 @@ function makeSuggestion(overrides: Partial = {}) rejectedByAgentId: null, rejectedByUserId: null, rejectedAt: null, + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, createdAt: new Date("2026-06-01T00:00:00Z"), updatedAt: new Date("2026-06-01T00:00:00Z"), comments: [], @@ -95,6 +98,7 @@ function makeIndex(overrides: Partial = {}): DocumentReview resolvedReviewThreads: 0, acceptedSuggestions: 0, rejectedSuggestions: 0, + resolvedSuggestions: 0, staleAnchors: 0, orphanedAnchors: 0, }, @@ -291,6 +295,36 @@ describe("DocumentReviewRail", () => { expect(onDoneReviewing).not.toHaveBeenCalled(); }); + it("shows a platform-aware comment hotkey in the empty state", async () => { + const emptyIndex = makeIndex({ + counts: { ...makeIndex().counts, openAnchoredThreads: 0, openReviewThreads: 0, pendingSuggestions: 0 }, + annotationThreads: [], + reviewThreads: [], + suggestions: [], + }); + const original = Object.getOwnPropertyDescriptor(window.navigator, "platform"); + + // Windows/Linux reviewers should see the Ctrl spelling… + Object.defineProperty(window.navigator, "platform", { value: "Win32", configurable: true }); + await act(() => + root.render( + , + ), + ); + expect(container.querySelector('[data-testid="rail-empty"]')?.textContent).toContain("Ctrl+Shift+M"); + + // …and macOS reviewers the ⌘ spelling. + Object.defineProperty(window.navigator, "platform", { value: "MacIntel", configurable: true }); + await act(() => + root.render( + , + ), + ); + expect(container.querySelector('[data-testid="rail-empty"]')?.textContent).toContain("⌘⇧M"); + + if (original) Object.defineProperty(window.navigator, "platform", original); + }); + it("fires the Done reviewing handler when the viewer can finish review", async () => { const onDoneReviewing = vi.fn(); await act(() => diff --git a/ui/src/components/documents/DoneReviewingDialog.test.tsx b/ui/src/components/documents/DoneReviewingDialog.test.tsx index 838af7bb0af..cf60d126150 100644 --- a/ui/src/components/documents/DoneReviewingDialog.test.tsx +++ b/ui/src/components/documents/DoneReviewingDialog.test.tsx @@ -41,6 +41,7 @@ function counts(overrides: Partial = {}): DocumentRev resolvedReviewThreads: 0, acceptedSuggestions: 0, rejectedSuggestions: 0, + resolvedSuggestions: 0, staleAnchors: 1, orphanedAnchors: 4, ...overrides, diff --git a/ui/src/components/documents/SuggestionCard.test.tsx b/ui/src/components/documents/SuggestionCard.test.tsx index dd55f1c1380..c2788168476 100644 --- a/ui/src/components/documents/SuggestionCard.test.tsx +++ b/ui/src/components/documents/SuggestionCard.test.tsx @@ -69,6 +69,9 @@ function makeSuggestion(overrides: Partial = {}) rejectedByAgentId: null, rejectedByUserId: null, rejectedAt: null, + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, createdAt: new Date("2026-06-01T00:00:00Z"), updatedAt: new Date("2026-06-01T00:00:00Z"), comments: [], @@ -198,6 +201,27 @@ describe("SuggestionCard", () => { expect(container.querySelector('[data-testid="suggestion-resolve-sug-1"]')).toBeNull(); }); + it("shows a Resolved badge and no actions for a resolved suggestion", async () => { + await act(() => + root.render( + , + ), + ); + expect(container.querySelector('[data-testid="suggestion-resolved-sug-1"]')).not.toBeNull(); + expect(container.textContent).toContain("Resolved"); + // Terminal status: accept/reject/resolve actions are gone. + expect(container.querySelector('[data-testid="suggestion-accept-sug-1"]')).toBeNull(); + 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 9d45feae5af..0af31396eaf 100644 --- a/ui/src/components/documents/SuggestionCard.tsx +++ b/ui/src/components/documents/SuggestionCard.tsx @@ -150,7 +150,7 @@ export function SuggestionCard({ 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", + (suggestion.status === "rejected" || suggestion.status === "resolved") && "opacity-70", className, )} > @@ -186,6 +186,15 @@ export function SuggestionCard({ Rejected ) : null} + {suggestion.status === "resolved" ? ( + + + ) : null} {onViewDiff ? (