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>
No files matched your search
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 436 KiB |
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
// Screenshot the C3 per-item verdict card stories (PAP-13249) at both
|
||||
// viewports and both themes against a served storybook-static (see PAP-13249).
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const BASE = "http://localhost:6102/iframe.html";
|
||||
const PREFIX = "chat-comments-issue-thread-interactions";
|
||||
const OUT_DIR = process.argv[2] || "screenshots/pap-13249";
|
||||
|
||||
const stories = [
|
||||
{ id: "item-verdicts-pending", label: "s1-s2-pending" },
|
||||
{ id: "item-verdicts-partial", label: "s4-partial" },
|
||||
{ id: "item-verdicts-complete", label: "s5-complete" },
|
||||
{ id: "item-verdicts-superseded", label: "s6-superseded" },
|
||||
{ id: "item-verdicts-many-items", label: "s7-many" },
|
||||
];
|
||||
const viewports = [
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
];
|
||||
const themes = ["light", "dark"];
|
||||
|
||||
await fs.mkdir(path.resolve(OUT_DIR), { recursive: true });
|
||||
const executablePath = process.env.CHROME_BIN || undefined;
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
args: ["--no-sandbox", "--headless=new"],
|
||||
});
|
||||
try {
|
||||
for (const vp of viewports) {
|
||||
for (const theme of themes) {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: vp.width, height: vp.height },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
for (const story of stories) {
|
||||
const url = `${BASE}?id=${PREFIX}--${story.id}&viewMode=story&globals=theme:${theme}`;
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(800);
|
||||
const out = path.join(OUT_DIR, `${story.label}_${vp.name}_${theme}.png`);
|
||||
await page.screenshot({ path: out, fullPage: true });
|
||||
console.log(`Wrote ${out}`);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -18,9 +18,14 @@ import {
|
||||
issueThreadInteractionFixtureMeta,
|
||||
issueThreadInteractionLiveRuns,
|
||||
issueThreadInteractionTranscriptsByRunId,
|
||||
completeRequestItemVerdictsInteraction,
|
||||
manyItemsRequestItemVerdictsInteraction,
|
||||
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
mixedIssueThreadInteractions,
|
||||
optionalDeclineRequestConfirmationInteraction,
|
||||
partialRequestItemVerdictsInteraction,
|
||||
pendingRequestItemVerdictsInteraction,
|
||||
supersededRequestItemVerdictsInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
pendingRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
@@ -39,6 +44,9 @@ import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestItemVerdictsInteraction,
|
||||
RequestItemVerdictsResultItem,
|
||||
RequestItemVerdictValue,
|
||||
SuggestTasksInteraction,
|
||||
} from "@/lib/issue-thread-interactions";
|
||||
import { storybookAgentMap } from "../fixtures/paperclipData";
|
||||
@@ -241,6 +249,53 @@ function InteractiveRequestCheckboxConfirmationCard({
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveRequestItemVerdictsCard({
|
||||
initial = pendingRequestItemVerdictsInteraction,
|
||||
}: {
|
||||
initial?: RequestItemVerdictsInteraction;
|
||||
}) {
|
||||
const [interaction, setInteraction] = useState<RequestItemVerdictsInteraction>(initial);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onSubmitInteractionVerdicts={(_interaction, verdicts) =>
|
||||
setInteraction((current) => {
|
||||
const existing = current.result?.items ?? [];
|
||||
const existingIds = new Set(existing.map((item) => item.id));
|
||||
const merged: RequestItemVerdictsResultItem[] = [
|
||||
...existing,
|
||||
...verdicts
|
||||
.filter((verdict) => !existingIds.has(verdict.id))
|
||||
.map((verdict) => ({
|
||||
id: verdict.id,
|
||||
verdict: verdict.verdict as RequestItemVerdictValue,
|
||||
reason: verdict.reason ?? null,
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:20:00.000Z"),
|
||||
})),
|
||||
];
|
||||
const complete = merged.length === current.payload.items.length;
|
||||
return {
|
||||
...current,
|
||||
status: complete ? "answered" : "pending",
|
||||
resolvedAt: complete ? new Date("2026-04-20T15:20:00.000Z") : null,
|
||||
resolvedByUserId: complete ? issueThreadInteractionFixtureMeta.currentUserId : null,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "resolved",
|
||||
complete,
|
||||
items: merged,
|
||||
},
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoOpenDeclineRequestConfirmationCard({
|
||||
interaction,
|
||||
}: {
|
||||
@@ -715,6 +770,81 @@ export const CheckboxConfirmationManyOptions: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const ItemVerdictsPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="S1 / S2 — draft then apply"
|
||||
description="Mark each item Approve or Reject (reject reveals a required reason), then Apply N decisions in one pass. Approve all is the common-case accelerator."
|
||||
>
|
||||
<InteractiveRequestItemVerdictsCard />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ItemVerdictsPartial: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="S3 / S4 — partial progress"
|
||||
description="Two items already applied (one approved, one rejected with its reason echoed); three remain actionable. The card stays alive and shows 2 of 5 decided."
|
||||
>
|
||||
<InteractiveRequestItemVerdictsCard initial={partialRequestItemVerdictsInteraction} />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ItemVerdictsComplete: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="S5 — complete"
|
||||
description="Every item has a terminal verdict. The summary chip reads 5 decided · 3 approved · 2 rejected and the row leaves the queue."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={completeRequestItemVerdictsInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ItemVerdictsSuperseded: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="S6 — stale / superseded"
|
||||
description="A later comment expired the review. Items already applied cannot be reverted; the remaining items were cancelled."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={supersededRequestItemVerdictsInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ItemVerdictsManyItems: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="S7 — long list"
|
||||
description="24 items decided in passes; the expanded list scrolls in a bounded region and reuses the 200-item cap."
|
||||
>
|
||||
<InteractiveRequestItemVerdictsCard initial={manyItemsRequestItemVerdictsInteraction} />
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ReviewSurface: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
|
||||