"use client"; /** * The problem-set creation dialog (practice slice 5, 2026-09-29; * cluster-picker + discretized-axes fixes same evening): the three * NAMED-LEVEL parameters — a 5-detent ladder slider, a 3-detent cue * slider, a Theory|Application toggle — + the CLUSTER 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). * * GRAPH-DOMAIN, NOT CHAT-DOMAIN (owner ruling 2026-09-29): problem sets * take NODES. The picker lists the birth-session clusters the graph * view renders (GET /api/graph/clusters); picking one resolves to its * member node ids, and the cluster's label rides along as the set's * title. This dialog is the ONLY creation entry for now — the graph-view * cluster entry comes later with the free-form selection UI. * * Honest failures inline: 400 empty_selection (the vanished-nodes edge * — the cluster's propositions are gone) renders the honest line; * 503 (models/key refusal) renders 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 { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core"; import type { GraphClusterGroup, PracticeParams } from "@/src/core"; /** The creation defaults (owner, 2026-09-29): rung 2 Direct exercise, * cue 1 Adjacent context, Theory. */ export const DEFAULT_PRACTICE_PARAMS: PracticeParams = { ladderRung: 2, cueDistance: 1, presentation: "theory", }; /** The two creation modes (chains overlay, owner plan 2026-09-29): * "cluster" — the /practice page's picker (stays cluster-ONLY, owner * ruling 2026-09-29); "custom" — the graph view's chain table hands in * a free-form node selection; no picker, a selection summary instead. */ export type PracticeDialogMode = "cluster" | "custom"; /** The custom mode's handoff: the flat node-id selection + the derived * shape (chainsForSelection over the selection — the overlay computes * it for its pinned preview and passes the same numbers here). */ export interface PracticeSelection { nodeIds: string[]; propositions: number; chains: number; } 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({ mode = "cluster", params, onParam, note, onNote, clusters, selectedKey, onSelect, selection, busy, failure, onOpenSettings, onClose, onRun, }: { mode?: PracticeDialogMode; params: PracticeParams; onParam: (patch: Partial) => void; /** The learner's free-form note (owner, 2026-09-29) — optional, * typing-only; carried to the partitioner + generators as additional * input they weigh but need not follow. */ note: string; onNote: (note: string) => void; clusters: GraphClusterGroup[]; selectedKey: string | null; onSelect: (key: string) => void; /** The custom mode's handoff (chain-table selections). */ selection?: PracticeSelection; busy: boolean; failure: PracticeFailure | null; onOpenSettings: () => void; onClose: () => void; onRun: () => void; }) { const canRun = !busy && (mode === "custom" ? (selection?.nodeIds.length ?? 0) > 0 : selectedKey !== 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 cluster picker — CLUSTER mode only (custom mode replaces it with the selection summary below): a birth-session group of the graph, resolved to node ids on create (graph-domain, owner 2026-09-29). */} {mode === "cluster" && (
Cluster
{clusters.map((cluster) => ( ))} {clusters.length === 0 && (
No clusters yet — a cluster appears once a session births its first node.
)}
)} {/* CUSTOM mode: no picker — the chain table already chose. The dated default title (the pipeline's own `Practice — date`, stamped server-side when no title is posted) + the derived shape of the handoff. */} {mode === "custom" && (
Selection
Practice — {new Date().toISOString().slice(0, 10)}
{selection?.propositions ?? 0} {selection?.propositions === 1 ? "node" : "nodes"} →{" "} {selection?.chains ?? 0} {selection?.chains === 1 ? "chain" : "chains"}
)} {/* The three parameters — named levels, never a number to interpolate (owner, 2026-09-29). Stepped sliders snap to the detents; each shows the level's NAME + its one-line definition; presentation is a two-segment toggle. */}
Ladder 0 · 1 · 2 · 3 · 4 onParam({ ladderRung: Number( e.target.value, ) as PracticeParams["ladderRung"], }) } className="h-1.5 w-full accent-primary" /> {LADDER_RUNGS[params.ladderRung].name} {" "} — {LADDER_RUNGS[params.ladderRung].definition}
Cue distance 0 · 1 · 2 onParam({ cueDistance: Number( e.target.value, ) as PracticeParams["cueDistance"], }) } className="h-1.5 w-full accent-primary" /> {CUE_LEVELS[params.cueDistance].name} {" "} — {CUE_LEVELS[params.cueDistance].definition}
Presentation
{(["theory", "application"] as const).map((pres) => ( ))}
{PRESENTATIONS[params.presentation].definition}
{/* The learner's free-form note — the last word before create: wishes for the problems, weighed by the agents, nothing owed. */}
Anything else? (optional)