feat(agent-mode): custom "Other" response option in the AskUserQuestion card (#2653)

* feat(agent-mode): add custom "Other" response to AskUserQuestion card

Each question in the inline AskUserQuestion card now offers an "Other" row (radio for single-select, checkbox for multi-select) that reveals a free-form textarea, mirroring Claude Code CLI. The typed text becomes the answer (single-select) or is appended to the checked labels (multi-select). Confined to the UI component — the answer stays a plain string, so no type/bridge/backend changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(agent-mode): cover custom "Other" response in AskUserQuestion card

Five RTL cases: single-select Other→trimmed text, multi-select presets+Other→joined, empty Other disables Submit, Cancel→{}, and a single-select preset regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-07-03 05:03:11 +08:00 committed by GitHub
parent 4cfea5761d
commit 9fc6153ae6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 238 additions and 23 deletions

View file

@ -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(<AskUserQuestionCard request={request} onResolve={onResolve} />);
}
// 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" });
});
});

View file

@ -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<string> | 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<string> | 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<string> | 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<AskUserQuestionCardProps> = ({ request, onResolve }) => {
const { questions, requestId } = request;
@ -38,10 +52,17 @@ export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ reques
const [activeTab, setActiveTab] = useState(0);
// Per-question selection: a single label for radio, a Set of labels for checkbox.
const [selections, setSelections] = useState<Record<number, string | Set<string>>>({});
// 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<Record<number, boolean>>({});
const [customTexts, setCustomTexts] = useState<Record<number, string>>({});
// 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<AskUserQuestionCardProps> = ({ 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<AskUserQuestionCardProps> = ({ 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 (
<div className="tw-mx-3 tw-my-2 tw-w-[calc(100%-1.5rem)] tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-secondary">
<div className="copilot-divider-b tw-flex tw-items-center tw-gap-2 tw-px-3 tw-py-2">
@ -113,19 +167,13 @@ export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ 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}
/>
</div>
@ -146,8 +194,14 @@ interface QuestionPanelProps {
/** Radio-group name; namespaced by requestId + index so cards don't collide. */
name: string;
selection: string | Set<string> | 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<QuestionPanelProps> = ({
question,
name,
selection,
otherActive,
customText,
disabled,
onToggle,
onTogglePreset,
onToggleOther,
onCustomTextChange,
onSubmitShortcut,
}) => {
const control = question.multiSelect ? "checkbox" : "radio";
return (
<div role="tabpanel" className="tw-flex tw-flex-col tw-gap-2">
<div className="tw-text-sm">{question.question}</div>
@ -177,11 +237,11 @@ const QuestionPanel: React.FC<QuestionPanelProps> = ({
native checkboxes/radios, which was throwing off alignment. */}
<span className="tw-flex tw-h-5 tw-shrink-0 tw-items-center">
<input
type={question.multiSelect ? "checkbox" : "radio"}
type={control}
name={name}
checked={checked}
disabled={disabled}
onChange={() => onToggle(opt.label)}
onChange={() => onTogglePreset(opt.label)}
className="tw-m-0"
/>
</span>
@ -194,7 +254,44 @@ const QuestionPanel: React.FC<QuestionPanelProps> = ({
</label>
);
})}
{/* "Other" escape hatch: shares the radio group name so single-select
grouping stays native, and reveals a free-form textarea when armed. */}
<label className="tw-flex tw-cursor-pointer tw-items-start tw-gap-2 tw-rounded tw-px-2 tw-py-1.5 hover:tw-bg-modifier-hover">
<span className="tw-flex tw-h-5 tw-shrink-0 tw-items-center">
<input
type={control}
name={name}
checked={otherActive}
disabled={disabled}
onChange={onToggleOther}
className="tw-m-0"
/>
</span>
<div className="tw-min-w-0">
<div className="tw-text-sm tw-leading-5">Other</div>
<div className="tw-text-xs tw-text-muted">Type your own response</div>
</div>
</label>
</div>
{otherActive ? (
<textarea
className="tw-min-h-9 tw-w-full tw-resize-y tw-rounded tw-border tw-border-solid tw-border-border tw-bg-primary tw-px-2 tw-py-1 tw-text-sm tw-text-normal tw-outline-none focus:tw-border-border-focus"
placeholder="Type your response…"
value={customText}
disabled={disabled}
autoFocus
onChange={(e) => onCustomTextChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onSubmitShortcut();
}
}}
rows={2}
/>
) : null}
</div>
);
};