diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index b3409342703..4fc2e7f58de 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -213,7 +213,7 @@ export const issuesApi = { acceptInteraction: ( id: string, interactionId: string, - data?: { selectedClientKeys?: string[] }, + data?: { selectedClientKeys?: string[]; selectedOptionIds?: string[] }, ) => api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), rejectInteraction: (id: string, interactionId: string, reason?: string) => diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index a7642d91a39..aeab5d70140 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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; onImageClick?: (src: string) => void; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, selectedClientKeys?: string[], + selectedOptionIds?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, reason?: string, ) => Promise | 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; onRejectInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 1ba1762ace0..9687ae8b61f 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -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); + }); }); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 73239fcf686..5b683cbafd1 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -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 | null; onAcceptInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, selectedClientKeys?: string[], + selectedOptionIds?: string[], ) => Promise | void; onRejectInteraction?: ( - interaction: SuggestTasksInteraction | RequestConfirmationInteraction, + interaction: + | SuggestTasksInteraction + | RequestConfirmationInteraction + | RequestCheckboxConfirmationInteraction, reason?: string, ) => Promise | 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 ( +
+
+ Declined + +
+ {result?.reason ? ( +
+ {result.reason} +
+ ) : null} +
+ ); +} + +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 ( +
+
+ {expiredByComment ? "Expired by comment" : "Expired by target change"} +
+

+ {expiredByComment + ? "A board comment superseded this request before it was resolved." + : "The requested target changed before this request was resolved."} +

+ {expiredByComment && result?.commentId ? ( + + ) : null} + {expiredByTargetChange ? ( +
+ + {staleTarget && target ? ( + + ) : null} + +
+ ) : null} +
+ ); +} + +function ConfirmationFailedBlock() { + return ( +

+ This request could not be resolved. Try again or create a new request. +

+ ); +} + 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 (
Confirmed - +
); } if (interaction.status === "rejected") { return ( -
-
- Declined - -
- {interaction.result?.reason ? ( -
- {interaction.result.reason} -
- ) : null} -
+ ); } if (interaction.status === "expired") { - const expiredByComment = outcome === "superseded_by_comment"; - const expiredByTargetChange = outcome === "stale_target"; return ( -
-
- {expiredByComment ? "Expired by comment" : "Expired by target change"} -
-

- {expiredByComment - ? "A board comment superseded this confirmation before it was resolved." - : "The requested target changed before this confirmation was resolved."} -

- {expiredByComment && interaction.result?.commentId ? ( - - ) : null} - {expiredByTargetChange ? ( -
- - {staleTarget && target ? ( - - ) : null} - -
- ) : null} -
+ ); } if (interaction.status === "failed") { - return ( -

- This request could not be resolved. Try again or create a new request. -

- ); + return ; } return null; @@ -1188,7 +1240,7 @@ function RequestConfirmationCard({ ) : null} @@ -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 ( +
+
+ + {selectedCount === 0 + ? "Confirmed with no options selected" + : `Confirmed ${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"}`} + + +
+ {visibleLabels.length > 0 ? ( +
+ {visibleLabels.map((label, index) => ( + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} more + + ) : null} +
+ ) : null} +
+ ); + } + + if (interaction.status === "rejected") { + return ( + + ); + } + + if (interaction.status === "expired") { + return ( + + ); + } + + if (interaction.status === "failed") { + return ; + } + + 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 ( + + ); +} + +function RequestCheckboxConfirmationCard({ + interaction, + onAcceptInteraction, + onRejectInteraction, +}: { + interaction: RequestCheckboxConfirmationInteraction; + onAcceptInteraction?: ( + interaction: RequestCheckboxConfirmationInteraction, + selectedClientKeys: undefined, + selectedOptionIds: string[], + ) => Promise | void; + onRejectInteraction?: ( + interaction: RequestCheckboxConfirmationInteraction, + reason?: string, + ) => Promise | 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>(() => 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(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 ( +
+ +
+ ); + } + + 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 ( +
+
+
{interaction.payload.prompt}
+ {interaction.payload.detailsMarkdown ? ( +
+ {interaction.payload.detailsMarkdown} +
+ ) : null} + +
+ +
+
+
+ {selectionSummary} + {boundsHint ? {boundsHint} : null} +
+
+ + +
+
+ +
+ {options.map((option) => { + const checked = selectedOptionIds.has(option.id); + return ( + toggleOption(option.id, value)} + /> + ); + })} +
+ + {acceptAttempted && validationMessage ? ( +

{validationMessage}

+ ) : null} + +
+ + +
+ + {rejecting ? ( +
+