diff --git a/screenshots/pap-13249/s1-s2-pending_desktop_dark.png b/screenshots/pap-13249/s1-s2-pending_desktop_dark.png new file mode 100644 index 00000000000..e518a1c741c Binary files /dev/null and b/screenshots/pap-13249/s1-s2-pending_desktop_dark.png differ diff --git a/screenshots/pap-13249/s1-s2-pending_desktop_light.png b/screenshots/pap-13249/s1-s2-pending_desktop_light.png new file mode 100644 index 00000000000..817a85952e4 Binary files /dev/null and b/screenshots/pap-13249/s1-s2-pending_desktop_light.png differ diff --git a/screenshots/pap-13249/s1-s2-pending_mobile_dark.png b/screenshots/pap-13249/s1-s2-pending_mobile_dark.png new file mode 100644 index 00000000000..143a976cd62 Binary files /dev/null and b/screenshots/pap-13249/s1-s2-pending_mobile_dark.png differ diff --git a/screenshots/pap-13249/s1-s2-pending_mobile_light.png b/screenshots/pap-13249/s1-s2-pending_mobile_light.png new file mode 100644 index 00000000000..639cf3bbbaa Binary files /dev/null and b/screenshots/pap-13249/s1-s2-pending_mobile_light.png differ diff --git a/screenshots/pap-13249/s4-partial_desktop_dark.png b/screenshots/pap-13249/s4-partial_desktop_dark.png new file mode 100644 index 00000000000..523d772ffd6 Binary files /dev/null and b/screenshots/pap-13249/s4-partial_desktop_dark.png differ diff --git a/screenshots/pap-13249/s4-partial_desktop_light.png b/screenshots/pap-13249/s4-partial_desktop_light.png new file mode 100644 index 00000000000..e07dc119d24 Binary files /dev/null and b/screenshots/pap-13249/s4-partial_desktop_light.png differ diff --git a/screenshots/pap-13249/s4-partial_mobile_dark.png b/screenshots/pap-13249/s4-partial_mobile_dark.png new file mode 100644 index 00000000000..0e958271be9 Binary files /dev/null and b/screenshots/pap-13249/s4-partial_mobile_dark.png differ diff --git a/screenshots/pap-13249/s4-partial_mobile_light.png b/screenshots/pap-13249/s4-partial_mobile_light.png new file mode 100644 index 00000000000..3b01af2f8d4 Binary files /dev/null and b/screenshots/pap-13249/s4-partial_mobile_light.png differ diff --git a/screenshots/pap-13249/s5-complete_desktop_dark.png b/screenshots/pap-13249/s5-complete_desktop_dark.png new file mode 100644 index 00000000000..0b0eb5c4fad Binary files /dev/null and b/screenshots/pap-13249/s5-complete_desktop_dark.png differ diff --git a/screenshots/pap-13249/s5-complete_desktop_light.png b/screenshots/pap-13249/s5-complete_desktop_light.png new file mode 100644 index 00000000000..9ee24c74ef1 Binary files /dev/null and b/screenshots/pap-13249/s5-complete_desktop_light.png differ diff --git a/screenshots/pap-13249/s5-complete_mobile_dark.png b/screenshots/pap-13249/s5-complete_mobile_dark.png new file mode 100644 index 00000000000..c906eeda413 Binary files /dev/null and b/screenshots/pap-13249/s5-complete_mobile_dark.png differ diff --git a/screenshots/pap-13249/s5-complete_mobile_light.png b/screenshots/pap-13249/s5-complete_mobile_light.png new file mode 100644 index 00000000000..f129c4e6182 Binary files /dev/null and b/screenshots/pap-13249/s5-complete_mobile_light.png differ diff --git a/screenshots/pap-13249/s6-superseded_desktop_dark.png b/screenshots/pap-13249/s6-superseded_desktop_dark.png new file mode 100644 index 00000000000..a967627a764 Binary files /dev/null and b/screenshots/pap-13249/s6-superseded_desktop_dark.png differ diff --git a/screenshots/pap-13249/s6-superseded_desktop_light.png b/screenshots/pap-13249/s6-superseded_desktop_light.png new file mode 100644 index 00000000000..4623a9b56b7 Binary files /dev/null and b/screenshots/pap-13249/s6-superseded_desktop_light.png differ diff --git a/screenshots/pap-13249/s6-superseded_mobile_dark.png b/screenshots/pap-13249/s6-superseded_mobile_dark.png new file mode 100644 index 00000000000..672a195c558 Binary files /dev/null and b/screenshots/pap-13249/s6-superseded_mobile_dark.png differ diff --git a/screenshots/pap-13249/s6-superseded_mobile_light.png b/screenshots/pap-13249/s6-superseded_mobile_light.png new file mode 100644 index 00000000000..512202fe1d9 Binary files /dev/null and b/screenshots/pap-13249/s6-superseded_mobile_light.png differ diff --git a/screenshots/pap-13249/s7-many_desktop_dark.png b/screenshots/pap-13249/s7-many_desktop_dark.png new file mode 100644 index 00000000000..8342a3691c1 Binary files /dev/null and b/screenshots/pap-13249/s7-many_desktop_dark.png differ diff --git a/screenshots/pap-13249/s7-many_desktop_light.png b/screenshots/pap-13249/s7-many_desktop_light.png new file mode 100644 index 00000000000..0e844efa548 Binary files /dev/null and b/screenshots/pap-13249/s7-many_desktop_light.png differ diff --git a/screenshots/pap-13249/s7-many_mobile_dark.png b/screenshots/pap-13249/s7-many_mobile_dark.png new file mode 100644 index 00000000000..50bc9b42ef4 Binary files /dev/null and b/screenshots/pap-13249/s7-many_mobile_dark.png differ diff --git a/screenshots/pap-13249/s7-many_mobile_light.png b/screenshots/pap-13249/s7-many_mobile_light.png new file mode 100644 index 00000000000..95f56a9347d Binary files /dev/null and b/screenshots/pap-13249/s7-many_mobile_light.png differ diff --git a/scripts/screenshot-verdicts.mjs b/scripts/screenshot-verdicts.mjs new file mode 100644 index 00000000000..85c295b1ccc --- /dev/null +++ b/scripts/screenshot-verdicts.mjs @@ -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(); +} diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 7e5957b94c3..add03fe096a 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -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(`[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(`[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( + `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"); + }); }); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index bcfb2f998e5..50231ed9f17 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -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 ? ( diff --git a/ui/src/fixtures/issueThreadInteractionFixtures.ts b/ui/src/fixtures/issueThreadInteractionFixtures.ts index 6970f74e96a..ab4d5282821 100644 --- a/ui/src/fixtures/issueThreadInteractionFixtures.ts +++ b/ui/src/fixtures/issueThreadInteractionFixtures.ts @@ -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 { + 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", diff --git a/ui/src/lib/issue-thread-interactions.test.ts b/ui/src/lib/issue-thread-interactions.test.ts index 9aa4ace0eff..a573fdce075 100644 --- a/ui/src/lib/issue-thread-interactions.test.ts +++ b/ui/src/lib/issue-thread-interactions.test.ts @@ -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 { + 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"); + }); +}); diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx index b3910078a40..23c45f1c77b 100644 --- a/ui/storybook/stories/issue-thread-interactions.stories.tsx +++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx @@ -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(initial); + + return ( + + 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: () => ( + + + + + + ), +}; + +export const ItemVerdictsPartial: Story = { + render: () => ( + + + + + + ), +}; + +export const ItemVerdictsComplete: Story = { + render: () => ( + + + + + + ), +}; + +export const ItemVerdictsSuperseded: Story = { + render: () => ( + + + + + + ), +}; + +export const ItemVerdictsManyItems: Story = { + render: () => ( + + + + + + ), +}; + export const ReviewSurface: Story = { render: () => (