Add checkbox confirmation issue-thread UI

Build the RequestCheckboxConfirmationCard for the request_checkbox_confirmation
interaction kind (PAP-10433), wiring it into the existing IssueThreadInteractionCard
shell and IssueDetail accept/reject flow.

- Dense Paperclip checkbox rows with aligned labels/descriptions, selected count,
  frontend-owned Select all / Clear selection, and a bounded scroll region.
- Accept submits selectedOptionIds (not suggest-task selectedClientKeys).
- min/max selection validation with disabled-at-max rows and a validation message.
- Resolved states: accepted summarizes by count first and bounds inline chips
  (+N more); rejected/request-changes, stale-target, and failed reuse the shared
  confirmation resolution blocks.
- Refactor request-confirmation target chip/href helpers to be issueId-based so
  the checkbox card can reuse staleness display.
- Fixtures, Storybook states (pending, bounded, accepted, accepted-many, rejected,
  stale, ~100 options), and focused UI/lib tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-06 13:17:48 +00:00
1 parent 356adb443c
commit a24117bb5a
9 files changed
+1181 -69

No files matched your search

+1 -1
View File
@@ -213,7 +213,7 @@ export const issuesApi = {
acceptInteraction: (
id: string,
interactionId: string,
data?: { selectedClientKeys?: string[] },
data?: { selectedClientKeys?: string[]; selectedOptionIds?: string[] },
) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
+19 -4
View File
@@ -59,6 +59,7 @@ import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
@@ -161,11 +162,18 @@ interface IssueChatMessageContext {
onDeleteComment?: (commentId: string) => Promise<void> | void;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -361,11 +369,18 @@ interface IssueChatThreadProps {
stoppingRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import {
acceptedManyRequestCheckboxConfirmationInteraction,
boundedRequestCheckboxConfirmationInteraction,
pendingAskUserQuestionsInteraction,
commentExpiredRequestConfirmationInteraction,
disabledDeclineReasonRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
manyOptionsRequestCheckboxConfirmationInteraction,
pendingRequestCheckboxConfirmationInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
staleTargetRequestCheckboxConfirmationInteraction,
staleTargetRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
@@ -338,4 +343,138 @@ describe("IssueThreadInteractionCard", () => {
"This request could not be resolved. Try again or create a new request.",
);
});
it("exposes pending checkbox options with select-all and clear controls", () => {
const host = renderCard({
interaction: pendingRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
expect(checkboxes).toHaveLength(
pendingRequestCheckboxConfirmationInteraction.payload.options.length,
);
expect(checkboxes[0]?.getAttribute("aria-checked")).toBe("false");
expect(host.textContent).toContain("0 of 4 options selected");
const selectAll = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent === "Select all",
);
act(() => {
selectAll?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("All 4 options selected");
const clear = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent === "Clear selection",
);
act(() => {
clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("0 of 4 options selected");
});
it("submits selected option ids on accept", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestCheckboxConfirmationInteraction,
onAcceptInteraction,
});
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
await act(async () => {
(checkboxes[0] as HTMLButtonElement).click();
(checkboxes[2] as HTMLButtonElement).click();
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Delete selected"),
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_checkbox_confirmation" }),
undefined,
["draft-march-report", "draft-scratch-notes"],
);
});
it("blocks accept until the minimum selection is met", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: {
...boundedRequestCheckboxConfirmationInteraction,
payload: {
...boundedRequestCheckboxConfirmationInteraction.payload,
defaultSelectedOptionIds: [],
},
},
onAcceptInteraction,
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Confirm regions"),
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).not.toHaveBeenCalled();
expect(host.textContent).toContain("Select at least 2 options.");
});
it("disables remaining checkboxes once the max selection is reached", () => {
const host = renderCard({
interaction: boundedRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
// Defaults select us-west + us-east; bumping to the 3-item max should lock the rest.
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[];
const unchecked = checkboxes.filter((box) => box.getAttribute("aria-checked") === "false");
act(() => {
unchecked[0]?.click();
});
const stillUnchecked = ([...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[])
.filter((box) => box.getAttribute("aria-checked") === "false");
expect(stillUnchecked.length).toBeGreaterThan(0);
expect(stillUnchecked.every((box) => box.hasAttribute("disabled"))).toBe(true);
});
it("summarizes large accepted selections by count and bounds the chips", () => {
const host = renderCard({
interaction: acceptedManyRequestCheckboxConfirmationInteraction,
});
expect(host.textContent).toContain("Confirmed 42 of 100 options");
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
// 42 selected, but only the first 8 labels render inline, then a "+N more" chip.
expect(host.textContent).toContain("+34 more");
});
it("stays compact and scrollable with around 100 options", () => {
const host = renderCard({
interaction: manyOptionsRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(100);
const scrollRegion = host.querySelector('[aria-label="Selectable options"]');
expect(scrollRegion?.className).toContain("max-h-80");
expect(scrollRegion?.className).toContain("overflow-y-auto");
});
it("renders stale-target expiry for checkbox confirmations", () => {
const host = renderCard({
interaction: staleTargetRequestCheckboxConfirmationInteraction,
});
expect(host.textContent).toContain("Expired by target change");
expect(host.textContent).toContain("Plan v3");
expect(host.textContent).toContain("Plan v4");
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
});
});
+535 -60
View File
@@ -7,11 +7,14 @@ import {
buildSuggestedTaskTree,
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getQuestionAnswerLabels,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type RequestConfirmationResult,
type RequestConfirmationTarget,
type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask,
@@ -34,11 +37,18 @@ interface IssueThreadInteractionCardProps {
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -96,6 +106,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
return "Ask user questions";
case "request_confirmation":
return "Confirmation";
case "request_checkbox_confirmation":
return "Checkbox confirmation";
default:
return kind;
}
@@ -972,32 +984,32 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) {
}
function requestConfirmationTargetHref({
interaction,
issueId,
target,
}: {
interaction: RequestConfirmationInteraction;
issueId: string;
target: RequestConfirmationTarget;
}) {
if (target.href) return target.href;
if (target.type === "issue_document") {
const issueId = target.issueId ?? interaction.issueId;
return `/issues/${issueId}#document-${encodeURIComponent(target.key)}`;
const targetIssueId = target.issueId ?? issueId;
return `/issues/${targetIssueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
function RequestConfirmationTargetChip({
interaction,
issueId,
target,
tone = "default",
}: {
interaction: RequestConfirmationInteraction;
issueId: string;
target: RequestConfirmationTarget | null | undefined;
tone?: "default" | "subtle";
}) {
if (!target) return null;
const href = requestConfirmationTargetHref({ interaction, target });
const href = requestConfirmationTargetHref({ issueId, target });
const className = cn(
"inline-flex max-w-full items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]",
tone === "default"
@@ -1027,81 +1039,121 @@ function RequestConfirmationTargetChip({
);
}
function ConfirmationDeclinedBlock({
issueId,
result,
target,
}: {
issueId: string;
result: RequestConfirmationResult | null | undefined;
target: RequestConfirmationTarget | null;
}) {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Declined</span>
<RequestConfirmationTargetChip issueId={issueId} target={target} />
</div>
{result?.reason ? (
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
{result.reason}
</blockquote>
) : null}
</div>
);
}
function ConfirmationExpiredBlock({
issueId,
result,
target,
}: {
issueId: string;
result: RequestConfirmationResult | null | undefined;
target: RequestConfirmationTarget | null;
}) {
const outcome = result?.outcome;
const staleTarget = result?.staleTarget ?? null;
const expiredByComment = outcome === "superseded_by_comment";
const expiredByTargetChange = outcome === "stale_target";
return (
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
{expiredByComment ? "Expired by comment" : "Expired by target change"}
</div>
<p className="leading-6">
{expiredByComment
? "A board comment superseded this request before it was resolved."
: "The requested target changed before this request was resolved."}
</p>
{expiredByComment && result?.commentId ? (
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
<a href={`#comment-${result.commentId}`}>Jump to comment</a>
</Button>
) : null}
{expiredByTargetChange ? (
<div className="flex flex-wrap items-center gap-2">
<RequestConfirmationTargetChip
issueId={issueId}
target={staleTarget}
tone="subtle"
/>
{staleTarget && target ? (
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
) : null}
<RequestConfirmationTargetChip issueId={issueId} target={target} />
</div>
) : null}
</div>
);
}
function ConfirmationFailedBlock() {
return (
<p className="text-sm leading-6 text-muted-foreground">
This request could not be resolved. Try again or create a new request.
</p>
);
}
function RequestConfirmationResolution({
interaction,
}: {
interaction: RequestConfirmationInteraction;
}) {
const outcome = interaction.result?.outcome;
const target = interaction.payload.target ?? null;
const staleTarget = interaction.result?.staleTarget ?? null;
if (interaction.status === "accepted") {
return (
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Confirmed</span>
<RequestConfirmationTargetChip interaction={interaction} target={target} />
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
</div>
);
}
if (interaction.status === "rejected") {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Declined</span>
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
{interaction.result?.reason ? (
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
{interaction.result.reason}
</blockquote>
) : null}
</div>
<ConfirmationDeclinedBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "expired") {
const expiredByComment = outcome === "superseded_by_comment";
const expiredByTargetChange = outcome === "stale_target";
return (
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
{expiredByComment ? "Expired by comment" : "Expired by target change"}
</div>
<p className="leading-6">
{expiredByComment
? "A board comment superseded this confirmation before it was resolved."
: "The requested target changed before this confirmation was resolved."}
</p>
{expiredByComment && interaction.result?.commentId ? (
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
<a href={`#comment-${interaction.result.commentId}`}>Jump to comment</a>
</Button>
) : null}
{expiredByTargetChange ? (
<div className="flex flex-wrap items-center gap-2">
<RequestConfirmationTargetChip
interaction={interaction}
target={staleTarget}
tone="subtle"
/>
{staleTarget && target ? (
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
) : null}
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
) : null}
</div>
<ConfirmationExpiredBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "failed") {
return (
<p className="text-sm leading-6 text-muted-foreground">
This request could not be resolved. Try again or create a new request.
</p>
);
return <ConfirmationFailedBlock />;
}
return null;
@@ -1188,7 +1240,7 @@ function RequestConfirmationCard({
</div>
) : null}
<RequestConfirmationTargetChip
interaction={interaction}
issueId={interaction.issueId}
target={interaction.payload.target}
/>
</div>
@@ -1289,6 +1341,421 @@ function RequestConfirmationCard({
);
}
const CHECKBOX_SUMMARY_LABEL_LIMIT = 8;
function RequestCheckboxConfirmationResolution({
interaction,
}: {
interaction: RequestCheckboxConfirmationInteraction;
}) {
const target = interaction.payload.target ?? null;
if (interaction.status === "accepted") {
const totalOptions = interaction.payload.options.length;
const selectedLabels = getCheckboxConfirmationSelectedLabels({
payload: interaction.payload,
result: interaction.result,
});
const selectedCount = interaction.result?.selectedOptionIds?.length ?? selectedLabels.length;
const visibleLabels = selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT);
const hiddenCount = selectedLabels.length - visibleLabels.length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">
{selectedCount === 0
? "Confirmed with no options selected"
: `Confirmed ${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"}`}
</span>
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
</div>
{visibleLabels.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{visibleLabels.map((label, index) => (
<TaskField key={`${label}-${index}`} label="Selected" value={label} />
))}
{hiddenCount > 0 ? (
<span className="inline-flex items-center rounded-sm border border-border/60 bg-transparent px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
+{hiddenCount} more
</span>
) : null}
</div>
) : null}
</div>
);
}
if (interaction.status === "rejected") {
return (
<ConfirmationDeclinedBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "expired") {
return (
<ConfirmationExpiredBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "failed") {
return <ConfirmationFailedBlock />;
}
return null;
}
function CheckboxOptionRow({
id,
label,
description,
checked,
disabled,
onToggle,
}: {
id: string;
label: string;
description?: string | null;
checked: boolean;
disabled: boolean;
onToggle: (checked: boolean) => void;
}) {
return (
<label
htmlFor={id}
className={cn(
"flex cursor-pointer items-start gap-2.5 border-b border-border/60 px-3 py-2 last:border-b-0 transition-colors",
checked ? "bg-sky-500/10" : "hover:bg-sky-500/5",
disabled && "cursor-not-allowed opacity-60",
)}
>
<Checkbox
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={(value) => onToggle(value === true)}
aria-label={label}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-5 text-foreground">{label}</div>
{description ? (
<p className="mt-0.5 text-sm leading-5 text-muted-foreground">{description}</p>
) : null}
</div>
</label>
);
}
function RequestCheckboxConfirmationCard({
interaction,
onAcceptInteraction,
onRejectInteraction,
}: {
interaction: RequestCheckboxConfirmationInteraction;
onAcceptInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
selectedClientKeys: undefined,
selectedOptionIds: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
}) {
const options = interaction.payload.options;
const optionIds = useMemo(() => options.map((option) => option.id), [options]);
const validOptionIds = useMemo(() => new Set(optionIds), [optionIds]);
const minSelected = interaction.payload.minSelected ?? 0;
const maxSelected = interaction.payload.maxSelected ?? null;
const defaultSelected = useMemo(
() =>
new Set(
(interaction.payload.defaultSelectedOptionIds ?? []).filter((id) => validOptionIds.has(id)),
),
[interaction.payload.defaultSelectedOptionIds, validOptionIds],
);
const [selectedOptionIds, setSelectedOptionIds] = useState<Set<string>>(() => new Set(defaultSelected));
const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
const [rejectAttempted, setRejectAttempted] = useState(false);
const [acceptAttempted, setAcceptAttempted] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const optionSeed = useMemo(() => optionIds.join("\n"), [optionIds]);
useEffect(() => {
setSelectedOptionIds(new Set(defaultSelected));
setRejectReason(interaction.result?.reason ?? "");
setRejectAttempted(false);
setAcceptAttempted(false);
setActionError(null);
if (interaction.status !== "pending") {
setRejecting(false);
setWorking(null);
}
// optionSeed guards against option list identity churn for the same interaction.
}, [interaction.id, interaction.status, interaction.result?.reason, defaultSelected, optionSeed]);
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
const trimmedRejectReason = rejectReason.trim();
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0;
const declineReasonInvalid = rejectRequiresReason && !canReject;
const declineReasonPlaceholder =
interaction.payload.declineReasonPlaceholder ?? "Optional: tell the agent what you'd change.";
const selectedCount = selectedOptionIds.size;
const totalOptions = options.length;
const atMax = maxSelected != null && selectedCount >= maxSelected;
const belowMin = selectedCount < minSelected;
const aboveMax = maxSelected != null && selectedCount > maxSelected;
const selectionValid = !belowMin && !aboveMax;
const validationMessage = belowMin
? minSelected === 1
? "Select at least 1 option."
: `Select at least ${minSelected} options.`
: aboveMax && maxSelected != null
? maxSelected === 1
? "Select at most 1 option."
: `Select at most ${maxSelected} options.`
: null;
function toggleOption(optionId: string, checked: boolean) {
setSelectedOptionIds((current) => {
const next = new Set(current);
if (checked) {
next.add(optionId);
} else {
next.delete(optionId);
}
return next;
});
}
function handleSelectAll() {
const capped = maxSelected != null ? optionIds.slice(0, maxSelected) : optionIds;
setSelectedOptionIds(new Set(capped));
}
function handleClearSelection() {
setSelectedOptionIds(new Set());
}
async function handleAccept() {
setAcceptAttempted(true);
if (!onAcceptInteraction || !selectionValid) return;
setWorking("accept");
setActionError(null);
try {
await onAcceptInteraction(interaction, undefined, [...selectedOptionIds]);
} catch {
setActionError("Try again");
} finally {
setWorking(null);
}
}
async function handleReject() {
setRejectAttempted(true);
if (!onRejectInteraction || !canReject) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, trimmedRejectReason || undefined);
setRejecting(false);
} catch {
setActionError("Try again");
} finally {
setWorking(null);
}
}
if (interaction.status !== "pending") {
return (
<div className="space-y-4">
<RequestCheckboxConfirmationResolution interaction={interaction} />
</div>
);
}
const selectionSummary = totalOptions > 0 && selectedCount === totalOptions
? `All ${totalOptions} options selected`
: `${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"} selected`;
const boundsHint = maxSelected != null
? `Pick ${minSelected === maxSelected ? `exactly ${maxSelected}` : `${minSelected}–${maxSelected}`}.`
: minSelected > 0
? `Pick at least ${minSelected}.`
: null;
return (
<div className="space-y-4">
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
{interaction.payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
</div>
) : null}
<RequestConfirmationTargetChip
issueId={interaction.issueId}
target={interaction.payload.target}
/>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{selectionSummary}</span>
{boundsHint ? <span>{boundsHint}</span> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null || selectedCount === totalOptions}
onClick={handleSelectAll}
>
Select all
</Button>
<Button
size="sm"
variant="ghost"
disabled={working !== null || selectedCount === 0}
onClick={handleClearSelection}
>
Clear selection
</Button>
</div>
</div>
<div
role="group"
aria-label="Selectable options"
className="max-h-80 overflow-y-auto rounded-sm border border-border/70"
>
{options.map((option) => {
const checked = selectedOptionIds.has(option.id);
return (
<CheckboxOptionRow
key={option.id}
id={`${interaction.id}-${option.id}`}
label={option.label}
description={option.description}
checked={checked}
disabled={working !== null || (!checked && atMax)}
onToggle={(value) => toggleOption(option.id, value)}
/>
);
})}
</div>
{acceptAttempted && validationMessage ? (
<p className="text-xs text-destructive">{validationMessage}</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant={rejecting ? "outline" : "default"}
disabled={!onAcceptInteraction || working !== null}
onClick={() => void handleAccept()}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Confirming...
</>
) : (
interaction.payload.acceptLabel ?? "Confirm selected"
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => {
if (!allowDeclineReason) {
void handleReject();
return;
}
setRejectAttempted(false);
setRejecting((current) => !current);
}}
>
{interaction.payload.rejectLabel ?? "Request changes"}
</Button>
</div>
{rejecting ? (
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
<Textarea
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder={declineReasonPlaceholder}
aria-invalid={rejectAttempted && declineReasonInvalid}
className={cn(
"min-h-24 bg-background text-sm",
rejectAttempted && declineReasonInvalid
&& "border-rose-500 focus-visible:ring-rose-500/25",
)}
/>
{rejectAttempted && declineReasonInvalid ? (
<p className="text-xs text-destructive">A reason is required.</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => {
setRejecting(false);
setRejectAttempted(false);
}}
>
Cancel
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => void handleReject()}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
interaction.payload.rejectLabel ?? "Request changes"
)}
</Button>
</div>
</div>
) : null}
{actionError ? (
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
</div>
</div>
);
}
export function IssueThreadInteractionCard({
interaction,
agentMap,
@@ -1347,7 +1814,9 @@ export function IssueThreadInteractionCard({
? "Suggested task tree"
: interaction.kind === "ask_user_questions"
? interaction.payload.title ?? "Questions for the operator"
: "Confirmation requested")}
: interaction.kind === "request_checkbox_confirmation"
? "Checkbox confirmation requested"
: "Confirmation requested")}
</div>
{interaction.summary ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
@@ -1385,6 +1854,12 @@ export function IssueThreadInteractionCard({
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
) : interaction.kind === "request_checkbox_confirmation" ? (
<RequestCheckboxConfirmationCard
interaction={interaction}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : (
<RequestConfirmationCard
interaction={interaction}
@@ -6,6 +6,7 @@ import type {
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
import type {
AskUserQuestionsInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
@@ -224,6 +225,65 @@ function createRequestConfirmationInteraction(
};
}
function createRequestCheckboxConfirmationInteraction(
overrides: Partial<RequestCheckboxConfirmationInteraction>,
): RequestCheckboxConfirmationInteraction {
return {
id: "interaction-checkbox-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "request_checkbox_confirmation",
title: "Choose the stale drafts to delete",
summary:
"The agent found several stale draft documents and needs the board to confirm exactly which ones to remove.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-codex",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-20T14:46:00.000Z"),
updatedAt: new Date("2026-04-20T14:46:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Check the draft documents you want me to delete.",
detailsMarkdown:
"Only the checked items will be deleted. Leave an item unchecked to keep it for now.",
options: [
{
id: "draft-march-report",
label: "Old draft report",
description: "Created by QA during the March test pass.",
},
{
id: "draft-spec-v1",
label: "Spec v1 (superseded)",
description: "Replaced by the approved v2 specification.",
},
{
id: "draft-scratch-notes",
label: "Scratch notes",
description: "Unstructured notes from the kickoff call.",
},
{
id: "draft-import-sample",
label: "Import sample fixture",
description: "Temporary fixture used while wiring the importer.",
},
],
defaultSelectedOptionIds: [],
minSelected: 0,
maxSelected: null,
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
rejectRequiresReason: false,
},
result: null,
...overrides,
};
}
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
@@ -465,6 +525,145 @@ export const failedRequestConfirmationInteraction = createRequestConfirmationInt
updatedAt: new Date("2026-04-20T14:42:00.000Z"),
});
export const pendingRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({});
export const boundedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-bounded",
title: "Pick the regions to deploy first",
summary: "Choose between two and three regions for the Phase 1 rollout.",
payload: {
version: 1,
prompt: "Which regions should we deploy to initially?",
detailsMarkdown: "Select at least 2 and at most 3 regions.",
options: [
{ id: "us-west", label: "US West (Oregon)", description: "Lowest latency for our primary user base." },
{ id: "us-east", label: "US East (Virginia)", description: "Redundancy and east coast coverage." },
{ id: "eu-west", label: "EU West (Ireland)", description: "GDPR compliance and European users." },
{ id: "ap-southeast", label: "AP Southeast (Singapore)", description: "Asia-Pacific expansion." },
],
defaultSelectedOptionIds: ["us-west", "us-east"],
minSelected: 2,
maxSelected: 3,
acceptLabel: "Confirm regions",
rejectLabel: "Reconsider",
rejectRequiresReason: true,
rejectReasonLabel: "What should change about the region set?",
},
});
export const acceptedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-accepted",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:49:00.000Z"),
updatedAt: new Date("2026-04-20T14:49:00.000Z"),
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["draft-march-report", "draft-spec-v1"],
},
});
const manyOptionList = Array.from({ length: 100 }, (_, index) => {
const number = index + 1;
return {
id: `record-${number}`,
label: `Customer record #${number}`,
description: number % 4 === 0
? "Flagged as a possible duplicate during the last import."
: undefined,
};
});
export const manyOptionsRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-many",
title: "Select the customer records to archive",
summary: "The cleanup job found 100 stale customer records. Confirm which ones to archive.",
payload: {
version: 1,
prompt: "Check every customer record you want archived.",
detailsMarkdown: "The list scrolls. Use Select all / Clear selection to move quickly.",
options: manyOptionList,
defaultSelectedOptionIds: [],
minSelected: 0,
maxSelected: null,
acceptLabel: "Archive selected",
rejectLabel: "Request changes",
rejectRequiresReason: false,
},
});
export const acceptedManyRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-many-accepted",
status: "accepted",
title: "Select the customer records to archive",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:52:00.000Z"),
updatedAt: new Date("2026-04-20T14:52:00.000Z"),
payload: manyOptionsRequestCheckboxConfirmationInteraction.payload,
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: manyOptionList.slice(0, 42).map((option) => option.id),
},
});
export const rejectedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-rejected",
status: "rejected",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:50:00.000Z"),
updatedAt: new Date("2026-04-20T14:50:00.000Z"),
result: {
version: 1,
outcome: "rejected",
reason: "Don't delete anything yet — let me confirm with the data owner first.",
},
});
export const staleTargetRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-stale",
status: "expired",
resolvedByAgentId: "agent-codex",
resolvedAt: new Date("2026-04-20T14:51:00.000Z"),
updatedAt: new Date("2026-04-20T14:51:00.000Z"),
payload: {
version: 1,
prompt: "Check the draft documents you want me to delete.",
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
options: [
{ id: "draft-march-report", label: "Old draft report" },
{ id: "draft-spec-v1", label: "Spec v1 (superseded)" },
],
target: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "44444444-4444-4444-8444-444444444444",
revisionNumber: 4,
},
},
result: {
version: 1,
outcome: "stale_target",
staleTarget: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
revisionNumber: 3,
},
},
});
export const issueThreadInteractionComments: IssueChatComment[] = [
createComment({
id: "comment-thread-board",
@@ -4,6 +4,7 @@ import {
buildSuggestedTaskTree,
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getQuestionAnswerLabels,
} from "./issue-thread-interactions";
@@ -126,6 +127,65 @@ describe("issue thread interaction helpers", () => {
})).toBe("Answered 1 question");
});
it("summarizes checkbox confirmation interactions by count", () => {
const base = {
id: "interaction-checkbox",
companyId: "company-1",
issueId: "issue-1",
kind: "request_checkbox_confirmation" as const,
continuationPolicy: "wake_assignee" as const,
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: {
version: 1 as const,
prompt: "Pick items",
options: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
{ id: "c", label: "C" },
],
},
};
expect(buildIssueThreadInteractionSummary({ ...base, status: "pending" }))
.toBe("Requested a selection from 3 options");
expect(buildIssueThreadInteractionSummary({
...base,
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: ["a", "c"] },
})).toBe("Confirmed 2 of 3 options");
expect(buildIssueThreadInteractionSummary({
...base,
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: [] },
})).toBe("Confirmed with no options selected");
expect(buildIssueThreadInteractionSummary({
...base,
status: "expired",
result: { version: 1, outcome: "stale_target" },
})).toBe("Selection expired after target changed");
});
it("maps selected checkbox option ids back to labels", () => {
const labels = getCheckboxConfirmationSelectedLabels({
payload: {
version: 1,
prompt: "Pick items",
options: [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Bravo" },
{ id: "c", label: "Charlie" },
],
},
result: { version: 1, outcome: "accepted", selectedOptionIds: ["c", "a", "missing"] },
});
expect(labels).toEqual(["Charlie", "Alpha"]);
});
it("maps stored option ids back to labels for answered summaries", () => {
const labels = getQuestionAnswerLabels({
question: {
+42
View File
@@ -10,6 +10,10 @@ export type {
IssueThreadInteractionBase,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionStatus,
RequestCheckboxConfirmationInteraction,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload,
@@ -26,6 +30,8 @@ import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
SuggestedTaskDraft,
SuggestTasksInteraction,
@@ -49,9 +55,24 @@ export function isIssueThreadInteraction(
candidate.kind === "suggest_tasks"
|| candidate.kind === "ask_user_questions"
|| candidate.kind === "request_confirmation"
|| candidate.kind === "request_checkbox_confirmation"
);
}
export function getCheckboxConfirmationSelectedLabels(args: {
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}): string[] {
const { payload, result } = args;
const selectedIds = result?.selectedOptionIds ?? [];
const optionLabelById = new Map(
payload.options.map((option) => [option.id, option.label] as const),
);
return selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
}
export function buildIssueThreadInteractionSummary(
interaction: IssueThreadInteraction,
) {
@@ -83,6 +104,27 @@ export function buildIssueThreadInteractionSummary(
return "Requested confirmation";
}
if (interaction.kind === "request_checkbox_confirmation") {
const optionCount = interaction.payload.options.length;
if (interaction.status === "accepted") {
const selectedCount = interaction.result?.selectedOptionIds?.length ?? 0;
if (selectedCount === 0) return "Confirmed with no options selected";
return selectedCount === 1
? `Confirmed 1 of ${optionCount} options`
: `Confirmed ${selectedCount} of ${optionCount} options`;
}
if (interaction.status === "rejected") return "Declined selection";
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Selection expired after comment";
if (outcome === "stale_target") return "Selection expired after target changed";
return "Selection expired";
}
return optionCount === 1
? "Requested a selection from 1 option"
: `Requested a selection from ${optionCount} options`;
}
const count = interaction.payload.questions.length;
if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
+13 -3
View File
@@ -156,13 +156,17 @@ import {
type IssueWorkProduct,
type IssueWorkMode,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
type IssueTreeControlMode,
} from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
type ActionableIssueThreadInteraction =
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null;
@@ -674,6 +678,7 @@ type IssueDetailChatTabProps = {
onAcceptInteraction: (
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void>;
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
onSubmitInteractionAnswers: (
@@ -2147,10 +2152,12 @@ export function IssueDetail() {
mutationFn: ({
interaction,
selectedClientKeys,
selectedOptionIds,
}: {
interaction: ActionableIssueThreadInteraction;
selectedClientKeys?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
selectedOptionIds?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys, selectedOptionIds }),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
@@ -2167,6 +2174,8 @@ export function IssueDetail() {
pushToast({
title: interaction.kind === "request_confirmation"
? "Request confirmed"
: interaction.kind === "request_checkbox_confirmation"
? "Selection confirmed"
: skippedCount > 0
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
: "Suggested tasks accepted",
@@ -3081,8 +3090,9 @@ export function IssueDetail() {
const handleAcceptInteraction = useCallback(async (
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => {
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys, selectedOptionIds });
}, [acceptInteraction]);
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
await rejectInteraction.mutateAsync({ interaction, reason });
@@ -4,9 +4,12 @@ import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
acceptedManyRequestCheckboxConfirmationInteraction,
acceptedRequestCheckboxConfirmationInteraction,
acceptedSuggestedTasksInteraction,
answeredAskUserQuestionsInteraction,
acceptedRequestConfirmationInteraction,
boundedRequestCheckboxConfirmationInteraction,
commentExpiredRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
genericPendingRequestConfirmationInteraction,
@@ -15,20 +18,25 @@ import {
issueThreadInteractionFixtureMeta,
issueThreadInteractionLiveRuns,
issueThreadInteractionTranscriptsByRunId,
manyOptionsRequestCheckboxConfirmationInteraction,
mixedIssueThreadInteractions,
optionalDeclineRequestConfirmationInteraction,
pendingAskUserQuestionsInteraction,
pendingRequestCheckboxConfirmationInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
planApprovalAcceptedRequestConfirmationInteraction,
rejectedNoReasonRequestConfirmationInteraction,
rejectedRequestCheckboxConfirmationInteraction,
rejectedRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
staleTargetRequestCheckboxConfirmationInteraction,
staleTargetRequestConfirmationInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@/lib/issue-thread-interactions";
@@ -191,6 +199,47 @@ function InteractiveRequestConfirmationCard() {
);
}
function InteractiveRequestCheckboxConfirmationCard({
pending,
accepted,
rejected,
}: {
pending: RequestCheckboxConfirmationInteraction;
accepted: RequestCheckboxConfirmationInteraction;
rejected: RequestCheckboxConfirmationInteraction;
}) {
const [interaction, setInteraction] = useState<RequestCheckboxConfirmationInteraction>(pending);
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={(_interaction, _selectedClientKeys, selectedOptionIds) =>
setInteraction({
...accepted,
payload: pending.payload,
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: selectedOptionIds ?? [],
},
})}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejected,
payload: pending.payload,
result: {
version: 1,
outcome: "rejected",
reason: reason || rejected.result?.reason || null,
},
})}
/>
);
}
function AutoOpenDeclineRequestConfirmationCard({
interaction,
}: {
@@ -224,7 +273,7 @@ const meta = {
docs: {
description: {
component:
"Interaction cards for `suggest_tasks`, `ask_user_questions`, and `request_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
"Interaction cards for `suggest_tasks`, `ask_user_questions`, `request_confirmation`, and `request_checkbox_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
},
},
},
@@ -524,6 +573,129 @@ export const RequestConfirmationFailed: Story = {
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
export const CheckboxConfirmationPending: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending checkbox confirmation"
description="Board users select any number of options, with frontend-owned select-all and clear controls."
>
<InteractiveRequestCheckboxConfirmationCard
pending={pendingRequestCheckboxConfirmationInteraction}
accepted={acceptedRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationBounded: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Min/max constrained selection"
description="The card enforces minimum and maximum selection counts and requires a decline reason."
>
<InteractiveRequestCheckboxConfirmationCard
pending={boundedRequestCheckboxConfirmationInteraction}
accepted={acceptedRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationAccepted: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted checkbox confirmation"
description="The resolved state leads with a count and lists the selected labels."
>
<IssueThreadInteractionCard
interaction={acceptedRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationAcceptedMany: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted large selection"
description="Large resolved selections summarize by count first and bound the inline chips."
>
<IssueThreadInteractionCard
interaction={acceptedManyRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationRejected: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Declined checkbox confirmation"
description="The decline reason stays attached to the request in the thread."
>
<IssueThreadInteractionCard
interaction={rejectedRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationStaleTarget: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Expired by target change"
description="The watched plan revision moved before the selection was confirmed."
>
<IssueThreadInteractionCard
interaction={staleTargetRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationManyOptions: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Around 100 options"
description="The list stays compact inside a bounded scroll region even with 100 options."
>
<InteractiveRequestCheckboxConfirmationCard
pending={manyOptionsRequestCheckboxConfirmationInteraction}
accepted={acceptedManyRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const ReviewSurface: Story = {
render: () => (
<StoryFrame>