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 (