mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +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
@@ -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 $$;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof createDocumentReviewThreadSchema>;
|
||||
export type CreateDocumentReviewComment = z.infer<typeof createDocumentReviewCommentSchema>;
|
||||
export type UpdateDocumentReviewThread = z.infer<typeof updateDocumentReviewThreadSchema>;
|
||||
@@ -87,3 +91,4 @@ export type CreateDocumentSuggestion = z.infer<typeof createDocumentSuggestionSc
|
||||
export type CreateDocumentSuggestionComment = z.infer<typeof createDocumentSuggestionCommentSchema>;
|
||||
export type AcceptDocumentSuggestion = z.infer<typeof acceptDocumentSuggestionSchema>;
|
||||
export type RejectDocumentSuggestion = z.infer<typeof rejectDocumentSuggestionSchema>;
|
||||
export type ResolveDocumentSuggestion = z.infer<typeof resolveDocumentSuggestionSchema>;
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DocumentSuggestion>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/suggestions/${suggestionId}/resolve`,
|
||||
data,
|
||||
),
|
||||
};
|
||||
@@ -72,6 +72,9 @@ function makeSuggestion(overrides: Partial<DocumentSuggestionWithComments> = {})
|
||||
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<DocumentReviewIndex> = {}): 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(
|
||||
<DocumentReviewRail reviewIndex={emptyIndex} canReview canFinishReview latestRevisionId="rev-1" {...baseHandlers} />,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<DocumentReviewRail reviewIndex={emptyIndex} canReview canFinishReview latestRevisionId="rev-1" {...baseHandlers} />,
|
||||
),
|
||||
);
|
||||
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(() =>
|
||||
|
||||
@@ -41,6 +41,7 @@ function counts(overrides: Partial<DocumentReviewIndexCounts> = {}): DocumentRev
|
||||
resolvedReviewThreads: 0,
|
||||
acceptedSuggestions: 0,
|
||||
rejectedSuggestions: 0,
|
||||
resolvedSuggestions: 0,
|
||||
staleAnchors: 1,
|
||||
orphanedAnchors: 4,
|
||||
...overrides,
|
||||
|
||||
@@ -69,6 +69,9 @@ function makeSuggestion(overrides: Partial<DocumentSuggestionWithComments> = {})
|
||||
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(
|
||||
<SuggestionCard
|
||||
suggestion={makeSuggestion({ status: "resolved", resolvedAt: new Date() })}
|
||||
latestRevisionId="rev-1"
|
||||
canReview
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onResolve={vi.fn()}
|
||||
onReply={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
</Badge>
|
||||
) : null}
|
||||
{suggestion.status === "resolved" ? (
|
||||
<Badge
|
||||
data-testid={`suggestion-resolved-${suggestion.id}`}
|
||||
className="gap-1 border-transparent bg-muted px-1.5 py-0 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" aria-hidden="true" />
|
||||
Resolved
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{onViewDiff ? (
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,18 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort detection of an Apple platform (macOS / iOS) so hotkey hints can
|
||||
* show `⌘` instead of `Ctrl`. Prefers the modern UA-Client-Hints `platform`
|
||||
* field and falls back to `navigator.platform`. SSR-safe (returns false).
|
||||
*/
|
||||
export function isApplePlatform(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform;
|
||||
const platform = uaPlatform || navigator.platform || "";
|
||||
return /mac|iphone|ipad|ipod/i.test(platform);
|
||||
}
|
||||
|
||||
export function asObject(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
|
||||
@@ -137,6 +137,7 @@ const emptyIndex = {
|
||||
resolvedReviewThreads: 0,
|
||||
acceptedSuggestions: 0,
|
||||
rejectedSuggestions: 0,
|
||||
resolvedSuggestions: 0,
|
||||
staleAnchors: 0,
|
||||
orphanedAnchors: 0,
|
||||
},
|
||||
|
||||
@@ -413,16 +413,12 @@ export function DocumentDetail() {
|
||||
[binding, invalidateReview],
|
||||
);
|
||||
|
||||
// "Resolve" = handled outside / no longer applies. The suggestion data model
|
||||
// only has pending/accepted/rejected today, so we record it as a reject with a
|
||||
// clear non-disagreement reason for the audit trail. A first-class `resolved`
|
||||
// status is tracked as a follow-up (child of PAP-10524).
|
||||
// "Resolve" = handled outside / no longer applies. A first-class `resolved`
|
||||
// status (distinct from reject/disagreement) keeps the audit trail honest.
|
||||
const resolveSuggestion = useCallback(
|
||||
async (suggestion: DocumentSuggestionWithComments) => {
|
||||
if (!binding) return;
|
||||
await documentReviewsApi.rejectSuggestion(binding.issueId, binding.key, suggestion.id, {
|
||||
reason: "Resolved — handled outside review / no longer applies.",
|
||||
});
|
||||
await documentReviewsApi.resolveSuggestion(binding.issueId, binding.key, suggestion.id);
|
||||
invalidateReview();
|
||||
},
|
||||
[binding, invalidateReview],
|
||||
@@ -666,7 +662,9 @@ export function DocumentDetail() {
|
||||
type="button"
|
||||
data-testid="document-rail-fab"
|
||||
onClick={() => setRailOpen(true)}
|
||||
className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-popover px-4 py-2 text-sm shadow-lg"
|
||||
// Lift the FAB above the iOS home indicator (§5.8) so it clears the
|
||||
// safe-area inset and never occludes the last paragraph.
|
||||
className="fixed bottom-[max(env(safe-area-inset-bottom),1rem)] left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full border border-border bg-popover px-4 py-2 text-sm shadow-lg"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" aria-hidden="true" />
|
||||
{(reviewIndex?.counts.openAnchoredThreads ?? 0) + (reviewIndex?.counts.openReviewThreads ?? 0)}
|
||||
|
||||
@@ -66,6 +66,9 @@ function suggestion(overrides: Partial<DocumentSuggestionWithComments>): Documen
|
||||
rejectedByAgentId: null,
|
||||
rejectedByUserId: null,
|
||||
rejectedAt: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date(now - 2 * HOUR),
|
||||
updatedAt: new Date(now - 2 * HOUR),
|
||||
comments: [
|
||||
@@ -103,6 +106,7 @@ const reviewIndex: DocumentReviewIndex = {
|
||||
resolvedReviewThreads: 0,
|
||||
acceptedSuggestions: 0,
|
||||
rejectedSuggestions: 1,
|
||||
resolvedSuggestions: 0,
|
||||
staleAnchors: 1,
|
||||
orphanedAnchors: 1,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user