mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
[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:
1 parent
a4fa0eaf5e
commit
4d5322c821
31 files changed
+2329
-139
No files matched your search
@@ -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]);
|
||||||
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-client-keys", "yes"]);
|
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:reject", ISSUE_ID, INTERACTION_ID, "--reason", "no"]);
|
||||||
await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]);
|
await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]);
|
||||||
await run([
|
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`],
|
||||||
["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}/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}/reject`],
|
||||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`],
|
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`],
|
||||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/respond`],
|
["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`],
|
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
|
||||||
["POST", `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();
|
vi.restoreAllMocks();
|
||||||
delete process.env.PAPERCLIP_API_KEY;
|
delete process.env.PAPERCLIP_API_KEY;
|
||||||
delete process.env.PAPERCLIP_API_URL;
|
delete process.env.PAPERCLIP_API_URL;
|
||||||
|
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ interface IssueRecoveryResolveOptions extends BaseClientOptions {
|
|||||||
|
|
||||||
interface InteractionAcceptOptions extends BaseClientOptions {
|
interface InteractionAcceptOptions extends BaseClientOptions {
|
||||||
selectedClientKeys?: string;
|
selectedClientKeys?: string;
|
||||||
|
selectedOptionIds?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractionReasonOptions extends BaseClientOptions {
|
interface InteractionReasonOptions extends BaseClientOptions {
|
||||||
@@ -745,11 +746,13 @@ export function registerIssueCommands(program: Command): void {
|
|||||||
.argument("<issueId>", "Issue ID")
|
.argument("<issueId>", "Issue ID")
|
||||||
.argument("<interactionId>", "Interaction ID")
|
.argument("<interactionId>", "Interaction ID")
|
||||||
.option("--selected-client-keys <csv>", "Client keys to accept")
|
.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) => {
|
.action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => {
|
||||||
try {
|
try {
|
||||||
const ctx = resolveCommandContext(opts);
|
const ctx = resolveCommandContext(opts);
|
||||||
const payload = acceptIssueThreadInteractionSchema.parse({
|
const payload = acceptIssueThreadInteractionSchema.parse({
|
||||||
selectedClientKeys: opts.selectedClientKeys === undefined ? undefined : parseCsv(opts.selectedClientKeys),
|
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);
|
const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/accept`, payload);
|
||||||
printOutput(interaction, { json: ctx.json });
|
printOutput(interaction, { json: ctx.json });
|
||||||
@@ -761,7 +764,7 @@ export function registerIssueCommands(program: Command): void {
|
|||||||
|
|
||||||
for (const [name, action, schema, description] of [
|
for (const [name, action, schema, description] of [
|
||||||
["interaction:reject", "reject", rejectIssueThreadInteractionSchema, "Reject an issue thread interaction"],
|
["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) {
|
] as const) {
|
||||||
addCommonClientOptions(
|
addCommonClientOptions(
|
||||||
issue
|
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 () => {
|
it("creates approvals with the expected company-scoped payload", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
mockJsonResponse({ id: "approval-1" }),
|
mockJsonResponse({ id: "approval-1" }),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createApprovalSchema,
|
createApprovalSchema,
|
||||||
createIssueInputSchema,
|
createIssueInputSchema,
|
||||||
issueThreadInteractionContinuationPolicySchema,
|
issueThreadInteractionContinuationPolicySchema,
|
||||||
|
requestCheckboxConfirmationPayloadSchema,
|
||||||
requestConfirmationPayloadSchema,
|
requestConfirmationPayloadSchema,
|
||||||
suggestTasksPayloadSchema,
|
suggestTasksPayloadSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
@@ -144,6 +145,17 @@ const createRequestConfirmationToolSchema = z.object({
|
|||||||
payload: requestConfirmationPayloadSchema,
|
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({
|
const approvalDecisionSchema = z.object({
|
||||||
approvalId: approvalIdSchema,
|
approvalId: approvalIdSchema,
|
||||||
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
|
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(
|
makeTool(
|
||||||
"paperclipUpsertIssueDocument",
|
"paperclipUpsertIssueDocument",
|
||||||
"Create or update an issue document",
|
"Create or update an issue document",
|
||||||
|
|||||||
@@ -1680,6 +1680,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
async requestConfirmation(issueId, interaction, companyId, options) {
|
async requestConfirmation(issueId, interaction, companyId, options) {
|
||||||
return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>;
|
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: {
|
documents: {
|
||||||
async list(issueId, companyId) {
|
async list(issueId, companyId) {
|
||||||
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
CreateIssueThreadInteraction,
|
CreateIssueThreadInteraction,
|
||||||
PluginIssueOriginKind,
|
PluginIssueOriginKind,
|
||||||
IssueSurfaceVisibility,
|
IssueSurfaceVisibility,
|
||||||
@@ -122,6 +123,7 @@ export type {
|
|||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
CreateIssueThreadInteraction,
|
CreateIssueThreadInteraction,
|
||||||
PluginIssueOriginKind,
|
PluginIssueOriginKind,
|
||||||
IssueSurfaceVisibility,
|
IssueSurfaceVisibility,
|
||||||
@@ -1321,7 +1323,7 @@ export interface PluginIssueSummariesClient {
|
|||||||
* - `issues.orchestration.read` for orchestration summaries
|
* - `issues.orchestration.read` for orchestration summaries
|
||||||
* - `issue.comments.read` for `listComments`
|
* - `issue.comments.read` for `listComments`
|
||||||
* - `issue.comments.create` for `createComment`
|
* - `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.read` for `documents.list` and `documents.get`
|
||||||
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
|
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
|
||||||
*/
|
*/
|
||||||
@@ -1454,6 +1456,12 @@ export interface PluginIssuesClient {
|
|||||||
companyId: string,
|
companyId: string,
|
||||||
options?: { authorAgentId?: string },
|
options?: { authorAgentId?: string },
|
||||||
): Promise<RequestConfirmationInteraction>;
|
): 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`. */
|
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
|
||||||
documents: PluginIssueDocumentsClient;
|
documents: PluginIssueDocumentsClient;
|
||||||
/** Read and write blocker relationships. */
|
/** Read and write blocker relationships. */
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import type {
|
import type {
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
PaperclipPluginManifestV1,
|
PaperclipPluginManifestV1,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
@@ -913,6 +914,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
}) as Promise<RequestConfirmationInteraction>;
|
}) 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: {
|
documents: {
|
||||||
async list(issueId: string, companyId: string) {
|
async list(issueId: string, companyId: string) {
|
||||||
return callHost("issues.documents.list", { issueId, companyId });
|
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 });
|
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",
|
"suggest_tasks",
|
||||||
"ask_user_questions",
|
"ask_user_questions",
|
||||||
"request_confirmation",
|
"request_confirmation",
|
||||||
|
"request_checkbox_confirmation",
|
||||||
] as const;
|
] as const;
|
||||||
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
|
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
|
||||||
|
|
||||||
|
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
|
||||||
|
|
||||||
export const ISSUE_THREAD_INTERACTION_STATUSES = [
|
export const ISSUE_THREAD_INTERACTION_STATUSES = [
|
||||||
"pending",
|
"pending",
|
||||||
"accepted",
|
"accepted",
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export {
|
|||||||
type IssueThreadInteractionKind,
|
type IssueThreadInteractionKind,
|
||||||
type IssueThreadInteractionStatus,
|
type IssueThreadInteractionStatus,
|
||||||
type IssueThreadInteractionContinuationPolicy,
|
type IssueThreadInteractionContinuationPolicy,
|
||||||
|
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
|
||||||
type BuiltInIssueOriginKind,
|
type BuiltInIssueOriginKind,
|
||||||
type PluginIssueOriginKind,
|
type PluginIssueOriginKind,
|
||||||
type IssueOriginKind,
|
type IssueOriginKind,
|
||||||
@@ -531,6 +532,9 @@ export type {
|
|||||||
RequestConfirmationTarget,
|
RequestConfirmationTarget,
|
||||||
RequestConfirmationPayload,
|
RequestConfirmationPayload,
|
||||||
RequestConfirmationResult,
|
RequestConfirmationResult,
|
||||||
|
RequestCheckboxConfirmationOption,
|
||||||
|
RequestCheckboxConfirmationPayload,
|
||||||
|
RequestCheckboxConfirmationResult,
|
||||||
AcceptedPlanDecompositionStatus,
|
AcceptedPlanDecompositionStatus,
|
||||||
AcceptedPlanDecompositionChild,
|
AcceptedPlanDecompositionChild,
|
||||||
AcceptedPlanDecomposition,
|
AcceptedPlanDecomposition,
|
||||||
@@ -541,6 +545,7 @@ export type {
|
|||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
IssueThreadInteraction,
|
IssueThreadInteraction,
|
||||||
IssueThreadInteractionPayload,
|
IssueThreadInteractionPayload,
|
||||||
IssueThreadInteractionResult,
|
IssueThreadInteractionResult,
|
||||||
@@ -977,6 +982,9 @@ export {
|
|||||||
requestConfirmationTargetSchema,
|
requestConfirmationTargetSchema,
|
||||||
requestConfirmationPayloadSchema,
|
requestConfirmationPayloadSchema,
|
||||||
requestConfirmationResultSchema,
|
requestConfirmationResultSchema,
|
||||||
|
requestCheckboxConfirmationOptionSchema,
|
||||||
|
requestCheckboxConfirmationPayloadSchema,
|
||||||
|
requestCheckboxConfirmationResultSchema,
|
||||||
createIssueThreadInteractionSchema,
|
createIssueThreadInteractionSchema,
|
||||||
acceptIssueThreadInteractionSchema,
|
acceptIssueThreadInteractionSchema,
|
||||||
rejectIssueThreadInteractionSchema,
|
rejectIssueThreadInteractionSchema,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createIssueThreadInteractionSchema } from "./validators/issue.js";
|
import {
|
||||||
|
acceptIssueThreadInteractionSchema,
|
||||||
|
createIssueThreadInteractionSchema,
|
||||||
|
} from "./validators/issue.js";
|
||||||
|
|
||||||
describe("issue thread interaction schemas", () => {
|
describe("issue thread interaction schemas", () => {
|
||||||
it("parses request_confirmation payloads with default no-wake continuation", () => {
|
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", () => {
|
it("accepts custom targets for request_confirmation interactions", () => {
|
||||||
const parsed = createIssueThreadInteractionSchema.parse({
|
for (const href of [
|
||||||
kind: "request_confirmation",
|
"https://example.com/checklist",
|
||||||
payload: {
|
"http://example.com/checklist",
|
||||||
version: 1,
|
"/PAP/issues/PAP-123#document-plan",
|
||||||
prompt: "Proceed with the external checklist?",
|
"#document-plan",
|
||||||
target: {
|
]) {
|
||||||
type: "custom",
|
const parsed = createIssueThreadInteractionSchema.parse({
|
||||||
key: "external-checklist",
|
kind: "request_confirmation",
|
||||||
revisionId: "checklist-v1",
|
payload: {
|
||||||
revisionNumber: 1,
|
version: 1,
|
||||||
label: "Checklist v1",
|
prompt: "Proceed with the external checklist?",
|
||||||
href: "https://example.com/checklist",
|
target: {
|
||||||
|
type: "custom",
|
||||||
|
key: "external-checklist",
|
||||||
|
revisionId: "checklist-v1",
|
||||||
|
revisionNumber: 1,
|
||||||
|
label: "Checklist v1",
|
||||||
|
href,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
expect(parsed.kind).toBe("request_confirmation");
|
expect(parsed.kind).toBe("request_confirmation");
|
||||||
if (parsed.kind !== "request_confirmation") return;
|
if (parsed.kind !== "request_confirmation") return;
|
||||||
expect(parsed.payload.target).toMatchObject({
|
expect(parsed.payload.target).toMatchObject({
|
||||||
type: "custom",
|
type: "custom",
|
||||||
key: "external-checklist",
|
key: "external-checklist",
|
||||||
label: "Checklist v1",
|
label: "Checklist v1",
|
||||||
});
|
href,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsafe request_confirmation target hrefs", () => {
|
it("rejects unsafe request_confirmation target hrefs", () => {
|
||||||
@@ -107,7 +118,16 @@ describe("issue thread interaction schemas", () => {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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({
|
expect(() => createIssueThreadInteractionSchema.parse({
|
||||||
...base,
|
...base,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -117,7 +137,134 @@ describe("issue thread interaction schemas", () => {
|
|||||||
href,
|
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,
|
RequestConfirmationTarget,
|
||||||
RequestConfirmationPayload,
|
RequestConfirmationPayload,
|
||||||
RequestConfirmationResult,
|
RequestConfirmationResult,
|
||||||
|
RequestCheckboxConfirmationOption,
|
||||||
|
RequestCheckboxConfirmationPayload,
|
||||||
|
RequestCheckboxConfirmationResult,
|
||||||
AcceptedPlanDecompositionStatus,
|
AcceptedPlanDecompositionStatus,
|
||||||
AcceptedPlanDecompositionChild,
|
AcceptedPlanDecompositionChild,
|
||||||
AcceptedPlanDecomposition,
|
AcceptedPlanDecomposition,
|
||||||
@@ -298,6 +301,7 @@ export type {
|
|||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
IssueThreadInteraction,
|
IssueThreadInteraction,
|
||||||
IssueThreadInteractionPayload,
|
IssueThreadInteractionPayload,
|
||||||
IssueThreadInteractionResult,
|
IssueThreadInteractionResult,
|
||||||
|
|||||||
@@ -786,6 +786,30 @@ export interface RequestConfirmationPayload {
|
|||||||
target?: RequestConfirmationTarget | null;
|
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 {
|
export interface RequestConfirmationResult {
|
||||||
version: 1;
|
version: 1;
|
||||||
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
|
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
|
||||||
@@ -794,6 +818,10 @@ export interface RequestConfirmationResult {
|
|||||||
staleTarget?: RequestConfirmationTarget | null;
|
staleTarget?: RequestConfirmationTarget | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult {
|
||||||
|
selectedOptionIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
|
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -829,20 +857,29 @@ export interface RequestConfirmationInteraction extends IssueThreadInteractionBa
|
|||||||
result?: RequestConfirmationResult | null;
|
result?: RequestConfirmationResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestCheckboxConfirmationInteraction extends IssueThreadInteractionBase {
|
||||||
|
kind: "request_checkbox_confirmation";
|
||||||
|
payload: RequestCheckboxConfirmationPayload;
|
||||||
|
result?: RequestCheckboxConfirmationResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type IssueThreadInteraction =
|
export type IssueThreadInteraction =
|
||||||
| SuggestTasksInteraction
|
| SuggestTasksInteraction
|
||||||
| AskUserQuestionsInteraction
|
| AskUserQuestionsInteraction
|
||||||
| RequestConfirmationInteraction;
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction;
|
||||||
|
|
||||||
export type IssueThreadInteractionPayload =
|
export type IssueThreadInteractionPayload =
|
||||||
| SuggestTasksPayload
|
| SuggestTasksPayload
|
||||||
| AskUserQuestionsPayload
|
| AskUserQuestionsPayload
|
||||||
| RequestConfirmationPayload;
|
| RequestConfirmationPayload
|
||||||
|
| RequestCheckboxConfirmationPayload;
|
||||||
|
|
||||||
export type IssueThreadInteractionResult =
|
export type IssueThreadInteractionResult =
|
||||||
| SuggestTasksResult
|
| SuggestTasksResult
|
||||||
| AskUserQuestionsResult
|
| AskUserQuestionsResult
|
||||||
| RequestConfirmationResult;
|
| RequestConfirmationResult
|
||||||
|
| RequestCheckboxConfirmationResult;
|
||||||
|
|
||||||
export interface IssueAttachment {
|
export interface IssueAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ export {
|
|||||||
requestConfirmationTargetSchema,
|
requestConfirmationTargetSchema,
|
||||||
requestConfirmationPayloadSchema,
|
requestConfirmationPayloadSchema,
|
||||||
requestConfirmationResultSchema,
|
requestConfirmationResultSchema,
|
||||||
|
requestCheckboxConfirmationOptionSchema,
|
||||||
|
requestCheckboxConfirmationPayloadSchema,
|
||||||
|
requestCheckboxConfirmationResultSchema,
|
||||||
createIssueThreadInteractionSchema,
|
createIssueThreadInteractionSchema,
|
||||||
acceptIssueThreadInteractionSchema,
|
acceptIssueThreadInteractionSchema,
|
||||||
rejectIssueThreadInteractionSchema,
|
rejectIssueThreadInteractionSchema,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ISSUE_THREAD_INTERACTION_KINDS,
|
ISSUE_THREAD_INTERACTION_KINDS,
|
||||||
ISSUE_THREAD_INTERACTION_STATUSES,
|
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||||
MODEL_PROFILE_KEYS,
|
MODEL_PROFILE_KEYS,
|
||||||
|
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
import { multilineTextSchema } from "./text.js";
|
import { multilineTextSchema } from "./text.js";
|
||||||
import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.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 requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => {
|
||||||
const lower = value.toLowerCase();
|
if (value.startsWith("#")) return true;
|
||||||
return !lower.startsWith("javascript:")
|
if (value.startsWith("/")) return !value.startsWith("//");
|
||||||
&& !lower.startsWith("data:")
|
return /^https?:\/\//i.test(value);
|
||||||
&& !value.startsWith("//");
|
}, "href must be a root-relative path, same-page fragment, or http(s) URL");
|
||||||
}, "href must not use javascript:, data:, or protocol-relative URLs");
|
|
||||||
|
|
||||||
const requestConfirmationTargetBaseSchema = z.object({
|
const requestConfirmationTargetBaseSchema = z.object({
|
||||||
label: z.string().trim().min(1).max(120).nullable().optional(),
|
label: z.string().trim().min(1).max(120).nullable().optional(),
|
||||||
@@ -727,6 +727,106 @@ export const requestConfirmationPayloadSchema = z.object({
|
|||||||
target: requestConfirmationTargetSchema.nullable().optional(),
|
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({
|
export const requestConfirmationResultSchema = z.object({
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
|
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
|
||||||
@@ -735,6 +835,25 @@ export const requestConfirmationResultSchema = z.object({
|
|||||||
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
|
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", [
|
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("suggest_tasks"),
|
kind: z.literal("suggest_tasks"),
|
||||||
@@ -766,12 +885,25 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
|
|||||||
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
|
||||||
payload: requestConfirmationPayloadSchema,
|
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 type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
|
||||||
|
|
||||||
export const acceptIssueThreadInteractionSchema = z.object({
|
export const acceptIssueThreadInteractionSchema = z.object({
|
||||||
selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(),
|
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) => {
|
}).superRefine((value, ctx) => {
|
||||||
const seenClientKeys = new Set<string>();
|
const seenClientKeys = new Set<string>();
|
||||||
for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) {
|
for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) {
|
||||||
@@ -785,6 +917,19 @@ export const acceptIssueThreadInteractionSchema = z.object({
|
|||||||
}
|
}
|
||||||
seenClientKeys.add(clientKey);
|
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>;
|
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 () => {
|
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
|
||||||
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
|
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
|
||||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -746,6 +746,164 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||||||
})).rejects.toThrow("A decline reason is required for this confirmation");
|
})).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 () => {
|
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const goalId = 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 =
|
const acceptedPlanConfirmation =
|
||||||
interaction.kind === "request_confirmation" &&
|
interaction.kind === "request_confirmation" &&
|
||||||
interaction.status === "accepted" &&
|
interaction.status === "accepted" &&
|
||||||
@@ -6091,7 +6093,7 @@ export function issueRoutes(
|
|||||||
rejectionReason:
|
rejectionReason:
|
||||||
interaction.kind === "suggest_tasks"
|
interaction.kind === "suggest_tasks"
|
||||||
? (interaction.result?.rejectionReason ?? null)
|
? (interaction.result?.rejectionReason ?? null)
|
||||||
: interaction.kind === "request_confirmation"
|
: interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation"
|
||||||
? (interaction.result?.reason ?? null)
|
? (interaction.result?.reason ?? null)
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
CancelIssueThreadInteraction,
|
CancelIssueThreadInteraction,
|
||||||
CreateIssueThreadInteraction,
|
CreateIssueThreadInteraction,
|
||||||
IssueThreadInteraction,
|
IssueThreadInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
RequestConfirmationTarget,
|
RequestConfirmationTarget,
|
||||||
RejectIssueThreadInteraction,
|
RejectIssueThreadInteraction,
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
cancelIssueThreadInteractionSchema,
|
cancelIssueThreadInteractionSchema,
|
||||||
createIssueThreadInteractionSchema,
|
createIssueThreadInteractionSchema,
|
||||||
rejectIssueThreadInteractionSchema,
|
rejectIssueThreadInteractionSchema,
|
||||||
|
requestCheckboxConfirmationPayloadSchema,
|
||||||
|
requestCheckboxConfirmationResultSchema,
|
||||||
requestConfirmationPayloadSchema,
|
requestConfirmationPayloadSchema,
|
||||||
requestConfirmationResultSchema,
|
requestConfirmationResultSchema,
|
||||||
suggestTasksPayloadSchema,
|
suggestTasksPayloadSchema,
|
||||||
@@ -70,6 +73,19 @@ type IssueResolutionContext = {
|
|||||||
assigneeUserId: string | null;
|
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 {
|
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
|
||||||
if (typeof error !== "object" || error === null) return false;
|
if (typeof error !== "object" || error === null) return false;
|
||||||
const err = error as { code?: string; constraint?: string; constraint_name?: string };
|
const err = error as { code?: string; constraint?: string; constraint_name?: string };
|
||||||
@@ -128,6 +144,13 @@ function hydrateInteraction(
|
|||||||
payload: requestConfirmationPayloadSchema.parse(row.payload),
|
payload: requestConfirmationPayloadSchema.parse(row.payload),
|
||||||
result: row.result ? requestConfirmationResultSchema.parse(row.result) : null,
|
result: row.result ? requestConfirmationResultSchema.parse(row.result) : null,
|
||||||
} satisfies RequestConfirmationInteraction;
|
} 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:
|
default:
|
||||||
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
|
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
|
||||||
}
|
}
|
||||||
@@ -149,7 +172,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
|||||||
current: IssueThreadInteractionRow;
|
current: IssueThreadInteractionRow;
|
||||||
actor: InteractionActor;
|
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.current.createdByAgentId) return false;
|
||||||
if (!args.actor.userId) return false;
|
if (!args.actor.userId) return false;
|
||||||
if (!args.issue.assigneeUserId) return false;
|
if (!args.issue.assigneeUserId) return false;
|
||||||
@@ -158,19 +181,31 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) {
|
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) {
|
||||||
return interaction.payload.supersedeOnUserComment === true;
|
return interaction.payload.supersedeOnUserComment === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction {
|
function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction {
|
||||||
if (input.kind !== "request_confirmation") return input;
|
switch (input.kind) {
|
||||||
return {
|
case "request_confirmation":
|
||||||
...input,
|
return {
|
||||||
payload: {
|
...input,
|
||||||
...input.payload,
|
payload: {
|
||||||
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
|
...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: {
|
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: {
|
function normalizeQuestionAnswers(args: {
|
||||||
questions: AskUserQuestionsInteraction["payload"]["questions"];
|
questions: AskUserQuestionsInteraction["payload"]["questions"];
|
||||||
answers: RespondIssueThreadInteraction["answers"];
|
answers: RespondIssueThreadInteraction["answers"];
|
||||||
@@ -401,8 +466,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
|
|||||||
row: IssueThreadInteractionRow;
|
row: IssueThreadInteractionRow;
|
||||||
actor: InteractionActor;
|
actor: InteractionActor;
|
||||||
}): Promise<IssueThreadInteraction | null> {
|
}): Promise<IssueThreadInteraction | null> {
|
||||||
if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null;
|
if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null;
|
||||||
const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction;
|
const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction;
|
||||||
const target = interaction.payload.target ?? null;
|
const target = interaction.payload.target ?? null;
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
if (target.type !== "issue_document") return null;
|
if (target.type !== "issue_document") return null;
|
||||||
@@ -522,6 +587,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
async function acceptRequestConfirmation(args: {
|
async function acceptRequestConfirmation(args: {
|
||||||
issue: { id: string; companyId: string };
|
issue: { id: string; companyId: string };
|
||||||
current: IssueThreadInteractionRow;
|
current: IssueThreadInteractionRow;
|
||||||
|
input: AcceptIssueThreadInteraction;
|
||||||
actor: InteractionActor;
|
actor: InteractionActor;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
interaction: IssueThreadInteraction;
|
interaction: IssueThreadInteraction;
|
||||||
@@ -535,6 +601,15 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
return { interaction: expired, continuationIssue: null };
|
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();
|
const now = new Date();
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
@@ -544,6 +619,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
result: {
|
result: {
|
||||||
version: 1,
|
version: 1,
|
||||||
outcome: "accepted",
|
outcome: "accepted",
|
||||||
|
...(selectedOptionIds ? { selectedOptionIds } : {}),
|
||||||
},
|
},
|
||||||
resolvedByAgentId: args.actor.agentId ?? null,
|
resolvedByAgentId: args.actor.agentId ?? null,
|
||||||
resolvedByUserId: args.actor.userId ?? null,
|
resolvedByUserId: args.actor.userId ?? null,
|
||||||
@@ -624,7 +700,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
return expired;
|
return expired;
|
||||||
}
|
}
|
||||||
|
|
||||||
const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction;
|
const interaction = hydrateInteraction(args.current) as RequestConfirmationLikeInteraction;
|
||||||
const reason = args.input.reason?.trim() ?? "";
|
const reason = args.input.reason?.trim() ?? "";
|
||||||
if (interaction.payload.rejectRequiresReason === true && reason.length === 0) {
|
if (interaction.payload.rejectRequiresReason === true && reason.length === 0) {
|
||||||
throw unprocessable("A decline reason is required for this confirmation");
|
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, {
|
await assertRequestConfirmationTargetIsCurrent(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
issueId: issue.id,
|
issueId: issue.id,
|
||||||
@@ -798,6 +874,21 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
const accepted = await acceptRequestConfirmation({
|
const accepted = await acceptRequestConfirmation({
|
||||||
issue,
|
issue,
|
||||||
current,
|
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,
|
actor,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -970,6 +1061,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
case "suggest_tasks":
|
case "suggest_tasks":
|
||||||
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current);
|
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current);
|
||||||
case "request_confirmation":
|
case "request_confirmation":
|
||||||
|
case "request_checkbox_confirmation":
|
||||||
return rejectRequestConfirmation({
|
return rejectRequestConfirmation({
|
||||||
issue,
|
issue,
|
||||||
current,
|
current,
|
||||||
@@ -1038,12 +1130,12 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||||
eq(issueThreadInteractions.issueId, issue.id),
|
eq(issueThreadInteractions.issueId, issue.id),
|
||||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
|
||||||
eq(issueThreadInteractions.status, "pending"),
|
eq(issueThreadInteractions.status, "pending"),
|
||||||
));
|
));
|
||||||
|
|
||||||
const superseded = rows.filter((row) => {
|
const superseded = rows.filter((row) => {
|
||||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
|
||||||
return (
|
return (
|
||||||
shouldSupersedeRequestConfirmationOnUserComment(interaction)
|
shouldSupersedeRequestConfirmationOnUserComment(interaction)
|
||||||
&& isCommentAtOrAfterInteraction({
|
&& isCommentAtOrAfterInteraction({
|
||||||
@@ -1096,7 +1188,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||||
eq(issueThreadInteractions.issueId, issue.id),
|
eq(issueThreadInteractions.issueId, issue.id),
|
||||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
|
||||||
eq(issueThreadInteractions.status, "pending"),
|
eq(issueThreadInteractions.status, "pending"),
|
||||||
)),
|
)),
|
||||||
db
|
db
|
||||||
@@ -1122,7 +1214,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
|
||||||
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
|
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
|
||||||
|
|
||||||
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
|
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
|
||||||
@@ -1182,12 +1274,12 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||||
eq(issueThreadInteractions.issueId, issue.id),
|
eq(issueThreadInteractions.issueId, issue.id),
|
||||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
|
||||||
eq(issueThreadInteractions.status, "pending"),
|
eq(issueThreadInteractions.status, "pending"),
|
||||||
));
|
));
|
||||||
|
|
||||||
const staleRows = rows.filter((row) => {
|
const staleRows = rows.filter((row) => {
|
||||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
|
||||||
const target = interaction.payload.target;
|
const target = interaction.payload.target;
|
||||||
if (!target || target.type !== "issue_document") return false;
|
if (!target || target.type !== "issue_document") return false;
|
||||||
const targetIssueId = target.issueId ?? issue.id;
|
const targetIssueId = target.issueId ?? issue.id;
|
||||||
@@ -1206,7 +1298,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expired: IssueThreadInteraction[] = [];
|
const expired: IssueThreadInteraction[] = [];
|
||||||
for (const row of staleRows) {
|
for (const row of staleRows) {
|
||||||
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
|
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
|
||||||
const target = interaction.payload.target ?? null;
|
const target = interaction.payload.target ?? null;
|
||||||
const currentTarget = buildIssueDocumentTargetFromDocument({
|
const currentTarget = buildIssueDocumentTargetFromDocument({
|
||||||
issueId: issue.id,
|
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.
|
`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
|
## Niche Workflow Pointers
|
||||||
|
|
||||||
Load `references/workflows.md` when the task matches one of these:
|
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.
|
- 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.
|
- 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
|
### Checking approval status
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -792,8 +904,8 @@ Terminal states: `done`, `cancelled`
|
|||||||
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
|
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
|
||||||
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
|
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
|
||||||
| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions |
|
| 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` | 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 |
|
| 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/reject` | Reject suggested tasks or confirmation |
|
||||||
| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions |
|
| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions |
|
||||||
| GET | `/api/issues/:issueId/documents` | List issue documents |
|
| GET | `/api/issues/:issueId/documents` | List issue documents |
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export const issuesApi = {
|
|||||||
acceptInteraction: (
|
acceptInteraction: (
|
||||||
id: string,
|
id: string,
|
||||||
interactionId: string,
|
interactionId: string,
|
||||||
data?: { selectedClientKeys?: string[] },
|
data?: { selectedClientKeys?: string[]; selectedOptionIds?: string[] },
|
||||||
) =>
|
) =>
|
||||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
|
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
|
||||||
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import type {
|
|||||||
AskUserQuestionsAnswer,
|
AskUserQuestionsAnswer,
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
IssueThreadInteraction,
|
IssueThreadInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
} from "../lib/issue-thread-interactions";
|
} from "../lib/issue-thread-interactions";
|
||||||
@@ -161,11 +162,18 @@ interface IssueChatMessageContext {
|
|||||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||||
onImageClick?: (src: string) => void;
|
onImageClick?: (src: string) => void;
|
||||||
onAcceptInteraction?: (
|
onAcceptInteraction?: (
|
||||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
interaction:
|
||||||
|
| SuggestTasksInteraction
|
||||||
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction,
|
||||||
selectedClientKeys?: string[],
|
selectedClientKeys?: string[],
|
||||||
|
selectedOptionIds?: string[],
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onRejectInteraction?: (
|
onRejectInteraction?: (
|
||||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
interaction:
|
||||||
|
| SuggestTasksInteraction
|
||||||
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onSubmitInteractionAnswers?: (
|
onSubmitInteractionAnswers?: (
|
||||||
@@ -361,11 +369,18 @@ interface IssueChatThreadProps {
|
|||||||
stoppingRunId?: string | null;
|
stoppingRunId?: string | null;
|
||||||
onImageClick?: (src: string) => void;
|
onImageClick?: (src: string) => void;
|
||||||
onAcceptInteraction?: (
|
onAcceptInteraction?: (
|
||||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
interaction:
|
||||||
|
| SuggestTasksInteraction
|
||||||
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction,
|
||||||
selectedClientKeys?: string[],
|
selectedClientKeys?: string[],
|
||||||
|
selectedOptionIds?: string[],
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onRejectInteraction?: (
|
onRejectInteraction?: (
|
||||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
interaction:
|
||||||
|
| SuggestTasksInteraction
|
||||||
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onSubmitInteractionAnswers?: (
|
onSubmitInteractionAnswers?: (
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
|||||||
import { ThemeProvider } from "../context/ThemeContext";
|
import { ThemeProvider } from "../context/ThemeContext";
|
||||||
import { TooltipProvider } from "./ui/tooltip";
|
import { TooltipProvider } from "./ui/tooltip";
|
||||||
import {
|
import {
|
||||||
|
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||||
|
boundedRequestCheckboxConfirmationInteraction,
|
||||||
pendingAskUserQuestionsInteraction,
|
pendingAskUserQuestionsInteraction,
|
||||||
commentExpiredRequestConfirmationInteraction,
|
commentExpiredRequestConfirmationInteraction,
|
||||||
disabledDeclineReasonRequestConfirmationInteraction,
|
disabledDeclineReasonRequestConfirmationInteraction,
|
||||||
failedRequestConfirmationInteraction,
|
failedRequestConfirmationInteraction,
|
||||||
|
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||||
|
pendingRequestCheckboxConfirmationInteraction,
|
||||||
pendingRequestConfirmationInteraction,
|
pendingRequestConfirmationInteraction,
|
||||||
pendingSuggestedTasksInteraction,
|
pendingSuggestedTasksInteraction,
|
||||||
|
staleTargetRequestCheckboxConfirmationInteraction,
|
||||||
staleTargetRequestConfirmationInteraction,
|
staleTargetRequestConfirmationInteraction,
|
||||||
rejectedSuggestedTasksInteraction,
|
rejectedSuggestedTasksInteraction,
|
||||||
} from "../fixtures/issueThreadInteractionFixtures";
|
} from "../fixtures/issueThreadInteractionFixtures";
|
||||||
@@ -338,4 +343,143 @@ describe("IssueThreadInteractionCard", () => {
|
|||||||
"This request could not be resolved. Try again or create a new request.",
|
"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,
|
buildSuggestedTaskTree,
|
||||||
collectSuggestedTaskClientKeys,
|
collectSuggestedTaskClientKeys,
|
||||||
countSuggestedTaskNodes,
|
countSuggestedTaskNodes,
|
||||||
|
getCheckboxConfirmationSelectedLabels,
|
||||||
|
getRequestConfirmationTargetHref,
|
||||||
getQuestionAnswerLabels,
|
getQuestionAnswerLabels,
|
||||||
type AskUserQuestionsAnswer,
|
type AskUserQuestionsAnswer,
|
||||||
type AskUserQuestionsInteraction,
|
type AskUserQuestionsInteraction,
|
||||||
type IssueThreadInteraction,
|
type IssueThreadInteraction,
|
||||||
|
type RequestCheckboxConfirmationInteraction,
|
||||||
type RequestConfirmationInteraction,
|
type RequestConfirmationInteraction,
|
||||||
|
type RequestConfirmationResult,
|
||||||
type RequestConfirmationTarget,
|
type RequestConfirmationTarget,
|
||||||
type SuggestTasksInteraction,
|
type SuggestTasksInteraction,
|
||||||
type SuggestTasksResultCreatedTask,
|
type SuggestTasksResultCreatedTask,
|
||||||
@@ -34,11 +38,18 @@ interface IssueThreadInteractionCardProps {
|
|||||||
currentUserId?: string | null;
|
currentUserId?: string | null;
|
||||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||||
onAcceptInteraction?: (
|
onAcceptInteraction?: (
|
||||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
interaction:
|
||||||
|
| SuggestTasksInteraction
|
||||||
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction,
|
||||||
selectedClientKeys?: string[],
|
selectedClientKeys?: string[],
|
||||||
|
selectedOptionIds?: string[],
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onRejectInteraction?: (
|
onRejectInteraction?: (
|
||||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
interaction:
|
||||||
|
| SuggestTasksInteraction
|
||||||
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onSubmitInteractionAnswers?: (
|
onSubmitInteractionAnswers?: (
|
||||||
@@ -96,6 +107,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
|
|||||||
return "Ask user questions";
|
return "Ask user questions";
|
||||||
case "request_confirmation":
|
case "request_confirmation":
|
||||||
return "Confirmation";
|
return "Confirmation";
|
||||||
|
case "request_checkbox_confirmation":
|
||||||
|
return "Checkbox confirmation";
|
||||||
default:
|
default:
|
||||||
return kind;
|
return kind;
|
||||||
}
|
}
|
||||||
@@ -971,33 +984,18 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) {
|
|||||||
return `${target.key}${revision}`;
|
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({
|
function RequestConfirmationTargetChip({
|
||||||
interaction,
|
issueId,
|
||||||
target,
|
target,
|
||||||
tone = "default",
|
tone = "default",
|
||||||
}: {
|
}: {
|
||||||
interaction: RequestConfirmationInteraction;
|
issueId: string;
|
||||||
target: RequestConfirmationTarget | null | undefined;
|
target: RequestConfirmationTarget | null | undefined;
|
||||||
tone?: "default" | "subtle";
|
tone?: "default" | "subtle";
|
||||||
}) {
|
}) {
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
const href = requestConfirmationTargetHref({ interaction, target });
|
const href = getRequestConfirmationTargetHref({ issueId, target });
|
||||||
const className = cn(
|
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]",
|
"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"
|
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({
|
function RequestConfirmationResolution({
|
||||||
interaction,
|
interaction,
|
||||||
}: {
|
}: {
|
||||||
interaction: RequestConfirmationInteraction;
|
interaction: RequestConfirmationInteraction;
|
||||||
}) {
|
}) {
|
||||||
const outcome = interaction.result?.outcome;
|
|
||||||
const target = interaction.payload.target ?? null;
|
const target = interaction.payload.target ?? null;
|
||||||
const staleTarget = interaction.result?.staleTarget ?? null;
|
|
||||||
|
|
||||||
if (interaction.status === "accepted") {
|
if (interaction.status === "accepted") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
||||||
<span className="font-medium">Confirmed</span>
|
<span className="font-medium">Confirmed</span>
|
||||||
<RequestConfirmationTargetChip interaction={interaction} target={target} />
|
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.status === "rejected") {
|
if (interaction.status === "rejected") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<ConfirmationDeclinedBlock
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
issueId={interaction.issueId}
|
||||||
<span className="font-medium">Declined</span>
|
result={interaction.result}
|
||||||
<RequestConfirmationTargetChip interaction={interaction} target={target} />
|
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.status === "expired") {
|
if (interaction.status === "expired") {
|
||||||
const expiredByComment = outcome === "superseded_by_comment";
|
|
||||||
const expiredByTargetChange = outcome === "stale_target";
|
|
||||||
return (
|
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">
|
<ConfirmationExpiredBlock
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
|
issueId={interaction.issueId}
|
||||||
{expiredByComment ? "Expired by comment" : "Expired by target change"}
|
result={interaction.result}
|
||||||
</div>
|
target={target}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.status === "failed") {
|
if (interaction.status === "failed") {
|
||||||
return (
|
return <ConfirmationFailedBlock />;
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
This request could not be resolved. Try again or create a new request.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -1188,7 +1226,7 @@ function RequestConfirmationCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<RequestConfirmationTargetChip
|
<RequestConfirmationTargetChip
|
||||||
interaction={interaction}
|
issueId={interaction.issueId}
|
||||||
target={interaction.payload.target}
|
target={interaction.payload.target}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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({
|
export function IssueThreadInteractionCard({
|
||||||
interaction,
|
interaction,
|
||||||
agentMap,
|
agentMap,
|
||||||
@@ -1347,7 +1800,9 @@ export function IssueThreadInteractionCard({
|
|||||||
? "Suggested task tree"
|
? "Suggested task tree"
|
||||||
: interaction.kind === "ask_user_questions"
|
: interaction.kind === "ask_user_questions"
|
||||||
? interaction.payload.title ?? "Questions for the operator"
|
? interaction.payload.title ?? "Questions for the operator"
|
||||||
: "Confirmation requested")}
|
: interaction.kind === "request_checkbox_confirmation"
|
||||||
|
? "Checkbox confirmation requested"
|
||||||
|
: "Confirmation requested")}
|
||||||
</div>
|
</div>
|
||||||
{interaction.summary ? (
|
{interaction.summary ? (
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||||
@@ -1385,6 +1840,12 @@ export function IssueThreadInteractionCard({
|
|||||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||||
onCancelInteraction={onCancelInteraction}
|
onCancelInteraction={onCancelInteraction}
|
||||||
/>
|
/>
|
||||||
|
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||||
|
<RequestCheckboxConfirmationCard
|
||||||
|
interaction={interaction}
|
||||||
|
onAcceptInteraction={onAcceptInteraction}
|
||||||
|
onRejectInteraction={onRejectInteraction}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RequestConfirmationCard
|
<RequestConfirmationCard
|
||||||
interaction={interaction}
|
interaction={interaction}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||||
import type {
|
import type {
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
} from "../lib/issue-thread-interactions";
|
} 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 pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
|
||||||
|
|
||||||
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
|
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
|
||||||
@@ -465,6 +525,145 @@ export const failedRequestConfirmationInteraction = createRequestConfirmationInt
|
|||||||
updatedAt: new Date("2026-04-20T14:42:00.000Z"),
|
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[] = [
|
export const issueThreadInteractionComments: IssueChatComment[] = [
|
||||||
createComment({
|
createComment({
|
||||||
id: "comment-thread-board",
|
id: "comment-thread-board",
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
buildSuggestedTaskTree,
|
buildSuggestedTaskTree,
|
||||||
collectSuggestedTaskClientKeys,
|
collectSuggestedTaskClientKeys,
|
||||||
countSuggestedTaskNodes,
|
countSuggestedTaskNodes,
|
||||||
|
getCheckboxConfirmationSelectedLabels,
|
||||||
|
getRequestConfirmationTargetHref,
|
||||||
getQuestionAnswerLabels,
|
getQuestionAnswerLabels,
|
||||||
|
normalizeRequestConfirmationTargetHref,
|
||||||
} from "./issue-thread-interactions";
|
} from "./issue-thread-interactions";
|
||||||
|
|
||||||
describe("buildSuggestedTaskTree", () => {
|
describe("buildSuggestedTaskTree", () => {
|
||||||
@@ -126,6 +129,110 @@ describe("issue thread interaction helpers", () => {
|
|||||||
})).toBe("Answered 1 question");
|
})).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", () => {
|
it("maps stored option ids back to labels for answered summaries", () => {
|
||||||
const labels = getQuestionAnswerLabels({
|
const labels = getQuestionAnswerLabels({
|
||||||
question: {
|
question: {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export type {
|
|||||||
IssueThreadInteractionBase,
|
IssueThreadInteractionBase,
|
||||||
IssueThreadInteractionContinuationPolicy,
|
IssueThreadInteractionContinuationPolicy,
|
||||||
IssueThreadInteractionStatus,
|
IssueThreadInteractionStatus,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
|
RequestCheckboxConfirmationOption,
|
||||||
|
RequestCheckboxConfirmationPayload,
|
||||||
|
RequestCheckboxConfirmationResult,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
RequestConfirmationIssueDocumentTarget,
|
RequestConfirmationIssueDocumentTarget,
|
||||||
RequestConfirmationPayload,
|
RequestConfirmationPayload,
|
||||||
@@ -26,7 +30,10 @@ import type {
|
|||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
AskUserQuestionsQuestion,
|
AskUserQuestionsQuestion,
|
||||||
IssueThreadInteraction,
|
IssueThreadInteraction,
|
||||||
|
RequestCheckboxConfirmationPayload,
|
||||||
|
RequestCheckboxConfirmationResult,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
|
RequestConfirmationTarget,
|
||||||
SuggestedTaskDraft,
|
SuggestedTaskDraft,
|
||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
SuggestTasksResultCreatedTask,
|
SuggestTasksResultCreatedTask,
|
||||||
@@ -49,9 +56,49 @@ export function isIssueThreadInteraction(
|
|||||||
candidate.kind === "suggest_tasks"
|
candidate.kind === "suggest_tasks"
|
||||||
|| candidate.kind === "ask_user_questions"
|
|| candidate.kind === "ask_user_questions"
|
||||||
|| candidate.kind === "request_confirmation"
|
|| 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(
|
export function buildIssueThreadInteractionSummary(
|
||||||
interaction: IssueThreadInteraction,
|
interaction: IssueThreadInteraction,
|
||||||
) {
|
) {
|
||||||
@@ -83,6 +130,27 @@ export function buildIssueThreadInteractionSummary(
|
|||||||
return "Requested confirmation";
|
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;
|
const count = interaction.payload.questions.length;
|
||||||
if (interaction.status === "answered") {
|
if (interaction.status === "answered") {
|
||||||
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
|
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
|
||||||
|
|||||||
@@ -156,13 +156,17 @@ import {
|
|||||||
type IssueWorkProduct,
|
type IssueWorkProduct,
|
||||||
type IssueWorkMode,
|
type IssueWorkMode,
|
||||||
type IssueThreadInteraction,
|
type IssueThreadInteraction,
|
||||||
|
type RequestCheckboxConfirmationInteraction,
|
||||||
type RequestConfirmationInteraction,
|
type RequestConfirmationInteraction,
|
||||||
type SuggestTasksInteraction,
|
type SuggestTasksInteraction,
|
||||||
type IssueTreeControlMode,
|
type IssueTreeControlMode,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
type CommentReassignment = IssueCommentReassignment;
|
type CommentReassignment = IssueCommentReassignment;
|
||||||
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
|
type ActionableIssueThreadInteraction =
|
||||||
|
| SuggestTasksInteraction
|
||||||
|
| RequestConfirmationInteraction
|
||||||
|
| RequestCheckboxConfirmationInteraction;
|
||||||
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
|
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
|
||||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
@@ -674,6 +678,7 @@ type IssueDetailChatTabProps = {
|
|||||||
onAcceptInteraction: (
|
onAcceptInteraction: (
|
||||||
interaction: ActionableIssueThreadInteraction,
|
interaction: ActionableIssueThreadInteraction,
|
||||||
selectedClientKeys?: string[],
|
selectedClientKeys?: string[],
|
||||||
|
selectedOptionIds?: string[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
|
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
|
||||||
onSubmitInteractionAnswers: (
|
onSubmitInteractionAnswers: (
|
||||||
@@ -2147,10 +2152,12 @@ export function IssueDetail() {
|
|||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
interaction,
|
interaction,
|
||||||
selectedClientKeys,
|
selectedClientKeys,
|
||||||
|
selectedOptionIds,
|
||||||
}: {
|
}: {
|
||||||
interaction: ActionableIssueThreadInteraction;
|
interaction: ActionableIssueThreadInteraction;
|
||||||
selectedClientKeys?: string[];
|
selectedClientKeys?: string[];
|
||||||
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
|
selectedOptionIds?: string[];
|
||||||
|
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys, selectedOptionIds }),
|
||||||
onSuccess: (interaction) => {
|
onSuccess: (interaction) => {
|
||||||
upsertInteractionInCache(interaction);
|
upsertInteractionInCache(interaction);
|
||||||
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
|
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
|
||||||
@@ -2167,6 +2174,8 @@ export function IssueDetail() {
|
|||||||
pushToast({
|
pushToast({
|
||||||
title: interaction.kind === "request_confirmation"
|
title: interaction.kind === "request_confirmation"
|
||||||
? "Request confirmed"
|
? "Request confirmed"
|
||||||
|
: interaction.kind === "request_checkbox_confirmation"
|
||||||
|
? "Selection confirmed"
|
||||||
: skippedCount > 0
|
: skippedCount > 0
|
||||||
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
|
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
|
||||||
: "Suggested tasks accepted",
|
: "Suggested tasks accepted",
|
||||||
@@ -3081,8 +3090,9 @@ export function IssueDetail() {
|
|||||||
const handleAcceptInteraction = useCallback(async (
|
const handleAcceptInteraction = useCallback(async (
|
||||||
interaction: ActionableIssueThreadInteraction,
|
interaction: ActionableIssueThreadInteraction,
|
||||||
selectedClientKeys?: string[],
|
selectedClientKeys?: string[],
|
||||||
|
selectedOptionIds?: string[],
|
||||||
) => {
|
) => {
|
||||||
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
|
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys, selectedOptionIds });
|
||||||
}, [acceptInteraction]);
|
}, [acceptInteraction]);
|
||||||
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
|
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
|
||||||
await rejectInteraction.mutateAsync({ interaction, reason });
|
await rejectInteraction.mutateAsync({ interaction, reason });
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { IssueChatThread } from "@/components/IssueChatThread";
|
|||||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
|
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||||
|
acceptedRequestCheckboxConfirmationInteraction,
|
||||||
acceptedSuggestedTasksInteraction,
|
acceptedSuggestedTasksInteraction,
|
||||||
answeredAskUserQuestionsInteraction,
|
answeredAskUserQuestionsInteraction,
|
||||||
acceptedRequestConfirmationInteraction,
|
acceptedRequestConfirmationInteraction,
|
||||||
|
boundedRequestCheckboxConfirmationInteraction,
|
||||||
commentExpiredRequestConfirmationInteraction,
|
commentExpiredRequestConfirmationInteraction,
|
||||||
failedRequestConfirmationInteraction,
|
failedRequestConfirmationInteraction,
|
||||||
genericPendingRequestConfirmationInteraction,
|
genericPendingRequestConfirmationInteraction,
|
||||||
@@ -15,20 +18,25 @@ import {
|
|||||||
issueThreadInteractionFixtureMeta,
|
issueThreadInteractionFixtureMeta,
|
||||||
issueThreadInteractionLiveRuns,
|
issueThreadInteractionLiveRuns,
|
||||||
issueThreadInteractionTranscriptsByRunId,
|
issueThreadInteractionTranscriptsByRunId,
|
||||||
|
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||||
mixedIssueThreadInteractions,
|
mixedIssueThreadInteractions,
|
||||||
optionalDeclineRequestConfirmationInteraction,
|
optionalDeclineRequestConfirmationInteraction,
|
||||||
pendingAskUserQuestionsInteraction,
|
pendingAskUserQuestionsInteraction,
|
||||||
|
pendingRequestCheckboxConfirmationInteraction,
|
||||||
pendingRequestConfirmationInteraction,
|
pendingRequestConfirmationInteraction,
|
||||||
pendingSuggestedTasksInteraction,
|
pendingSuggestedTasksInteraction,
|
||||||
planApprovalAcceptedRequestConfirmationInteraction,
|
planApprovalAcceptedRequestConfirmationInteraction,
|
||||||
rejectedNoReasonRequestConfirmationInteraction,
|
rejectedNoReasonRequestConfirmationInteraction,
|
||||||
|
rejectedRequestCheckboxConfirmationInteraction,
|
||||||
rejectedRequestConfirmationInteraction,
|
rejectedRequestConfirmationInteraction,
|
||||||
rejectedSuggestedTasksInteraction,
|
rejectedSuggestedTasksInteraction,
|
||||||
|
staleTargetRequestCheckboxConfirmationInteraction,
|
||||||
staleTargetRequestConfirmationInteraction,
|
staleTargetRequestConfirmationInteraction,
|
||||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||||
import type {
|
import type {
|
||||||
AskUserQuestionsAnswer,
|
AskUserQuestionsAnswer,
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
|
RequestCheckboxConfirmationInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
} from "@/lib/issue-thread-interactions";
|
} 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({
|
function AutoOpenDeclineRequestConfirmationCard({
|
||||||
interaction,
|
interaction,
|
||||||
}: {
|
}: {
|
||||||
@@ -224,7 +273,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
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 RequestConfirmationAccepted = RequestConfirmationConfirmed;
|
||||||
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
|
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 = {
|
export const ReviewSurface: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<StoryFrame>
|
<StoryFrame>
|
||||||
|
|||||||
Reference in new issue
Block a user