Files
paseo/packages/app/src/components/question-form-card.tsx
T
Mohamed Boudra 0f7fa55a0f Centralize platform gating and fix iPad/tablet support
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
2026-04-13 13:24:00 +07:00

399 lines
12 KiB
TypeScript

import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator } 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";
import { isWeb } from "@/constants/platform";
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<string, unknown>).questions)
) {
return null;
}
const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: Question[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return null;
const q = item as Record<string, unknown>;
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<string, unknown>;
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 = isWeb;
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
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<number>();
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<number>() };
});
}
}, []);
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<string, string> = {};
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 (
<View
style={[
styles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
return (
<View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => {
const isSelected = selected.has(optIndex);
return (
<Pressable
key={optIndex}
style={({ pressed, hovered = false }) => [
styles.optionItem,
(hovered || isSelected) && {
backgroundColor: theme.colors.surface2,
},
pressed && styles.optionItemPressed,
]}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
<Text
style={[
styles.optionDescription,
{ color: theme.colors.foregroundMuted },
]}
>
{opt.description}
</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
})}
</View>
<TextInput
style={[
styles.otherInput,
{
borderColor:
otherText.length > 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}
/>
</View>
);
})}
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
]}
onPress={handleDeny}
disabled={isResponding}
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
</View>
)}
</Pressable>
<Pressable
style={({ pressed, hovered = false }) => {
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" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.actionText,
{
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
>
Submit
</Text>
</View>
)}
</Pressable>
</View>
</View>
);
}
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,
},
}));