返回 JoyAI-Echo
PeSelectModal.tsx
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
84 lines Plain Text