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(() => ({ selected: isSelected }), [isSelected]); return ( {option.label} {option.description ? ( {option.description} ) : null} {isSelected ? ( ) : null} ); } interface QuestionNavButtonProps { index: number; total: number; isActive: boolean; isResponding: boolean; onSelect: (index: number) => void; } function QuestionNavButton({ index, total, isActive, 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 ( {index + 1} ); } 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 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], ); const questionNavStyle = useMemo( () => [styles.questionNav, isMobile && styles.questionNavMobile], [isMobile], ); 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} {questions.map((question, qIndex) => { const isActive = qIndex === resolvedActiveQuestionIndex; return ( ); })} {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], }, questionTopRow: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", gap: theme.spacing[3], }, 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", alignItems: "center", justifyContent: "flex-end", gap: theme.spacing[1], }, questionNavMobile: { paddingRight: theme.spacing[1], }, questionNavButton: { minWidth: 28, height: 28, alignItems: "center", justifyContent: "center", borderRadius: 999, borderWidth: theme.borderWidth[1], }, questionNavText: { fontSize: theme.fontSize.xs, fontWeight: "700", }, 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: "center", gap: theme.spacing[2], }, optionTextBlock: { flex: 1, gap: 2, }, optionLabel: { fontSize: theme.fontSize.sm, }, optionDescription: { fontSize: theme.fontSize.xs, lineHeight: 16, }, optionCheckSlot: { width: 16, alignItems: "center", justifyContent: "center", marginLeft: "auto", }, 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, }, }));