Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<ToolViewProps>(({ tool, sessionId, interaction }) => {
Expand All @@ -210,14 +237,11 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ tool, sessionId,
const [freeformAnswers, setFreeformAnswers] = React.useState<Map<number, string>>(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;
Expand All @@ -229,6 +253,16 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ 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];
Expand All @@ -241,6 +275,18 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ 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;

Expand Down Expand Up @@ -336,6 +382,11 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ 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);
Expand Down Expand Up @@ -370,6 +421,89 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ tool, sessionId,
);
}

const renderQuestionContent = (question: Question, qIndex: number) => {
const selectedOptions = selections.get(qIndex) || new Set();
const options = Array.isArray(question.options) ? question.options : [];

return (
<View key={qIndex} style={styles.questionSection}>
{!showTabs && (
<View style={styles.headerChip}>
<Text style={styles.headerText}>{question.header}</Text>
</View>
)}
<Text style={styles.questionText}>{question.question}</Text>
<View style={styles.optionsContainer}>
{options.length === 0 ? (
<View>
<TextInput
style={styles.freeformInput}
value={freeformAnswers.get(qIndex) ?? ''}
onChangeText={(text) => {
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 ? (
<Text style={styles.freeformDescription}>{question.freeform.description}</Text>
) : null}
</View>
) : null}
{options.map((option, oIndex) => {
const isSelected = selectedOptions.has(oIndex);

return (
<TouchableOpacity
key={oIndex}
style={[
styles.optionButton,
isSelected && styles.optionButtonSelected,
!canInteract && styles.optionButtonDisabled,
]}
onPress={() => handleOptionToggle(qIndex, oIndex, question.multiSelect)}
disabled={!canInteract}
activeOpacity={0.7}
>
{question.multiSelect ? (
<View style={[
styles.checkboxOuter,
isSelected && styles.checkboxOuterSelected,
]}>
{isSelected && (
<Ionicons name="checkmark" size={14} color={theme.colors.button.primary.tint} />
)}
</View>
) : (
<View style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
]}>
{isSelected && <View style={styles.radioInner} />}
</View>
)}
<View style={styles.optionContent}>
<Text style={styles.optionLabel}>{option.label}</Text>
{option.description && (
<Text style={styles.optionDescription}>{option.description}</Text>
)}
</View>
</TouchableOpacity>
);
})}
</View>
</View>
);
};

return (
<ToolSectionView>
<View style={styles.container}>
Expand All @@ -378,86 +512,41 @@ export const AskUserQuestionView = React.memo<ToolViewProps>(({ tool, sessionId,
{disabledMessage}
</Text>
) : null}
{questions.map((question, qIndex) => {
const selectedOptions = selections.get(qIndex) || new Set();
const options = Array.isArray(question.options) ? question.options : [];

return (
<View key={qIndex} style={styles.questionSection}>
<View style={styles.headerChip}>
<Text style={styles.headerText}>{question.header}</Text>
</View>
<Text style={styles.questionText}>{question.question}</Text>
<View style={styles.optionsContainer}>
{options.length === 0 ? (
<View>
<TextInput
style={styles.freeformInput}
value={freeformAnswers.get(qIndex) ?? ''}
onChangeText={(text) => {
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 ? (
<Text style={styles.freeformDescription}>{question.freeform.description}</Text>
) : null}
</View>
) : null}
{options.map((option, oIndex) => {
const isSelected = selectedOptions.has(oIndex);

{showTabs ? (
<>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.tabBar}>
{questions.map((q, qIndex) => {
const isActive = qIndex === clampedActiveTab;
const answered = isQuestionAnswered(qIndex);
return (
<TouchableOpacity
key={oIndex}
style={[
styles.optionButton,
isSelected && styles.optionButtonSelected,
!canInteract && styles.optionButtonDisabled,
]}
onPress={() => handleOptionToggle(qIndex, oIndex, question.multiSelect)}
disabled={!canInteract}
key={qIndex}
style={[styles.tab, isActive && styles.tabActive]}
onPress={() => setActiveTab(qIndex)}
activeOpacity={0.7}
>
{question.multiSelect ? (
<View style={[
styles.checkboxOuter,
isSelected && styles.checkboxOuterSelected,
]}>
{isSelected && (
<Ionicons name="checkmark" size={14} color={theme.colors.button.primary.tint} />
)}
</View>
) : (
<View style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
]}>
{isSelected && <View style={styles.radioInner} />}
</View>
<Text style={[styles.tabText, isActive && styles.tabTextActive]}>
{q.header}
</Text>
{answered && (
<Ionicons
name="checkmark-circle"
size={14}
color={isActive ? theme.colors.button.primary.background : theme.colors.textSecondary}
/>
)}
<View style={styles.optionContent}>
<Text style={styles.optionLabel}>{option.label}</Text>
{option.description && (
<Text style={styles.optionDescription}>{option.description}</Text>
)}
</View>
</TouchableOpacity>
);
})}
</View>
</View>
);
})}
</ScrollView>
{renderQuestionContent(questions[clampedActiveTab]!, clampedActiveTab)}
</>
) : (
renderQuestionContent(questions[0]!, 0)
)}

{canInteract && (
<View style={styles.actionsContainer}>
Expand Down