[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2329 additions and 139 deletions

View File

@ -163,6 +163,7 @@ describe("issue subresource commands", () => {
]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-client-keys", "yes"]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-option-ids", "file-a,file-b"]);
await run(["issue", "interaction:reject", ISSUE_ID, INTERACTION_ID, "--reason", "no"]);
await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]);
await run([
@ -196,6 +197,7 @@ describe("issue subresource commands", () => {
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/reject`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/respond`],
@ -215,6 +217,9 @@ describe("issue subresource commands", () => {
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
]);
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
selectedOptionIds: ["file-a", "file-b"],
});
});
});

View File

@ -24,6 +24,7 @@ describe("project and goal commands", () => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_COMPANY_ID;
});
afterEach(() => {

View File

@ -149,6 +149,7 @@ interface IssueRecoveryResolveOptions extends BaseClientOptions {
interface InteractionAcceptOptions extends BaseClientOptions {
selectedClientKeys?: string;
selectedOptionIds?: string;
}
interface InteractionReasonOptions extends BaseClientOptions {
@ -745,11 +746,13 @@ export function registerIssueCommands(program: Command): void {
.argument("<issueId>", "Issue ID")
.argument("<interactionId>", "Interaction ID")
.option("--selected-client-keys <csv>", "Client keys to accept")
.option("--selected-option-ids <csv>", "Checkbox option IDs to accept")
.action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = acceptIssueThreadInteractionSchema.parse({
selectedClientKeys: opts.selectedClientKeys === undefined ? undefined : parseCsv(opts.selectedClientKeys),
selectedOptionIds: opts.selectedOptionIds === undefined ? undefined : parseCsv(opts.selectedOptionIds),
});
const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/accept`, payload);
printOutput(interaction, { json: ctx.json });
@ -761,7 +764,7 @@ export function registerIssueCommands(program: Command): void {
for (const [name, action, schema, description] of [
["interaction:reject", "reject", rejectIssueThreadInteractionSchema, "Reject an issue thread interaction"],
["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an ask_user_questions issue thread interaction"],
["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an issue thread interaction"],
] as const) {
addCommonClientOptions(
issue

View File

@ -292,6 +292,62 @@ describe("paperclip MCP tools", () => {
});
});
it("creates request_checkbox_confirmation interactions with checkbox payloads", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "interaction-1", kind: "request_checkbox_confirmation" }),
);
vi.stubGlobal("fetch", fetchMock);
const tool = getTool("paperclipRequestCheckboxConfirmation");
await tool.execute({
issueId: "PAP-1135",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
});
it("creates approvals with the expected company-scoped payload", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "approval-1" }),

View File

@ -6,6 +6,7 @@ import {
createApprovalSchema,
createIssueInputSchema,
issueThreadInteractionContinuationPolicySchema,
requestCheckboxConfirmationPayloadSchema,
requestConfirmationPayloadSchema,
suggestTasksPayloadSchema,
updateIssueSchema,
@ -144,6 +145,17 @@ const createRequestConfirmationToolSchema = z.object({
payload: requestConfirmationPayloadSchema,
});
const createRequestCheckboxConfirmationToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
});
const approvalDecisionSchema = z.object({
approvalId: approvalIdSchema,
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
@ -516,6 +528,18 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio
},
}),
),
makeTool(
"paperclipRequestCheckboxConfirmation",
"Create a request_checkbox_confirmation interaction on an issue",
createRequestCheckboxConfirmationToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "request_checkbox_confirmation",
...body,
},
}),
),
makeTool(
"paperclipUpsertIssueDocument",
"Create or update an issue document",

View File

@ -1680,6 +1680,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
async requestConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>;
},
async requestCheckboxConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_checkbox_confirmation" }, companyId, options) as Promise<any>;
},
documents: {
async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");

View File

@ -27,6 +27,7 @@ import type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
@ -122,6 +123,7 @@ export type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
@ -1321,7 +1323,7 @@ export interface PluginIssueSummariesClient {
* - `issues.orchestration.read` for orchestration summaries
* - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment`
* - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, and `requestConfirmation`
* - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, `requestConfirmation`, and `requestCheckboxConfirmation`
* - `issue.documents.read` for `documents.list` and `documents.get`
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
*/
@ -1454,6 +1456,12 @@ export interface PluginIssuesClient {
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestConfirmationInteraction>;
requestCheckboxConfirmation(
issueId: string,
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "request_checkbox_confirmation" }>, "kind">,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
/** Read and write blocker relationships. */

View File

@ -43,6 +43,7 @@ import { fileURLToPath } from "node:url";
import type {
AskUserQuestionsInteraction,
PaperclipPluginManifestV1,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@paperclipai/shared";
@ -913,6 +914,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}) as Promise<RequestConfirmationInteraction>;
},
async requestCheckboxConfirmation(
issueId: string,
interaction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction> {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction: {
...interaction,
kind: "request_checkbox_confirmation",
},
authorAgentId: options?.authorAgentId,
}) as Promise<RequestCheckboxConfirmationInteraction>;
},
documents: {
async list(issueId: string, companyId: string) {
return callHost("issues.documents.list", { issueId, companyId });

View File

@ -72,3 +72,59 @@ describe("createTestHarness action context", () => {
await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true });
});
});
describe("createTestHarness issue interactions", () => {
it("creates request_checkbox_confirmation interactions through the typed host helper", async () => {
const harness = createTestHarness({
manifest,
capabilities: ["issues.create", "issue.interactions.create"],
});
const issue = await harness.ctx.issues.create({
companyId: "company-1",
title: "Pick files",
});
const interaction = await harness.ctx.issues.requestCheckboxConfirmation(
issue.id,
{
idempotencyKey: "checkbox:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
options: [
{ id: "file-a", label: "File A" },
{ id: "file-b", label: "File B", description: "Secondary draft" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
},
},
"company-1",
{ authorAgentId: "agent-1" },
);
expect(interaction).toMatchObject({
issueId: issue.id,
companyId: "company-1",
kind: "request_checkbox_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
idempotencyKey: "checkbox:files",
title: "Choose files",
createdByAgentId: "agent-1",
payload: {
version: 1,
prompt: "Which files should be included?",
options: [
{ id: "file-a", label: "File A" },
{ id: "file-b", label: "File B", description: "Secondary draft" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
},
});
});
});

View File

@ -179,9 +179,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
"suggest_tasks",
"ask_user_questions",
"request_confirmation",
"request_checkbox_confirmation",
] as const;
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
export const ISSUE_THREAD_INTERACTION_STATUSES = [
"pending",
"accepted",

View File

@ -179,6 +179,7 @@ export {
type IssueThreadInteractionKind,
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
@ -531,6 +532,9 @@ export type {
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@ -541,6 +545,7 @@ export type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
@ -977,6 +982,9 @@ export {
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,

View File

@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { createIssueThreadInteractionSchema } from "./validators/issue.js";
import {
acceptIssueThreadInteractionSchema,
createIssueThreadInteractionSchema,
} from "./validators/issue.js";
describe("issue thread interaction schemas", () => {
it("parses request_confirmation payloads with default no-wake continuation", () => {
@ -67,29 +70,37 @@ describe("issue thread interaction schemas", () => {
});
it("accepts custom targets for request_confirmation interactions", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the external checklist?",
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
revisionNumber: 1,
label: "Checklist v1",
href: "https://example.com/checklist",
for (const href of [
"https://example.com/checklist",
"http://example.com/checklist",
"/PAP/issues/PAP-123#document-plan",
"#document-plan",
]) {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the external checklist?",
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
revisionNumber: 1,
label: "Checklist v1",
href,
},
},
},
});
});
expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({
type: "custom",
key: "external-checklist",
label: "Checklist v1",
});
expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({
type: "custom",
key: "external-checklist",
label: "Checklist v1",
href,
});
}
});
it("rejects unsafe request_confirmation target hrefs", () => {
@ -107,7 +118,16 @@ describe("issue thread interaction schemas", () => {
},
} as const;
for (const href of ["javascript:alert(1)", "data:text/html,hi", "//evil.example/path"]) {
for (const href of [
"javascript:alert(1)",
"data:text/html,hi",
"//evil.example/path",
"file:///tmp/x",
"mailto:user@example.com",
"slack://channel?id=1",
"vscode://file/tmp/x",
"ftp://example.com/file",
]) {
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
@ -117,7 +137,134 @@ describe("issue thread interaction schemas", () => {
href,
},
},
})).toThrow("href must not use javascript:, data:, or protocol-relative URLs");
})).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL");
}
});
it("parses request_checkbox_confirmation payloads with checkbox defaults", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-2", label: "Old screenshot", description: "Captured during QA." },
],
defaultSelectedOptionIds: ["item-2"],
minSelected: 0,
maxSelected: 2,
acceptLabel: "Archive selected",
rejectRequiresReason: true,
target: {
type: "issue_document",
key: "plan",
revisionId: "33333333-3333-4333-8333-333333333333",
revisionNumber: 2,
},
},
});
expect(parsed).toMatchObject({
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
payload: {
allowDeclineReason: true,
defaultSelectedOptionIds: ["item-2"],
minSelected: 0,
maxSelected: 2,
},
});
});
it("rejects invalid request_checkbox_confirmation option references and bounds", () => {
const base = {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-2", label: "Old screenshot" },
],
},
} as const;
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-1", label: "Duplicate" },
],
},
})).toThrow("Option ids must be unique within one checkbox confirmation");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
defaultSelectedOptionIds: ["missing"],
},
})).toThrow("defaultSelectedOptionIds must reference existing option ids");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
defaultSelectedOptionIds: ["item-1"],
minSelected: 2,
},
})).toThrow("defaultSelectedOptionIds must satisfy minSelected");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
minSelected: 2,
maxSelected: 1,
},
})).toThrow("maxSelected must be greater than or equal to minSelected");
});
it("rejects unsafe request_checkbox_confirmation target hrefs", () => {
const base = {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [{ id: "item-1", label: "Draft report" }],
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
label: "Checklist v1",
},
},
} as const;
for (const href of ["file:///tmp/x", "slack://channel?id=1", "vscode://file/tmp/x"]) {
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
target: {
...base.payload.target,
href,
},
},
})).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL");
}
});
it("accepts empty checkbox selections and rejects duplicate selected option ids", () => {
expect(acceptIssueThreadInteractionSchema.parse({ selectedOptionIds: [] })).toEqual({
selectedOptionIds: [],
});
expect(() => acceptIssueThreadInteractionSchema.parse({
selectedOptionIds: ["item-1", "item-1"],
})).toThrow("selectedOptionIds must be unique");
});
});

