[codex] Add checkbox confirmation issue interactions (#7649)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent work is coordinated through issues, comments, interactions,
and approval-style handoffs.
> - Existing issue-thread interactions could ask questions, suggest
tasks, and request confirmation, but they did not support a structured
checkbox confirmation payload for choosing one or more options.
> - That gap made board/user confirmations harder to validate
consistently across API callers, plugin helpers, CLI tooling, and the
UI.
> - This pull request adds the shared checkbox confirmation contract,
server handling, client helpers, and issue-thread UI needed to render
and submit structured selections.
> - The benefit is that agents can request bounded multi-select
confirmations in the same audited issue-thread flow as other Paperclip
interactions.

## Linked Issues or Issue Description

- No public GitHub issue found for this exact branch. Internal Paperclip
issue: PAP-10415 / PAP-10441 requested creating this PR for the checkbox
confirmation issue-thread UI component work.
- GitHub duplicate search performed for checkbox confirmation /
issue-thread interaction PRs; no matching open PR was found.
- Related issue search result `#7497` was unrelated company file cleanup
work, so it is not linked as a related issue.

## What Changed

- Added shared types, validators, constants, and tests for
`request_checkbox_confirmation` interactions.
- Extended server issue-thread interaction service and routes for
checkbox confirmation creation, validation, expiration, and response
handling.
- Added CLI, MCP, and plugin SDK helper coverage so external callers can
create the new interaction shape consistently.
- Updated the issue-thread interaction UI to render checkbox
confirmations with min/max bounds, selection summaries, stale-target
states, and accept/decline flows.
- Documented the checkbox confirmation interaction contract in the
Paperclip skill/API reference.

## Verification

- Rebased cleanly on `paperclipai/paperclip` `master` fetched into
`public-gh/master` at `a4fa0eaf5`.
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.
- Ran focused tests with `NODE_ENV=test`:

```sh
NODE_ENV=test pnpm run preflight:workspace-links
NODE_ENV=test pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/lib/issue-thread-interactions.test.ts cli/src/__tests__/issue-subresources.test.ts cli/src/__tests__/project-goal.test.ts packages/mcp-server/src/tools.test.ts packages/plugins/sdk/tests/testing-actions.test.ts
```

Result: 8 test files passed, 78 tests passed.
- CI on latest head `63b9e55` is green.
- Greptile Review passed on latest head; GraphQL review-thread check
shows all Greptile threads resolved.

## Risks

- Medium surface area because the interaction contract touches shared
validators, server routes/services, UI rendering, CLI, MCP, plugin SDK
helpers, and docs.
- No database migrations are included.
- `pnpm-lock.yaml` is intentionally excluded per repository lockfile
policy.
- UI screenshots are not attached because the task explicitly requested
not to add design screenshots or images unless they were part of the
work; component tests cover the new rendering and interaction states.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with repository file access,
shell command execution, git/GitHub CLI tooling, and Paperclip
control-plane API access. Exact hosted model ID/context-window metadata
is not exposed inside this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-06-06 08:48:43 -05:00
committed by GitHub
parent a4fa0eaf5e
commit 4d5322c821
31 changed files with 2329 additions and 139 deletions
+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,143 @@ 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);
const selectAllButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Select all"),
);
expect(selectAllButton?.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);
});
});
+532 -71
View File
@@ -7,11 +7,15 @@ import {
buildSuggestedTaskTree,
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type RequestConfirmationResult,
type RequestConfirmationTarget,
type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask,
@@ -34,11 +38,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 +107,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;
}
@@ -971,33 +984,18 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) {
return `${target.key}${revision}`;
}
function requestConfirmationTargetHref({
interaction,
target,
}: {
interaction: RequestConfirmationInteraction;
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)}`;
}
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 = getRequestConfirmationTargetHref({ 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 +1025,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 +1226,7 @@ function RequestConfirmationCard({
</div>
) : null}
<RequestConfirmationTargetChip
interaction={interaction}
issueId={interaction.issueId}
target={interaction.payload.target}
/>
</div>
@@ -1289,6 +1327,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 || (maxSelected != null && selectedCount >= maxSelected)}
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 +1800,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 +1840,12 @@ export function IssueThreadInteractionCard({
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
) : interaction.kind === "request_checkbox_confirmation" ? (
<RequestCheckboxConfirmationCard
interaction={interaction}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : (
<RequestConfirmationCard
interaction={interaction}