From 5c4b7453e509d0950659d3b0b3cfe7e13510257b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Guldmund?= Date: Tue, 10 Mar 2026 20:20:30 +0100 Subject: [PATCH 1/4] feat(ui): add tabbed navigation to AskUserQuestion for multi-question flows When multiple questions are present, renders a tab bar with checkmark indicators instead of stacking all questions vertically. --- .../workflow/AskUserQuestionView.tsx | 236 ++++++++++++------ 1 file changed, 157 insertions(+), 79 deletions(-) diff --git a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx index 91ae98404..34c7278b0 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx @@ -202,6 +202,33 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.text, flex: 1, }, + tabBar: { + flexDirection: 'row', + gap: 2, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 12, + paddingVertical: 8, + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + tabActive: { + borderBottomColor: theme.colors.button.primary.background, + }, + tabText: { + fontSize: 13, + fontWeight: '500', + color: theme.colors.textSecondary, + }, + tabTextActive: { + color: theme.colors.text, + fontWeight: '600', + }, })); export const AskUserQuestionView = React.memo(({ tool, sessionId, interaction }) => { @@ -210,6 +237,7 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, const [freeformAnswers, setFreeformAnswers] = React.useState>(new Map()); const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitted, setIsSubmitted] = React.useState(false); + const [activeTab, setActiveTab] = React.useState(0); // Parse input const input = tool.input as AskUserQuestionInput | undefined; @@ -229,6 +257,8 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, ? t('session.sharing.permissionApprovalsDisabledReadOnly') : t('session.sharing.permissionApprovalsDisabledNotGranted'); + const showTabs = questions.length > 1; + // Check if all questions have at least one selection const allQuestionsAnswered = questions.every((_, qIndex) => { const q = questions[qIndex]; @@ -241,6 +271,18 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, return Boolean(selected && selected.size > 0); }); + // Helper: check if a specific question is answered + const isQuestionAnswered = React.useCallback((qIndex: number): boolean => { + const q = questions[qIndex]; + const options = Array.isArray(q?.options) ? q.options : []; + if (options.length === 0) { + const value = freeformAnswers.get(qIndex); + return typeof value === 'string' && value.trim().length > 0; + } + const selected = selections.get(qIndex); + return Boolean(selected && selected.size > 0); + }, [questions, selections, freeformAnswers]); + const handleOptionToggle = React.useCallback((questionIndex: number, optionIndex: number, multiSelect: boolean) => { if (!canInteract) return; @@ -370,6 +412,89 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, ); } + const renderQuestionContent = (question: Question, qIndex: number) => { + const selectedOptions = selections.get(qIndex) || new Set(); + const options = Array.isArray(question.options) ? question.options : []; + + return ( + + {!showTabs && ( + + {question.header} + + )} + {question.question} + + {options.length === 0 ? ( + + { + if (!canInteract) return; + setFreeformAnswers((prev) => { + const next = new Map(prev); + next.set(qIndex, text); + return next; + }); + }} + placeholder={question.freeform?.placeholder ?? t('tools.askUserQuestion.otherPlaceholder')} + placeholderTextColor={theme.colors.textSecondary} + editable={canInteract} + autoCapitalize="none" + autoCorrect={false} + /> + {question.freeform?.description ? ( + {question.freeform.description} + ) : null} + + ) : null} + {options.map((option, oIndex) => { + const isSelected = selectedOptions.has(oIndex); + + return ( + handleOptionToggle(qIndex, oIndex, question.multiSelect)} + disabled={!canInteract} + activeOpacity={0.7} + > + {question.multiSelect ? ( + + {isSelected && ( + + )} + + ) : ( + + {isSelected && } + + )} + + {option.label} + {option.description && ( + {option.description} + )} + + + ); + })} + + + ); + }; + return ( @@ -378,86 +503,39 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, {disabledMessage} ) : null} - {questions.map((question, qIndex) => { - const selectedOptions = selections.get(qIndex) || new Set(); - const options = Array.isArray(question.options) ? question.options : []; - - return ( - - - {question.header} - - {question.question} - - {options.length === 0 ? ( - - { - if (!canInteract) return; - setFreeformAnswers((prev) => { - const next = new Map(prev); - next.set(qIndex, text); - return next; - }); - }} - placeholder={question.freeform?.placeholder ?? t('tools.askUserQuestion.otherPlaceholder')} - placeholderTextColor={theme.colors.textSecondary} - editable={canInteract} - autoCapitalize="none" - autoCorrect={false} - /> - {question.freeform?.description ? ( - {question.freeform.description} - ) : null} - - ) : null} - {options.map((option, oIndex) => { - const isSelected = selectedOptions.has(oIndex); - - return ( - handleOptionToggle(qIndex, oIndex, question.multiSelect)} - disabled={!canInteract} - activeOpacity={0.7} - > - {question.multiSelect ? ( - - {isSelected && ( - - )} - - ) : ( - - {isSelected && } - - )} - - {option.label} - {option.description && ( - {option.description} - )} - - - ); - })} - + + {showTabs ? ( + <> + + {questions.map((q, qIndex) => { + const isActive = qIndex === activeTab; + const answered = isQuestionAnswered(qIndex); + return ( + setActiveTab(qIndex)} + activeOpacity={0.7} + > + + {q.header} + + {answered && ( + + )} + + ); + })} - ); - })} + {renderQuestionContent(questions[activeTab]!, activeTab)} + + ) : ( + renderQuestionContent(questions[0]!, 0) + )} {canInteract && ( From 06482095bd7f44623a8791d83406a0224ec6a676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Guldmund?= Date: Tue, 10 Mar 2026 22:18:40 +0100 Subject: [PATCH 2/4] fix(ui): move early return after all hooks in AskUserQuestionView Normalize questions array before hooks to prevent Rules of Hooks violation when the component re-renders with different question counts. --- .../renderers/workflow/AskUserQuestionView.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx index 34c7278b0..019e54700 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx @@ -239,13 +239,9 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, const [isSubmitted, setIsSubmitted] = React.useState(false); const [activeTab, setActiveTab] = React.useState(0); - // Parse input + // Parse input — normalize before any hooks to avoid Rules of Hooks violation const input = tool.input as AskUserQuestionInput | undefined; - const questions = input?.questions; - - if (!questions || !Array.isArray(questions) || questions.length === 0) { - return null; - } + const questions = input?.questions && Array.isArray(input.questions) ? input.questions : []; const isRunning = tool.state === 'running'; const canApprovePermissions = interaction?.canApprovePermissions ?? true; @@ -378,6 +374,11 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, } }, [sessionId, questions, selections, freeformAnswers, allQuestionsAnswered, isSubmitting, tool.permission?.id]); + // Early return after all hooks + if (questions.length === 0) { + return null; + } + // Show submitted state if (isSubmitted || tool.state === 'completed') { const answersFromResult = parseAskUserQuestionAnswersFromToolResult(tool.result); From bebcf38fc3f375a553334616a7cffe900f1c64bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Guldmund?= Date: Tue, 10 Mar 2026 22:19:07 +0100 Subject: [PATCH 3/4] fix(ui): clamp activeTab to prevent out-of-bounds access If the questions array shrinks while activeTab points beyond its length, the component would crash. Clamp to valid range and sync state via effect. --- .../tools/renderers/workflow/AskUserQuestionView.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx index 019e54700..3216ef033 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx @@ -255,6 +255,14 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, const showTabs = questions.length > 1; + // Clamp activeTab to valid range when questions array shrinks + const clampedActiveTab = Math.min(activeTab, Math.max(questions.length - 1, 0)); + React.useEffect(() => { + if (clampedActiveTab !== activeTab) { + setActiveTab(clampedActiveTab); + } + }, [clampedActiveTab, activeTab]); + // Check if all questions have at least one selection const allQuestionsAnswered = questions.every((_, qIndex) => { const q = questions[qIndex]; @@ -509,7 +517,7 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, <> {questions.map((q, qIndex) => { - const isActive = qIndex === activeTab; + const isActive = qIndex === clampedActiveTab; const answered = isQuestionAnswered(qIndex); return ( (({ tool, sessionId, ); })} - {renderQuestionContent(questions[activeTab]!, activeTab)} + {renderQuestionContent(questions[clampedActiveTab]!, clampedActiveTab)} ) : ( renderQuestionContent(questions[0]!, 0) From 5c1c340a727daccff28f43329048c24cece2b830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Guldmund?= Date: Tue, 10 Mar 2026 22:19:27 +0100 Subject: [PATCH 4/4] fix(ui): wrap tab bar in horizontal ScrollView to prevent overflow With 5+ questions on smaller screens, tabs would compress or overflow. Wrapping in a horizontal ScrollView keeps all tabs accessible. --- .../workflow/AskUserQuestionView.tsx | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx index 3216ef033..391b521ad 100644 --- a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx +++ b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, TextInput, ScrollView } from 'react-native'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; import { ToolViewProps } from '../core/_registry'; import { ToolSectionView } from '../../shell/presentation/ToolSectionView'; @@ -515,31 +515,33 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, {showTabs ? ( <> - - {questions.map((q, qIndex) => { - const isActive = qIndex === clampedActiveTab; - const answered = isQuestionAnswered(qIndex); - return ( - setActiveTab(qIndex)} - activeOpacity={0.7} - > - - {q.header} - - {answered && ( - - )} - - ); - })} - + + + {questions.map((q, qIndex) => { + const isActive = qIndex === clampedActiveTab; + const answered = isQuestionAnswered(qIndex); + return ( + setActiveTab(qIndex)} + activeOpacity={0.7} + > + + {q.header} + + {answered && ( + + )} + + ); + })} + + {renderQuestionContent(questions[clampedActiveTab]!, clampedActiveTab)} ) : (