import { useState, useCallback } from "react"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; interface QuestionOption { label: string; description?: string; } interface Question { question: string; header: string; options: QuestionOption[]; multiSelect: boolean; } function parseQuestions(input: unknown): Question[] | null { if ( typeof input !== "object" || input === null || !("questions" in input) || !Array.isArray((input as Record).questions) ) { return null; } const raw = (input as Record).questions as unknown[]; const questions: Question[] = []; for (const item of raw) { if (typeof item !== "object" || item === null) return null; const q = item as Record; if (typeof q.question !== "string" || typeof q.header !== "string") return null; if (!Array.isArray(q.options)) return null; const options: QuestionOption[] = []; for (const opt of q.options as unknown[]) { if (typeof opt !== "object" || opt === null) return null; const o = opt as Record; if (typeof o.label !== "string") return null; options.push({ label: o.label, description: typeof o.description === "string" ? o.description : undefined, }); } questions.push({ question: q.question, header: q.header, options, multiSelect: q.multiSelect === true, }); } return questions.length > 0 ? questions : null; } interface QuestionFormCardProps { permission: PendingPermission; onRespond: (response: AgentPermissionResponse) => void; isResponding: boolean; } const IS_WEB = Platform.OS === "web"; export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); const questions = parseQuestions(permission.request.input); const [selections, setSelections] = useState>>({}); const [otherTexts, setOtherTexts] = useState>({}); const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => { setSelections((prev) => { const current = prev[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); } } return { ...prev, [qIndex]: next }; }); setOtherTexts((prev) => { if (!prev[qIndex]) return prev; const next = { ...prev }; delete next[qIndex]; return next; }); }, []); 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() }; }); } }, []); if (!questions) { return null; } const allAnswered = questions.every((_, qIndex) => { const selected = selections[qIndex]; const otherText = otherTexts[qIndex]?.trim(); return (selected && selected.size > 0) || (otherText && otherText.length > 0); }); function handleSubmit() { if (!allAnswered || isResponding) return; setRespondingAction("submit"); const answers: Record = {}; for (let i = 0; i < questions!.length; i++) { const q = questions![i]; const selected = selections[i]; const otherText = otherTexts[i]?.trim(); if (otherText && otherText.length > 0) { answers[q.header] = otherText; } else if (selected && selected.size > 0) { const labels = Array.from(selected).map((idx) => q.options[idx].label); answers[q.header] = labels.join(", "); } } onRespond({ behavior: "allow", updatedInput: { ...permission.request.input, answers }, }); } function handleDeny() { setRespondingAction("dismiss"); onRespond({ behavior: "deny", message: "Dismissed by user", }); } return ( {questions.map((q, qIndex) => { const selected = selections[qIndex] ?? new Set(); const otherText = otherTexts[qIndex] ?? ""; return ( {q.question} {q.options.map((opt, optIndex) => { const isSelected = selected.has(optIndex); return ( [ styles.optionItem, (hovered || isSelected) && { backgroundColor: theme.colors.surface2, }, pressed && styles.optionItemPressed, ]} onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)} disabled={isResponding} > {opt.label} {opt.description ? ( {opt.description} ) : null} {isSelected ? ( ) : null} ); })} 0 ? theme.colors.borderAccent : theme.colors.border, color: theme.colors.foreground, backgroundColor: theme.colors.surface2, }, // @ts-expect-error - outlineStyle is web-only IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" }, ]} placeholder="Other..." placeholderTextColor={theme.colors.foregroundMuted} value={otherText} onChangeText={(text) => setOtherText(qIndex, text)} onSubmitEditing={handleSubmit} editable={!isResponding} blurOnSubmit={false} /> ); })} [ styles.actionButton, { backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: theme.colors.borderAccent, }, pressed && styles.optionItemPressed, ]} onPress={handleDeny} disabled={isResponding} > {respondingAction === "dismiss" ? ( ) : ( Dismiss )} { const disabled = !allAnswered || isResponding; return [ styles.actionButton, { backgroundColor: hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, opacity: disabled ? 0.5 : 1, }, pressed && !disabled ? styles.optionItemPressed : null, ]; }} onPress={handleSubmit} disabled={!allAnswered || isResponding} > {respondingAction === "submit" ? ( ) : ( Submit )} ); } 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], }, questionText: { flex: 1, fontSize: theme.fontSize.base, lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], }, 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, }, }));