"use client"; /** * The problem-set creation dialog (practice slice 5, 2026-09-29): the * three parameter dials + the session picker → POST /api/practice/sets * → router.push to the new exam page. In the delete-session dialog's * shape (portaled z-50, pure body exported for SSR pins). * * TWO OPEN MODES: * - generic (the list page's "New problem set"): a session picker — * the selection is "everything this session touched"; * - preselected (the sidebar's "Practice this session's * propositions"): picker hidden, the session shown locked. * * Honest failures inline: 400 empty_selection → "This session has no * propositions yet" (verbatim per brief); 503 (models/key refusal) → * the server's line + an "Open settings" deep link (the zero-keys * precedent — setup owns the fix, the dialog owns the pointer). */ import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { useSettingsDialog } from "@/components/settings/settings-dialog"; import type { ChatListItem } from "@/src/core"; /** The three dials — key order matches PracticeParams; anchors are the * honest display (a raw 0..1 number tells the learner nothing). */ export const PRACTICE_DIALS = [ { key: "searchSolutionRatio", label: "Search ↔ Solve", anchors: "flashcard · competition" }, { key: "cueDistance", label: "Cue distance", anchors: "near the lesson · far transfer" }, { key: "presentation", label: "Presentation", anchors: "theory · application" }, ] as const; export interface PracticeFailure { status: number; error: string; code?: string; } /** The dialog's pure body — exported for SSR pins (the delete-session / * settings body pattern). The wrapper owns state + fetch + portal. */ export function PracticeDialogBody({ values, onDial, chats, selectedId, onSelect, lockedTitle, busy, failure, onOpenSettings, onClose, onRun, }: { values: Record<(typeof PRACTICE_DIALS)[number]["key"], number>; onDial: (key: (typeof PRACTICE_DIALS)[number]["key"], v: number) => void; /** Null = preselected mode (picker hidden, `lockedTitle` shown). */ chats: ChatListItem[] | null; selectedId: string | null; onSelect: (id: string) => void; lockedTitle: string | null; busy: boolean; failure: PracticeFailure | null; onOpenSettings: () => void; onClose: () => void; onRun: () => void; }) { const canRun = !busy && (lockedTitle !== null || selectedId !== null); return (
e.stopPropagation()} className="flex max-h-[85vh] w-[32rem] max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-card p-5 shadow-xl" >

New problem set

{/* The session: everything it touched (any provenance point). */}
{lockedTitle !== null ? (
From session — {lockedTitle}
) : ( <> Session
{(chats ?? []).map((chat) => ( ))} {(chats ?? []).length === 0 && (
No sessions yet.
)}
)}
{/* The three dials (SettingSlider's markup, anchors as display). */}
{PRACTICE_DIALS.map((dial) => ( ))}
{failure && (
{failure.code === "empty_selection" ? "This session has no propositions yet" : failure.error} {failure.status === 503 && ( )}
)}
); } /** The stateful wrapper: fetches the session list (generic mode), runs * the create POST, navigates on 202. Null `preselected` = generic. */ export function PracticeDialog({ preselected, onClose, }: { preselected: { chatId: string; title: string } | null; onClose: () => void; }) { const router = useRouter(); const { open: openSettings } = useSettingsDialog(); const [values, setValues] = useState>({ searchSolutionRatio: 0.5, cueDistance: 0.5, presentation: 0.5, }); const [chats, setChats] = useState(null); const [selectedId, setSelectedId] = useState(null); const [busy, setBusy] = useState(false); const [failure, setFailure] = useState(null); // Generic mode: the picker's truth is the chat list (fresh on open). useEffect(() => { if (preselected) return; let alive = true; fetch("/api/chats", { cache: "no-store" }) .then((res) => res.json() as Promise<{ chats: ChatListItem[] }>) .then((d) => { if (alive) setChats(d.chats); }) .catch(() => { if (alive) setChats([]); }); return () => { alive = false; }; }, [preselected]); // Esc closes (claimed, the delete-session discipline). useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [onClose]); const run = useCallback(async () => { const chatId = preselected?.chatId ?? selectedId; if (!chatId || busy) return; setBusy(true); setFailure(null); try { const res = await fetch("/api/practice/sets", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chatId, params: values }), }); const body = (await res.json()) as { id?: string; error?: string; code?: string }; if (!res.ok || !body.id) { setFailure({ status: res.status, error: body.error ?? `Create failed (${res.status})`, code: body.code }); setBusy(false); return; } router.push(`/practice/${body.id}`); } catch { setFailure({ status: 0, error: "Create failed — network error. Try again." }); setBusy(false); } }, [preselected, selectedId, busy, values, router]); return createPortal(
{ if (!busy) onClose(); }} > setValues((prev) => ({ ...prev, [key]: v }))} chats={preselected ? null : (chats ?? [])} selectedId={selectedId} onSelect={setSelectedId} lockedTitle={preselected?.title ?? null} busy={busy} failure={failure} onOpenSettings={() => openSettings()} onClose={onClose} onRun={() => void run()} />
, document.body, ); }