View File

@ -288,6 +288,9 @@ export type {
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@ -298,6 +301,7 @@ export type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,

View File

@ -786,6 +786,30 @@ export interface RequestConfirmationPayload {
target?: RequestConfirmationTarget | null;
}
export interface RequestCheckboxConfirmationOption {
id: string;
label: string;
description?: string | null;
}
export interface RequestCheckboxConfirmationPayload {
version: 1;
prompt: string;
detailsMarkdown?: string | null;
options: RequestCheckboxConfirmationOption[];
defaultSelectedOptionIds?: string[];
minSelected?: number;
maxSelected?: number | null;
acceptLabel?: string | null;
rejectLabel?: string | null;
rejectRequiresReason?: boolean;
rejectReasonLabel?: string | null;
allowDeclineReason?: boolean;
declineReasonPlaceholder?: string | null;
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
}
export interface RequestConfirmationResult {
version: 1;
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
@ -794,6 +818,10 @@ export interface RequestConfirmationResult {
staleTarget?: RequestConfirmationTarget | null;
}
export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult {
selectedOptionIds?: string[];
}
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string;
companyId: string;
@ -829,20 +857,29 @@ export interface RequestConfirmationInteraction extends IssueThreadInteractionBa
result?: RequestConfirmationResult | null;
}
export interface RequestCheckboxConfirmationInteraction extends IssueThreadInteractionBase {
kind: "request_checkbox_confirmation";
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}
export type IssueThreadInteraction =
| SuggestTasksInteraction
| AskUserQuestionsInteraction
| RequestConfirmationInteraction;
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
export type IssueThreadInteractionPayload =
| SuggestTasksPayload
| AskUserQuestionsPayload
| RequestConfirmationPayload;
| RequestConfirmationPayload
| RequestCheckboxConfirmationPayload;
export type IssueThreadInteractionResult =
| SuggestTasksResult
| AskUserQuestionsResult
| RequestConfirmationResult;
| RequestConfirmationResult
| RequestCheckboxConfirmationResult;
export interface IssueAttachment {
id: string;

View File

@ -261,6 +261,9 @@ export {
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,

View File

@ -25,6 +25,7 @@ import {
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
MODEL_PROFILE_KEYS,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js";
@ -681,11 +682,10 @@ export const askUserQuestionsResultSchema = z.object({
});
const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => {
const lower = value.toLowerCase();
return !lower.startsWith("javascript:")
&& !lower.startsWith("data:")
&& !value.startsWith("//");
}, "href must not use javascript:, data:, or protocol-relative URLs");
if (value.startsWith("#")) return true;
if (value.startsWith("/")) return !value.startsWith("//");
return /^https?:\/\//i.test(value);
}, "href must be a root-relative path, same-page fragment, or http(s) URL");
const requestConfirmationTargetBaseSchema = z.object({
label: z.string().trim().min(1).max(120).nullable().optional(),
@ -727,6 +727,106 @@ export const requestConfirmationPayloadSchema = z.object({
target: requestConfirmationTargetSchema.nullable().optional(),
});
export const requestCheckboxConfirmationOptionSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
});
export const requestCheckboxConfirmationPayloadSchema = z.object({
version: z.literal(1),
prompt: z.string().trim().min(1).max(1000),
detailsMarkdown: z.string().max(20000).nullable().optional(),
options: z.array(requestCheckboxConfirmationOptionSchema)
.min(1)
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT),
defaultSelectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional()
.default([]),
minSelected: z.number().int().min(0).optional().default(0),
maxSelected: z.number().int().min(0).nullable().optional(),
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectRequiresReason: z.boolean().optional(),
rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
allowDeclineReason: z.boolean().optional().default(true),
declineReasonPlaceholder: z.string().trim().min(1).max(240).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
}).superRefine((value, ctx) => {
const optionIds = new Set<string>();
for (const [index, option] of value.options.entries()) {
if (optionIds.has(option.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Option ids must be unique within one checkbox confirmation",
path: ["options", index, "id"],
});
}
optionIds.add(option.id);
}
const defaultSelectedOptionIds = new Set<string>();
for (const [index, optionId] of value.defaultSelectedOptionIds.entries()) {
if (defaultSelectedOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must be unique",
path: ["defaultSelectedOptionIds", index],
});
continue;
}
defaultSelectedOptionIds.add(optionId);
if (!optionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must reference existing option ids",
path: ["defaultSelectedOptionIds", index],
});
}
}
const maxSelected = value.maxSelected ?? null;
if (value.minSelected > value.options.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "minSelected cannot exceed the option count",
path: ["minSelected"],
});
}
if (value.defaultSelectedOptionIds.length < value.minSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must satisfy minSelected",
path: ["defaultSelectedOptionIds"],
});
}
if (maxSelected != null) {
if (maxSelected < value.minSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "maxSelected must be greater than or equal to minSelected",
path: ["maxSelected"],
});
}
if (maxSelected > value.options.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "maxSelected cannot exceed the option count",
path: ["maxSelected"],
});
}
if (value.defaultSelectedOptionIds.length > maxSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds cannot exceed maxSelected",
path: ["defaultSelectedOptionIds"],
});
}
}
});
export const requestConfirmationResultSchema = z.object({
version: z.literal(1),
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
@ -735,6 +835,25 @@ export const requestConfirmationResultSchema = z.object({
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
});
export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({
selectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional(),
}).superRefine((value, ctx) => {
const selectedOptionIds = value.selectedOptionIds ?? [];
const seenOptionIds = new Set<string>();
for (const [index, optionId] of selectedOptionIds.entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
}
seenOptionIds.add(optionId);
}
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("suggest_tasks"),
@ -766,12 +885,25 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
payload: requestConfirmationPayloadSchema,
}),
z.object({
kind: z.literal("request_checkbox_confirmation"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
}),
]);
export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
export const acceptIssueThreadInteractionSchema = z.object({
selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(),
selectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional(),
}).superRefine((value, ctx) => {
const seenClientKeys = new Set<string>();
for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) {
@ -785,6 +917,19 @@ export const acceptIssueThreadInteractionSchema = z.object({
}
seenClientKeys.add(clientKey);
}
const seenOptionIds = new Set<string>();
for (const [index, optionId] of (value.selectedOptionIds ?? []).entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
continue;
}
seenOptionIds.add(optionId);
}
});
export type AcceptIssueThreadInteraction = z.infer<typeof acceptIssueThreadInteractionSchema>;

