feat(agents): show AskUserQuestion as a question card (#1758)
CI / E2E (push) Successful in 16m14s
CI / Frontend (push) Successful in 25m33s
CI / Changes (push) Successful in 2s
CI / Rust (macos-latest) (push) Canceled after 0s
CI / Rust (windows-latest) (push) Canceled after 0s
Language smoke / Language smoke (java) (push) Failing after 7s
Language smoke / Language smoke (typescript) (push) Failing after 4s
Language smoke / Language smoke (kotlin) (push) Failing after 6s
Language smoke / Language smoke (go) (push) Failing after 7s
Language smoke / Language smoke (python) (push) Failing after 3s
CI / Plugins (SDK, official, fixtures) (push) Failing after 50s
Language smoke / Language smoke (rust) (push) Failing after 6s
CI / Remote server (linux musl) (push) Failing after 1m30s
CI / Rust (ubuntu-22.04) (push) Failing after 1m34s
CI / Cargo deny (push) Failing after 54s

The native Claude Code provider turned each AskUserQuestion question into a
permission request with only the option labels, shown in the permission
banner. Descriptions, headers, previews and multi-select were lost, and the
only way to give another answer was Skip, which tells Claude the user didn't
answer.

All questions now go out in one request carrying the full tool input, and
the conversation shows them as a card above the composer: each option with
its description, radio buttons or checkboxes for multi-select, an Other
choice with a text field, the option's preview beside the options on a wide
card (below them on a narrow one, sized to the tallest preview so hovering
never moves the options), number keys to pick, Esc to skip, and one Submit
for every answer.
Ordinary permission requests keep the banner, and Skip answers as before.

The answers go back in Claude Code's own format: question text to answer,
multi-select picks and Other text comma-joined, with an item that contains
the separator or a quote JSON-quoted so it reads back as one item.

Closes #1628

Co-authored-by: NealimeKenna <[email protected]>
Цей коміт міститься в:
NealimeKennaтаNealimeKenna authored and GitHub committed 2026-11-07 07:47:10 +08:00
1 джерело 99fafffaac
коміт 2d34bc956d
14 files changed
+787 -34

No files matched your search

+150
Переглянути файл
@@ -995,3 +995,153 @@
opacity: 0.4;
cursor: default;
}
/* An open AskUserQuestion: every question with its options, answered at once above the composer. */
.questionCard {
flex: none;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 50vh;
overflow-y: auto;
margin: 0 12px 8px;
padding: 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
background: var(--bg-elev);
container: question-card / inline-size;
}
.question {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.questionText {
margin-bottom: 6px;
padding: 0;
color: var(--fg-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.questionTag {
margin: 0 8px 0 0;
padding: 1px 6px;
border-radius: var(--radius-small);
background: var(--bg-hover);
color: var(--fg-muted);
font-size: var(--fs-small);
}
.questionLabel .questionTag {
margin: 0 0 0 8px;
}
.question .questionOption {
display: flex;
align-items: flex-start;
padding: 4px 6px;
border-radius: var(--radius-row);
}
.question .questionOption:hover {
background: var(--bg-hover);
}
.questionOption > :first-child {
margin-top: 1px;
}
.questionOption > :last-child {
flex: 1;
}
.questionChoice {
display: flex;
align-items: flex-start;
gap: 8px;
}
.questionOptionText {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.questionLabel {
flex: 1;
color: var(--fg);
}
.questionDescription {
color: var(--fg-muted);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.questionKey {
flex: none;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.questionOther {
margin: 2px 0 0 30px;
}
.questionBody {
display: flex;
flex-direction: column;
gap: 6px;
}
.questionChoices {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
@container question-card (min-width: 640px) {
.questionBodyWithPreview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.questionBodyWithPreview .questionPreview {
margin: 0;
}
}
.questionPreview {
display: grid;
min-width: 0;
margin: 0 0 0 30px;
}
.questionPreviewItem {
display: flex;
flex-direction: column;
grid-area: 1 / 1;
gap: 4px;
min-width: 0;
visibility: hidden;
}
.questionPreviewActive {
visibility: visible;
}
.questionPreviewCaption {
color: var(--fg-muted);
font-size: var(--fs-small);
}
.questionPreviewBody {
margin: 0;
padding: 8px;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-editor);
font-family: var(--font-mono);
font-size: var(--fs-code);
}
.questionPreviewEmpty {
padding: 8px;
border: 1px dashed var(--border);
border-radius: var(--radius-small);
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.questionActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
+256 -4
Переглянути файл
@@ -13,16 +13,28 @@ import {
} from "react";
import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, AgentTask, AgentUsage } from "@/bindings";
import type {
AgentChange,
AgentEntry,
AgentImage,
AgentLimit,
AgentQuestion,
AgentRetry,
AgentTask,
AgentUsage,
} from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { basename, relativeDir, useProject } from "@/platform/project";
import { editorService, notifications, useEditorShown, type EditorInput } from "@/workbench/contributions";
import {
Button,
Checkbox,
EmptyState,
Icon,
IconButton,
Input,
Radio,
SearchField,
Spinner,
T,
@@ -172,7 +184,10 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
<T>Subagents take their instructions from the agent that started them.</T>
</div>
) : (
<SessionComposer task={task} entries={entries ?? null} onOpenSubagent={openSubagent} />
<>
<QuestionCard task={task} entries={entries ?? null} />
<SessionComposer task={task} entries={entries ?? null} onOpenSubagent={openSubagent} />
</>
)}
</div>
{changesOpen && (
@@ -1256,7 +1271,9 @@ function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEnt
{shortTitle(task, entry.title)}
</span>
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
{!entry.chosen && <PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />}
{!entry.chosen && !entry.questions?.length && (
<PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />
)}
</div>
</div>
);
@@ -1265,7 +1282,7 @@ function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEnt
/** The open permission request, as an editor notification banner above the conversation. */
function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
const entry = entries?.find((e): e is PermissionEntry => e.type === "permission" && !e.chosen);
if (!entry) return null;
if (!entry || entry.questions?.length) return null;
return (
<div className={styles.banner} role="alert" aria-label={entry.title}>
<Icon name="warning" size={16} />
@@ -1275,6 +1292,241 @@ function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEn
);
}
interface QuestionAnswer {
question: AgentQuestion;
picked: string[];
/** The typed answer; `null` while "Other" isn't chosen. */
other: string | null;
}
const answerItems = ({ picked, other }: QuestionAnswer) => [...picked, ...(other?.trim() ? [other.trim()] : [])];
const isAnswered = (answer: QuestionAnswer) => answerItems(answer).length > 0 && answer.other?.trim() !== "";
const RECOMMENDED_SUFFIX = " (Recommended)";
const isRecommended = (label: string) => label.endsWith(RECOMMENDED_SUFFIX);
const plainLabel = (label: string) => (isRecommended(label) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label);
/** An open `AskUserQuestion` request, as a card above the composer that answers all its questions at once. */
function QuestionCard({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
const entry = entries?.find(
(candidate): candidate is PermissionEntry =>
candidate.type === "permission" && !candidate.chosen && !!candidate.questions?.length,
);
return entry?.questions ? (
<QuestionForm key={entry.id} task={task} entry={entry} questions={entry.questions} />
) : null;
}
function QuestionForm({
task,
entry,
questions,
}: {
task: AgentTask;
entry: PermissionEntry;
questions: AgentQuestion[];
}) {
const [answers, setAnswers] = useState<QuestionAnswer[]>(() =>
questions.map((question) => ({ question, picked: [], other: null })),
);
const [focusedPosition, setFocusedPosition] = useState(0);
const [hovered, setHovered] = useState<{ position: number; label: string } | null>(null);
const [busy, setBusy] = useState(false);
const complete = answers.every(isAnswered);
const send = (option: string) => {
setBusy(true);
void respond(task.id, entry.id, option).finally(() => setBusy(false));
};
const submit = () =>
send(JSON.stringify(Object.fromEntries(answers.map((answer) => [answer.question.question, answerItems(answer)]))));
const update = (previous: QuestionAnswer, next: QuestionAnswer) =>
setAnswers(answers.map((answer) => (answer === previous ? next : answer)));
const choose = (answer: QuestionAnswer, label: string, checked: boolean) =>
update(
answer,
answer.question.multiSelect
? {
...answer,
picked: checked ? [...answer.picked, label] : answer.picked.filter((picked) => picked !== label),
}
: { ...answer, picked: [label], other: null },
);
const chooseOther = (answer: QuestionAnswer, checked: boolean) =>
update(
answer,
answer.question.multiSelect ? { ...answer, other: checked ? "" : null } : { ...answer, picked: [], other: "" },
);
const chooseByNumber = (number: number) => {
const answer = answers[focusedPosition];
if (!answer) return;
const option = answer.question.options[number - 1];
if (option) choose(answer, option.label, !answer.picked.includes(option.label));
else if (number === answer.question.options.length + 1) chooseOther(answer, answer.other === null);
};
return (
<form
className={styles.questionCard}
aria-label={entry.title}
onSubmit={(event) => {
event.preventDefault();
if (complete) submit();
}}
onKeyDown={(event) => {
const typing = event.target instanceof HTMLInputElement && event.target.type === "text";
if (event.key === "Escape") send("reject");
else if (!typing && !event.ctrlKey && !event.metaKey && !event.altKey && /^[1-9]$/.test(event.key)) {
event.preventDefault();
chooseByNumber(Number(event.key));
}
}}
>
{answers.map((answer, position) => {
const { question } = answer;
const group = `question-${entry.id}-${position}`;
const hasPreviews = question.options.some((option) => option.preview);
const targetLabel =
(hovered?.position === position ? hovered.label : undefined) ??
answer.picked[0] ??
(answer.other !== null ? group : undefined);
const previewed =
targetLabel === undefined
? question.options.find((option) => option.preview)
: question.options.find((option) => option.label === targetLabel);
const choices = [
...question.options.map((option) => ({
key: option.label,
checked: answer.picked.includes(option.label),
onChange: (checked: boolean) => choose(answer, option.label, checked),
label: (
<span className={styles.questionOptionText}>
<span className={styles.questionLabel}>
{plainLabel(option.label)}
{isRecommended(option.label) && (
<span className={styles.questionTag}>
<T>Recommended</T>
</span>
)}
</span>
{option.description && <span className={styles.questionDescription}>{option.description}</span>}
</span>
),
})),
{
key: group,
checked: answer.other !== null,
onChange: (checked: boolean) => chooseOther(answer, checked),
label: (
<span className={styles.questionLabel}>
<T>Other</T>
</span>
),
},
];
return (
<fieldset
key={group}
className={styles.question}
disabled={busy}
onFocus={() => setFocusedPosition(position)}
>
<legend className={styles.questionText}>
{question.header && <span className={styles.questionTag}>{question.header}</span>}
{question.question}
</legend>
<div className={clsx(styles.questionBody, hasPreviews && styles.questionBodyWithPreview)}>
<div className={styles.questionChoices}>
{choices.map((choice, index) => {
const label = (
<span className={styles.questionChoice}>
{choice.label}
<span className={styles.questionKey}>{index + 1}</span>
</span>
);
return (
<div
key={choice.key}
onMouseEnter={() => setHovered({ position, label: choice.key })}
onMouseLeave={() => setHovered(null)}
>
{question.multiSelect ? (
<Checkbox
className={styles.questionOption}
checked={choice.checked}
label={label}
onChange={choice.onChange}
/>
) : (
<Radio
className={styles.questionOption}
name={group}
checked={choice.checked}
label={label}
onChange={() => choice.onChange(true)}
/>
)}
</div>
);
})}
{answer.other !== null && (
<Input
autoFocus
className={styles.questionOther}
value={answer.other}
placeholder="Type your answer"
onChange={(event) => update(answer, { ...answer, other: event.target.value })}
onKeyDown={(event) => {
if (event.key === "Escape" && answer.other) {
event.stopPropagation();
update(answer, { ...answer, other: "" });
}
}}
/>
)}
</div>
{hasPreviews && (
<div className={styles.questionPreview}>
{[...question.options, null].map((option) => {
const active = option === (previewed ?? null);
return (
<div
key={option?.label ?? group}
className={clsx(styles.questionPreviewItem, active && styles.questionPreviewActive)}
aria-hidden={!active}
>
<span className={styles.questionPreviewCaption}>
{option ? plainLabel(option.label) : <T>Other</T>}
</span>
{option?.preview ? (
<pre className={styles.questionPreviewBody}>{option.preview}</pre>
) : (
<span className={styles.questionPreviewEmpty}>
<T>No preview</T>
</span>
)}
</div>
);
})}
</div>
)}
</div>
</fieldset>
);
})}
<div className={styles.questionActions}>
<Button variant="ghost" disabled={busy} onClick={() => send("reject")}>
Skip
</Button>
<Button type="submit" variant="primary" disabled={busy || !complete}>
Submit
</Button>
</div>
</form>
);
}
function SessionComposer({
task,
entries,
+102
Переглянути файл
@@ -138,6 +138,108 @@ describe("a pending question", () => {
});
});
describe("a pending AskUserQuestion", () => {
it("shows every question with its descriptions in a card and sends all answers at once", async () => {
backend.entries.set("A", [
{
type: "permission",
id: 9,
title: "Which layout?",
options: [{ id: "reject", name: "Skip", kind: "reject_once" }],
questions: [
{
header: "Layout",
question: "Which layout?",
multiSelect: false,
options: [
{ label: "Table (Recommended)", description: "One row per check", preview: null },
{ label: "List", description: "Grouped under headings", preview: null },
],
},
{
header: "Checks",
question: "Which checks?",
multiSelect: true,
options: [
{ label: "Disk", description: "", preview: null },
{ label: "Updates", description: "", preview: null },
],
},
],
chosen: null,
},
]);
backend.answers = [];
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const card = await screen.findByRole("form", { name: "Which layout?" });
expect(screen.queryByRole("alert")).toBeNull();
expect(within(card).getByText("One row per check")).toBeTruthy();
expect(within(card).getByText("Recommended")).toBeTruthy();
const submit = within(card).getByRole("button", { name: "Submit" });
fireEvent.click(within(card).getByRole("radio", { name: /^List/ }));
expect(submit).toHaveProperty("disabled", true);
fireEvent.click(within(card).getByRole("checkbox", { name: /^Disk/ }));
const others = within(card).getAllByRole("checkbox", { name: /^Other/ });
fireEvent.click(others[0]!);
expect(submit).toHaveProperty("disabled", true);
fireEvent.change(within(card).getByPlaceholderText("Type your answer"), { target: { value: "GPU temps" } });
fireEvent.click(submit);
await waitFor(() =>
expect(backend.answers).toEqual([
["A", 9, JSON.stringify({ "Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"] })],
]),
);
});
it("picks options with number keys, previews the picked or hovered one, and skips with Escape", async () => {
backend.entries.set("A", [
{
type: "permission",
id: 10,
title: "Which layout?",
options: [{ id: "reject", name: "Skip", kind: "reject_once" }],
questions: [
{
header: "",
question: "Which layout?",
multiSelect: false,
options: [
{ label: "Table", description: "", preview: "| a | b |" },
{ label: "List", description: "", preview: null },
],
},
],
chosen: null,
},
]);
backend.answers = [];
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const card = await screen.findByRole("form", { name: "Which layout?" });
const table = within(card).getByRole("radio", { name: /^Table/ });
const list = within(card).getByRole("radio", { name: /^List/ });
fireEvent.focus(table);
fireEvent.keyDown(table, { key: "2" });
expect(list).toHaveProperty("checked", true);
const shown = () => card.querySelector<HTMLElement>(`.${styles.questionPreviewActive}`)!;
const caption = () => shown().querySelector(`.${styles.questionPreviewCaption}`)?.textContent;
expect(card.querySelectorAll(`.${styles.questionPreviewActive}`)).toHaveLength(1);
expect(caption()).toBe("List");
expect(within(shown()).getByText("No preview")).toBeTruthy();
expect(within(shown()).queryByText("| a | b |")).toBeNull();
fireEvent.mouseEnter(table.closest("label")!.parentElement!);
expect(caption()).toBe("Table");
expect(within(shown()).getByText("| a | b |")).toBeTruthy();
fireEvent.mouseLeave(table.closest("label")!.parentElement!);
fireEvent.keyDown(table, { key: "3" });
expect(caption()).toBe("Other");
expect(within(shown()).queryByText("| a | b |")).toBeNull();
fireEvent.keyDown(table, { key: "Escape" });
await waitFor(() => expect(backend.answers).toEqual([["A", 10, "reject"]]));
});
});
describe("a pending permission request in the conversation", () => {
it("is answered from its line too, and one without options can be cancelled", async () => {
backend.entries.set("A", [