Skip to content

Commit 4b7a659

Browse files
centdixclaude
andauthored
feat: render AskUserQuestion tool as a clickable web-chat card (#276)
* feat: render AskUserQuestion tool as a clickable web-chat card Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: poll history for terminal-routed claude runs so questions show in web chat Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert: drop terminal-routed history polling (pending TUI questions aren't persisted) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a55f06c commit 4b7a659

9 files changed

Lines changed: 561 additions & 9 deletions

bin/src/oneshot.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,17 @@ function summarizeToolInput(toolName: string, jsonText: string): string {
275275
}
276276
if (!input) return truncateInline(jsonText, 100);
277277

278+
if (toolName.toLowerCase() === "askuserquestion" && Array.isArray(input.questions)) {
279+
const headers: string[] = [];
280+
for (const question of input.questions) {
281+
if (question && typeof question === "object" && !Array.isArray(question)) {
282+
const header = (question as Record<string, unknown>).header;
283+
if (typeof header === "string") headers.push(header);
284+
}
285+
}
286+
if (headers.length > 0) return truncateInline(headers.join(", "), 120);
287+
}
288+
278289
const keys = TOOL_PRIMARY_KEY[toolName.toLowerCase()];
279290
if (keys) {
280291
const values: string[] = [];
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<script lang="ts">
2+
import { formatAskUserQuestionAnswer } from "./ask-user-question";
3+
import type { AskUserQuestionInput } from "./types";
4+
5+
interface Props {
6+
input: AskUserQuestionInput;
7+
disabled: boolean;
8+
onSubmit: (text: string) => void;
9+
}
10+
11+
const { input, disabled, onSubmit }: Props = $props();
12+
13+
// One single-select question can answer on a single tap; anything else
14+
// (multiple questions, or a multi-select) needs an explicit Submit.
15+
const autoSend = $derived(input.questions.length === 1 && input.questions[0]?.multiSelect !== true);
16+
17+
let selections = $state<Record<number, string[]>>({});
18+
let customText = $state<Record<number, string>>({});
19+
20+
function setSelection(qIndex: number, next: string[]): void {
21+
selections = { ...selections, [qIndex]: next };
22+
}
23+
24+
function setCustom(qIndex: number, value: string): void {
25+
customText = { ...customText, [qIndex]: value };
26+
}
27+
28+
function isSelected(qIndex: number, label: string): boolean {
29+
return (selections[qIndex] ?? []).includes(label);
30+
}
31+
32+
function buildAnswers(): Array<{ header: string; values: string[] }> {
33+
return input.questions.map((question, index) => {
34+
const custom = customText[index]?.trim() ?? "";
35+
const values = [...(selections[index] ?? [])];
36+
if (custom.length > 0) values.push(custom);
37+
return { header: question.header, values };
38+
});
39+
}
40+
41+
const canSubmit = $derived(!disabled && buildAnswers().some((answer) => answer.values.length > 0));
42+
43+
function submitSingle(header: string, value: string): void {
44+
onSubmit(formatAskUserQuestionAnswer([{ header, values: [value] }]));
45+
}
46+
47+
function submitAll(): void {
48+
if (disabled) return;
49+
const text = formatAskUserQuestionAnswer(buildAnswers());
50+
if (text.length === 0) return;
51+
onSubmit(text);
52+
}
53+
54+
function toggleOption(qIndex: number, label: string): void {
55+
if (disabled) return;
56+
const question = input.questions[qIndex];
57+
if (!question) return;
58+
if (autoSend) {
59+
submitSingle(question.header, label);
60+
return;
61+
}
62+
const current = selections[qIndex] ?? [];
63+
if (question.multiSelect) {
64+
setSelection(qIndex, current.includes(label) ? current.filter((value) => value !== label) : [...current, label]);
65+
} else {
66+
setSelection(qIndex, current.includes(label) ? [] : [label]);
67+
}
68+
}
69+
70+
function handleCustomKeydown(event: KeyboardEvent, qIndex: number): void {
71+
if (event.key !== "Enter" || event.shiftKey) return;
72+
event.preventDefault();
73+
if (disabled) return;
74+
const custom = customText[qIndex]?.trim() ?? "";
75+
if (autoSend) {
76+
const question = input.questions[qIndex];
77+
if (!question || custom.length === 0) return;
78+
submitSingle(question.header, custom);
79+
return;
80+
}
81+
if (canSubmit) submitAll();
82+
}
83+
</script>
84+
85+
<div class="self-start w-full max-w-[94%] min-w-0 rounded-md border border-accent/40 bg-topbar/40 text-xs text-primary">
86+
<div class="border-b border-edge/60 px-3 py-2 text-[10px] uppercase tracking-[0.12em] text-muted">
87+
Question
88+
</div>
89+
90+
<div class="flex flex-col gap-4 px-3 py-3">
91+
{#each input.questions as question, qIndex (`${question.header}:${qIndex}`)}
92+
<div class="flex min-w-0 flex-col gap-2">
93+
<div class="text-[10px] uppercase tracking-[0.12em] text-muted">{question.header}</div>
94+
<div class="text-sm text-primary">{question.question}</div>
95+
<div class="flex flex-wrap gap-2">
96+
{#each question.options as option (option.label)}
97+
<button
98+
type="button"
99+
class={`min-w-0 max-w-full rounded-md border px-3 py-1.5 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${
100+
isSelected(qIndex, option.label)
101+
? "border-accent bg-accent text-white"
102+
: "border-edge bg-surface text-primary enabled:hover:bg-hover"
103+
}`}
104+
{disabled}
105+
onclick={() => toggleOption(qIndex, option.label)}
106+
>
107+
<span class="block break-words font-medium">{option.label}</span>
108+
{#if option.description}
109+
<span class={`mt-0.5 block break-words text-[10px] ${isSelected(qIndex, option.label) ? "text-white/80" : "text-muted"}`}>
110+
{option.description}
111+
</span>
112+
{/if}
113+
</button>
114+
{/each}
115+
</div>
116+
<input
117+
type="text"
118+
class="w-full rounded-md border border-edge bg-surface px-3 py-1.5 text-xs text-primary outline-none transition placeholder:text-muted/70 focus:border-accent disabled:cursor-not-allowed disabled:opacity-60"
119+
placeholder="Custom answer…"
120+
value={customText[qIndex] ?? ""}
121+
oninput={(event) => setCustom(qIndex, event.currentTarget.value)}
122+
onkeydown={(event) => handleCustomKeydown(event, qIndex)}
123+
{disabled}
124+
/>
125+
</div>
126+
{/each}
127+
</div>
128+
129+
{#if !autoSend}
130+
<div class="flex justify-end border-t border-edge/60 px-3 py-2">
131+
<button
132+
type="button"
133+
class="rounded-md border border-accent bg-accent px-3 py-1.5 text-xs font-medium text-white transition enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-45"
134+
onclick={submitAll}
135+
disabled={!canSubmit}
136+
>
137+
Submit answer
138+
</button>
139+
</div>
140+
{/if}
141+
</div>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import AskUserQuestionCard from "./AskUserQuestionCard.svelte";
4+
import type { AskUserQuestionInput } from "./types";
5+
6+
const singleSelect: AskUserQuestionInput = {
7+
questions: [
8+
{
9+
question: "Do you prefer cats or dogs?",
10+
header: "Pet type",
11+
multiSelect: false,
12+
options: [
13+
{ label: "Cats", description: "Independent." },
14+
{ label: "Dogs", description: "Loyal." },
15+
],
16+
},
17+
],
18+
};
19+
20+
const multiSelect: AskUserQuestionInput = {
21+
questions: [
22+
{
23+
question: "Which toppings?",
24+
header: "Toppings",
25+
multiSelect: true,
26+
options: [{ label: "Cheese" }, { label: "Olives" }],
27+
},
28+
],
29+
};
30+
31+
describe("AskUserQuestionCard", () => {
32+
afterEach(() => cleanup());
33+
34+
it("renders the question, options and a custom input", () => {
35+
render(AskUserQuestionCard, { props: { input: singleSelect, disabled: false, onSubmit: vi.fn() } });
36+
37+
expect(screen.getByText("Do you prefer cats or dogs?")).toBeInTheDocument();
38+
expect(screen.getByRole("button", { name: /Cats/ })).toBeInTheDocument();
39+
expect(screen.getByRole("button", { name: /Dogs/ })).toBeInTheDocument();
40+
expect(screen.getByPlaceholderText("Custom answer…")).toBeInTheDocument();
41+
});
42+
43+
it("auto-sends a single-select answer on click", async () => {
44+
const onSubmit = vi.fn();
45+
render(AskUserQuestionCard, { props: { input: singleSelect, disabled: false, onSubmit } });
46+
47+
await fireEvent.click(screen.getByRole("button", { name: /Cats/ }));
48+
49+
expect(onSubmit).toHaveBeenCalledTimes(1);
50+
expect(onSubmit).toHaveBeenCalledWith("Pet type: Cats");
51+
});
52+
53+
it("auto-sends a typed custom answer on Enter", async () => {
54+
const onSubmit = vi.fn();
55+
render(AskUserQuestionCard, { props: { input: singleSelect, disabled: false, onSubmit } });
56+
57+
const input = screen.getByPlaceholderText("Custom answer…");
58+
await fireEvent.input(input, { target: { value: "A goldfish" } });
59+
await fireEvent.keyDown(input, { key: "Enter" });
60+
61+
expect(onSubmit).toHaveBeenCalledWith("Pet type: A goldfish");
62+
});
63+
64+
it("uses a submit button for multi-select and joins selections", async () => {
65+
const onSubmit = vi.fn();
66+
render(AskUserQuestionCard, { props: { input: multiSelect, disabled: false, onSubmit } });
67+
68+
await fireEvent.click(screen.getByRole("button", { name: "Cheese" }));
69+
await fireEvent.click(screen.getByRole("button", { name: "Olives" }));
70+
expect(onSubmit).not.toHaveBeenCalled();
71+
72+
await fireEvent.click(screen.getByRole("button", { name: "Submit answer" }));
73+
expect(onSubmit).toHaveBeenCalledWith("Toppings: Cheese, Olives");
74+
});
75+
76+
it("does not submit when disabled", async () => {
77+
const onSubmit = vi.fn();
78+
render(AskUserQuestionCard, { props: { input: singleSelect, disabled: true, onSubmit } });
79+
80+
await fireEvent.click(screen.getByRole("button", { name: /Cats/ }));
81+
82+
expect(onSubmit).not.toHaveBeenCalled();
83+
});
84+
});

frontend/src/lib/MobileChatSurface.svelte

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
let conversationLoading = $state(false);
3939
let composerText = $state("");
4040
let isSending = $state(false);
41+
let isAnsweringQuestion = $state(false);
4142
let refreshPollingState = $state<{
4243
token: number;
4344
baselineSignature: string | null;
@@ -207,39 +208,46 @@
207208
};
208209
}
209210
210-
async function sendSelectedConversationMessage(): Promise<void> {
211-
if (!conversation) return;
211+
async function sendConversationText(text: string): Promise<boolean> {
212+
if (!conversation) return false;
212213
const baselineConversation = conversation;
213-
const text = composerText.trim();
214-
if (text.length === 0) return;
214+
const trimmed = text.trim();
215+
if (trimmed.length === 0) return false;
215216
216217
isSending = true;
217218
conversationError = null;
218219
try {
219220
syncConversationStream(true);
220-
const response = await sendWorktreeConversationMessage(worktree.branch, { text });
221-
composerText = "";
221+
const response = await sendWorktreeConversationMessage(worktree.branch, { text: trimmed });
222222
if (conversation.conversationId !== response.conversationId) {
223223
conversation = {
224224
...conversation,
225225
conversationId: response.conversationId,
226226
};
227227
}
228-
conversation = markConversationTurnStarted(conversation, response.turnId, text);
228+
conversation = markConversationTurnStarted(conversation, response.turnId, trimmed);
229229
if (response.streaming) {
230230
syncConversationStream();
231231
} else {
232232
closeConversationStream();
233233
startRefreshPolling(baselineConversation);
234234
}
235235
onConversationMessageSent();
236+
return true;
236237
} catch (error) {
237238
conversationError = error instanceof Error ? error.message : String(error);
239+
return false;
238240
} finally {
239241
isSending = false;
240242
}
241243
}
242244
245+
async function sendSelectedConversationMessage(): Promise<void> {
246+
if (composerText.trim().length === 0) return;
247+
const sent = await sendConversationText(composerText);
248+
if (sent) composerText = "";
249+
}
250+
243251
async function interruptSelectedConversation(): Promise<void> {
244252
const baselineConversation = conversation;
245253
conversationError = null;
@@ -256,6 +264,22 @@
256264
}
257265
}
258266
267+
// Answering an AskUserQuestion is a new turn, so the run that asked it must end
268+
// first. In headless `claude -p` the question is auto-dismissed and the turn
269+
// keeps going, so interrupt the active run before sending the answer.
270+
async function answerConversationQuestion(text: string): Promise<void> {
271+
if (!conversation || isSending || isAnsweringQuestion) return;
272+
isAnsweringQuestion = true;
273+
try {
274+
if (conversation.running) {
275+
await interruptSelectedConversation();
276+
}
277+
await sendConversationText(text);
278+
} finally {
279+
isAnsweringQuestion = false;
280+
}
281+
}
282+
259283
onMount(() => {
260284
void loadConversation("attach");
261285
return () => {
@@ -328,4 +352,5 @@
328352
onInterrupt={() => void interruptSelectedConversation()}
329353
onRefresh={() => void loadConversation("history")}
330354
onSend={() => void sendSelectedConversationMessage()}
355+
onAnswerQuestion={(text) => void answerConversationQuestion(text)}
331356
/>

0 commit comments

Comments
 (0)