import { useState, useCallback, useMemo } from "react"; import { View, Text, TextInput, Pressable, ActivityIndicator, type PressableStateCallbackType, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { isWeb } from "@/constants/platform"; import { areQuestionsAnswered, buildQuestionFormAnswers, isQuestionAnswered, parseQuestionFormQuestions, questionShowsTextInput, resolveDismissLabel, shouldSubmitEmptyOnDismiss, type QuestionFormQuestion, type QuestionOption, } from "./question-form-card-core"; interface QuestionFormCardProps { permission: PendingPermission; onRespond: (response: AgentPermissionResponse) => void; isResponding: boolean; } const IS_WEB = isWeb; function getQuestionInputPlaceholder({ question, answerPlaceholder, otherPlaceholder, }: { question: QuestionFormQuestion; answerPlaceholder: string; otherPlaceholder: string; }): string { return ( question.placeholder ?? (question.options.length === 0 ? answerPlaceholder : otherPlaceholder) ); } interface QuestionOptionRowProps { qIndex: number; optIndex: number; option: QuestionOption; isSelected: boolean; multiSelect: boolean; isResponding: boolean; onToggle: (qIndex: number, optIndex: number, multiSelect: boolean) => void; } function QuestionOptionRow({ qIndex, optIndex, option, isSelected, multiSelect, isResponding, onToggle, }: QuestionOptionRowProps) { const { theme } = useUnistyles(); const handlePress = useCallback(() => { onToggle(qIndex, optIndex, multiSelect); }, [onToggle, qIndex, optIndex, multiSelect]); const pressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.optionItem, (Boolean(hovered) || isSelected) && { backgroundColor: theme.colors.surface2, }, pressed && styles.optionItemPressed, ], [isSelected, theme.colors.surface2], ); const optionLabelStyle = useMemo( () => [styles.optionLabel, { color: theme.colors.foreground }], [theme.colors.foreground], ); const optionDescriptionStyle = useMemo( () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]); // Static left-side control: square for multi-select, circle for single-select. // Always rendered so toggling only swaps fill/border — the row never reflows. const controlStyle = useMemo( () => [ styles.selectionControl, multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio, { borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted, backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent", }, ], [isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted], ); const radioDotStyle = useMemo( () => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }], [theme.colors.accent], ); return ( {isSelected && multiSelect ? ( ) : null} {isSelected && !multiSelect ? : null} {option.label} {option.description ? ( {option.description} ) : null} ); } interface QuestionNavButtonProps { index: number; total: number; header: string; isActive: boolean; isAnswered: boolean; isResponding: boolean; onSelect: (index: number) => void; } function QuestionNavButton({ index, total, header, isActive, isAnswered, isResponding, onSelect, }: QuestionNavButtonProps) { const { theme } = useUnistyles(); const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]); const handlePress = useCallback(() => { onSelect(index); }, [index, onSelect]); const pressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => { return [ styles.questionNavButton, { backgroundColor: isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1, borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border, }, pressed && styles.optionItemPressed, ]; }, [ isActive, theme.colors.border, theme.colors.foregroundMuted, theme.colors.surface1, theme.colors.surface2, ], ); const textStyle = useMemo( () => [ styles.questionNavText, { color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted }, ], [isActive, theme.colors.foreground, theme.colors.foregroundMuted], ); return ( {isAnswered ? ( ) : null} {header} ); } interface QuestionNavProps { questions: QuestionFormQuestion[]; activeIndex: number; isAnswered: (qIndex: number) => boolean; isResponding: boolean; onSelect: (index: number) => void; } // Titled tabs (one per question header) with a check on answered ones. Hidden for // a lone question — a single "1 of 1" tab carries no information. function QuestionNav({ questions, activeIndex, isAnswered, isResponding, onSelect, }: QuestionNavProps) { if (questions.length <= 1) { return null; } return ( {questions.map((question, qIndex) => ( ))} ); } interface QuestionOtherInputProps { qIndex: number; accessibilityLabel: string; value: string; placeholder: string; isResponding: boolean; onChange: (qIndex: number, text: string) => void; onSubmit: () => void; } function QuestionOtherInput({ qIndex, accessibilityLabel, value, placeholder, isResponding, onChange, onSubmit, }: QuestionOtherInputProps) { const { theme } = useUnistyles(); const handleChange = useCallback( (text: string) => { onChange(qIndex, text); }, [onChange, qIndex], ); const otherInputStyle = useMemo( () => [ styles.otherInput, { borderColor: value.length > 0 ? theme.colors.borderAccent : theme.colors.border, color: theme.colors.foreground, backgroundColor: theme.colors.surface2, }, IS_WEB ? { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" } : null, ] as const, [ value.length, theme.colors.borderAccent, theme.colors.border, theme.colors.foreground, theme.colors.surface2, ], ); return ( ); } export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const questions = useMemo( () => parseQuestionFormQuestions(permission.request.input), [permission.request.input], ); const [selections, setSelections] = useState>>({}); const [otherTexts, setOtherTexts] = useState>({}); const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); const toggleOption = useCallback( (qIndex: number, optIndex: number, multiSelect: boolean) => { const current = selections[qIndex] ?? new Set(); const next = new Set(current); if (multiSelect) { if (next.has(optIndex)) { next.delete(optIndex); } else { next.add(optIndex); } } else if (next.has(optIndex)) { next.clear(); } else { next.clear(); next.add(optIndex); } setSelections((prev) => ({ ...prev, [qIndex]: next })); setOtherTexts((prev) => { if (!prev[qIndex]) return prev; const nextTexts = { ...prev }; delete nextTexts[qIndex]; return nextTexts; }); if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) { setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1)); } }, [activeQuestionIndex, questions, selections], ); const setOtherText = useCallback((qIndex: number, text: string) => { setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); if (text.length > 0) { setSelections((prev) => { if (!prev[qIndex] || prev[qIndex].size === 0) return prev; return { ...prev, [qIndex]: new Set() }; }); } }, []); const allAnswered = areQuestionsAnswered(questions, selections, otherTexts); const resolvedActiveQuestionIndex = questions ? Math.min(activeQuestionIndex, questions.length - 1) : 0; const activeQuestion = questions?.[resolvedActiveQuestionIndex]; const activeQuestionAnswered = activeQuestion ? isQuestionAnswered(activeQuestion, resolvedActiveQuestionIndex, selections, otherTexts) : false; const isLastQuestion = questions ? resolvedActiveQuestionIndex === questions.length - 1 : true; const handleSubmit = useCallback(() => { if (!questions || !allAnswered || isResponding) return; setRespondingAction("submit"); onRespond({ behavior: "allow", updatedInput: { ...permission.request.input, answers: buildQuestionFormAnswers(questions, selections, otherTexts), }, }); }, [ questions, allAnswered, isResponding, selections, otherTexts, onRespond, permission.request.input, ]); const handleDeny = useCallback(() => { if (!questions) return; setRespondingAction("dismiss"); if (shouldSubmitEmptyOnDismiss(questions)) { onRespond({ behavior: "allow", updatedInput: { ...permission.request.input, answers: buildQuestionFormAnswers(questions, selections, otherTexts), }, }); return; } onRespond({ behavior: "deny", message: "Dismissed by user", }); }, [questions, onRespond, otherTexts, permission.request.input, selections]); const handleSelectQuestion = useCallback((index: number) => { setActiveQuestionIndex(index); }, []); const navIsAnswered = useCallback( (qIndex: number) => questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false, [questions, selections, otherTexts], ); const handlePrimaryAction = useCallback(() => { if (!isLastQuestion) { if (!activeQuestionAnswered || isResponding) return; setActiveQuestionIndex((index) => Math.min(index + 1, (questions?.length ?? 1) - 1)); return; } handleSubmit(); }, [activeQuestionAnswered, handleSubmit, isLastQuestion, isResponding, questions?.length]); const dismissButtonStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, { backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: theme.colors.borderAccent, }, pressed && styles.optionItemPressed, ], [theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent], ); const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered); const primaryActionLabel = isLastQuestion ? t("message.question.submit") : t("message.question.next"); const submitButtonStyle = useCallback( ({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent, opacity: primaryDisabled ? 0.5 : 1, }, pressed && !primaryDisabled ? styles.optionItemPressed : null, ], [primaryDisabled, theme.colors.accent], ); const containerStyle = useMemo( () => [ styles.container, { backgroundColor: theme.colors.surface1, borderColor: theme.colors.border, }, ], [theme.colors.surface1, theme.colors.border], ); const questionTextStyle = useMemo( () => [styles.questionText, { color: theme.colors.foreground }], [theme.colors.foreground], ); // Single-select radios need a group; checkboxes are valid standalone. const optionsGroupAccessibility = useMemo( () => activeQuestion && !activeQuestion.multiSelect ? ({ accessibilityRole: "radiogroup", accessibilityLabel: activeQuestion.question, } as const) : {}, [activeQuestion], ); const actionsContainerStyle = useMemo( () => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop], [isMobile], ); const dismissActionTextStyle = useMemo( () => [styles.actionText, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); const submitActionTextColor = theme.colors.accentForeground; const submitActionTextStyle = useMemo( () => [styles.actionText, { color: submitActionTextColor }], [submitActionTextColor], ); if (!questions) { return null; } const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss")); const selected = selections[resolvedActiveQuestionIndex] ?? new Set(); const otherText = otherTexts[resolvedActiveQuestionIndex] ?? ""; const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false; return ( {activeQuestion?.question} {activeQuestion ? ( {activeQuestion.options.length > 0 ? ( {activeQuestion.options.map((opt, optIndex) => ( ))} ) : null} {showTextInput ? ( ) : null} ) : null} {respondingAction === "dismiss" ? ( ) : ( {dismissLabel} )} {respondingAction === "submit" ? ( ) : ( {primaryActionLabel} )} ); } const styles = StyleSheet.create((theme) => ({ container: { padding: theme.spacing[3], borderRadius: theme.spacing[2], borderWidth: 1, gap: theme.spacing[3], }, questionBlock: { gap: theme.spacing[2], }, questionHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[1], flex: 1, }, questionText: { flex: 1, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], }, questionNav: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[3], }, questionNavButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], minHeight: 28, paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], }, questionNavText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, optionItem: { flexDirection: "row", alignItems: "center", paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, }, optionItemPressed: { opacity: 0.9, }, optionItemContent: { flex: 1, flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], }, optionTextBlock: { flex: 1, gap: theme.spacing[1], }, optionLabel: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, lineHeight: 22, }, optionDescription: { fontSize: theme.fontSize.sm, lineHeight: 20, }, selectionControl: { width: 18, height: 18, alignItems: "center", justifyContent: "center", borderWidth: theme.borderWidth[1], marginTop: 2, // optical-align 18px control to the 22px label first line }, selectionControlCheckbox: { borderRadius: theme.borderRadius.base, }, selectionControlRadio: { borderRadius: 999, }, selectionRadioDot: { width: 8, height: 8, borderRadius: 999, }, otherInput: { borderWidth: 1, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[3], fontSize: theme.fontSize.sm, }, actionsContainer: { gap: theme.spacing[2], }, actionsContainerDesktop: { flexDirection: "row", justifyContent: "flex-start", alignItems: "center", }, actionButton: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, alignItems: "center", borderWidth: theme.borderWidth[1], }, actionContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, actionText: { fontSize: theme.fontSize.sm, }, }));