From 60f69c2062bb3673266520dec73f4f02e65a86d7 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: Sat, 9 May 2026 21:48:16 +0800 Subject: [PATCH] fix: respect AskUserQuestion multi-select markers Desktop question prompts can carry CLI-provided multiSelect flags, so the answer UI now stores selections per question as arrays and only allows multiple checked options when that flag is true. The message renderer also passes the owning session id into permission UI so pending responses go back to the session that produced the prompt instead of whichever tab is active. Constraint: AskUserQuestion input may arrive as either questions[] or single question shape Rejected: Make all option questions multi-select | single-select prompts must remain constrained unless the CLI marks them multiSelect Confidence: high Scope-risk: narrow Directive: Do not infer multi-select from option count; honor the explicit multiSelect flag Tested: cd desktop && bun run test -- src/components/chat/AskUserQuestion.test.tsx src/components/chat/chatBlocks.test.tsx src/components/chat/MessageList.test.tsx Tested: bun run check:desktop Tested: Chrome/CDP smoke against /tmp/cc-haha-ask-user-real verified multi-select and single-select behavior Not-tested: Chrome extension RPC path, which timed out in this environment despite native host checks passing --- .../components/chat/AskUserQuestion.test.tsx | 152 ++++++++++++++++++ .../src/components/chat/AskUserQuestion.tsx | 71 +++++--- desktop/src/components/chat/MessageList.tsx | 5 + .../src/components/chat/PermissionDialog.tsx | 12 +- 4 files changed, 217 insertions(+), 23 deletions(-) diff --git a/desktop/src/components/chat/AskUserQuestion.test.tsx b/desktop/src/components/chat/AskUserQuestion.test.tsx index b6d15d85..0ac78b33 100644 --- a/desktop/src/components/chat/AskUserQuestion.test.tsx +++ b/desktop/src/components/chat/AskUserQuestion.test.tsx @@ -108,4 +108,156 @@ describe('AskUserQuestion', () => { }, }) }) + + it('allows multiple selections when a question is marked multiSelect', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^Lint$/ })) + fireEvent.click(screen.getByRole('button', { name: /^Tests$/ })) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + expect(sendMock).toHaveBeenCalledWith(ACTIVE_TAB, { + type: 'permission_response', + requestId: 'perm-1', + allowed: true, + updatedInput: { + questions: [ + { + question: 'Which tasks should run?', + multiSelect: true, + options: [ + { label: 'Lint' }, + { label: 'Tests' }, + { label: 'Build' }, + ], + }, + ], + answers: { + 'Which tasks should run?': 'Lint, Tests', + }, + }, + }) + }) + + it('preserves multiSelect for single-question input shape', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^Lint$/ })) + fireEvent.click(screen.getByRole('button', { name: /^Tests$/ })) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + expect(sendMock).toHaveBeenCalledWith(ACTIVE_TAB, { + type: 'permission_response', + requestId: 'perm-1', + allowed: true, + updatedInput: { + question: 'Which tasks should run?', + multiSelect: true, + options: [ + { label: 'Lint' }, + { label: 'Tests' }, + { label: 'Build' }, + ], + answers: { + 'Which tasks should run?': 'Lint, Tests', + }, + }, + }) + }) + + it('responds to the provided session instead of the active tab', () => { + useTabStore.setState({ + activeTabId: 'other-tab', + tabs: [ + { sessionId: 'other-tab', title: 'Other', type: 'session', status: 'idle' }, + { sessionId: 'target-tab', title: 'Target', type: 'session', status: 'idle' }, + ], + }) + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + 'target-tab': { + ...state.sessions[ACTIVE_TAB]!, + pendingPermission: { + requestId: 'perm-target', + toolName: 'AskUserQuestion', + toolUseId: 'tool-target', + input: { + questions: [ + { + question: 'Run tests?', + options: [{ label: 'No' }, { label: 'Yes' }], + }, + ], + }, + }, + }, + }, + })) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^Yes$/ })) + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + expect(sendMock).toHaveBeenCalledWith('target-tab', { + type: 'permission_response', + requestId: 'perm-target', + allowed: true, + updatedInput: { + questions: [ + { + question: 'Run tests?', + options: [{ label: 'No' }, { label: 'Yes' }], + }, + ], + answers: { + 'Run tests?': 'Yes', + }, + }, + }) + }) }) diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx index 93694544..7a967091 100644 --- a/desktop/src/components/chat/AskUserQuestion.tsx +++ b/desktop/src/components/chat/AskUserQuestion.tsx @@ -13,15 +13,19 @@ type Question = { question: string header?: string options?: QuestionOption[] + multiSelect?: boolean } type AskUserInput = { questions?: Question[] question?: string + header?: string options?: QuestionOption[] + multiSelect?: boolean } type Props = { + sessionId?: string | null toolUseId: string input: unknown result?: unknown @@ -41,21 +45,34 @@ function parseInput(input: unknown): Question[] { // Shape 2: { question: "...", options: [...] } if (typeof obj.question === 'string') { - return [{ question: obj.question, options: obj.options }] + return [{ + question: obj.question, + header: obj.header, + options: obj.options, + multiSelect: obj.multiSelect, + }] } return [] } -export function AskUserQuestion({ toolUseId, input, result }: Props) { +type QuestionSelections = Record + +function getSelectedAnswer(question: Question, selected: string[] | undefined) { + if (!selected || selected.length === 0) return '' + return question.multiSelect ? selected.join(', ') : selected[0] ?? '' +} + +export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) { const { respondToPermission } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) - const pendingPermission = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.pendingPermission : undefined) + const targetSessionId = sessionId ?? activeTabId + const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined) const t = useTranslation() const questions = parseInput(input) const inputObject = (input && typeof input === 'object') ? input as Record : {} const [activeTab, setActiveTab] = useState(0) - const [selections, setSelections] = useState>({}) + const [selections, setSelections] = useState({}) const [freeText, setFreeText] = useState('') const [hasSubmitted, setHasSubmitted] = useState(false) const composingRef = useRef(false) @@ -78,20 +95,36 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) { .filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0) .join(', ') } - return freeText.trim() || Object.values(selections).join(', ') + return freeText.trim() || questions + .map((question, index) => getSelectedAnswer(question, selections[index])) + .filter(Boolean) + .join('; ') }, [freeText, questions, resultAnswers, selections]) const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted const handleSelect = (qIndex: number, label: string) => { if (submitted) return setSelections((prev) => { - // Toggle: deselect if already selected - if (prev[qIndex] === label) { + const question = questions[qIndex] + const selected = prev[qIndex] ?? [] + if (question?.multiSelect) { + const nextSelected = selected.includes(label) + ? selected.filter((value) => value !== label) + : [...selected, label] + const next = { ...prev } + if (nextSelected.length > 0) { + next[qIndex] = nextSelected + } else { + delete next[qIndex] + } + return next + } + if (selected[0] === label) { const next = { ...prev } delete next[qIndex] return next } - return { ...prev, [qIndex]: label } + return { ...prev, [qIndex]: [label] } }) setFreeText('') } @@ -101,25 +134,26 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) { const parts: string[] = [] for (let i = 0; i < questions.length; i++) { - const selected = selections[i] + const selected = getSelectedAnswer(questions[i]!, selections[i]) if (selected) parts.push(selected) } const response = freeText.trim() || parts.join('; ') || '' if (!response) return - if (!activeTabId || !pendingRequest) return + if (!targetSessionId || !pendingRequest) return const answers = questions.reduce>((acc, question, index) => { if (freeText.trim()) { acc[question.question] = freeText.trim() - } else if (selections[index]) { - acc[question.question] = selections[index]! + } else { + const selected = getSelectedAnswer(question, selections[index]) + if (selected) acc[question.question] = selected } return acc }, {}) setHasSubmitted(true) - respondToPermission(activeTabId, pendingRequest.requestId, true, { + respondToPermission(targetSessionId, pendingRequest.requestId, true, { updatedInput: { ...inputObject, answers, @@ -128,7 +162,7 @@ export function AskUserQuestion({ 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] !== undefined) + 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] @@ -168,7 +202,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
{questions.map((q, i) => { const isActive = safeActiveTab === i - const isAnswered = selections[i] !== undefined + const isAnswered = (selections[i]?.length ?? 0) > 0 const tabLabel = q.header || `Q${i + 1}` return (