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:
DottaandPaperclip committed 2026-07-10 20:08:05 +00:00
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", () => {
"![bug.png](https://cdn.example/shot.png)",
);
});
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");
});
});