* Fix Pi ask_user optional input handling * Clarify Pi ask_user optional comment prompt * Combine Pi ask_user optional comment UI
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
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, CircleHelp, X } from "lucide-react-native";
|
|
import type { PendingPermission } from "@/types/shared";
|
|
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
|
import { isWeb } from "@/constants/platform";
|
|
import {
|
|
areQuestionsAnswered,
|
|
buildQuestionFormAnswers,
|
|
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: QuestionFormQuestion): string {
|
|
return (
|
|
question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...")
|
|
);
|
|
}
|
|
|
|
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],
|
|
);
|
|
|
|
return (
|
|
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
|
<View style={styles.optionItemContent}>
|
|
<View style={styles.optionTextBlock}>
|
|
<Text style={optionLabelStyle}>{option.label}</Text>
|
|
{option.description ? (
|
|
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
|
) : null}
|
|
</View>
|
|
{isSelected ? (
|
|
<View style={styles.optionCheckSlot}>
|
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
interface QuestionOtherInputProps {
|
|
qIndex: number;
|
|
value: string;
|
|
placeholder: string;
|
|
isResponding: boolean;
|
|
onChange: (qIndex: number, text: string) => void;
|
|
onSubmit: () => void;
|
|
}
|
|
|
|
function QuestionOtherInput({
|
|
qIndex,
|
|
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 (
|
|
<TextInput
|
|
// @ts-expect-error - outlineStyle is web-only
|
|
style={otherInputStyle}
|
|
placeholder={placeholder}
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
value={value}
|
|
onChangeText={handleChange}
|
|
onSubmitEditing={onSubmit}
|
|
editable={!isResponding}
|
|
blurOnSubmit={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
|
const { theme } = useUnistyles();
|
|
const isMobile = useIsCompactFormFactor();
|
|
const questions = parseQuestionFormQuestions(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>() };
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
|
|
|
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 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 submitDisabled = !allAnswered || isResponding;
|
|
const submitButtonStyle = useCallback(
|
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.actionButton,
|
|
{
|
|
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
|
|
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
|
|
opacity: submitDisabled ? 0.5 : 1,
|
|
},
|
|
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
|
],
|
|
[
|
|
submitDisabled,
|
|
theme.colors.surface2,
|
|
theme.colors.surface1,
|
|
theme.colors.border,
|
|
theme.colors.borderAccent,
|
|
],
|
|
);
|
|
|
|
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 actionsContainerStyle = useMemo(
|
|
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
|
[isMobile],
|
|
);
|
|
const dismissActionTextStyle = useMemo(
|
|
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
|
[theme.colors.foregroundMuted],
|
|
);
|
|
const submitActionTextColor = allAnswered
|
|
? theme.colors.foreground
|
|
: theme.colors.foregroundMuted;
|
|
const submitActionTextStyle = useMemo(
|
|
() => [styles.actionText, { color: submitActionTextColor }],
|
|
[submitActionTextColor],
|
|
);
|
|
|
|
if (!questions) {
|
|
return null;
|
|
}
|
|
|
|
const dismissLabel = resolveDismissLabel(questions);
|
|
|
|
return (
|
|
<View style={containerStyle}>
|
|
{questions.map((q, qIndex) => {
|
|
const selected = selections[qIndex] ?? new Set<number>();
|
|
const otherText = otherTexts[qIndex] ?? "";
|
|
const showTextInput = questionShowsTextInput(q);
|
|
|
|
return (
|
|
<View key={q.question} style={styles.questionBlock}>
|
|
<View style={styles.questionHeader}>
|
|
<Text style={questionTextStyle}>{q.question}</Text>
|
|
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
|
</View>
|
|
{q.options.length > 0 ? (
|
|
<View style={styles.optionsWrap}>
|
|
{q.options.map((opt, optIndex) => (
|
|
<QuestionOptionRow
|
|
key={opt.label}
|
|
qIndex={qIndex}
|
|
optIndex={optIndex}
|
|
option={opt}
|
|
isSelected={selected.has(optIndex)}
|
|
multiSelect={q.multiSelect}
|
|
isResponding={isResponding}
|
|
onToggle={toggleOption}
|
|
/>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
{showTextInput ? (
|
|
<QuestionOtherInput
|
|
qIndex={qIndex}
|
|
value={otherText}
|
|
placeholder={getQuestionInputPlaceholder(q)}
|
|
isResponding={isResponding}
|
|
onChange={setOtherText}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
})}
|
|
|
|
<View style={actionsContainerStyle}>
|
|
<Pressable style={dismissButtonStyle} 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={dismissActionTextStyle}>{dismissLabel}</Text>
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
|
|
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
|
|
{respondingAction === "submit" ? (
|
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
|
) : (
|
|
<View style={styles.actionContent}>
|
|
<Check size={14} color={submitActionTextColor} />
|
|
<Text style={submitActionTextStyle}>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,
|
|
},
|
|
}));
|