← vibesdk
src/routes/chat/components/clarifying-questions-popup.tsx
8.4 KB
Download file
import { useState, useCallback, useEffect, useRef } from 'react';
import { HelpCircle, X, ArrowLeft, ArrowRight, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Input } from '@/components/ui/input';
import type { ClarifyingQuestion } from '../utils/message-helpers';
export type QuestionAnswer = {
question: string;
selected: string[];
custom: string;
};
interface ClarifyingQuestionsPopupProps {
questions: ClarifyingQuestion[];
open: boolean;
onSubmit: (answers: QuestionAnswer[]) => void;
onDismiss?: () => void;
}
export function ClarifyingQuestionsPopup({
questions,
open,
onSubmit,
onDismiss,
}: ClarifyingQuestionsPopupProps) {
const [answers, setAnswers] = useState<QuestionAnswer[]>(() =>
questions.map((q) => ({ question: q.question, selected: [], custom: '' })),
);
// Index of the question currently on screen (slide-style, one at a time).
const [current, setCurrent] = useState(0);
// Slide direction for the enter/exit animation: 1 = forward, -1 = back.
const directionRef = useRef(1);
useEffect(() => {
setAnswers(questions.map((q) => ({ question: q.question, selected: [], custom: '' })));
setCurrent(0);
directionRef.current = 1;
}, [questions]);
const total = questions.length;
const isLast = current === total - 1;
const currentAnswer = answers[current];
const isAnswered =
(currentAnswer?.selected.length ?? 0) > 0 ||
(currentAnswer?.custom.trim() ?? '') !== '';
const toggleOption = useCallback((questionIndex: number, option: string) => {
setAnswers((prev) => {
const next = [...prev];
const current = next[questionIndex];
if (!current) return prev;
const q = questions[questionIndex];
const allowMultiple = q?.allow_multiple ?? false;
if (current.selected.includes(option)) {
next[questionIndex] = {
...current,
selected: current.selected.filter((o) => o !== option),
};
} else if (allowMultiple) {
next[questionIndex] = {
...current,
selected: [...current.selected, option],
custom: '',
};
} else {
next[questionIndex] = {
...current,
selected: [option],
custom: '',
};
}
return next;
});
}, [questions]);
const setCustom = useCallback((questionIndex: number, value: string) => {
setAnswers((prev) => {
const next = [...prev];
const current = next[questionIndex];
if (!current) return prev;
next[questionIndex] = {
...current,
custom: value,
selected: value.trim() === '' ? current.selected : [],
};
return next;
});
}, []);
const goBack = useCallback(() => {
directionRef.current = -1;
setCurrent((c) => Math.max(0, c - 1));
}, []);
// Advance to the next question, or finish on the last one. `skip` simply
// leaves the current answer empty; answered questions carry their value.
const goNext = useCallback(() => {
if (isLast) {
onSubmit(answers);
return;
}
directionRef.current = 1;
setCurrent((c) => Math.min(total - 1, c + 1));
}, [isLast, onSubmit, answers, total]);
const q = questions[current];
return (
<AnimatePresence>
{open && total > 0 && q && (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.2 }}
>
<div
style={{ paddingBottom: 18, marginBottom: -12 }}
className="rounded-t-xl border border-b-0 bg-kumo-elevated dark:bg-kumo-base shadow-sm px-4 pt-4"
>
<div className="flex items-start gap-3 mb-3">
<div className="mt-0.5 p-1.5 rounded-md bg-brand/10 text-kumo-brand">
<HelpCircle className="size-4" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-text-primary">
Clarifying questions
</h3>
<p className="text-xs text-text-tertiary mt-0.5">
Answer to help me build what you want.
</p>
</div>
<span className="shrink-0 text-xs font-medium text-text-tertiary tabular-nums mt-0.5">
{current + 1}/{total}
</span>
{onDismiss && (
<Button
variant="ghost"
size="icon"
className="size-7 -mr-2 -mt-2 text-text-tertiary hover:text-text-primary"
onClick={onDismiss}
aria-label="Skip all questions"
>
<X className="size-4" />
</Button>
)}
</div>
{/* Progress segments */}
<div className="flex gap-1 mb-3">
{questions.map((_, i) => (
<div
key={i}
className={`h-1 flex-1 rounded-full transition-colors ${
i < current
? 'bg-brand'
: i === current
? 'bg-brand/60'
: 'bg-border-secondary'
}`}
/>
))}
</div>
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" custom={directionRef.current}>
<motion.div
key={current}
custom={directionRef.current}
initial={{ opacity: 0, x: directionRef.current * 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: directionRef.current * -40 }}
transition={{ duration: 0.18 }}
className="flex flex-col gap-2 rounded-lg border bg-bg-4/60 dark:bg-kumo-elevated/50 p-3"
>
<div className="text-sm text-text-primary font-medium">
{q.question}
</div>
<div className="flex flex-col gap-2">
{q.options && q.options.length > 0 && (
q.allow_multiple ? (
<div className="flex flex-col gap-1.5">
{q.options.map((option) => {
const checked = currentAnswer?.selected.includes(option) ?? false;
return (
<label
key={option}
className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer hover:text-text-primary"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggleOption(current, option)}
className="border-text-tertiary dark:border-text-tertiary"
/>
<span>{option}</span>
</label>
);
})}
</div>
) : (
<RadioGroup
value={currentAnswer?.selected[0] ?? ''}
onValueChange={(option) => toggleOption(current, option)}
className="gap-1.5"
>
{q.options.map((option) => (
<label
key={option}
className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer hover:text-text-primary"
>
<RadioGroupItem
value={option}
className="border-text-tertiary dark:border-text-tertiary"
/>
<span>{option}</span>
</label>
))}
</RadioGroup>
)
)}
{q.allow_custom !== false && (
<Input
placeholder="Or add your own answer"
value={currentAnswer?.custom ?? ''}
onChange={(e) => setCustom(current, e.target.value)}
className="text-sm"
/>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center justify-between gap-2 mt-4">
<Button
variant="ghost"
size="sm"
onClick={goBack}
disabled={current === 0}
className="text-text-tertiary hover:text-text-primary"
>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={goNext}>
{isLast ? 'Skip & finish' : 'Skip'}
</Button>
<Button size="sm" onClick={goNext} disabled={!isAnswered}>
{isLast ? (
<>
<Check className="size-4 mr-1" />
Submit
</>
) : (
<>
Next
<ArrowRight className="size-4 ml-1" />
</>
)}
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}