import { useMemo, useState } from 'react'; import { Check } from 'lucide-react'; import { api } from '@/api/client'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Button } from '@/components/ui/button'; import type { AskUserAnswer, AskUserAnswerSet, AskUserQuestion, ToolCall, ToolResult, } from '@/api/types'; // Batch 9.7: Inline interactive picker. Renders inside MessageList in place of // the standard ToolCallLine when the assistant emits an ask_user_input tool // call. While the tool result is null (server pre-stamps a sentinel with // output=null), shows the form; once the WS tool_result frame arrives with a // real AnswerSet, flips to read-only review mode. interface Props { toolCall: ToolCall; toolResult: ToolResult | null; chatId: string; } function parseQuestions(raw: unknown): AskUserQuestion[] { if (!raw || typeof raw !== 'object' || !('questions' in raw)) return []; const arr = (raw as { questions: unknown }).questions; if (!Array.isArray(arr)) return []; const out: AskUserQuestion[] = []; for (const item of arr) { if (!item || typeof item !== 'object') continue; const q = item as { question?: unknown; type?: unknown; options?: unknown }; if (typeof q.question !== 'string') continue; if (q.type !== 'single_select' && q.type !== 'multi_select') continue; if (!Array.isArray(q.options)) continue; const opts = q.options.filter((o): o is string => typeof o === 'string'); if (opts.length < 2) continue; out.push({ question: q.question, type: q.type, options: opts }); } return out; } function parseAnswerSet(raw: unknown): AskUserAnswerSet | null { if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null; const arr = (raw as { answers: unknown }).answers; if (!Array.isArray(arr)) return null; const answers: AskUserAnswer[] = []; for (const item of arr) { if (!item || typeof item !== 'object') continue; const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown }; if (typeof a.question !== 'string') continue; if (!Array.isArray(a.selected_options)) continue; if (a.free_text !== null && typeof a.free_text !== 'string') continue; const sel = a.selected_options.filter((s): s is string => typeof s === 'string'); answers.push({ question: a.question, selected_options: sel, free_text: (a.free_text as string | null) ?? null, }); } return { answers }; } export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) { const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]); if (questions.length === 0) { return (
ask_user_input: malformed tool args
); } // Tool result with a non-null output means the answer is already submitted. // The pending sentinel uses output=null, so this branch only triggers after // the real WS tool_result frame lands. const answered = toolResult && toolResult.output !== null; if (answered) { const answerSet = parseAnswerSet(toolResult!.output); return ; } return ( ); } function PendingView({ questions, toolCallId, chatId, }: { questions: AskUserQuestion[]; toolCallId: string; chatId: string; }) { // Per-question selections + free text. Selections are option arrays so the // multi_select case is uniform; single_select just constrains to length 1. const [selections, setSelections] = useState(() => questions.map(() => [])); const [freeTexts, setFreeTexts] = useState(() => questions.map(() => '')); const [submitting, setSubmitting] = useState(false); const singleQuestion = questions.length === 1; const anyFreeText = freeTexts.some((t) => t.trim().length > 0); // Submit button shows when: // - more than one question (always batched), OR // - one question and the user has typed free text (committing it needs an // explicit Submit so an accidental Tab/click doesn't lose it). // For one question with no free text, clicking an option submits inline. const showSubmitButton = !singleQuestion || anyFreeText; // Every question must have at least one of (option, free text). const allComplete = questions.every((_, i) => { return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0; }); function buildAnswers(): AskUserAnswer[] { return questions.map((q, i) => { const freeText = freeTexts[i]!.trim(); return { question: q.question, selected_options: selections[i]!, free_text: freeText.length > 0 ? freeText : null, }; }); } async function submit(answers: AskUserAnswer[]) { if (submitting) return; setSubmitting(true); try { await api.chats.answerUserInput(chatId, toolCallId, answers); // Card stays mounted; the incoming WS tool_result frame will flip it // into AnsweredView via the parent prop change. } catch (err) { console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err); setSubmitting(false); } } function pickSingle(qIdx: number, option: string) { setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr))); // Immediate submit for the single-question single-select shortcut. Only // fires when no free text exists anywhere — once the user typed, the // Submit button takes over so the typed text isn't silently dropped. if (singleQuestion && !anyFreeText) { const answers: AskUserAnswer[] = [ { question: questions[0]!.question, selected_options: [option], free_text: null, }, ]; void submit(answers); } } function toggleMulti(qIdx: number, option: string) { setSelections((prev) => prev.map((arr, i) => { if (i !== qIdx) return arr; return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option]; }), ); } function setFreeText(qIdx: number, value: string) { setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t))); } return (
{questions.map((q, i) => (
{questions.length > 1 && (
Question {i + 1}
)}
{q.question}
{q.type === 'single_select' ? ( pickSingle(i, v)} disabled={submitting} className="gap-1.5" > {q.options.map((opt, j) => { const id = `q${i}-opt${j}`; return ( ); })} ) : (
{q.options.map((opt, j) => { const id = `q${i}-opt${j}`; const checked = selections[i]!.includes(opt); return ( ); })}
)}
Or type a custom answer
setFreeText(i, e.target.value)} className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60" />
))}
{showSubmitButton && (
)}
); } function AnsweredView({ questions, answers, }: { questions: AskUserQuestion[]; answers: AskUserAnswerSet | null; }) { if (!answers) { return (
ask_user_input: answers unavailable
); } return (
{questions.map((q, i) => { const a = answers.answers[i]; if (!a) return null; return (
{questions.length > 1 && (
Question {i + 1}
)}
{q.question}
{q.options.map((opt, j) => { const selected = a.selected_options.includes(opt); return (
{selected && } {opt}
); })}
{a.free_text && (
{a.free_text}
)}
); })}
); }