View File

@ -542,6 +542,74 @@ describe.sequential("issue thread interaction routes", () => {
);
});
it("accepts request checkbox confirmations with selected option ids and wakes the assignee", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-checkbox",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_checkbox_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-checkbox",
payload: {
version: 1,
prompt: "Delete selected files?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
],
},
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["file-b"],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-checkbox/accept")
.send({ selectedOptionIds: ["file-b"] });
expect(res.status).toBe(200);
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-checkbox",
{ selectedOptionIds: ["file-b"] },
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-checkbox",
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_accepted",
details: expect.objectContaining({
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
}),
}),
);
});
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({

View File

@ -746,6 +746,164 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
})).rejects.toThrow("A decline reason is required for this confirmation");
});
it("accepts request_checkbox_confirmation interactions with selected option ids", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation accept");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which files should be deleted?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
{ id: "file-c", label: "c.txt" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 0,
maxSelected: 2,
},
}, {
userId: "local-board",
});
expect(created).toMatchObject({
kind: "request_checkbox_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
supersedeOnUserComment: true,
allowDeclineReason: true,
},
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["file-c", "file-a"],
}, {
userId: "local-board",
});
expect(accepted.createdIssues).toEqual([]);
expect(accepted.interaction).toMatchObject({
kind: "request_checkbox_confirmation",
status: "accepted",
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["file-a", "file-c"],
},
resolvedByUserId: "local-board",
});
});
it("enforces request_checkbox_confirmation selected option references and bounds", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation bounds");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Pick one or two options.",
options: [
{ id: "one", label: "One" },
{ id: "two", label: "Two" },
{ id: "three", label: "Three" },
],
defaultSelectedOptionIds: ["one"],
minSelected: 1,
maxSelected: 2,
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: [],
}, {
userId: "local-board",
})).rejects.toThrow("Select at least 1 checkbox confirmation option(s)");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["missing"],
}, {
userId: "local-board",
})).rejects.toThrow("Unknown checkbox confirmation optionId: missing");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["one", "two", "three"],
}, {
userId: "local-board",
})).rejects.toThrow("Select no more than 2 checkbox confirmation option(s)");
});
it("expires request_checkbox_confirmation interactions when a user comments after creation", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Checkbox confirmation supersede");
const commentId = randomUUID();
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which files should be deleted?",
options: [{ id: "file-a", label: "a.txt" }],
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
kind: "request_checkbox_confirmation",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId,
},
});
});
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();

