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,
},
}));