[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:
@@ -59,6 +59,7 @@ import type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
IssueThreadInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
@@ -161,11 +162,18 @@ interface IssueChatMessageContext {
|
||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
@@ -361,11 +369,18 @@ interface IssueChatThreadProps {
|
||||
stoppingRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
|
||||
@@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import {
|
||||
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
boundedRequestCheckboxConfirmationInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
disabledDeclineReasonRequestConfirmationInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
staleTargetRequestCheckboxConfirmationInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
rejectedSuggestedTasksInteraction,
|
||||
} from "../fixtures/issueThreadInteractionFixtures";
|
||||
@@ -338,4 +343,143 @@ describe("IssueThreadInteractionCard", () => {
|
||||
"This request could not be resolved. Try again or create a new request.",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes pending checkbox options with select-all and clear controls", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
|
||||
expect(checkboxes).toHaveLength(
|
||||
pendingRequestCheckboxConfirmationInteraction.payload.options.length,
|
||||
);
|
||||
expect(checkboxes[0]?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(host.textContent).toContain("0 of 4 options selected");
|
||||
|
||||
const selectAll = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent === "Select all",
|
||||
);
|
||||
act(() => {
|
||||
selectAll?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(host.textContent).toContain("All 4 options selected");
|
||||
|
||||
const clear = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent === "Clear selection",
|
||||
);
|
||||
act(() => {
|
||||
clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(host.textContent).toContain("0 of 4 options selected");
|
||||
});
|
||||
|
||||
it("submits selected option ids on accept", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction,
|
||||
});
|
||||
|
||||
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
|
||||
await act(async () => {
|
||||
(checkboxes[0] as HTMLButtonElement).click();
|
||||
(checkboxes[2] as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Delete selected"),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onAcceptInteraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "request_checkbox_confirmation" }),
|
||||
undefined,
|
||||
["draft-march-report", "draft-scratch-notes"],
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks accept until the minimum selection is met", async () => {
|
||||
const onAcceptInteraction = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: {
|
||||
...boundedRequestCheckboxConfirmationInteraction,
|
||||
payload: {
|
||||
...boundedRequestCheckboxConfirmationInteraction.payload,
|
||||
defaultSelectedOptionIds: [],
|
||||
},
|
||||
},
|
||||
onAcceptInteraction,
|
||||
});
|
||||
|
||||
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Confirm regions"),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onAcceptInteraction).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain("Select at least 2 options.");
|
||||
});
|
||||
|
||||
it("disables remaining checkboxes once the max selection is reached", () => {
|
||||
const host = renderCard({
|
||||
interaction: boundedRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
// Defaults select us-west + us-east; bumping to the 3-item max should lock the rest.
|
||||
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[];
|
||||
const unchecked = checkboxes.filter((box) => box.getAttribute("aria-checked") === "false");
|
||||
act(() => {
|
||||
unchecked[0]?.click();
|
||||
});
|
||||
|
||||
const stillUnchecked = ([...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[])
|
||||
.filter((box) => box.getAttribute("aria-checked") === "false");
|
||||
expect(stillUnchecked.length).toBeGreaterThan(0);
|
||||
expect(stillUnchecked.every((box) => box.hasAttribute("disabled"))).toBe(true);
|
||||
|
||||
const selectAllButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Select all"),
|
||||
);
|
||||
expect(selectAllButton?.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("summarizes large accepted selections by count and bounds the chips", () => {
|
||||
const host = renderCard({
|
||||
interaction: acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Confirmed 42 of 100 options");
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
|
||||
// 42 selected, but only the first 8 labels render inline, then a "+N more" chip.
|
||||
expect(host.textContent).toContain("+34 more");
|
||||
});
|
||||
|
||||
it("stays compact and scrollable with around 100 options", () => {
|
||||
const host = renderCard({
|
||||
interaction: manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(100);
|
||||
const scrollRegion = host.querySelector('[aria-label="Selectable options"]');
|
||||
expect(scrollRegion?.className).toContain("max-h-80");
|
||||
expect(scrollRegion?.className).toContain("overflow-y-auto");
|
||||
});
|
||||
|
||||
it("renders stale-target expiry for checkbox confirmations", () => {
|
||||
const host = renderCard({
|
||||
interaction: staleTargetRequestCheckboxConfirmationInteraction,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Expired by target change");
|
||||
expect(host.textContent).toContain("Plan v3");
|
||||
expect(host.textContent).toContain("Plan v4");
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
buildSuggestedTaskTree,
|
||||
collectSuggestedTaskClientKeys,
|
||||
countSuggestedTaskNodes,
|
||||
getCheckboxConfirmationSelectedLabels,
|
||||
getRequestConfirmationTargetHref,
|
||||
getQuestionAnswerLabels,
|
||||
type AskUserQuestionsAnswer,
|
||||
type AskUserQuestionsInteraction,
|
||||
type IssueThreadInteraction,
|
||||
type RequestCheckboxConfirmationInteraction,
|
||||
type RequestConfirmationInteraction,
|
||||
type RequestConfirmationResult,
|
||||
type RequestConfirmationTarget,
|
||||
type SuggestTasksInteraction,
|
||||
type SuggestTasksResultCreatedTask,
|
||||
@@ -34,11 +38,18 @@ interface IssueThreadInteractionCardProps {
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
@@ -96,6 +107,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
|
||||
return "Ask user questions";
|
||||
case "request_confirmation":
|
||||
return "Confirmation";
|
||||
case "request_checkbox_confirmation":
|
||||
return "Checkbox confirmation";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
@@ -971,33 +984,18 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) {
|
||||
return `${target.key}${revision}`;
|
||||
}
|
||||
|
||||
function requestConfirmationTargetHref({
|
||||
interaction,
|
||||
target,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
target: RequestConfirmationTarget;
|
||||
}) {
|
||||
if (target.href) return target.href;
|
||||
if (target.type === "issue_document") {
|
||||
const issueId = target.issueId ?? interaction.issueId;
|
||||
return `/issues/${issueId}#document-${encodeURIComponent(target.key)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function RequestConfirmationTargetChip({
|
||||
interaction,
|
||||
issueId,
|
||||
target,
|
||||
tone = "default",
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
issueId: string;
|
||||
target: RequestConfirmationTarget | null | undefined;
|
||||
tone?: "default" | "subtle";
|
||||
}) {
|
||||
if (!target) return null;
|
||||
|
||||
const href = requestConfirmationTargetHref({ interaction, target });
|
||||
const href = getRequestConfirmationTargetHref({ issueId, target });
|
||||
const className = cn(
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]",
|
||||
tone === "default"
|
||||
@@ -1027,81 +1025,121 @@ function RequestConfirmationTargetChip({
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationDeclinedBlock({
|
||||
issueId,
|
||||
result,
|
||||
target,
|
||||
}: {
|
||||
issueId: string;
|
||||
result: RequestConfirmationResult | null | undefined;
|
||||
target: RequestConfirmationTarget | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
||||
<span className="font-medium">Declined</span>
|
||||
<RequestConfirmationTargetChip issueId={issueId} target={target} />
|
||||
</div>
|
||||
{result?.reason ? (
|
||||
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
|
||||
{result.reason}
|
||||
</blockquote>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationExpiredBlock({
|
||||
issueId,
|
||||
result,
|
||||
target,
|
||||
}: {
|
||||
issueId: string;
|
||||
result: RequestConfirmationResult | null | undefined;
|
||||
target: RequestConfirmationTarget | null;
|
||||
}) {
|
||||
const outcome = result?.outcome;
|
||||
const staleTarget = result?.staleTarget ?? null;
|
||||
const expiredByComment = outcome === "superseded_by_comment";
|
||||
const expiredByTargetChange = outcome === "stale_target";
|
||||
return (
|
||||
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
|
||||
{expiredByComment ? "Expired by comment" : "Expired by target change"}
|
||||
</div>
|
||||
<p className="leading-6">
|
||||
{expiredByComment
|
||||
? "A board comment superseded this request before it was resolved."
|
||||
: "The requested target changed before this request was resolved."}
|
||||
</p>
|
||||
{expiredByComment && result?.commentId ? (
|
||||
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
|
||||
<a href={`#comment-${result.commentId}`}>Jump to comment</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{expiredByTargetChange ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RequestConfirmationTargetChip
|
||||
issueId={issueId}
|
||||
target={staleTarget}
|
||||
tone="subtle"
|
||||
/>
|
||||
{staleTarget && target ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
|
||||
) : null}
|
||||
<RequestConfirmationTargetChip issueId={issueId} target={target} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationFailedBlock() {
|
||||
return (
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
This request could not be resolved. Try again or create a new request.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestConfirmationResolution({
|
||||
interaction,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
}) {
|
||||
const outcome = interaction.result?.outcome;
|
||||
const target = interaction.payload.target ?? null;
|
||||
const staleTarget = interaction.result?.staleTarget ?? null;
|
||||
|
||||
if (interaction.status === "accepted") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
||||
<span className="font-medium">Confirmed</span>
|
||||
<RequestConfirmationTargetChip interaction={interaction} target={target} />
|
||||
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.status === "rejected") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
||||
<span className="font-medium">Declined</span>
|
||||
<RequestConfirmationTargetChip interaction={interaction} target={target} />
|
||||
</div>
|
||||
{interaction.result?.reason ? (
|
||||
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
|
||||
{interaction.result.reason}
|
||||
</blockquote>
|
||||
) : null}
|
||||
</div>
|
||||
<ConfirmationDeclinedBlock
|
||||
issueId={interaction.issueId}
|
||||
result={interaction.result}
|
||||
target={target}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.status === "expired") {
|
||||
const expiredByComment = outcome === "superseded_by_comment";
|
||||
const expiredByTargetChange = outcome === "stale_target";
|
||||
return (
|
||||
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
|
||||
{expiredByComment ? "Expired by comment" : "Expired by target change"}
|
||||
</div>
|
||||
<p className="leading-6">
|
||||
{expiredByComment
|
||||
? "A board comment superseded this confirmation before it was resolved."
|
||||
: "The requested target changed before this confirmation was resolved."}
|
||||
</p>
|
||||
{expiredByComment && interaction.result?.commentId ? (
|
||||
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
|
||||
<a href={`#comment-${interaction.result.commentId}`}>Jump to comment</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{expiredByTargetChange ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RequestConfirmationTargetChip
|
||||
interaction={interaction}
|
||||
target={staleTarget}
|
||||
tone="subtle"
|
||||
/>
|
||||
{staleTarget && target ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
|
||||
) : null}
|
||||
<RequestConfirmationTargetChip interaction={interaction} target={target} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ConfirmationExpiredBlock
|
||||
issueId={interaction.issueId}
|
||||
result={interaction.result}
|
||||
target={target}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.status === "failed") {
|
||||
return (
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
This request could not be resolved. Try again or create a new request.
|
||||
</p>
|
||||
);
|
||||
return <ConfirmationFailedBlock />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -1188,7 +1226,7 @@ function RequestConfirmationCard({
|
||||
</div>
|
||||
) : null}
|
||||
<RequestConfirmationTargetChip
|
||||
interaction={interaction}
|
||||
issueId={interaction.issueId}
|
||||
target={interaction.payload.target}
|
||||
/>
|
||||
</div>
|
||||
@@ -1289,6 +1327,421 @@ function RequestConfirmationCard({
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKBOX_SUMMARY_LABEL_LIMIT = 8;
|
||||
|
||||
function RequestCheckboxConfirmationResolution({
|
||||
interaction,
|
||||
}: {
|
||||
interaction: RequestCheckboxConfirmationInteraction;
|
||||
}) {
|
||||
const target = interaction.payload.target ?? null;
|
||||
|
||||
if (interaction.status === "accepted") {
|
||||
const totalOptions = interaction.payload.options.length;
|
||||
const selectedLabels = getCheckboxConfirmationSelectedLabels({
|
||||
payload: interaction.payload,
|
||||
result: interaction.result,
|
||||
});
|
||||
const selectedCount = interaction.result?.selectedOptionIds?.length ?? selectedLabels.length;
|
||||
const visibleLabels = selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT);
|
||||
const hiddenCount = selectedLabels.length - visibleLabels.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
|
||||
<span className="font-medium">
|
||||
{selectedCount === 0
|
||||
? "Confirmed with no options selected"
|
||||
: `Confirmed ${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"}`}
|
||||
</span>
|
||||
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
|
||||
</div>
|
||||
{visibleLabels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visibleLabels.map((label, index) => (
|
||||
<TaskField key={`${label}-${index}`} label="Selected" value={label} />
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<span className="inline-flex items-center rounded-sm border border-border/60 bg-transparent px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
+{hiddenCount} more
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.status === "rejected") {
|
||||
return (
|
||||
<ConfirmationDeclinedBlock
|
||||
issueId={interaction.issueId}
|
||||
result={interaction.result}
|
||||
target={target}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.status === "expired") {
|
||||
return (
|
||||
<ConfirmationExpiredBlock
|
||||
issueId={interaction.issueId}
|
||||
result={interaction.result}
|
||||
target={target}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction.status === "failed") {
|
||||
return <ConfirmationFailedBlock />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function CheckboxOptionRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-2.5 border-b border-border/60 px-3 py-2 last:border-b-0 transition-colors",
|
||||
checked ? "bg-sky-500/10" : "hover:bg-sky-500/5",
|
||||
disabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(value) => onToggle(value === true)}
|
||||
aria-label={label}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium leading-5 text-foreground">{label}</div>
|
||||
{description ? (
|
||||
<p className="mt-0.5 text-sm leading-5 text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestCheckboxConfirmationCard({
|
||||
interaction,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
}: {
|
||||
interaction: RequestCheckboxConfirmationInteraction;
|
||||
onAcceptInteraction?: (
|
||||
interaction: RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys: undefined,
|
||||
selectedOptionIds: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
}) {
|
||||
const options = interaction.payload.options;
|
||||
const optionIds = useMemo(() => options.map((option) => option.id), [options]);
|
||||
const validOptionIds = useMemo(() => new Set(optionIds), [optionIds]);
|
||||
const minSelected = interaction.payload.minSelected ?? 0;
|
||||
const maxSelected = interaction.payload.maxSelected ?? null;
|
||||
|
||||
const defaultSelected = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(interaction.payload.defaultSelectedOptionIds ?? []).filter((id) => validOptionIds.has(id)),
|
||||
),
|
||||
[interaction.payload.defaultSelectedOptionIds, validOptionIds],
|
||||
);
|
||||
|
||||
const [selectedOptionIds, setSelectedOptionIds] = useState<Set<string>>(() => new Set(defaultSelected));
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
|
||||
const [rejectAttempted, setRejectAttempted] = useState(false);
|
||||
const [acceptAttempted, setAcceptAttempted] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const optionSeed = useMemo(() => optionIds.join("\n"), [optionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOptionIds(new Set(defaultSelected));
|
||||
setRejectReason(interaction.result?.reason ?? "");
|
||||
setRejectAttempted(false);
|
||||
setAcceptAttempted(false);
|
||||
setActionError(null);
|
||||
if (interaction.status !== "pending") {
|
||||
setRejecting(false);
|
||||
setWorking(null);
|
||||
}
|
||||
// optionSeed guards against option list identity churn for the same interaction.
|
||||
}, [interaction.id, interaction.status, interaction.result?.reason, defaultSelected, optionSeed]);
|
||||
|
||||
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
|
||||
const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
|
||||
const trimmedRejectReason = rejectReason.trim();
|
||||
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0;
|
||||
const declineReasonInvalid = rejectRequiresReason && !canReject;
|
||||
const declineReasonPlaceholder =
|
||||
interaction.payload.declineReasonPlaceholder ?? "Optional: tell the agent what you'd change.";
|
||||
|
||||
const selectedCount = selectedOptionIds.size;
|
||||
const totalOptions = options.length;
|
||||
const atMax = maxSelected != null && selectedCount >= maxSelected;
|
||||
const belowMin = selectedCount < minSelected;
|
||||
const aboveMax = maxSelected != null && selectedCount > maxSelected;
|
||||
const selectionValid = !belowMin && !aboveMax;
|
||||
|
||||
const validationMessage = belowMin
|
||||
? minSelected === 1
|
||||
? "Select at least 1 option."
|
||||
: `Select at least ${minSelected} options.`
|
||||
: aboveMax && maxSelected != null
|
||||
? maxSelected === 1
|
||||
? "Select at most 1 option."
|
||||
: `Select at most ${maxSelected} options.`
|
||||
: null;
|
||||
|
||||
function toggleOption(optionId: string, checked: boolean) {
|
||||
setSelectedOptionIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(optionId);
|
||||
} else {
|
||||
next.delete(optionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectAll() {
|
||||
const capped = maxSelected != null ? optionIds.slice(0, maxSelected) : optionIds;
|
||||
setSelectedOptionIds(new Set(capped));
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
setSelectedOptionIds(new Set());
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
setAcceptAttempted(true);
|
||||
if (!onAcceptInteraction || !selectionValid) return;
|
||||
setWorking("accept");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onAcceptInteraction(interaction, undefined, [...selectedOptionIds]);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setRejectAttempted(true);
|
||||
if (!onRejectInteraction || !canReject) return;
|
||||
setWorking("reject");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction, trimmedRejectReason || undefined);
|
||||
setRejecting(false);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.status !== "pending") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<RequestCheckboxConfirmationResolution interaction={interaction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectionSummary = totalOptions > 0 && selectedCount === totalOptions
|
||||
? `All ${totalOptions} options selected`
|
||||
: `${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"} selected`;
|
||||
const boundsHint = maxSelected != null
|
||||
? `Pick ${minSelected === maxSelected ? `exactly ${maxSelected}` : `${minSelected}–${maxSelected}`}.`
|
||||
: minSelected > 0
|
||||
? `Pick at least ${minSelected}.`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
|
||||
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
|
||||
{interaction.payload.detailsMarkdown ? (
|
||||
<div className="border-t border-border/60 pt-3 text-sm">
|
||||
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
<RequestConfirmationTargetChip
|
||||
issueId={interaction.issueId}
|
||||
target={interaction.payload.target}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{selectionSummary}</span>
|
||||
{boundsHint ? <span>{boundsHint}</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null || selectedCount === totalOptions || (maxSelected != null && selectedCount >= maxSelected)}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null || selectedCount === 0}
|
||||
onClick={handleClearSelection}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Selectable options"
|
||||
className="max-h-80 overflow-y-auto rounded-sm border border-border/70"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const checked = selectedOptionIds.has(option.id);
|
||||
return (
|
||||
<CheckboxOptionRow
|
||||
key={option.id}
|
||||
id={`${interaction.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
checked={checked}
|
||||
disabled={working !== null || (!checked && atMax)}
|
||||
onToggle={(value) => toggleOption(option.id, value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{acceptAttempted && validationMessage ? (
|
||||
<p className="text-xs text-destructive">{validationMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={rejecting ? "outline" : "default"}
|
||||
disabled={!onAcceptInteraction || working !== null}
|
||||
onClick={() => void handleAccept()}
|
||||
>
|
||||
{working === "accept" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Confirming...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.acceptLabel ?? "Confirm selected"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => {
|
||||
if (!allowDeclineReason) {
|
||||
void handleReject();
|
||||
return;
|
||||
}
|
||||
setRejectAttempted(false);
|
||||
setRejecting((current) => !current);
|
||||
}}
|
||||
>
|
||||
{interaction.payload.rejectLabel ?? "Request changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rejecting ? (
|
||||
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder={declineReasonPlaceholder}
|
||||
aria-invalid={rejectAttempted && declineReasonInvalid}
|
||||
className={cn(
|
||||
"min-h-24 bg-background text-sm",
|
||||
rejectAttempted && declineReasonInvalid
|
||||
&& "border-rose-500 focus-visible:ring-rose-500/25",
|
||||
)}
|
||||
/>
|
||||
{rejectAttempted && declineReasonInvalid ? (
|
||||
<p className="text-xs text-destructive">A reason is required.</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setRejecting(false);
|
||||
setRejectAttempted(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => void handleReject()}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.rejectLabel ?? "Request changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueThreadInteractionCard({
|
||||
interaction,
|
||||
agentMap,
|
||||
@@ -1347,7 +1800,9 @@ export function IssueThreadInteractionCard({
|
||||
? "Suggested task tree"
|
||||
: interaction.kind === "ask_user_questions"
|
||||
? interaction.payload.title ?? "Questions for the operator"
|
||||
: "Confirmation requested")}
|
||||
: interaction.kind === "request_checkbox_confirmation"
|
||||
? "Checkbox confirmation requested"
|
||||
: "Confirmation requested")}
|
||||
</div>
|
||||
{interaction.summary ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
@@ -1385,6 +1840,12 @@ export function IssueThreadInteractionCard({
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<RequestCheckboxConfirmationCard
|
||||
interaction={interaction}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
interaction={interaction}
|
||||
|
||||
Reference in New Issue
Block a user