From cc997576cf91e925b2271c1591eb60743c66d91b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 21 Jan 2026 12:25:51 +0000 Subject: [PATCH] fix(ui): stabilize question tool selection and custom answers --- packages/ui/src/components/tool-call.tsx | 519 ++++++++++++++--------- 1 file changed, 322 insertions(+), 197 deletions(-) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index c0a32612..dd60a79a 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js" +import { createSignal, Show, For, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import { messageStoreBus } from "../stores/message-v2/bus" import { Markdown } from "./markdown" import { ToolCallDiffViewer } from "./diff-viewer" @@ -32,6 +32,29 @@ type ToolState = import("@opencode-ai/sdk").ToolState type AnsiRenderCache = RenderCache & { hasAnsi: boolean } +type QuestionOption = { label: string; description: string } + +type QuestionPrompt = { + header: string + question: string + options: QuestionOption[] + multiple?: boolean +} + +type QuestionToolBlockProps = { + toolName: Accessor + toolState: Accessor + toolCallId: Accessor + request: Accessor + active: Accessor + submitting: Accessor + error: Accessor + draftAnswers: Accessor> + setDraftAnswers: (updater: (prev: Record) => Record) => void + onSubmit: () => void | Promise + onDismiss: () => void | Promise +} + const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 const TOOL_SCROLL_INTENT_WINDOW_MS = 600 @@ -107,6 +130,288 @@ function getSeverityMeta(tone: DiagnosticEntry["tone"]) { return { label: "INFO", icon: "i", rank: 2 } } +function QuestionToolBlock(props: QuestionToolBlockProps) { + const requestId = createMemo(() => { + const state = props.toolState() + const request = props.request() + return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}` + }) + + const questions = createMemo(() => { + const state = props.toolState() + const request = props.request() + const isQuestionTool = props.toolName() === "question" + if (!request && !isQuestionTool) return [] as QuestionPrompt[] + + const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? [] + const list = Array.isArray(questionsSource) ? questionsSource : [] + return list as QuestionPrompt[] + }) + + const isVisible = createMemo(() => { + const request = props.request() + const isQuestionTool = props.toolName() === "question" + return Boolean(request) || isQuestionTool + }) + + const answers = createMemo(() => { + const state = props.toolState() + + const completedAnswers = + (state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers) + ? ((state as any).metadata.answers as string[][]) + : undefined + + if (completedAnswers) return completedAnswers + + const request = props.request() + const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines) + + if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) { + return requestAnswers as string[][] + } + + const draft = props.draftAnswers()[requestId()] ?? [] + return Array.isArray(draft) ? draft : [] + }) + + const updateAnswer = (questionIndex: number, next: string[]) => { + if (!props.active()) return + props.setDraftAnswers((prev) => { + const current = prev[requestId()] ?? [] + const updated = [...current] + updated[questionIndex] = next + return { ...prev, [requestId()]: updated } + }) + } + + const toggleOption = (questionIndex: number, label: string) => { + const info = questions()[questionIndex] + const multi = info?.multiple === true + const existing = answers()[questionIndex] ?? [] + if (multi) { + const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label] + updateAnswer(questionIndex, next) + return + } + updateAnswer(questionIndex, [label]) + } + + const submitDisabled = () => { + if (!props.active()) return true + if (props.submitting()) return true + return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0) + } + + const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => { + if (!props.active()) return + const value = input?.value?.trim() ?? "" + if (!value) return + + const info = questions()[questionIndex] + const multi = info?.multiple === true + if (!multi) { + // When switching a radio to custom, clear existing selection first. + updateAnswer(questionIndex, []) + } + + toggleOption(questionIndex, value) + } + + const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => { + if (!props.active()) return + if (valuesToRemove.length === 0) return + const existing = answers()[questionIndex] ?? [] + const next = existing.filter((value) => !valuesToRemove.includes(value)) + updateAnswer(questionIndex, next) + } + + const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => { + if (!props.active()) return + + const value = input.value.trim() + const info = questions()[questionIndex] + const multi = info?.multiple === true + + if (!multi) { + updateAnswer(questionIndex, value ? [value] : []) + return + } + + const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label)) + const existing = answers()[questionIndex] ?? [] + const last = input.dataset.lastValue ?? "" + + let next = existing.filter((item) => item !== last) + + if (value) { + if (!optionLabels.has(value) && !next.includes(value)) { + next = [...next, value] + } else if (optionLabels.has(value)) { + // If they typed an existing option label, don't treat it as custom. + } else if (!next.includes(value)) { + next = [...next, value] + } + input.dataset.lastValue = value + } else { + delete input.dataset.lastValue + } + + updateAnswer(questionIndex, next) + } + + return ( + 0}> +
+
+ + {props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"} + + {questions().length === 1 ? "Question" : "Questions"} +
+ +
+
+ + {(q, index) => { + const i = () => index() + const multi = () => q?.multiple === true + const selected = () => answers()[i()] ?? [] + const inputType = () => (multi() ? "checkbox" : "radio") + const groupName = () => `question-${requestId()}-${i()}` + const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label)) + const customSelected = () => selected().filter((value) => !optionLabels().has(value)) + const customValue = () => customSelected()[0] ?? "" + const customChecked = () => customValue().length > 0 + + return ( +
+
+
+ Q{i() + 1}: {q?.header} +
+ +
Multiple
+
+
+ +
{q?.question}
+ +
+ + {(opt) => { + const checked = () => selected().includes(opt.label) + return ( + + ) + }} + + + +
+
+ ) + }} +
+ + +
+
+ + +
+ +
+ Enter + Submit + Esc + Dismiss +
+ + +
{props.error()}
+
+
+
+ + +

