diff --git a/src/agentMode/ui/AskUserQuestionCard.test.tsx b/src/agentMode/ui/AskUserQuestionCard.test.tsx new file mode 100644 index 00000000..81754cc5 --- /dev/null +++ b/src/agentMode/ui/AskUserQuestionCard.test.tsx @@ -0,0 +1,118 @@ +import { AskUserQuestionCard } from "@/agentMode/ui/AskUserQuestionCard"; +import type { AskUserQuestionPrompt, SessionId } from "@/agentMode/session/types"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; + +const SESSION_ID = "s1" as SessionId; +const REQUEST_ID = "req-1"; + +function makeRequest(questions: AskUserQuestionPrompt["questions"]): AskUserQuestionPrompt { + return { sessionId: SESSION_ID, requestId: REQUEST_ID, questions }; +} + +function renderCard(request: AskUserQuestionPrompt, onResolve: jest.Mock) { + return render(); +} + +// The "Other" row's accessible name is its label plus the "Type your own +// response" description (jsdom concatenates them with no separator), so anchor +// on the leading "Other" rather than an exact match. Preset labels here are +// A/B/C, so this can't collide with a preset. +function getOtherControl(role: "radio" | "checkbox"): HTMLElement { + return screen.getByRole(role, { name: /^other/i }); +} + +const submitButton = (): HTMLElement => screen.getByRole("button", { name: /submit/i }); +const cancelButton = (): HTMLElement => screen.getByRole("button", { name: /cancel/i }); +const otherTextarea = (): HTMLElement => screen.getByPlaceholderText(/type your response/i); + +describe("AskUserQuestionCard custom 'Other' response", () => { + it("single-select 'Other' → the trimmed typed text is the answer", () => { + const onResolve = jest.fn(); + const request = makeRequest([ + { + question: "When do we ship?", + options: [{ label: "A" }, { label: "B" }], + }, + ]); + renderCard(request, onResolve); + + fireEvent.click(getOtherControl("radio")); + // Surrounding whitespace proves the answer is trimmed on submit. + fireEvent.change(otherTextarea(), { target: { value: " ship it Friday " } }); + fireEvent.click(submitButton()); + + expect(onResolve).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, { "When do we ship?": "ship it Friday" }); + }); + + it("multi-select presets + 'Other' → checked labels and trimmed text joined with ', '", () => { + const onResolve = jest.fn(); + const request = makeRequest([ + { + question: "Pick tasks", + multiSelect: true, + options: [{ label: "A" }, { label: "B" }, { label: "C" }], + }, + ]); + renderCard(request, onResolve); + + fireEvent.click(screen.getByRole("checkbox", { name: /^A$/ })); + fireEvent.click(screen.getByRole("checkbox", { name: /^C$/ })); + fireEvent.click(getOtherControl("checkbox")); + fireEvent.change(otherTextarea(), { target: { value: " rollback plan " } }); + fireEvent.click(submitButton()); + + expect(onResolve).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, { "Pick tasks": "A, C, rollback plan" }); + }); + + it("disables Submit while 'Other' is armed with empty text, enabling it once text is typed", () => { + const onResolve = jest.fn(); + const request = makeRequest([ + { + question: "When do we ship?", + options: [{ label: "A" }, { label: "B" }], + }, + ]); + renderCard(request, onResolve); + + fireEvent.click(getOtherControl("radio")); + expect((submitButton() as HTMLButtonElement).disabled).toBe(true); + + fireEvent.change(otherTextarea(), { target: { value: "x" } }); + expect((submitButton() as HTMLButtonElement).disabled).toBe(false); + }); + + it("Cancel resolves with an empty answer map", () => { + const onResolve = jest.fn(); + const request = makeRequest([ + { + question: "When do we ship?", + options: [{ label: "A" }, { label: "B" }], + }, + ]); + renderCard(request, onResolve); + + fireEvent.click(cancelButton()); + + expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, {}); + }); + + it("regression: single-select preset still resolves with the chosen label", () => { + const onResolve = jest.fn(); + const request = makeRequest([ + { + question: "When do we ship?", + options: [{ label: "A" }, { label: "B" }], + }, + ]); + renderCard(request, onResolve); + + fireEvent.click(screen.getByRole("radio", { name: /^A$/ })); + fireEvent.click(submitButton()); + + expect(onResolve).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, { "When do we ship?": "A" }); + }); +}); diff --git a/src/agentMode/ui/AskUserQuestionCard.tsx b/src/agentMode/ui/AskUserQuestionCard.tsx index fc534fc4..39f44405 100644 --- a/src/agentMode/ui/AskUserQuestionCard.tsx +++ b/src/agentMode/ui/AskUserQuestionCard.tsx @@ -13,8 +13,18 @@ interface AskUserQuestionCardProps { onResolve: (requestId: string, answers: AgentQuestionAnswers) => void; } -/** A single-select pick is "answered" once a label is chosen; multi-select is always satisfiable. */ -function isAnswered(question: AgentQuestion, selection: string | Set | undefined): boolean { +/** + * Whether a question has enough input to submit. Mirrors Claude Code's "Other" + * affordance: an active "Other" row is only satisfied once its free-form text + * is non-empty, so it can gate Submit independently of the preset options. + */ +function isAnswered( + question: AgentQuestion, + selection: string | Set | undefined, + otherActive: boolean, + customText: string +): boolean { + if (otherActive) return customText.trim().length > 0; if (question.multiSelect) return true; return typeof selection === "string" && selection !== ""; } @@ -31,6 +41,10 @@ function isAnswered(question: AgentQuestion, selection: string | Set | u * SDK's single-response contract. Submitting routes the answers map through the * ask-question prompter's happy path; Cancel resolves with `{}`, which the * bridge maps to the "User cancelled the question" deny. + * + * Each question also offers an "Other" row that reveals a free-form textarea, + * so the user can answer when none of the agent's options fit — the typed text + * is folded into the same plain-string answer the presets produce. */ export const AskUserQuestionCard: React.FC = ({ request, onResolve }) => { const { questions, requestId } = request; @@ -38,10 +52,17 @@ export const AskUserQuestionCard: React.FC = ({ reques const [activeTab, setActiveTab] = useState(0); // Per-question selection: a single label for radio, a Set of labels for checkbox. const [selections, setSelections] = useState>>({}); + // Per-question "Other" state, kept out of the option-label value space so a + // custom answer can never collide with a real option label. + const [otherActive, setOtherActive] = useState>({}); + const [customTexts, setCustomTexts] = useState>({}); - // Gate Submit until every single-select question has a pick. Multi-select - // questions may be left empty (the user can decline every option). - const canSubmit = questions.every((q, idx) => isAnswered(q, selections[idx])); + // Gate Submit until every single-select question has a pick (or a non-empty + // "Other"). Multi-select questions may be left empty unless "Other" is armed, + // in which case its text must be filled. + const canSubmit = questions.every((q, idx) => + isAnswered(q, selections[idx], otherActive[idx] ?? false, customTexts[idx] ?? "") + ); const submit = (): void => { if (busy || !canSubmit) return; @@ -50,10 +71,15 @@ export const AskUserQuestionCard: React.FC = ({ reques for (let i = 0; i < questions.length; i++) { const q = questions[i]; const sel = selections[i]; + const other = otherActive[i] ?? false; + const text = (customTexts[i] ?? "").trim(); if (q.multiSelect) { - answers[q.question] = sel instanceof Set ? Array.from(sel).join(", ") : ""; + const labels = sel instanceof Set ? Array.from(sel) : []; + if (other && text) labels.push(text); + answers[q.question] = labels.join(", "); } else { - answers[q.question] = typeof sel === "string" ? sel : ""; + // "Other" wins over any stale preset (radio exclusivity clears it anyway). + answers[q.question] = other ? text : typeof sel === "string" ? sel : ""; } } onResolve(requestId, answers); @@ -69,6 +95,34 @@ export const AskUserQuestionCard: React.FC = ({ reques const active = questions[activeTab] ?? questions[0]; const activeIdx = questions[activeTab] ? activeTab : 0; + // Choosing a preset. Single-select picks one label and disarms "Other"; + // multi-select toggles the label in its Set and leaves "Other" alone. + const togglePreset = (label: string): void => { + if (active.multiSelect) { + setSelections((prev) => { + const cur = prev[activeIdx]; + const next = new Set(cur instanceof Set ? cur : []); + if (next.has(label)) next.delete(label); + else next.add(label); + return { ...prev, [activeIdx]: next }; + }); + return; + } + setSelections((prev) => ({ ...prev, [activeIdx]: label })); + setOtherActive((prev) => ({ ...prev, [activeIdx]: false })); + }; + + // Choosing "Other". Single-select arms it exclusively (clearing the radio + // pick); multi-select toggles it alongside any checked presets. + const toggleOther = (): void => { + if (active.multiSelect) { + setOtherActive((prev) => ({ ...prev, [activeIdx]: !(prev[activeIdx] ?? false) })); + return; + } + setOtherActive((prev) => ({ ...prev, [activeIdx]: true })); + setSelections((prev) => ({ ...prev, [activeIdx]: "" })); + }; + return (
@@ -113,19 +167,13 @@ export const AskUserQuestionCard: React.FC = ({ reques question={active} name={`askq-${requestId}-${activeIdx}`} selection={selections[activeIdx]} + otherActive={otherActive[activeIdx] ?? false} + customText={customTexts[activeIdx] ?? ""} disabled={busy} - onToggle={(label) => - setSelections((prev) => { - if (active.multiSelect) { - const cur = prev[activeIdx]; - const next = new Set(cur instanceof Set ? cur : []); - if (next.has(label)) next.delete(label); - else next.add(label); - return { ...prev, [activeIdx]: next }; - } - return { ...prev, [activeIdx]: label }; - }) - } + onTogglePreset={togglePreset} + onToggleOther={toggleOther} + onCustomTextChange={(text) => setCustomTexts((prev) => ({ ...prev, [activeIdx]: text }))} + onSubmitShortcut={submit} />
@@ -146,8 +194,14 @@ interface QuestionPanelProps { /** Radio-group name; namespaced by requestId + index so cards don't collide. */ name: string; selection: string | Set | undefined; + otherActive: boolean; + customText: string; disabled: boolean; - onToggle: (label: string) => void; + onTogglePreset: (label: string) => void; + onToggleOther: () => void; + onCustomTextChange: (text: string) => void; + /** Cmd/Ctrl+Enter in the textarea; the parent guards on `canSubmit`. */ + onSubmitShortcut: () => void; } /** The active question's prompt text plus its single- or multi-select option list. */ @@ -155,9 +209,15 @@ const QuestionPanel: React.FC = ({ question, name, selection, + otherActive, + customText, disabled, - onToggle, + onTogglePreset, + onToggleOther, + onCustomTextChange, + onSubmitShortcut, }) => { + const control = question.multiSelect ? "checkbox" : "radio"; return (
{question.question}
@@ -177,11 +237,11 @@ const QuestionPanel: React.FC = ({ native checkboxes/radios, which was throwing off alignment. */} onToggle(opt.label)} + onChange={() => onTogglePreset(opt.label)} className="tw-m-0" /> @@ -194,7 +254,44 @@ const QuestionPanel: React.FC = ({ ); })} + + {/* "Other" escape hatch: shares the radio group name so single-select + grouping stays native, and reveals a free-form textarea when armed. */} +
+ + {otherActive ? ( +