mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Add fixtures, Storybook stories, tests + screenshots for C3 verdict card
- Fixtures: pending (S1/S2), partial (S3/S4), complete (S5), superseded (S6), many-items (S7) request_item_verdicts interactions. - Storybook: interactive draft->apply story + terminal-state stories under Chat & Comments/Issue Thread Interactions. - Tests: getItemVerdictProgress + buildItemVerdictsSummary (lib) and card render/apply/reason-gate/terminal/superseded (component). 40 related tests green. - scripts/screenshot-verdicts.mjs + screenshots/pap-13249 (S1/S2,S4,S5,S6,S7 at 1440x900 and 390x844, light + dark). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8d7fb594f2
commit
8804ee9bba
26 files changed
+562
-2
No files matched your search
@@ -15,7 +15,10 @@ import {
|
||||
failedRequestConfirmationInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
planApprovalResumeFailedRequestConfirmationInteraction,
|
||||
pendingRequestItemVerdictsInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
completeRequestItemVerdictsInteraction,
|
||||
supersededRequestItemVerdictsInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
rejectedSuggestedTasksInteraction,
|
||||
} from "../fixtures/issueThreadInteractionFixtures";
|
||||
@@ -498,4 +501,88 @@ describe("IssueThreadInteractionCard", () => {
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("submits an approve verdict once a draft is marked and applied", async () => {
|
||||
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestItemVerdictsInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
});
|
||||
|
||||
const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
|
||||
const approveButton = Array.from(
|
||||
host.querySelectorAll<HTMLButtonElement>(`[data-item-id="${firstItemId}"] button[data-verdict="approve"]`),
|
||||
)[0];
|
||||
expect(approveButton).toBeTruthy();
|
||||
// 44px minimum target (a11y).
|
||||
expect(approveButton?.className).toContain("min-h-11");
|
||||
|
||||
await act(async () => {
|
||||
approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Apply 1 decision"),
|
||||
);
|
||||
expect(applyButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubmitInteractionVerdicts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "request_item_verdicts" }),
|
||||
[{ id: firstItemId, verdict: "approve", reason: undefined }],
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks apply for a rejected item until a reason is entered", async () => {
|
||||
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestItemVerdictsInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
});
|
||||
|
||||
const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
|
||||
const rejectButton = Array.from(
|
||||
host.querySelectorAll<HTMLButtonElement>(`[data-item-id="${firstItemId}"] button[data-verdict="reject"]`),
|
||||
)[0];
|
||||
await act(async () => {
|
||||
rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Reject reveals a required reason field.
|
||||
const reasonField = host.querySelector<HTMLTextAreaElement>(
|
||||
`textarea[id="${pendingRequestItemVerdictsInteraction.id}-${firstItemId}-reason"]`,
|
||||
);
|
||||
expect(reasonField).toBeTruthy();
|
||||
|
||||
const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Apply 1 decision"),
|
||||
);
|
||||
// Attempting to apply without a reason does not submit.
|
||||
await act(async () => {
|
||||
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onSubmitInteractionVerdicts).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain("A reason is required to reject this item.");
|
||||
});
|
||||
|
||||
it("renders resolved verdicts as terminal chips with reason echo", () => {
|
||||
const host = renderCard({ interaction: completeRequestItemVerdictsInteraction });
|
||||
expect(host.textContent).toContain("Approved");
|
||||
expect(host.textContent).toContain("Rejected");
|
||||
expect(host.textContent).toContain("Tone is off-brand");
|
||||
// S5 summary chip.
|
||||
expect(host.textContent).toContain("3 approved");
|
||||
// No actionable verdict buttons once terminal.
|
||||
expect(host.querySelector("button[data-verdict]")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an already-applied, cannot-revert notice when superseded", () => {
|
||||
const host = renderCard({ interaction: supersededRequestItemVerdictsInteraction });
|
||||
expect(host.textContent).toContain("expired after a later comment");
|
||||
expect(host.textContent).toContain("cannot be");
|
||||
expect(host.textContent?.toLowerCase()).toContain("revert");
|
||||
});
|
||||
});
|
||||
@@ -2141,7 +2141,11 @@ function RequestItemVerdictsCard({
|
||||
.filter(([, draft]) => requireReasonOn.has(draft.verdict) && draft.reason.trim().length === 0)
|
||||
.map(([id]) => id),
|
||||
);
|
||||
const canApply = draftCount > 0 && invalidDraftIds.size === 0 && !working && Boolean(onSubmitInteractionVerdicts);
|
||||
// Apply is enabled as soon as there is ≥1 draft (spec §1). If a required
|
||||
// reject reason is missing, clicking Apply reveals the inline error instead
|
||||
// of silently submitting — the reason gates the actual submit (spec AC).
|
||||
const hasDrafts = draftCount > 0 && !working && Boolean(onSubmitInteractionVerdicts);
|
||||
const canApply = hasDrafts && invalidDraftIds.size === 0;
|
||||
|
||||
function toggleDraft(itemId: string, verdict: RequestItemVerdictValue) {
|
||||
setDrafts((current) => {
|
||||
@@ -2372,7 +2376,7 @@ function RequestItemVerdictsCard({
|
||||
size="sm"
|
||||
variant="default"
|
||||
aria-disabled={!canApply}
|
||||
disabled={!canApply}
|
||||
disabled={!hasDrafts}
|
||||
onClick={() => void handleApply()}
|
||||
>
|
||||
{working ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestItemVerdictsInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
|
||||
@@ -697,6 +698,215 @@ export const staleTargetRequestCheckboxConfirmationInteraction =
|
||||
},
|
||||
});
|
||||
|
||||
// --- Per-item verdicts (C3, PAP-13249) ---------------------------------
|
||||
|
||||
function createRequestItemVerdictsInteraction(
|
||||
overrides: Partial<RequestItemVerdictsInteraction>,
|
||||
): RequestItemVerdictsInteraction {
|
||||
return {
|
||||
id: "interaction-verdicts-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "request_item_verdicts",
|
||||
title: "Review 5 blog posts",
|
||||
summary:
|
||||
"This task drafted five blog posts. Approve the ones that are ready and reject the rest with a reason — each decision fans out on its own.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-20T15:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:02:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review the 5 blog posts this task drafted.",
|
||||
detailsMarkdown:
|
||||
"Each approved post publishes immediately; rejected posts go back for a revision pass with your reason attached.",
|
||||
items: [
|
||||
{
|
||||
id: "post-spring-recap",
|
||||
label: "Spring launch recap",
|
||||
description: "820 words · product marketing",
|
||||
previewMarkdown: "**Spring launch recap** — a warm retrospective on the Q1 launch and what shipped.",
|
||||
href: "/PAP/issues/PAP-9001",
|
||||
},
|
||||
{
|
||||
id: "post-changelog-digest",
|
||||
label: "Monthly changelog digest",
|
||||
description: "540 words · engineering",
|
||||
previewMarkdown: "A tidy digest of the month's shipped changes, grouped by area.",
|
||||
href: "/PAP/issues/PAP-9002",
|
||||
},
|
||||
{
|
||||
id: "post-founder-note",
|
||||
label: "Founder's note on reliability",
|
||||
description: "1,100 words · leadership",
|
||||
previewMarkdown: "A candid note on the reliability push and the road ahead.",
|
||||
href: "/PAP/issues/PAP-9003",
|
||||
},
|
||||
{
|
||||
id: "post-customer-story",
|
||||
label: "Customer story: Northwind",
|
||||
description: "760 words · customer marketing",
|
||||
previewMarkdown: "How Northwind cut review time in half with the new workflow.",
|
||||
href: "/PAP/issues/PAP-9004",
|
||||
},
|
||||
{
|
||||
id: "post-hiring-push",
|
||||
label: "We're hiring: platform engineers",
|
||||
description: "420 words · recruiting",
|
||||
previewMarkdown: "An open call for platform engineers to join the team.",
|
||||
href: "/PAP/issues/PAP-9005",
|
||||
},
|
||||
],
|
||||
verdicts: ["approve", "reject"],
|
||||
requireReasonOn: ["reject"],
|
||||
reasonLabel: "Why reject?",
|
||||
allowBulkApprove: true,
|
||||
supersedeOnUserComment: true,
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** S1 — expanded, all pending. */
|
||||
export const pendingRequestItemVerdictsInteraction = createRequestItemVerdictsInteraction({});
|
||||
|
||||
/** S3/S4 — partial: two items applied (one approved, one rejected), three still actionable. */
|
||||
export const partialRequestItemVerdictsInteraction = createRequestItemVerdictsInteraction({
|
||||
id: "interaction-verdicts-partial",
|
||||
updatedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: false,
|
||||
items: [
|
||||
{
|
||||
id: "post-spring-recap",
|
||||
verdict: "approve",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "post-changelog-digest",
|
||||
verdict: "reject",
|
||||
reason: "Tone is off-brand — too dry. Warm it up and re-submit.",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/** S5 — complete: every item has a terminal verdict. */
|
||||
export const completeRequestItemVerdictsInteraction = createRequestItemVerdictsInteraction({
|
||||
id: "interaction-verdicts-complete",
|
||||
status: "answered",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:14:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:14:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: true,
|
||||
items: [
|
||||
{
|
||||
id: "post-spring-recap",
|
||||
verdict: "approve",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "post-changelog-digest",
|
||||
verdict: "reject",
|
||||
reason: "Tone is off-brand — too dry. Warm it up and re-submit.",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "post-founder-note",
|
||||
verdict: "approve",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:14:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "post-customer-story",
|
||||
verdict: "approve",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:14:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "post-hiring-push",
|
||||
verdict: "reject",
|
||||
reason: "Hold the recruiting post until the req is approved.",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:14:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/** S6 — superseded by a later comment after two items were already applied. */
|
||||
export const supersededRequestItemVerdictsInteraction = createRequestItemVerdictsInteraction({
|
||||
id: "interaction-verdicts-superseded",
|
||||
status: "expired",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:16:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:16:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
complete: false,
|
||||
commentId: "33333333-3333-4333-8333-333333333333",
|
||||
items: [
|
||||
{
|
||||
id: "post-spring-recap",
|
||||
verdict: "approve",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "post-changelog-digest",
|
||||
verdict: "reject",
|
||||
reason: "Tone is off-brand — too dry. Warm it up and re-submit.",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:08:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/** S7 — long list (24 items) that virtualizes/paginates in the expanded view. */
|
||||
const manyVerdictItems = Array.from({ length: 24 }, (_, index) => {
|
||||
const number = index + 1;
|
||||
return {
|
||||
id: `draft-post-${number}`,
|
||||
label: `Draft post #${number}`,
|
||||
description: `${300 + number * 17} words · auto-generated series`,
|
||||
};
|
||||
});
|
||||
|
||||
export const manyItemsRequestItemVerdictsInteraction = createRequestItemVerdictsInteraction({
|
||||
id: "interaction-verdicts-many",
|
||||
title: "Review 24 generated posts",
|
||||
summary: "A batch-generation task produced 24 posts. Decide them in passes; the card stays until all are decided.",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review the 24 posts this batch produced.",
|
||||
detailsMarkdown: "The expanded list scrolls. Approve all to accept the batch, or decide item by item.",
|
||||
items: manyVerdictItems,
|
||||
verdicts: ["approve", "reject"],
|
||||
requireReasonOn: ["reject"],
|
||||
reasonLabel: "Why reject?",
|
||||
allowBulkApprove: true,
|
||||
supersedeOnUserComment: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const issueThreadInteractionComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-thread-board",
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
collectSuggestedTaskClientKeys,
|
||||
countSuggestedTaskNodes,
|
||||
getCheckboxConfirmationSelectedLabels,
|
||||
getItemVerdictProgress,
|
||||
getRequestConfirmationTargetHref,
|
||||
getQuestionAnswerLabels,
|
||||
normalizeRequestConfirmationTargetHref,
|
||||
} from "./issue-thread-interactions";
|
||||
import type { RequestItemVerdictsInteraction } from "./issue-thread-interactions";
|
||||
|
||||
describe("buildSuggestedTaskTree", () => {
|
||||
it("preserves parent-child relationships from client keys", () => {
|
||||
@@ -284,3 +286,77 @@ describe("issue thread interaction helpers", () => {
|
||||
expect(labels).toEqual(["Option 2", "Option 1", "Other: A written answer"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-item verdict helpers", () => {
|
||||
function verdictInteraction(
|
||||
overrides: Partial<RequestItemVerdictsInteraction> = {},
|
||||
): RequestItemVerdictsInteraction {
|
||||
return {
|
||||
id: "interaction-verdicts",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "request_item_verdicts",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
updatedAt: "2026-04-06T12:00:00.000Z",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Review the posts.",
|
||||
items: [
|
||||
{ id: "a", label: "A" },
|
||||
{ id: "b", label: "B" },
|
||||
{ id: "c", label: "C" },
|
||||
],
|
||||
verdicts: ["approve", "reject"],
|
||||
requireReasonOn: ["reject"],
|
||||
},
|
||||
...overrides,
|
||||
} as RequestItemVerdictsInteraction;
|
||||
}
|
||||
|
||||
it("counts decided items and lists still-pending ids in payload order", () => {
|
||||
const progress = getItemVerdictProgress({
|
||||
payload: verdictInteraction().payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: false,
|
||||
items: [
|
||||
{ id: "a", verdict: "approve", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" },
|
||||
{ id: "c", verdict: "reject", reason: "no", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(progress).toMatchObject({ total: 3, decided: 2, approved: 1, rejected: 1, deferred: 0 });
|
||||
expect(progress.pendingItemIds).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("summarizes pending, complete, and superseded verdict cards", () => {
|
||||
expect(buildIssueThreadInteractionSummary(verdictInteraction())).toBe("0 of 3 decided");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary(verdictInteraction({
|
||||
status: "answered",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete: true,
|
||||
items: [
|
||||
{ id: "a", verdict: "approve", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" },
|
||||
{ id: "b", verdict: "approve", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" },
|
||||
{ id: "c", verdict: "reject", reason: "no", resolvedByUserId: "u", resolvedAt: "2026-04-06T12:01:00.000Z" },
|
||||
],
|
||||
},
|
||||
}))).toBe("3 decided · 2 approved · 1 rejected");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary(verdictInteraction({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
complete: false,
|
||||
items: [],
|
||||
},
|
||||
}))).toBe("Verdicts expired after comment");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user