mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user