feat(agents): show AskUserQuestion as a question card (#1758)

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]>
This commit is contained in:
NealimeKennaandNealimeKenna authored and GitHub committed 2026-11-07 07:47:10 +08:00
1 parent 99fafffaac
commit 2d34bc956d
14 files changed
+787 -34

No files matched your search

+26 -5
View File
@@ -1647,17 +1647,38 @@ impl Task {
.collect()
})
.unwrap_or_default();
let questions: Vec<AgentQuestion> = params
.pointer("/toolCall/_meta/questions")
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
.unwrap_or_default();
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
let (tx, rx) = std::sync::mpsc::channel();
let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None });
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
self.approve_pending();
let answer = rx.recv().ok().flatten();
let name = answer
.as_ref()
.and_then(|a| options.iter().find(|o| &o.id == a))
.map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone());
let answers = answer
.as_deref()
.filter(|_| !asked.is_empty())
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
let name = match answers {
Some(answers) => asked
.iter()
.filter_map(|question| answers.get(question))
.map(|items| match items {
Value::Array(items) => items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", "),
item => item.as_str().unwrap_or_default().to_owned(),
})
.collect::<Vec<_>>()
.join("; "),
None => answer
.as_ref()
.and_then(|reply| options.iter().find(|option| &option.id == reply))
.map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()),
};
{
let mut s = self.state.lock();
if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) {
+26
View File
@@ -278,6 +278,28 @@ pub struct AgentPermissionOption {
pub kind: String,
}
/// A question the agent asks with `AskUserQuestion`, answered by picking options or typing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestion {
#[serde(default)]
pub header: String,
pub question: String,
#[serde(default)]
pub multi_select: bool,
pub options: Vec<AgentQuestionOption>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestionOption {
pub label: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub preview: Option<String>,
}
/// One item of a task's conversation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -350,6 +372,10 @@ pub enum AgentEntry {
id: u32,
title: String,
options: Vec<AgentPermissionOption>,
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
/// of question to answer instead of an option.
#[serde(default)]
questions: Vec<AgentQuestion>,
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
chosen: Option<String>,
},
+32 -24
View File
@@ -1117,38 +1117,46 @@ fn permission_result(
}
}
/// `AskUserQuestion`: each question as a permission request whose options are its
/// answers; `None` when one is not answered.
/// `AskUserQuestion`: one permission request carrying all its questions, answered with a
/// JSON object of question to the chosen labels and typed text; `None` when skipped.
async fn ask_questions<F, Fut>(input: &Value, call: &Value, ask: F) -> Option<Value>
where
F: Fn(Value, Value) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let mut answers = serde_json::Map::new();
for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() {
let question = q.get("question").and_then(Value::as_str).unwrap_or_default();
let labels: Vec<&str> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| o.get("label")?.as_str())
.collect();
let mut options: Vec<Value> = labels
.iter()
.enumerate()
.map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" }))
.collect();
options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" }));
let mut call = call.clone();
call["title"] = json!(question);
let chosen = ask(call, Value::Array(options)).await?;
let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?;
answers.insert(question.to_owned(), json!(labels.get(i)?));
}
let questions = input.get("questions")?;
let mut call = call.clone();
call["title"] = questions.pointer("/0/question").cloned().unwrap_or_else(|| json!("Claude has a question"));
call["_meta"] = json!({ "questions": questions });
let options = json!([{ "optionId": "reject", "name": "Skip", "kind": "reject_once" }]);
let chosen = ask(call, options).await?;
let chosen: serde_json::Map<String, Value> = serde_json::from_str(&chosen).ok()?;
let answers = questions
.as_array()?
.iter()
.filter_map(|entry| {
let question = entry.get("question")?.as_str()?;
let items: Vec<&str> = chosen.get(question)?.as_array()?.iter().filter_map(Value::as_str).collect();
let multi = entry.get("multiSelect").and_then(Value::as_bool) == Some(true);
let answer = if multi { join_answers(&items) } else { items.first()?.to_string() };
Some((question.to_owned(), json!(answer)))
})
.collect();
Some(Value::Object(answers))
}
/// A multi-select answer as Claude Code's own question UI writes it: comma-joined, with an
/// item that holds the separator or a quote JSON-quoted so it reads back as one item.
fn join_answers(items: &[&str]) -> String {
items
.iter()
.map(
|item| if item.contains(", ") || item.contains('"') { json!(item).to_string() } else { (*item).to_owned() },
)
.collect::<Vec<_>>()
.join(", ")
}
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
@@ -1106,3 +1106,67 @@ fn tool_calls_plans_and_subagents() {
assert_eq!(finished[1].update["content"][0]["text"], "It is x");
assert_eq!(finished[1].update["senderSessionId"], "ag");
}
#[tokio::test]
async fn questions_go_out_as_one_request() {
let questions = json!([
{"header": "Files", "question": "Where should they live?", "multiSelect": false,
"options": [{"label": "Local", "description": "Kept out of git"}, {"label": "Committed", "description": "In the repo"}]},
{"header": "Checks", "question": "Which checks?", "multiSelect": true,
"options": [{"label": "Lint", "description": ""}, {"label": "Tests", "description": ""}]},
]);
let input = json!({ "questions": questions });
let asked = std::sync::Mutex::new(Vec::new());
let reply = json!({"Where should they live?": ["Local"], "Which checks?": ["Lint", "Tests"]}).to_string();
let answers = json!({"Where should they live?": "Local", "Which checks?": "Lint, Tests"});
let result = ask_questions(&input, &json!({"toolCallId": "t"}), |call, options| {
asked.lock().unwrap().push((call, options));
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(result, Some(answers));
let asked = asked.into_inner().unwrap();
assert_eq!(asked.len(), 1);
let (call, options) = &asked[0];
assert_eq!(call["title"], "Where should they live?");
assert_eq!(call["_meta"]["questions"], questions);
assert_eq!(options[0]["optionId"], "reject");
}
#[tokio::test]
async fn answers_keep_claude_codes_format_with_other_text() {
let input = json!({"questions": [
{"question": "Which cache?", "multiSelect": false, "options": [{"label": "Redis"}, {"label": "None"}]},
{"question": "Which checks?", "multiSelect": true, "options": [{"label": "Lint"}, {"label": "Tests"}]},
{"question": "Which stores?", "multiSelect": true, "options": [{"label": "Disk"}, {"label": "S3"}]},
]});
let reply = json!({
"Which cache?": ["Memcached, for now"],
"Which checks?": ["Lint", "Tests"],
"Which stores?": ["Disk", "Redis, not Memcached", "a \"cold\" tier"],
})
.to_string();
let result = ask_questions(&input, &json!({}), |_, _| {
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(
result,
Some(json!({
"Which cache?": "Memcached, for now",
"Which checks?": "Lint, Tests",
"Which stores?": "Disk, \"Redis, not Memcached\", \"a \\\"cold\\\" tier\"",
}))
);
}
#[tokio::test]
async fn skipped_questions_have_no_answers() {
let input = json!({"questions": [{"question": "Which?", "options": [{"label": "A"}]}]});
let skipped = ask_questions(&input, &json!({}), |_, _| async { Some("reject".to_owned()) }).await;
assert_eq!(skipped, None);
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
assert_eq!(cancelled, None);
}
+56
View File
@@ -156,6 +156,24 @@ impl Handler for FakeAgent {
self.say(&session, "ok");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session,
"toolCall": {"toolCallId": "q1", "title": "AskUserQuestion", "_meta": {"questions": [
{"question": "Which layout?", "options": [{"label": "List"}, {"label": "Grid"}]},
{"question": "Which checks?", "multiSelect": true,
"options": [{"label": "Disk"}, {"label": "GPU temps"}]}]}},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("answered {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "wait" {
// Works until cancelled.
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
@@ -609,6 +627,44 @@ fn prompts_become_acp_content_blocks() {
assert!(clean(text(" ")).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn answered_questions_close_the_question_with_every_picked_label() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let question = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
mgr.respond(&task.id, question, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "List; Disk, GPU temps")),
"{entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
+7 -1
View File
@@ -434,7 +434,13 @@ mod tests {
t.push(AgentEntry::Agent { text: "between".into() });
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
t.push(AgentEntry::Permission {
id: 1,
title: "Run?".into(),
options: vec![],
questions: vec![],
chosen: None,
});
assert_eq!(t.end_turn(None), vec![2, 3]);
let again = Timeline::from_entries(t.entries.clone());
assert_eq!(again.plan, Some(0));
+19
View File
@@ -1502,6 +1502,11 @@ endedMs: number | null;
cancellable: boolean } |
/** A message between sessions in a subagent's conversation: its task (`incoming`) or its report. */
{ type: "message"; incoming: boolean; text: string } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[];
/**
* Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
* of question to answer instead of an option.
*/
questions?: AgentQuestion[];
/** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */
chosen: string | null } | { type: "error"; message: string };
@@ -1598,6 +1603,20 @@ export type AgentProvider = {
source: AgentSource,
};
/** A question the agent asks with `AskUserQuestion`, answered by picking options or typing. */
export type AgentQuestion = {
header?: string,
question: string,
multiSelect?: boolean,
options: AgentQuestionOption[],
};
export type AgentQuestionOption = {
label: string,
description?: string,
preview?: string | null,
};
export type AgentQueued = {
id: string,
prompt: AgentPrompt,
+150
View File
@@ -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
View File
@@ -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
View File
@@ -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", [
+3
View File
@@ -51,6 +51,9 @@
"Xdebug is not loaded in PHP {version}": "Xdebug is not loaded in PHP {version}",
"{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.",
"Open PHP Settings": "Open PHP Settings",
"Type your answer": "Type your answer",
"Recommended": "Recommended",
"No preview": "No preview",
"Running in the background": "Running in the background",
"Couldn't stop the background task": "Couldn't stop the background task"
}
+3
View File
@@ -6417,6 +6417,9 @@
"Xdebug is not loaded in PHP {version}": "PHP {version} 未加载 Xdebug",
"{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} 运行时没有 Xdebug,断点会被跳过。请为此 PHP 安装 Xdebug 3,或选择其他 PHP 解释器。",
"Open PHP Settings": "打开 PHP 设置",
"Type your answer": "输入你的回答",
"Recommended": "推荐",
"No preview": "无预览",
"Running in the background": "后台运行中",
"Couldn't stop the background task": "无法停止后台任务"
}
+10
View File
@@ -57,6 +57,16 @@
.disabled .on {
background: var(--border);
}
.radio {
border-radius: 50%;
}
.radio.on::after {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.native:focus-visible + .box {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
+33
View File
@@ -43,6 +43,39 @@ export function Checkbox({ checked, onChange, label, disabled, indeterminate, cl
);
}
export interface RadioProps {
checked: boolean;
onChange: () => void;
/** Radios with the same name form one group, moved through with the arrow keys. */
name: string;
label?: ReactNode;
disabled?: boolean;
className?: string;
}
/** Radio button, drawn like the checkbox with a round box and a dot. */
export function Radio({ checked, onChange, name, label, disabled, className }: RadioProps) {
const id = useId();
const tx = useTx();
return (
<label className={clsx(styles.row, disabled && styles.disabled, className)} htmlFor={id}>
<span className={styles.boxWrap}>
<input
id={id}
type="radio"
name={name}
className={styles.native}
checked={checked}
disabled={disabled}
onChange={onChange}
/>
<span className={clsx(styles.box, styles.radio, checked && styles.on)} aria-hidden />
</span>
{label != null && <span className={styles.label}>{typeof label === "string" ? tx(label) : label}</span>}
</label>
);
}
export interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;