diff --git a/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx b/apps/ui/sources/components/tools/renderers/workflow/AskUserQuestionView.tsx index 91ae98404..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'; @@ -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,14 +237,11 @@ 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 + // 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; @@ -229,6 +253,16 @@ export const AskUserQuestionView = React.memo(({ tool, sessionId, ? t('session.sharing.permissionApprovalsDisabledReadOnly') : t('session.sharing.permissionApprovalsDisabledNotGranted'); + 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]; @@ -241,6 +275,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; @@ -336,6 +382,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); @@ -370,6 +421,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 +512,41 @@ 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); + {showTabs ? ( + <> + + + {questions.map((q, qIndex) => { + const isActive = qIndex === clampedActiveTab; + const answered = isQuestionAnswered(qIndex); return ( handleOptionToggle(qIndex, oIndex, question.multiSelect)} - disabled={!canInteract} + key={qIndex} + style={[styles.tab, isActive && styles.tabActive]} + onPress={() => setActiveTab(qIndex)} activeOpacity={0.7} > - {question.multiSelect ? ( - - {isSelected && ( - - )} - - ) : ( - - {isSelected && } - + + {q.header} + + {answered && ( + )} - - {option.label} - {option.description && ( - {option.description} - )} - ); })} - - ); - })} + + {renderQuestionContent(questions[clampedActiveTab]!, clampedActiveTab)} + + ) : ( + renderQuestionContent(questions[0]!, 0) + )} {canInteract && (