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

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

+53
View File
@@ -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", () => {
"![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");
});
});
@@ -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>