Add first-class resolved suggestion status + doc-review P3 polish (PAP-10574)

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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-06-07 02:47:20 +00:00
1 parent 2730223160
commit 0f0bb8e107
21 files changed
+271 -10

No files matched your search

@@ -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(
+47
View File
@@ -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),
+33
View File
@@ -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<number>`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number),
acceptedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number),
rejectedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number),
resolvedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'resolved')`.mapWith(Number),
staleAnchors: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'stale')`.mapWith(Number),
orphanedAnchors: sql<number>`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,