Waiting for earlier responses.

+
+
+
+
+
+ ) +} + function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { if (!state) return [] const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) @@ -573,7 +878,6 @@ export default function ToolCall(props: ToolCallProps) { const [questionError, setQuestionError] = createSignal(null) const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal>({}) - const [questionCustomDraft, setQuestionCustomDraft] = createSignal>({}) function isTextInputFocused() { const active = document.activeElement @@ -1055,196 +1359,21 @@ export default function ToolCall(props: ToolCallProps) { ) } - const renderQuestionBlock = () => { - const state = toolState() - const request = questionDetails() - const isQuestionTool = toolName() === "question" - - if (!request && !isQuestionTool) return null - - const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? [] - const questions = Array.isArray(questionsSource) ? questionsSource : [] - if (questions.length === 0) return null - - const requestId = request?.id ?? (state as any)?.input?.requestID ?? `question-${toolCallMemo()?.id ?? "unknown"}` - const active = Boolean(request && isQuestionActive()) - - const completedAnswers = Array.isArray((state as any)?.metadata?.answers) ? ((state as any).metadata.answers as string[][]) : undefined - const answers = completedAnswers ?? questionDraftAnswers()[requestId] ?? [] - const customInputs = questionCustomDraft()[requestId] ?? [] - - const updateAnswer = (questionIndex: number, next: string[]) => { - if (!active) return - setQuestionDraftAnswers((prev) => { - const current = prev[requestId] ?? [] - const updated = [...current] - updated[questionIndex] = next - return { ...prev, [requestId]: updated } - }) - } - - const updateCustom = (questionIndex: number, value: string) => { - if (!active) return - setQuestionCustomDraft((prev) => { - const current = prev[requestId] ?? [] - const updated = [...current] - updated[questionIndex] = value - return { ...prev, [requestId]: updated } - }) - } - - const toggleOption = (questionIndex: number, label: string) => { - const info = questions[questionIndex] - const multi = info?.multiple === true - const existing = answers[questionIndex] ?? [] - if (multi) { - const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label] - updateAnswer(questionIndex, next) - return - } - updateAnswer(questionIndex, [label]) - } - - const submitDisabled = () => { - if (!active) return true - if (questionSubmitting()) return true - return questions.some((_, index) => (answers[index]?.length ?? 0) === 0) - } - - const showButtons = () => active - - return ( -
-
- - {active ? "Question Required" : request ? "Question Queued" : "Questions"} - - {questions.length === 1 ? "Question" : "Questions"} -
- -
-
- - {(q, index) => { - const i = () => index() - const multi = () => q?.multiple === true - const selected = () => answers[i()] ?? [] - const customValue = () => customInputs[i()] ?? "" - const inputType = () => (multi() ? "checkbox" : "radio") - const groupName = () => `question-${requestId}-${i()}` - - return ( -
-
-
- Q{i() + 1}: {q?.header} -
- -
Multiple
-
-
- -
{q?.question}
- -
- - {(opt) => { - const checked = () => selected().includes(opt.label) - return ( - - ) - }} - - - -
- updateCustom(i(), e.currentTarget.value)} - /> - -
-
-
- -
- ) - }} -
- - -
-
- - -
- -
- Enter - Submit - Esc - Dismiss -
- - -
{questionError()}
-
-
-
- - -

Waiting for earlier responses.

-
-
-
-
- ) - } + const renderQuestionBlock = () => ( + void handleQuestionSubmit()} + onDismiss={() => void handleQuestionDismiss()} + /> + ) createEffect(() => { const request = questionDetails() @@ -1260,11 +1389,7 @@ export default function ToolCall(props: ToolCallProps) { const initial = request.questions.map(() => []) return { ...prev, [requestId]: initial } }) - setQuestionCustomDraft((prev) => { - if (prev[requestId]) return prev - const initial = request.questions.map(() => "") - return { ...prev, [requestId]: initial } - }) + }) const status = () => toolState()?.status || ""