From 331372e866b4b0abe2f5bd8d4aa7a25b3326020e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 14:23:47 +0800 Subject: [PATCH] Keep multi-question replies isolated in AskUserQuestion AskUserQuestion used one free-text value for every tab, so typing a custom response on one question leaked into the next and option selection could erase another question's answer. Store free-text answers per question index and submit the per-question answer map so mixed custom and option replies stay intact. Constraint: Multiple questions share one AskUserQuestion component instance Rejected: Keep a single free-text fallback | it cannot distinguish which question owns the custom reply Confidence: high Scope-risk: narrow Directive: Keep custom text and option state scoped to a question index when changing this component Tested: Reproduced in Web UI on 127.0.0.1:1420 with server 127.0.0.1:4678 and deepseek-v4-pro in /private/tmp Tested: cd desktop && bun run test -- AskUserQuestion.test.tsx --run Tested: bun run verify Not-tested: none --- .../components/chat/AskUserQuestion.test.tsx | 58 +++++++++++++++++ .../src/components/chat/AskUserQuestion.tsx | 65 +++++++++++++------ 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/desktop/src/components/chat/AskUserQuestion.test.tsx b/desktop/src/components/chat/AskUserQuestion.test.tsx index 0ac78b33..0e4171e1 100644 --- a/desktop/src/components/chat/AskUserQuestion.test.tsx +++ b/desktop/src/components/chat/AskUserQuestion.test.tsx @@ -260,4 +260,62 @@ describe('AskUserQuestion', () => { }, }) }) + + it('keeps custom responses scoped to each question tab', () => { + const input = { + questions: [ + { + header: 'Q1', + question: 'First question?', + options: [{ label: 'A1' }, { label: 'B1' }], + }, + { + header: 'Q2', + question: 'Second question?', + options: [{ label: 'A2' }, { label: 'B2' }], + }, + ], + } + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Type your answer...'), { + target: { value: 'transient-q1' }, + }) + fireEvent.change(screen.getByPlaceholderText('Type your answer...'), { + target: { value: '' }, + }) + fireEvent.click(screen.getByRole('button', { name: /^A1$/ })) + fireEvent.change(screen.getByPlaceholderText('Type your answer...'), { + target: { value: 'custom-q1' }, + }) + fireEvent.click(screen.getByRole('button', { name: /Q2$/ })) + + expect((screen.getByPlaceholderText('Type your answer...') as HTMLInputElement).value).toBe('') + + fireEvent.click(screen.getByRole('button', { name: /^A2$/ })) + fireEvent.click(screen.getByRole('button', { name: /Q1$/ })) + + expect((screen.getByPlaceholderText('Type your answer...') as HTMLInputElement).value).toBe('custom-q1') + + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + expect(sendMock).toHaveBeenCalledWith(ACTIVE_TAB, { + type: 'permission_response', + requestId: 'perm-1', + allowed: true, + updatedInput: { + ...input, + answers: { + 'First question?': 'custom-q1', + 'Second question?': 'A2', + }, + }, + }) + }) }) diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx index 7a967091..ecd71cc8 100644 --- a/desktop/src/components/chat/AskUserQuestion.tsx +++ b/desktop/src/components/chat/AskUserQuestion.tsx @@ -57,6 +57,7 @@ function parseInput(input: unknown): Question[] { } type QuestionSelections = Record +type QuestionFreeTexts = Record function getSelectedAnswer(question: Question, selected: string[] | undefined) { if (!selected || selected.length === 0) return '' @@ -73,11 +74,13 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) const inputObject = (input && typeof input === 'object') ? input as Record : {} const [activeTab, setActiveTab] = useState(0) const [selections, setSelections] = useState({}) - const [freeText, setFreeText] = useState('') + const [freeTexts, setFreeTexts] = useState({}) const [hasSubmitted, setHasSubmitted] = useState(false) const composingRef = useRef(false) if (questions.length === 0) return null + const safeActiveTab = Math.min(activeTab, questions.length - 1) + const activeQuestion = questions[safeActiveTab] const resultAnswers = useMemo(() => { if (!result || typeof result !== 'object') return {} @@ -95,11 +98,11 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) .filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0) .join(', ') } - return freeText.trim() || questions - .map((question, index) => getSelectedAnswer(question, selections[index])) + return questions + .map((question, index) => freeTexts[index]?.trim() || getSelectedAnswer(question, selections[index])) .filter(Boolean) .join('; ') - }, [freeText, questions, resultAnswers, selections]) + }, [freeTexts, questions, resultAnswers, selections]) const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted const handleSelect = (qIndex: number, label: string) => { @@ -126,7 +129,33 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) } return { ...prev, [qIndex]: [label] } }) - setFreeText('') + setFreeTexts((prev) => { + if (!prev[qIndex]) return prev + const next = { ...prev } + delete next[qIndex] + return next + }) + } + + const handleFreeTextChange = (qIndex: number, value: string) => { + if (submitted) return + setFreeTexts((prev) => { + const next = { ...prev } + if (value) { + next[qIndex] = value + } else { + delete next[qIndex] + } + return next + }) + if (value.trim()) { + setSelections((prev) => { + if (!prev[qIndex]) return prev + const next = { ...prev } + delete next[qIndex] + return next + }) + } } const handleSubmit = () => { @@ -134,17 +163,18 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) const parts: string[] = [] for (let i = 0; i < questions.length; i++) { - const selected = getSelectedAnswer(questions[i]!, selections[i]) - if (selected) parts.push(selected) + const answer = freeTexts[i]?.trim() || getSelectedAnswer(questions[i]!, selections[i]) + if (answer) parts.push(answer) } - const response = freeText.trim() || parts.join('; ') || '' + const response = parts.join('; ') if (!response) return if (!targetSessionId || !pendingRequest) return const answers = questions.reduce>((acc, question, index) => { - if (freeText.trim()) { - acc[question.question] = freeText.trim() + const freeText = freeTexts[index]?.trim() + if (freeText) { + acc[question.question] = freeText } else { const selected = getSelectedAnswer(question, selections[index]) if (selected) acc[question.question] = selected @@ -162,9 +192,9 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) } // All questions must be answered (via selection or free text) to enable submit - const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => (selections[i]?.length ?? 0) > 0) - const safeActiveTab = Math.min(activeTab, questions.length - 1) - const activeQuestion = questions[safeActiveTab] + const allAnswered = questions.every((_, i) => + Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0, + ) if (!activeQuestion) return null @@ -202,7 +232,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
{questions.map((q, i) => { const isActive = safeActiveTab === i - const isAnswered = (selections[i]?.length ?? 0) > 0 + const isAnswered = Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0 const tabLabel = q.header || `Q${i + 1}` return (