mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Add checkbox confirmation issue-thread UI
Build the RequestCheckboxConfirmationCard for the request_checkbox_confirmation interaction kind (PAP-10433), wiring it into the existing IssueThreadInteractionCard shell and IssueDetail accept/reject flow. - Dense Paperclip checkbox rows with aligned labels/descriptions, selected count, frontend-owned Select all / Clear selection, and a bounded scroll region. - Accept submits selectedOptionIds (not suggest-task selectedClientKeys). - min/max selection validation with disabled-at-max rows and a validation message. - Resolved states: accepted summarizes by count first and bounds inline chips (+N more); rejected/request-changes, stale-target, and failed reuse the shared confirmation resolution blocks. - Refactor request-confirmation target chip/href helpers to be issueId-based so the checkbox card can reuse staleness display. - Fixtures, Storybook states (pending, bounded, accepted, accepted-many, rejected, stale, ~100 options), and focused UI/lib tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
356adb443c
commit
a24117bb5a
9 files changed
+1181
-69
No files matched your search
@@ -213,7 +213,7 @@ export const issuesApi = {
|
||||
acceptInteraction: (
|
||||
id: string,
|
||||
interactionId: string,
|
||||
data?: { selectedClientKeys?: string[] },
|
||||
data?: { selectedClientKeys?: string[]; selectedOptionIds?: string[] },
|
||||
) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
|
||||
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
IssueThreadInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
@@ -161,11 +162,18 @@ interface IssueChatMessageContext {
|
||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
@@ -361,11 +369,18 @@ interface IssueChatThreadProps {
|
||||
stoppingRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
interaction:
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
onSubmitInteractionAnswers?: (
|
||||
|
||||
@@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import {
|
||||
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
boundedRequestCheckboxConfirmationInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
disabledDeclineReasonRequestConfirmationInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
staleTargetRequestCheckboxConfirmationInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
rejectedSuggestedTasksInteraction,
|
||||
} from "../fixtures/issueThreadInteractionFixtures";
|
||||
@@ -338,4 +343,138 @@ 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);
|
||||
});
|
||||
|
||||
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,14 @@ import {
|
||||
buildSuggestedTaskTree,
|
||||
collectSuggestedTaskClientKeys,
|
||||
countSuggestedTaskNodes,
|
||||
getCheckboxConfirmationSelectedLabels,
|
||||
getQuestionAnswerLabels,
|
||||
type AskUserQuestionsAnswer,
|
||||
type AskUserQuestionsInteraction,
|
||||
type IssueThreadInteraction,
|
||||
type RequestCheckboxConfirmationInteraction,
|
||||
type RequestConfirmationInteraction,
|
||||
type RequestConfirmationResult,
|
||||
type RequestConfirmationTarget,
|
||||
type SuggestTasksInteraction,
|
||||
type SuggestTasksResultCreatedTask,
|
||||
@@ -34,11 +37,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 +106,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;
|
||||
}
|
||||
@@ -972,32 +984,32 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) {
|
||||
}
|
||||
|
||||
function requestConfirmationTargetHref({
|
||||
interaction,
|
||||
issueId,
|
||||
target,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
issueId: string;
|
||||
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)}`;
|
||||
const targetIssueId = target.issueId ?? issueId;
|
||||
return `/issues/${targetIssueId}#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 = requestConfirmationTargetHref({ 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 +1039,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 +1240,7 @@ function RequestConfirmationCard({
|
||||
</div>
|
||||
) : null}
|
||||
<RequestConfirmationTargetChip
|
||||
interaction={interaction}
|
||||
issueId={interaction.issueId}
|
||||
target={interaction.payload.target}
|
||||
/>
|
||||
</div>
|
||||
@@ -1289,6 +1341,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}
|
||||
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 +1814,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 +1854,12 @@ export function IssueThreadInteractionCard({
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
/>
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<RequestCheckboxConfirmationCard
|
||||
interaction={interaction}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationCard
|
||||
interaction={interaction}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
@@ -224,6 +225,65 @@ function createRequestConfirmationInteraction(
|
||||
};
|
||||
}
|
||||
|
||||
function createRequestCheckboxConfirmationInteraction(
|
||||
overrides: Partial<RequestCheckboxConfirmationInteraction>,
|
||||
): RequestCheckboxConfirmationInteraction {
|
||||
return {
|
||||
id: "interaction-checkbox-default",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
title: "Choose the stale drafts to delete",
|
||||
summary:
|
||||
"The agent found several stale draft documents and needs the board to confirm exactly which ones to remove.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-20T14:46:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:46:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Check the draft documents you want me to delete.",
|
||||
detailsMarkdown:
|
||||
"Only the checked items will be deleted. Leave an item unchecked to keep it for now.",
|
||||
options: [
|
||||
{
|
||||
id: "draft-march-report",
|
||||
label: "Old draft report",
|
||||
description: "Created by QA during the March test pass.",
|
||||
},
|
||||
{
|
||||
id: "draft-spec-v1",
|
||||
label: "Spec v1 (superseded)",
|
||||
description: "Replaced by the approved v2 specification.",
|
||||
},
|
||||
{
|
||||
id: "draft-scratch-notes",
|
||||
label: "Scratch notes",
|
||||
description: "Unstructured notes from the kickoff call.",
|
||||
},
|
||||
{
|
||||
id: "draft-import-sample",
|
||||
label: "Import sample fixture",
|
||||
description: "Temporary fixture used while wiring the importer.",
|
||||
},
|
||||
],
|
||||
defaultSelectedOptionIds: [],
|
||||
minSelected: 0,
|
||||
maxSelected: null,
|
||||
acceptLabel: "Delete selected",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: false,
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
|
||||
|
||||
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
|
||||
@@ -465,6 +525,145 @@ export const failedRequestConfirmationInteraction = createRequestConfirmationInt
|
||||
updatedAt: new Date("2026-04-20T14:42:00.000Z"),
|
||||
});
|
||||
|
||||
export const pendingRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({});
|
||||
|
||||
export const boundedRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-bounded",
|
||||
title: "Pick the regions to deploy first",
|
||||
summary: "Choose between two and three regions for the Phase 1 rollout.",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Which regions should we deploy to initially?",
|
||||
detailsMarkdown: "Select at least 2 and at most 3 regions.",
|
||||
options: [
|
||||
{ id: "us-west", label: "US West (Oregon)", description: "Lowest latency for our primary user base." },
|
||||
{ id: "us-east", label: "US East (Virginia)", description: "Redundancy and east coast coverage." },
|
||||
{ id: "eu-west", label: "EU West (Ireland)", description: "GDPR compliance and European users." },
|
||||
{ id: "ap-southeast", label: "AP Southeast (Singapore)", description: "Asia-Pacific expansion." },
|
||||
],
|
||||
defaultSelectedOptionIds: ["us-west", "us-east"],
|
||||
minSelected: 2,
|
||||
maxSelected: 3,
|
||||
acceptLabel: "Confirm regions",
|
||||
rejectLabel: "Reconsider",
|
||||
rejectRequiresReason: true,
|
||||
rejectReasonLabel: "What should change about the region set?",
|
||||
},
|
||||
});
|
||||
|
||||
export const acceptedRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-accepted",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:49:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:49:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
selectedOptionIds: ["draft-march-report", "draft-spec-v1"],
|
||||
},
|
||||
});
|
||||
|
||||
const manyOptionList = Array.from({ length: 100 }, (_, index) => {
|
||||
const number = index + 1;
|
||||
return {
|
||||
id: `record-${number}`,
|
||||
label: `Customer record #${number}`,
|
||||
description: number % 4 === 0
|
||||
? "Flagged as a possible duplicate during the last import."
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
export const manyOptionsRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-many",
|
||||
title: "Select the customer records to archive",
|
||||
summary: "The cleanup job found 100 stale customer records. Confirm which ones to archive.",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Check every customer record you want archived.",
|
||||
detailsMarkdown: "The list scrolls. Use Select all / Clear selection to move quickly.",
|
||||
options: manyOptionList,
|
||||
defaultSelectedOptionIds: [],
|
||||
minSelected: 0,
|
||||
maxSelected: null,
|
||||
acceptLabel: "Archive selected",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const acceptedManyRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-many-accepted",
|
||||
status: "accepted",
|
||||
title: "Select the customer records to archive",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:52:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:52:00.000Z"),
|
||||
payload: manyOptionsRequestCheckboxConfirmationInteraction.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
selectedOptionIds: manyOptionList.slice(0, 42).map((option) => option.id),
|
||||
},
|
||||
});
|
||||
|
||||
export const rejectedRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-rejected",
|
||||
status: "rejected",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T14:50:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:50:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: "Don't delete anything yet — let me confirm with the data owner first.",
|
||||
},
|
||||
});
|
||||
|
||||
export const staleTargetRequestCheckboxConfirmationInteraction =
|
||||
createRequestCheckboxConfirmationInteraction({
|
||||
id: "interaction-checkbox-stale",
|
||||
status: "expired",
|
||||
resolvedByAgentId: "agent-codex",
|
||||
resolvedAt: new Date("2026-04-20T14:51:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T14:51:00.000Z"),
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Check the draft documents you want me to delete.",
|
||||
acceptLabel: "Delete selected",
|
||||
rejectLabel: "Request changes",
|
||||
options: [
|
||||
{ id: "draft-march-report", label: "Old draft report" },
|
||||
{ id: "draft-spec-v1", label: "Spec v1 (superseded)" },
|
||||
],
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "44444444-4444-4444-8444-444444444444",
|
||||
revisionNumber: 4,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget: {
|
||||
type: "issue_document",
|
||||
issueId: issueThreadInteractionFixtureMeta.issueId,
|
||||
key: "plan",
|
||||
revisionId: "11111111-1111-4111-8111-111111111111",
|
||||
revisionNumber: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const issueThreadInteractionComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-thread-board",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildSuggestedTaskTree,
|
||||
collectSuggestedTaskClientKeys,
|
||||
countSuggestedTaskNodes,
|
||||
getCheckboxConfirmationSelectedLabels,
|
||||
getQuestionAnswerLabels,
|
||||
} from "./issue-thread-interactions";
|
||||
|
||||
@@ -126,6 +127,65 @@ describe("issue thread interaction helpers", () => {
|
||||
})).toBe("Answered 1 question");
|
||||
});
|
||||
|
||||
it("summarizes checkbox confirmation interactions by count", () => {
|
||||
const base = {
|
||||
id: "interaction-checkbox",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "request_checkbox_confirmation" as const,
|
||||
continuationPolicy: "wake_assignee" as const,
|
||||
createdAt: "2026-04-06T12:00:00.000Z",
|
||||
updatedAt: "2026-04-06T12:00:00.000Z",
|
||||
payload: {
|
||||
version: 1 as const,
|
||||
prompt: "Pick items",
|
||||
options: [
|
||||
{ id: "a", label: "A" },
|
||||
{ id: "b", label: "B" },
|
||||
{ id: "c", label: "C" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildIssueThreadInteractionSummary({ ...base, status: "pending" }))
|
||||
.toBe("Requested a selection from 3 options");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary({
|
||||
...base,
|
||||
status: "accepted",
|
||||
result: { version: 1, outcome: "accepted", selectedOptionIds: ["a", "c"] },
|
||||
})).toBe("Confirmed 2 of 3 options");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary({
|
||||
...base,
|
||||
status: "accepted",
|
||||
result: { version: 1, outcome: "accepted", selectedOptionIds: [] },
|
||||
})).toBe("Confirmed with no options selected");
|
||||
|
||||
expect(buildIssueThreadInteractionSummary({
|
||||
...base,
|
||||
status: "expired",
|
||||
result: { version: 1, outcome: "stale_target" },
|
||||
})).toBe("Selection expired after target changed");
|
||||
});
|
||||
|
||||
it("maps selected checkbox option ids back to labels", () => {
|
||||
const labels = getCheckboxConfirmationSelectedLabels({
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Pick items",
|
||||
options: [
|
||||
{ id: "a", label: "Alpha" },
|
||||
{ id: "b", label: "Bravo" },
|
||||
{ id: "c", label: "Charlie" },
|
||||
],
|
||||
},
|
||||
result: { version: 1, outcome: "accepted", selectedOptionIds: ["c", "a", "missing"] },
|
||||
});
|
||||
|
||||
expect(labels).toEqual(["Charlie", "Alpha"]);
|
||||
});
|
||||
|
||||
it("maps stored option ids back to labels for answered summaries", () => {
|
||||
const labels = getQuestionAnswerLabels({
|
||||
question: {
|
||||
|
||||
@@ -10,6 +10,10 @@ export type {
|
||||
IssueThreadInteractionBase,
|
||||
IssueThreadInteractionContinuationPolicy,
|
||||
IssueThreadInteractionStatus,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestCheckboxConfirmationOption,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
RequestConfirmationInteraction,
|
||||
RequestConfirmationIssueDocumentTarget,
|
||||
RequestConfirmationPayload,
|
||||
@@ -26,6 +30,8 @@ import type {
|
||||
AskUserQuestionsInteraction,
|
||||
AskUserQuestionsQuestion,
|
||||
IssueThreadInteraction,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestedTaskDraft,
|
||||
SuggestTasksInteraction,
|
||||
@@ -49,9 +55,24 @@ export function isIssueThreadInteraction(
|
||||
candidate.kind === "suggest_tasks"
|
||||
|| candidate.kind === "ask_user_questions"
|
||||
|| candidate.kind === "request_confirmation"
|
||||
|| candidate.kind === "request_checkbox_confirmation"
|
||||
);
|
||||
}
|
||||
|
||||
export function getCheckboxConfirmationSelectedLabels(args: {
|
||||
payload: RequestCheckboxConfirmationPayload;
|
||||
result?: RequestCheckboxConfirmationResult | null;
|
||||
}): string[] {
|
||||
const { payload, result } = args;
|
||||
const selectedIds = result?.selectedOptionIds ?? [];
|
||||
const optionLabelById = new Map(
|
||||
payload.options.map((option) => [option.id, option.label] as const),
|
||||
);
|
||||
return selectedIds
|
||||
.map((optionId) => optionLabelById.get(optionId))
|
||||
.filter((label): label is string => typeof label === "string");
|
||||
}
|
||||
|
||||
export function buildIssueThreadInteractionSummary(
|
||||
interaction: IssueThreadInteraction,
|
||||
) {
|
||||
@@ -83,6 +104,27 @@ export function buildIssueThreadInteractionSummary(
|
||||
return "Requested confirmation";
|
||||
}
|
||||
|
||||
if (interaction.kind === "request_checkbox_confirmation") {
|
||||
const optionCount = interaction.payload.options.length;
|
||||
if (interaction.status === "accepted") {
|
||||
const selectedCount = interaction.result?.selectedOptionIds?.length ?? 0;
|
||||
if (selectedCount === 0) return "Confirmed with no options selected";
|
||||
return selectedCount === 1
|
||||
? `Confirmed 1 of ${optionCount} options`
|
||||
: `Confirmed ${selectedCount} of ${optionCount} options`;
|
||||
}
|
||||
if (interaction.status === "rejected") return "Declined selection";
|
||||
if (interaction.status === "expired") {
|
||||
const outcome = interaction.result?.outcome;
|
||||
if (outcome === "superseded_by_comment") return "Selection expired after comment";
|
||||
if (outcome === "stale_target") return "Selection expired after target changed";
|
||||
return "Selection expired";
|
||||
}
|
||||
return optionCount === 1
|
||||
? "Requested a selection from 1 option"
|
||||
: `Requested a selection from ${optionCount} options`;
|
||||
}
|
||||
|
||||
const count = interaction.payload.questions.length;
|
||||
if (interaction.status === "answered") {
|
||||
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
|
||||
|
||||
@@ -156,13 +156,17 @@ import {
|
||||
type IssueWorkProduct,
|
||||
type IssueWorkMode,
|
||||
type IssueThreadInteraction,
|
||||
type RequestCheckboxConfirmationInteraction,
|
||||
type RequestConfirmationInteraction,
|
||||
type SuggestTasksInteraction,
|
||||
type IssueTreeControlMode,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
|
||||
type ActionableIssueThreadInteraction =
|
||||
| SuggestTasksInteraction
|
||||
| RequestConfirmationInteraction
|
||||
| RequestCheckboxConfirmationInteraction;
|
||||
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
runId?: string | null;
|
||||
@@ -674,6 +678,7 @@ type IssueDetailChatTabProps = {
|
||||
onAcceptInteraction: (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => Promise<void>;
|
||||
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
|
||||
onSubmitInteractionAnswers: (
|
||||
@@ -2147,10 +2152,12 @@ export function IssueDetail() {
|
||||
mutationFn: ({
|
||||
interaction,
|
||||
selectedClientKeys,
|
||||
selectedOptionIds,
|
||||
}: {
|
||||
interaction: ActionableIssueThreadInteraction;
|
||||
selectedClientKeys?: string[];
|
||||
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
|
||||
selectedOptionIds?: string[];
|
||||
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys, selectedOptionIds }),
|
||||
onSuccess: (interaction) => {
|
||||
upsertInteractionInCache(interaction);
|
||||
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
|
||||
@@ -2167,6 +2174,8 @@ export function IssueDetail() {
|
||||
pushToast({
|
||||
title: interaction.kind === "request_confirmation"
|
||||
? "Request confirmed"
|
||||
: interaction.kind === "request_checkbox_confirmation"
|
||||
? "Selection confirmed"
|
||||
: skippedCount > 0
|
||||
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
|
||||
: "Suggested tasks accepted",
|
||||
@@ -3081,8 +3090,9 @@ export function IssueDetail() {
|
||||
const handleAcceptInteraction = useCallback(async (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
selectedClientKeys?: string[],
|
||||
selectedOptionIds?: string[],
|
||||
) => {
|
||||
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
|
||||
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys, selectedOptionIds });
|
||||
}, [acceptInteraction]);
|
||||
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
|
||||
await rejectInteraction.mutateAsync({ interaction, reason });
|
||||
|
||||
@@ -4,9 +4,12 @@ import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
acceptedManyRequestCheckboxConfirmationInteraction,
|
||||
acceptedRequestCheckboxConfirmationInteraction,
|
||||
acceptedSuggestedTasksInteraction,
|
||||
answeredAskUserQuestionsInteraction,
|
||||
acceptedRequestConfirmationInteraction,
|
||||
boundedRequestCheckboxConfirmationInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
genericPendingRequestConfirmationInteraction,
|
||||
@@ -15,20 +18,25 @@ import {
|
||||
issueThreadInteractionFixtureMeta,
|
||||
issueThreadInteractionLiveRuns,
|
||||
issueThreadInteractionTranscriptsByRunId,
|
||||
manyOptionsRequestCheckboxConfirmationInteraction,
|
||||
mixedIssueThreadInteractions,
|
||||
optionalDeclineRequestConfirmationInteraction,
|
||||
pendingAskUserQuestionsInteraction,
|
||||
pendingRequestCheckboxConfirmationInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
planApprovalAcceptedRequestConfirmationInteraction,
|
||||
rejectedNoReasonRequestConfirmationInteraction,
|
||||
rejectedRequestCheckboxConfirmationInteraction,
|
||||
rejectedRequestConfirmationInteraction,
|
||||
rejectedSuggestedTasksInteraction,
|
||||
staleTargetRequestCheckboxConfirmationInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import type {
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "@/lib/issue-thread-interactions";
|
||||
@@ -191,6 +199,47 @@ function InteractiveRequestConfirmationCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveRequestCheckboxConfirmationCard({
|
||||
pending,
|
||||
accepted,
|
||||
rejected,
|
||||
}: {
|
||||
pending: RequestCheckboxConfirmationInteraction;
|
||||
accepted: RequestCheckboxConfirmationInteraction;
|
||||
rejected: RequestCheckboxConfirmationInteraction;
|
||||
}) {
|
||||
const [interaction, setInteraction] = useState<RequestCheckboxConfirmationInteraction>(pending);
|
||||
|
||||
return (
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAcceptInteraction={(_interaction, _selectedClientKeys, selectedOptionIds) =>
|
||||
setInteraction({
|
||||
...accepted,
|
||||
payload: pending.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
selectedOptionIds: selectedOptionIds ?? [],
|
||||
},
|
||||
})}
|
||||
onRejectInteraction={(_interaction, reason) =>
|
||||
setInteraction({
|
||||
...rejected,
|
||||
payload: pending.payload,
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: reason || rejected.result?.reason || null,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoOpenDeclineRequestConfirmationCard({
|
||||
interaction,
|
||||
}: {
|
||||
@@ -224,7 +273,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Interaction cards for `suggest_tasks`, `ask_user_questions`, and `request_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
|
||||
"Interaction cards for `suggest_tasks`, `ask_user_questions`, `request_confirmation`, and `request_checkbox_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -524,6 +573,129 @@ export const RequestConfirmationFailed: Story = {
|
||||
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
|
||||
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
|
||||
|
||||
export const CheckboxConfirmationPending: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Pending checkbox confirmation"
|
||||
description="Board users select any number of options, with frontend-owned select-all and clear controls."
|
||||
>
|
||||
<InteractiveRequestCheckboxConfirmationCard
|
||||
pending={pendingRequestCheckboxConfirmationInteraction}
|
||||
accepted={acceptedRequestCheckboxConfirmationInteraction}
|
||||
rejected={rejectedRequestCheckboxConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationBounded: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Min/max constrained selection"
|
||||
description="The card enforces minimum and maximum selection counts and requires a decline reason."
|
||||
>
|
||||
<InteractiveRequestCheckboxConfirmationCard
|
||||
pending={boundedRequestCheckboxConfirmationInteraction}
|
||||
accepted={acceptedRequestCheckboxConfirmationInteraction}
|
||||
rejected={rejectedRequestCheckboxConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationAccepted: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Accepted checkbox confirmation"
|
||||
description="The resolved state leads with a count and lists the selected labels."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationAcceptedMany: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Accepted large selection"
|
||||
description="Large resolved selections summarize by count first and bound the inline chips."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={acceptedManyRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationRejected: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Declined checkbox confirmation"
|
||||
description="The decline reason stays attached to the request in the thread."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={rejectedRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationStaleTarget: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Expired by target change"
|
||||
description="The watched plan revision moved before the selection was confirmed."
|
||||
>
|
||||
<IssueThreadInteractionCard
|
||||
interaction={staleTargetRequestCheckboxConfirmationInteraction}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const CheckboxConfirmationManyOptions: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
<ScenarioCard
|
||||
title="Around 100 options"
|
||||
description="The list stays compact inside a bounded scroll region even with 100 options."
|
||||
>
|
||||
<InteractiveRequestCheckboxConfirmationCard
|
||||
pending={manyOptionsRequestCheckboxConfirmationInteraction}
|
||||
accepted={acceptedManyRequestCheckboxConfirmationInteraction}
|
||||
rejected={rejectedRequestCheckboxConfirmationInteraction}
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</StoryFrame>
|
||||
),
|
||||
};
|
||||
|
||||
export const ReviewSurface: Story = {
|
||||
render: () => (
|
||||
<StoryFrame>
|
||||
|
||||
Reference in new issue
Block a user