| 1 | import { useEffect, useState } from "react"; |
| 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; |
| 3 | import { FlaskConical } from "lucide-react"; |
| 4 | |
| 5 | import { Button } from "@/components/ui/button"; |
| 6 | import { cn } from "@/lib/utils"; |
| 7 | import type { PeSet } from "@/lib/pe-api"; |
| 8 | |
| 9 | interface Props { |
| 10 | open: boolean; |
| 11 | sets: PeSet[]; |
| 12 | onConfirm: (name: string) => void; |
| 13 | } |
| 14 | |
| 15 | /** |
| 16 | * Mandatory PE-set picker shown before a new conversation starts. |
| 17 | * |
| 18 | * Non-dismissable on purpose: the selected set is injected into the session's |
| 19 | * system prompt for the whole conversation, so an A/B run must begin from an |
| 20 | * explicit, conscious choice. Escape / outside-click are suppressed and there |
| 21 | * is no close affordance — the only exit is confirming a selection. |
| 22 | */ |
| 23 | export function PeSelectModal({ open, sets, onConfirm }: Props) { |
| 24 | const [selected, setSelected] = useState<string | null>(null); |
| 25 | |
| 26 | // Force a conscious choice each time the modal is (re)opened. |
| 27 | useEffect(() => { |
| 28 | if (open) setSelected(null); |
| 29 | }, [open]); |
| 30 | |
| 31 | return ( |
| 32 | <DialogPrimitive.Root open={open}> |
| 33 | <DialogPrimitive.Portal> |
| 34 | <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0" /> |
| 35 | <DialogPrimitive.Content |
| 36 | onEscapeKeyDown={(e) => e.preventDefault()} |
| 37 | onPointerDownOutside={(e) => e.preventDefault()} |
| 38 | onInteractOutside={(e) => e.preventDefault()} |
| 39 | className="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg" |
| 40 | > |
| 41 | <div className="flex flex-col space-y-1.5"> |
| 42 | <DialogPrimitive.Title className="flex items-center gap-2 text-lg font-semibold leading-none tracking-tight"> |
| 43 | <FlaskConical className="h-4 w-4" /> Select Prompt Kit |
| 44 | </DialogPrimitive.Title> |
| 45 | <DialogPrimitive.Description className="text-sm text-muted-foreground"> |
| 46 | Choose a prompt kit for this project before the conversation begins. |
| 47 | </DialogPrimitive.Description> |
| 48 | </div> |
| 49 | <div className="flex max-h-[50vh] flex-col gap-1.5 overflow-y-auto"> |
| 50 | {sets.map((s) => ( |
| 51 | <button |
| 52 | key={s.name} |
| 53 | type="button" |
| 54 | onClick={() => setSelected(s.name)} |
| 55 | className={cn( |
| 56 | "flex flex-col items-start gap-0.5 rounded-md border px-3 py-2 text-left text-sm transition-colors", |
| 57 | selected === s.name |
| 58 | ? "border-primary bg-primary/10" |
| 59 | : "border-border hover:bg-accent/40", |
| 60 | )} |
| 61 | > |
| 62 | <span className="font-medium">{s.label || s.name}</span> |
| 63 | {s.description ? ( |
| 64 | <span className="text-xs text-muted-foreground"> |
| 65 | {s.description} |
| 66 | </span> |
| 67 | ) : null} |
| 68 | </button> |
| 69 | ))} |
| 70 | </div> |
| 71 | <div className="flex justify-end"> |
| 72 | <Button |
| 73 | disabled={!selected} |
| 74 | onClick={() => selected && onConfirm(selected)} |
| 75 | > |
| 76 | Start Project |
| 77 | </Button> |
| 78 | </div> |
| 79 | </DialogPrimitive.Content> |
| 80 | </DialogPrimitive.Portal> |
| 81 | </DialogPrimitive.Root> |
| 82 | ); |
| 83 | } |
| 84 |