View File

@ -6033,7 +6033,9 @@ export function issueRoutes(
});
}
const acceptedPlanTarget = readAcceptedPlanConfirmationTarget(interaction.payload);
const acceptedPlanTarget = interaction.kind === "request_confirmation"
? readAcceptedPlanConfirmationTarget(interaction.payload)
: null;
const acceptedPlanConfirmation =
interaction.kind === "request_confirmation" &&
interaction.status === "accepted" &&
@ -6091,7 +6093,7 @@ export function issueRoutes(
rejectionReason:
interaction.kind === "suggest_tasks"
? (interaction.result?.rejectionReason ?? null)
: interaction.kind === "request_confirmation"
: interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation"
? (interaction.result?.reason ?? null)
: null,
},

View File

@ -16,6 +16,7 @@ import type {
CancelIssueThreadInteraction,
CreateIssueThreadInteraction,
IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
RequestConfirmationTarget,
RejectIssueThreadInteraction,
@ -30,6 +31,8 @@ import {
cancelIssueThreadInteractionSchema,
createIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
suggestTasksPayloadSchema,
@ -70,6 +73,19 @@ type IssueResolutionContext = {
assigneeUserId: string | null;
};
const REQUEST_CONFIRMATION_INTERACTION_KINDS = [
"request_confirmation",
"request_checkbox_confirmation",
] as const;
type RequestConfirmationLikeKind = (typeof REQUEST_CONFIRMATION_INTERACTION_KINDS)[number];
type RequestConfirmationLikeInteraction =
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
}
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const err = error as { code?: string; constraint?: string; constraint_name?: string };
@ -128,6 +144,13 @@ function hydrateInteraction(
payload: requestConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestConfirmationInteraction;
case "request_checkbox_confirmation":
return {
...base,
kind: "request_checkbox_confirmation",
payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestCheckboxConfirmationInteraction;
default:
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
}
@ -149,7 +172,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
current: IssueThreadInteractionRow;
actor: InteractionActor;
}) {
if (args.current.kind !== "request_confirmation") return false;
if (!isRequestConfirmationLikeKind(args.current.kind)) return false;
if (!args.current.createdByAgentId) return false;
if (!args.actor.userId) return false;
if (!args.issue.assigneeUserId) return false;
@ -158,19 +181,31 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
return true;
}
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) {
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) {
return interaction.payload.supersedeOnUserComment === true;
}
function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction {
if (input.kind !== "request_confirmation") return input;
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
switch (input.kind) {
case "request_confirmation":
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
case "request_checkbox_confirmation":
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
default:
return input;
}
}
function isCommentAtOrAfterInteraction(args: {
@ -255,6 +290,36 @@ function resolveSelectedSuggestedTasks(args: {
};
}
function resolveSelectedCheckboxConfirmationOptions(args: {
interaction: RequestCheckboxConfirmationInteraction;
selectedOptionIds?: AcceptIssueThreadInteraction["selectedOptionIds"];
}) {
const optionIds = new Set(args.interaction.payload.options.map((option) => option.id));
const selectedOptionIds = args.selectedOptionIds ?? args.interaction.payload.defaultSelectedOptionIds ?? [];
const selectedOptionIdSet = new Set<string>();
for (const optionId of selectedOptionIds) {
if (!optionIds.has(optionId)) {
throw unprocessable(`Unknown checkbox confirmation optionId: ${optionId}`);
}
selectedOptionIdSet.add(optionId);
}
const selectedCount = selectedOptionIdSet.size;
const minSelected = args.interaction.payload.minSelected ?? 0;
const maxSelected = args.interaction.payload.maxSelected ?? null;
if (selectedCount < minSelected) {
throw unprocessable(`Select at least ${minSelected} checkbox confirmation option(s)`);
}
if (maxSelected != null && selectedCount > maxSelected) {
throw unprocessable(`Select no more than ${maxSelected} checkbox confirmation option(s)`);
}
return args.interaction.payload.options
.filter((option) => selectedOptionIdSet.has(option.id))
.map((option) => option.id);
}
function normalizeQuestionAnswers(args: {
questions: AskUserQuestionsInteraction["payload"]["questions"];
answers: RespondIssueThreadInteraction["answers"];
@ -401,8 +466,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
row: IssueThreadInteractionRow;
actor: InteractionActor;
}): Promise<IssueThreadInteraction | null> {
if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null;
const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction;
if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null;
const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target ?? null;
if (!target) return null;
if (target.type !== "issue_document") return null;
@ -522,6 +587,7 @@ export function issueThreadInteractionService(db: Db) {
async function acceptRequestConfirmation(args: {
issue: { id: string; companyId: string };
current: IssueThreadInteractionRow;
input: AcceptIssueThreadInteraction;
actor: InteractionActor;
}): Promise<{
interaction: IssueThreadInteraction;
@ -535,6 +601,15 @@ export function issueThreadInteractionService(db: Db) {
return { interaction: expired, continuationIssue: null };
}
const interaction = hydrateInteraction(args.current);
const selectedOptionIds =
interaction.kind === "request_checkbox_confirmation"
? resolveSelectedCheckboxConfirmationOptions({
interaction,
selectedOptionIds: args.input.selectedOptionIds,
})
: undefined;
const now = new Date();
return db.transaction(async (tx) => {
const [updated] = await tx
@ -544,6 +619,7 @@ export function issueThreadInteractionService(db: Db) {
result: {
version: 1,
outcome: "accepted",
...(selectedOptionIds ? { selectedOptionIds } : {}),
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null,
@ -624,7 +700,7 @@ export function issueThreadInteractionService(db: Db) {
return expired;
}
const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(args.current) as RequestConfirmationLikeInteraction;
const reason = args.input.reason?.trim() ?? "";
if (interaction.payload.rejectRequiresReason === true && reason.length === 0) {
throw unprocessable("A decline reason is required for this confirmation");
@ -729,7 +805,7 @@ export function issueThreadInteractionService(db: Db) {
}
}
if (data.kind === "request_confirmation") {
if (data.kind === "request_confirmation" || data.kind === "request_checkbox_confirmation") {
await assertRequestConfirmationTargetIsCurrent(db, {
companyId: issue.companyId,
issueId: issue.id,
@ -798,6 +874,21 @@ export function issueThreadInteractionService(db: Db) {
const accepted = await acceptRequestConfirmation({
issue,
current,
input: data,
actor,
});
return {
interaction: accepted.interaction,
continuationIssue: accepted.continuationIssue,
createdIssues: [],
};
}
case "request_checkbox_confirmation": {
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
const accepted = await acceptRequestConfirmation({
issue,
current,
input: data,
actor,
});
return {
@ -970,6 +1061,7 @@ export function issueThreadInteractionService(db: Db) {
case "suggest_tasks":
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current);
case "request_confirmation":
case "request_checkbox_confirmation":
return rejectRequestConfirmation({
issue,
current,
@ -1038,12 +1130,12 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
));
const superseded = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
return (
shouldSupersedeRequestConfirmationOnUserComment(interaction)
&& isCommentAtOrAfterInteraction({
@ -1096,7 +1188,7 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
)),
db
@ -1122,7 +1214,7 @@ export function issueThreadInteractionService(db: Db) {
}
>();
for (const row of rows) {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
@ -1182,12 +1274,12 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
));
const staleRows = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target;
if (!target || target.type !== "issue_document") return false;
const targetIssueId = target.issueId ?? issue.id;
@ -1206,7 +1298,7 @@ export function issueThreadInteractionService(db: Db) {
const now = new Date();
const expired: IssueThreadInteraction[] = [];
for (const row of staleRows) {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target ?? null;
const currentTarget = buildIssueDocumentTargetFromDocument({
issueId: issue.id,

View File

@ -190,6 +190,67 @@ POST /api/companies/{companyId}/approvals
`issueIds` links the approval into the issue thread. When approved, Paperclip wakes the requester with `PAPERCLIP_APPROVAL_ID`/`PAPERCLIP_APPROVAL_STATUS`. Keep the payload concise and decision-ready.
## Issue-Thread Interactions
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
Four kinds are supported. Pick the smallest kind that fits the decision shape:
| Kind | When to use | When **not** to use |
| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. |
| `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
| `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. |
Key shared semantics:
- **Continuation policy.** `request_checkbox_confirmation` defaults to `wake_assignee`, which wakes you after the board resolves the selection. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
- **Target binding and staleness.** `request_confirmation` and `request_checkbox_confirmation` both accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction.
- **Supersede on user comment.** Both confirmation kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required.
- **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards.
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path.
Create a `request_checkbox_confirmation` (board selects any subset, then confirms):
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{ "id": "draft-report-march", "label": "Old draft report", "description": "QA test pass, March." },
{ "id": "tmp-export-2025", "label": "tmp/export-2025.csv" }
],
"defaultSelectedOptionIds": ["draft-report-march"],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
When the board accepts, your wake delivers `result.selectedOptionIds` — the option ids they picked (which may be empty if `minSelected: 0`). Rejection delivers `result.reason` and a `commentId`.
For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**.
## Niche Workflow Pointers
Load `references/workflows.md` when the task matches one of these:

View File

@ -686,6 +686,118 @@ Rules:
- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide.
- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks.
### Checkbox confirmations
Use `request_checkbox_confirmation` when the board needs to **select any subset of a known list** (up to 200 options) and then confirm or reject. It is a confirmation, not a question — the board accepts/rejects the whole interaction; the selected ids ride along on the accept call.
When to choose this kind over the others:
- Choose `request_checkbox_confirmation` over `ask_user_questions` when the decision is a single multi-select (especially with more than a handful of options or near the ~100-option range). `ask_user_questions` is for short structured forms, not long lists.
- Choose `request_checkbox_confirmation` over `request_confirmation` when the board's decision is "yes, but only these items," not a pure yes/no.
- Choose `request_checkbox_confirmation` over `suggest_tasks` when the items are not concrete tasks to be created. `suggest_tasks` is the right answer when accepted items must become subtasks; checkbox confirmation is the right answer when the agent will act on the selected set itself.
Create a checkbox confirmation:
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{ "id": "draft-report-march", "label": "Old draft report", "description": "QA test pass, March." },
{ "id": "tmp-export-2025", "label": "tmp/export-2025.csv" }
],
"defaultSelectedOptionIds": ["draft-report-march"],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"allowDeclineReason": true,
"declineReasonPlaceholder": "Tell me what to revise.",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
Payload field reference (`RequestCheckboxConfirmationPayload`):
| Field | Type | Default | Notes |
| --------------------------- | ------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | `1` | required | Versioned for forward compatibility. |
| `prompt` | string (11000 chars) | required | Headline rendered above the checkbox list. |
| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. |
| `options` | `[{ id, label, description? }]` | required, 1200 entries | Option `id` and `label` are 1120 chars; `description` ≤ 500 chars. Option ids must be unique within the payload. |
| `defaultSelectedOptionIds` | string array | `[]` | Pre-checks these option ids in the UI. Each id must reference an option in `options`. Length must not exceed `maxSelected` when set. |
| `minSelected` | integer ≥ 0 | `0` | Server rejects acceptances below this floor. Cannot exceed `options.length`. |
| `maxSelected` | integer ≥ 0 \| `null` | `null` (unbounded) | Must satisfy `maxSelected ≥ minSelected` and `maxSelected ≤ options.length` when set. |
| `acceptLabel` | string (180) \| `null` | `null` (UI default) | Button label for accept. |
| `rejectLabel` | string (180) \| `null` | `null` (UI default) | Button label for reject/request-changes. |
| `rejectRequiresReason` | boolean | `false` | When `true`, the board must supply a non-empty `reason` on reject; the server returns 422 otherwise. |
| `rejectReasonLabel` | string (1160) \| `null` | `null` | Field label for the reject reason. |
| `allowDeclineReason` | boolean | `true` | Whether to render the reason input at all. |
| `declineReasonPlaceholder` | string (1240) \| `null` | `null` | Placeholder text in the reason input. |
| `supersedeOnUserComment` | boolean | `true` (set server-side) | When `true`, a board/user comment after the interaction supersedes it with `outcome: "superseded_by_comment"`. |
| `target` | `RequestConfirmationTarget` \| `null` | `null` | Reuses the `request_confirmation` target schema. Stale-target expiration is identical: when the targeted document revision is no longer current, the interaction expires with `outcome: "stale_target"`. |
Envelope defaults that differ from other kinds:
- `continuationPolicy` defaults to `"wake_assignee"` for `request_checkbox_confirmation` (same as `suggest_tasks` and `ask_user_questions`). Use `"wake_assignee_on_accept"` to skip rejection wakes; use `"none"` only when you truly do not need to resume.
Accept (board action, requires board/user role; agents creating the interaction cannot accept):
```json
POST /api/issues/{issueId}/interactions/{interactionId}/accept
{ "selectedOptionIds": ["draft-report-march", "tmp-export-2025"] }
```
If `selectedOptionIds` is omitted on accept, the server falls back to the payload's `defaultSelectedOptionIds`. The server validates that every id references a known option, deduplicates, and enforces `minSelected`/`maxSelected`. Unknown ids return 422.
Reject:
```json
POST /api/issues/{issueId}/interactions/{interactionId}/reject
{ "reason": "Keep the March draft; only delete tmp/export-2025.csv." }
```
`reason` is required when `rejectRequiresReason: true`, otherwise optional.
Resolved result (`RequestCheckboxConfirmationResult`):
```json
{
"version": 1,
"outcome": "accepted",
"selectedOptionIds": ["draft-report-march", "tmp-export-2025"]
}
```
Other outcomes match `request_confirmation`:
- `rejected``{ outcome: "rejected", reason, commentId }`. `selectedOptionIds` is absent.
- `superseded_by_comment``{ outcome: "superseded_by_comment", commentId }`. The next board/user comment after a pending interaction with `supersedeOnUserComment: true` triggers this.
- `stale_target``{ outcome: "stale_target", staleTarget }`. Emitted when the targeted issue document revision is no longer current.
Best practice:
- Use a deterministic idempotency key like `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries (e.g. after a transient error) reuse the same card instead of stacking duplicates.
- After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`.
- When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id.
### Checking approval status
```
@ -792,8 +904,8 @@ Terminal states: `done`, `cancelled`
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions |
| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation |
| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation |
| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions |
| GET | `/api/issues/:issueId/documents` | List issue documents |

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) =>

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?: (

View File

@ -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);
});
});

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}

View File

@ -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",

View File

@ -4,7 +4,10 @@ import {
buildSuggestedTaskTree,
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels,
normalizeRequestConfirmationTargetHref,
} from "./issue-thread-interactions";
describe("buildSuggestedTaskTree", () => {
@ -126,6 +129,110 @@ 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("allows only safe confirmation target hrefs for rendering", () => {
for (const href of [
"https://example.com/checklist",
"http://example.com/checklist",
"/PAP/issues/PAP-123#document-plan",
"#document-plan",
]) {
expect(normalizeRequestConfirmationTargetHref(href)).toBe(href);
}
for (const href of [
"file:///tmp/x",
"mailto:user@example.com",
"slack://channel?id=1",
"vscode://file/tmp/x",
"ftp://example.com/file",
"//evil.example/path",
]) {
expect(normalizeRequestConfirmationTargetHref(href)).toBeNull();
}
});
it("does not return unsafe custom target hrefs from accepted payloads", () => {
expect(getRequestConfirmationTargetHref({
issueId: "issue-1",
target: {
type: "custom",
key: "unsafe-target",
label: "Unsafe target",
href: "file:///tmp/x",
},
})).toBeNull();
expect(getRequestConfirmationTargetHref({
issueId: "issue-1",
target: {
type: "issue_document",
issueId: "issue-2",
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
href: "slack://channel?id=1",
},
})).toBe("/issues/issue-2#document-plan");
});
it("maps stored option ids back to labels for answered summaries", () => {
const labels = getQuestionAnswerLabels({
question: {

View File

@ -10,6 +10,10 @@ export type {
IssueThreadInteractionBase,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionStatus,
RequestCheckboxConfirmationInteraction,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload,
@ -26,7 +30,10 @@ import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationTarget,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksResultCreatedTask,
@ -49,9 +56,49 @@ 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 normalizeRequestConfirmationTargetHref(href: string) {
const value = href.trim();
if (value.startsWith("#")) return value;
if (value.startsWith("/")) return value.startsWith("//") ? null : value;
return /^https?:\/\//i.test(value) ? value : null;
}
export function getRequestConfirmationTargetHref({
issueId,
target,
}: {
issueId: string;
target: RequestConfirmationTarget;
}) {
if (target.href) {
const safeHref = normalizeRequestConfirmationTargetHref(target.href);
if (safeHref) return safeHref;
}
if (target.type === "issue_document") {
const targetIssueId = target.issueId ?? issueId;
return `/issues/${targetIssueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
export function buildIssueThreadInteractionSummary(
interaction: IssueThreadInteraction,
) {
@ -83,6 +130,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`;

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 });

View File

@ -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>