[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:
parent
a4fa0eaf5e
commit
4d5322c821
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -261,6 +261,9 @@ export {
|
|||
requestConfirmationTargetSchema,
|
||||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResultSchema,
|
||||
requestCheckboxConfirmationOptionSchema,
|
||||
requestCheckboxConfirmationPayloadSchema,
|
||||
requestCheckboxConfirmationResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
acceptIssueThreadInteractionSchema,
|
||||
rejectIssueThreadInteractionSchema,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (1–1000 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, 1–200 entries | Option `id` and `label` are 1–120 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 (1–80) \| `null` | `null` (UI default) | Button label for accept. |
|
||||
| `rejectLabel` | string (1–80) \| `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 (1–160) \| `null` | `null` | Field label for the reject reason. |
|
||||
| `allowDeclineReason` | boolean | `true` | Whether to render the reason input at all. |
|
||||
| `declineReasonPlaceholder` | string (1–240) \| `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 |
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import type {
|
|||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
IssueThreadInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
|
|
@ -161,11 +162,18 @@ interface IssueChatMessageContext {
|
|||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
|
|
@ -361,11 +369,18 @@ interface IssueChatThreadProps {
|
|||
stoppingRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
|
|
|
|||
|
|
@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
|||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import {
|
||||
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
boundedRequestCheckboxConfirmationInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
disabledDeclineReasonRequestConfirmationInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
staleTargetRequestCheckboxConfirmationInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
rejectedSuggestedTasksInteraction,
|
||||
} from "../fixtures/issueThreadInteractionFixtures";
|
||||
|
|
@ -338,4 +343,143 @@ describe("IssueThreadInteractionCard", () => {
|
|||
"This request could not be resolved. Try again or create a new request.",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes pending checkbox options with select-all and clear controls", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
|
||||
expect(checkboxes).toHaveLength(
|
||||
pendingRequestCheckboxConfirmationInteraction.payload.options.length,
|
||||
);
|
||||
expect(checkboxes[0]?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(host.textContent).toContain("0 of 4 options selected");
|
||||
|
||||
const selectAll = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent === "Select all",
|
||||
);
|
||||
act(() => {
|
||||
selectAll?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(host.textContent).toContain("All 4 options selected");
|
||||
|
||||
const clear = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent === "Clear selection",
|
||||
);
|
||||
act(() => {
|
||||
clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(host.textContent).toContain("0 of 4 options selected");
|
||||
});
|
||||
|
||||
it("submits selected option ids on accept", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction,
|
||||
});
|
||||
|
||||
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
|
||||
await act(async () => {
|
||||
(checkboxes[0] as HTMLButtonElement).click();
|
||||
(checkboxes[2] as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Delete selected"),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onAcceptInteraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "request_checkbox_confirmation" }),
|
||||
undefined,
|
||||
["draft-march-report", "draft-scratch-notes"],
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks accept until the minimum selection is met", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: {
|
||||
...boundedRequestCheckboxConfirmationInteraction,
|
||||
payload: {
|
||||
...boundedRequestCheckboxConfirmationInteraction.payload,
|
||||
defaultSelectedOptionIds: [],
|
||||
},
|
||||
},
|
||||
onAcceptInteraction,
|
||||
});
|
||||
|
||||
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Confirm regions"),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onAcceptInteraction).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain("Select at least 2 options.");
|
||||
});
|
||||
|
||||
it("disables remaining checkboxes once the max selection is reached", () => {
|
||||
const host = renderCard({
|
||||
interaction: boundedRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
// Defaults select us-west + us-east; bumping to the 3-item max should lock the rest.
|
||||
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[];
|
||||
const unchecked = checkboxes.filter((box) => box.getAttribute("aria-checked") === "false");
|
||||
act(() => {
|
||||
unchecked[0]?.click();
|
||||
});
|
||||
|
||||
const stillUnchecked = ([...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[])
|
||||
.filter((box) => box.getAttribute("aria-checked") === "false");
|
||||
expect(stillUnchecked.length).toBeGreaterThan(0);
|
||||
expect(stillUnchecked.every((box) => box.hasAttribute("disabled"))).toBe(true);
|
||||
|
||||
const selectAllButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Select all"),
|
||||
);
|
||||
expect(selectAllButton?.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("summarizes large accepted selections by count and bounds the chips", () => {
|
||||
const host = renderCard({
|
||||
interaction: acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Confirmed 42 of 100 options");
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
|
||||
// 42 selected, but only the first 8 labels render inline, then a "+N more" chip.
|
||||
expect(host.textContent).toContain("+34 more");
|
||||
});
|
||||
|
||||
it("stays compact and scrollable with around 100 options", () => {
|
||||
const host = renderCard({
|
||||
interaction: manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(100);
|
||||
const scrollRegion = host.querySelector('[aria-label="Selectable options"]');
|
||||
expect(scrollRegion?.className).toContain("max-h-80");
|
||||
expect(scrollRegion?.className).toContain("overflow-y-auto");
|
||||
});
|
||||
|
||||
it("renders stale-target expiry for checkbox confirmations", () => {
|
||||
const host = renderCard({
|
||||
interaction: staleTargetRequestCheckboxConfirmationInteraction,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Expired by target change");
|
||||
expect(host.textContent).toContain("Plan v3");
|
||||
expect(host.textContent).toContain("Plan v4");
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
|
|
@ -224,6 +225,65 @@ function createRequestConfirmationInteraction(
|
|||
};
|
||||
}
|
||||
|
||||
function createRequestCheckboxConfirmationInteraction(
|
||||
overrides: Partial<RequestCheckboxConfirmationInteraction>,
|
||||
): RequestCheckboxConfirmationInteraction {
|
||||
return {
|
||||
id: "interaction-checkbox-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
title: "Choose the stale drafts to delete",
|
||||
summary:
|
||||
"The agent found several stale draft documents and needs the board to confirm exactly which ones to remove.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-20T14:46:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:46:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Check the draft documents you want me to delete.",
|
||||
detailsMarkdown:
|
||||
"Only the checked items will be deleted. Leave an item unchecked to keep it for now.",
|
||||
options: [
|
||||
{
|
||||
id: "draft-march-report",
|
||||
label: "Old draft report",
|
||||
description: "Created by QA during the March test pass.",
|
||||
},
|
||||
{
|
||||
id: "draft-spec-v1",
|
||||
label: "Spec v1 (superseded)",
|
||||
description: "Replaced by the approved v2 specification.",
|
||||
},
|
||||
{
|
||||
id: "draft-scratch-notes",
|
||||
label: "Scratch notes",
|
||||
description: "Unstructured notes from the kickoff call.",
|
||||
},
|
||||
{
|
||||
id: "draft-import-sample",
|
||||
label: "Import sample fixture",
|
||||
description: "Temporary fixture used while wiring the importer.",
|
||||
},
|
||||
],
|
||||
defaultSelectedOptionIds: [],
|
||||
minSelected: 0,
|
||||
maxSelected: null,
|
||||
acceptLabel: "Delete selected",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: false,
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
|
||||
|
||||
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
|
||||
|
|
@ -465,6 +525,145 @@ export const failedRequestConfirmationInteraction = createRequestConfirmationInt
|
|||
updatedAt: new Date("2026-04-20T14:42:00.000Z"),
|
||||
});
|
||||
|
||||
export const pendingRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({});
|
||||
|
||||
export const boundedRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-bounded",
|
||||
title: "Pick the regions to deploy first",
|
||||
summary: "Choose between two and three regions for the Phase 1 rollout.",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Which regions should we deploy to initially?",
|
||||
detailsMarkdown: "Select at least 2 and at most 3 regions.",
|
||||
options: [
|
||||
{ id: "us-west", label: "US West (Oregon)", description: "Lowest latency for our primary user base." },
|
||||
{ id: "us-east", label: "US East (Virginia)", description: "Redundancy and east coast coverage." },
|
||||
{ id: "eu-west", label: "EU West (Ireland)", description: "GDPR compliance and European users." },
|
||||
{ id: "ap-southeast", label: "AP Southeast (Singapore)", description: "Asia-Pacific expansion." },
|
||||
],
|
||||
defaultSelectedOptionIds: ["us-west", "us-east"],
|
||||
minSelected: 2,
|
||||
maxSelected: 3,
|
||||
acceptLabel: "Confirm regions",
|
||||
rejectLabel: "Reconsider",
|
||||
rejectRequiresReason: true,
|
||||
rejectReasonLabel: "What should change about the region set?",
|
||||
},
|
||||
});
|
||||
|
||||
export const acceptedRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-accepted",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:49:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:49:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
selectedOptionIds: ["draft-march-report", "draft-spec-v1"],
|
||||
},
|
||||
});
|
||||
|
||||
const manyOptionList = Array.from({ length: 100 }, (_, index) => {
|
||||
const number = index + 1;
|
||||
return {
|
||||
id: `record-${number}`,
|
||||
label: `Customer record #${number}`,
|
||||
description: number % 4 === 0
|
||||
? "Flagged as a possible duplicate during the last import."
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
export const manyOptionsRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-many",
|
||||
title: "Select the customer records to archive",
|
||||
summary: "The cleanup job found 100 stale customer records. Confirm which ones to archive.",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Check every customer record you want archived.",
|
||||
detailsMarkdown: "The list scrolls. Use Select all / Clear selection to move quickly.",
|
||||
options: manyOptionList,
|
||||
defaultSelectedOptionIds: [],
|
||||
minSelected: 0,
|
||||
maxSelected: null,
|
||||
acceptLabel: "Archive selected",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const acceptedManyRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-many-accepted",
|
||||
status: "accepted",
|
||||
title: "Select the customer records to archive",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:52:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:52:00.000Z"),
|
||||
payload: manyOptionsRequestCheckboxConfirmationInteraction.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
selectedOptionIds: manyOptionList.slice(0, 42).map((option) => option.id),
|
||||
},
|
||||
});
|
||||
|
||||
export const rejectedRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-rejected",
|
||||
status: "rejected",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:50:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:50:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: "Don't delete anything yet — let me confirm with the data owner first.",
|
||||
},
|
||||
});
|
||||
|
||||
export const staleTargetRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-stale",
|
||||
status: "expired",
|
||||
resolvedByAgentId: "agent-codex",
|
||||
resolvedAt: new Date("2026-04-20T14:51:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:51:00.000Z"),
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Check the draft documents you want me to delete.",
|
||||
acceptLabel: "Delete selected",
|
||||
rejectLabel: "Request changes",
|
||||
options: [
|
||||
{ id: "draft-march-report", label: "Old draft report" },
|
||||
{ id: "draft-spec-v1", label: "Spec v1 (superseded)" },
|
||||
],
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "44444444-4444-4444-8444-444444444444",
|
||||
revisionNumber: 4,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "11111111-1111-4111-8111-111111111111",
|
||||
revisionNumber: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const issueThreadInteractionComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-thread-board",
|
||||
|
|
|
|||
|
|
@ -4,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: {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -156,13 +156,17 @@ import {
|
|||
type IssueWorkProduct,
|
||||
type IssueWorkMode,
|
||||
type IssueThreadInteraction,
|
||||
type RequestCheckboxConfirmationInteraction,
|
||||
type RequestConfirmationInteraction,
|
||||
type SuggestTasksInteraction,
|
||||
type IssueTreeControlMode,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
|
||||
type ActionableIssueThreadInteraction =
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction;
|
||||
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
runId?: string | null;
|
||||
|
|
@ -674,6 +678,7 @@ type IssueDetailChatTabProps = {
|
|||
onAcceptInteraction: (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void>;
|
||||
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
|
||||
onSubmitInteractionAnswers: (
|
||||
|
|
@ -2147,10 +2152,12 @@ export function IssueDetail() {
|
|||
mutationFn: ({
|
||||
interaction,
|
||||
selectedClientKeys,
|
||||
selectedOptionIds,
|
||||
}: {
|
||||
interaction: ActionableIssueThreadInteraction;
|
||||
selectedClientKeys?: string[];
|
||||
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
|
||||
selectedOptionIds?: string[];
|
||||
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys, selectedOptionIds }),
|
||||
onSuccess: (interaction) => {
|
||||
upsertInteractionInCache(interaction);
|
||||
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
|
||||
|
|
@ -2167,6 +2174,8 @@ export function IssueDetail() {
|
|||
pushToast({
|
||||
title: interaction.kind === "request_confirmation"
|
||||
? "Request confirmed"
|
||||
: interaction.kind === "request_checkbox_confirmation"
|
||||
? "Selection confirmed"
|
||||
: skippedCount > 0
|
||||
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
|
||||
: "Suggested tasks accepted",
|
||||
|
|
@ -3081,8 +3090,9 @@ export function IssueDetail() {
|
|||
const handleAcceptInteraction = useCallback(async (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => {
|
||||
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
|
||||
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys, selectedOptionIds });
|
||||
}, [acceptInteraction]);
|
||||
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
|
||||
await rejectInteraction.mutateAsync({ interaction, reason });
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import { IssueChatThread } from "@/components/IssueChatThread";
|
|||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
acceptedRequestCheckboxConfirmationInteraction,
|
||||
acceptedSuggestedTasksInteraction,
|
||||
answeredAskUserQuestionsInteraction,
|
||||
acceptedRequestConfirmationInteraction,
|
||||
boundedRequestCheckboxConfirmationInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
genericPendingRequestConfirmationInteraction,
|
||||
|
|
@ -15,20 +18,25 @@ import {
|
|||
issueThreadInteractionFixtureMeta,
|
||||
issueThreadInteractionLiveRuns,
|
||||
issueThreadInteractionTranscriptsByRunId,
|
||||
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
mixedIssueThreadInteractions,
|
||||
optionalDeclineRequestConfirmationInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
pendingRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
planApprovalAcceptedRequestConfirmationInteraction,
|
||||
rejectedNoReasonRequestConfirmationInteraction,
|
||||
rejectedRequestCheckboxConfirmationInteraction,
|
||||
rejectedRequestConfirmationInteraction,
|
||||
rejectedSuggestedTasksInteraction,
|
||||
staleTargetRequestCheckboxConfirmationInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "@/lib/issue-thread-interactions";
|
||||
|
|
@ -191,6 +199,47 @@ function InteractiveRequestConfirmationCard() {
|
|||
);
|
||||
}
|
||||
|
||||
function InteractiveRequestCheckboxConfirmationCard({
|
||||
pending,
|
||||
accepted,
|
||||
rejected,
|
||||
}: {
|
||||
pending: RequestCheckboxConfirmationInteraction;
|
||||
accepted: RequestCheckboxConfirmationInteraction;
|
||||
rejected: RequestCheckboxConfirmationInteraction;
|
||||
}) {
|
||||
const [interaction, setInteraction] = useState<RequestCheckboxConfirmationInteraction>(pending);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={(_interaction, _selectedClientKeys, selectedOptionIds) =>
|
||||
setInteraction({
|
||||
...accepted,
|
||||
payload: pending.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
selectedOptionIds: selectedOptionIds ?? [],
|
||||
},
|
||||
})}
|
||||
onRejectInteraction={(_interaction, reason) =>
|
||||
setInteraction({
|
||||
...rejected,
|
||||
payload: pending.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: reason || rejected.result?.reason || null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoOpenDeclineRequestConfirmationCard({
|
||||
interaction,
|
||||
}: {
|
||||
|
|
@ -224,7 +273,7 @@ const meta = {
|
|||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Interaction cards for `suggest_tasks`, `ask_user_questions`, and `request_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
|
||||
"Interaction cards for `suggest_tasks`, `ask_user_questions`, `request_confirmation`, and `request_checkbox_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -524,6 +573,129 @@ export const RequestConfirmationFailed: Story = {
|
|||
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
|
||||
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
|
||||
|
||||
export const CheckboxConfirmationPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending checkbox confirmation"
|
||||
description="Board users select any number of options, with frontend-owned select-all and clear controls."
|
||||
>
|
||||
<InteractiveRequestCheckboxConfirmationCard
|
||||
pending={pendingRequestCheckboxConfirmationInteraction}
|
||||
accepted={acceptedRequestCheckboxConfirmationInteraction}
|
||||
rejected={rejectedRequestCheckboxConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationBounded: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Min/max constrained selection"
|
||||
description="The card enforces minimum and maximum selection counts and requires a decline reason."
|
||||
>
|
||||
<InteractiveRequestCheckboxConfirmationCard
|
||||
pending={boundedRequestCheckboxConfirmationInteraction}
|
||||
accepted={acceptedRequestCheckboxConfirmationInteraction}
|
||||
rejected={rejectedRequestCheckboxConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationAccepted: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Accepted checkbox confirmation"
|
||||
description="The resolved state leads with a count and lists the selected labels."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationAcceptedMany: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Accepted large selection"
|
||||
description="Large resolved selections summarize by count first and bound the inline chips."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedManyRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationRejected: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Declined checkbox confirmation"
|
||||
description="The decline reason stays attached to the request in the thread."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={rejectedRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationStaleTarget: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Expired by target change"
|
||||
description="The watched plan revision moved before the selection was confirmed."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={staleTargetRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationManyOptions: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Around 100 options"
|
||||
description="The list stays compact inside a bounded scroll region even with 100 options."
|
||||
>
|
||||
<InteractiveRequestCheckboxConfirmationCard
|
||||
pending={manyOptionsRequestCheckboxConfirmationInteraction}
|
||||
accepted={acceptedManyRequestCheckboxConfirmationInteraction}
|
||||
rejected={rejectedRequestCheckboxConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ReviewSurface: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
|
|
|
|||
Loading…
Reference in New Issue