Contracts: LadderRung 0-4, CueDistance 0-2, Presentation theory|
application; PracticeParams carries the three named levels. Doc
comments hold the three definition tables verbatim (rung names +
definitions from the ladder doctrine; cue levels; presentation
levels).
Core: LADDER_RUNGS/CUE_LEVELS/PRESENTATIONS tables in src/core/
practice.ts (definitions verbatim) — ONE source spoken by the agents'
prompts, the dialog's stepped controls, and the exam header; core
barrel re-exports practice.ts (pure domain, browser-safe).
Agents: Part I's ladder intro now 'five rungs, numbered; the request
arrives as a rung' with bullets numbered 0-4 (definitions untouched);
Part III's parameters block replaced with the named-level doctrine
(rung = exact class, no continuum; cue counts lesson examples AND
previous problems' situations; presentation dresses, never changes);
partitioner's RESPECT THE PARAMETERS replaced (low rungs = many small
assignments, high = larger; far cue makes confusables-together MORE
important; presentation does not shape the partition). renderParams
outputs the exact named-level block. Anchor/0..1 language swept dead.
Service + store: strict union validation — floats between levels,
out-of-range, wrong strings, legacy dial shapes all 400 at the API
and fail isProblemSet (legacy sets skipped from lists; no migration —
data/practice is disposable scratch, owner wipes before testing).
UI: dialog = 5-detent ladder slider + 3-detent cue slider (each shows
the level NAME + one-line definition) + Theory|Application segmented
toggle; defaults rung 2 / cue 1 / theory; Create posts the new shape.
Exam header gains the levels line ('Direct exercise · Adjacent
context · Theory').
Tests: renderParams exact-block pin; strict-validation pins (new
tests/practice-service.test.ts — bad unions 400, legacy shape 400);
store pins (legacy floats + 2.5 rung fail); render pins (detents +
names + definitions, defaults, detent-swap, toggle active segment,
levels line); every params fixture moved off floats. 1138/1138, tsc
0, lint 0, build 0.
Docs: architecture.md — discretization paragraph (compact tables,
named-level invocation, defaults, one-table source), UI mentions;
README axes sentence.
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
"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",
|
|
};
|
|
|
|
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({
|
|
params,
|
|
onParam,
|
|
clusters,
|
|
selectedKey,
|
|
onSelect,
|
|
busy,
|
|
failure,
|
|
onOpenSettings,
|
|
onClose,
|
|
onRun,
|
|
}: {
|
|
params: PracticeParams;
|
|
onParam: (patch: Partial<PracticeParams>) => void;
|
|
clusters: GraphClusterGroup[];
|
|
selectedKey: string | null;
|
|
onSelect: (key: string) => void;
|
|
busy: boolean;
|
|
failure: PracticeFailure | null;
|
|
onOpenSettings: () => void;
|
|
onClose: () => void;
|
|
onRun: () => void;
|
|
}) {
|
|
const canRun = !busy && selectedKey !== null;
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="New problem set"
|
|
data-testid="practice-dialog"
|
|
onClick={(e) => 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"
|
|
>
|
|
<h2 className="font-title text-2xl tracking-tight">New problem set</h2>
|
|
|
|
{/* The cluster: a birth-session group of the graph, resolved to
|
|
node ids on create (graph-domain, owner 2026-09-29). */}
|
|
<div className="mt-4 flex flex-col gap-1">
|
|
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Cluster</span>
|
|
<div className="max-h-40 overflow-y-auto rounded-lg border" data-testid="practice-dialog-clusters">
|
|
{clusters.map((cluster) => (
|
|
<button
|
|
type="button"
|
|
key={cluster.key}
|
|
data-testid={`practice-cluster-${cluster.key}`}
|
|
onClick={() => onSelect(cluster.key)}
|
|
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
|
|
selectedKey === cluster.key ? "bg-accent" : ""
|
|
}`}
|
|
>
|
|
<span className="min-w-0 truncate">
|
|
{cluster.label ?? <span className="text-muted-foreground/60">unlabeled cluster</span>}
|
|
</span>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{cluster.kind === "notes" ? "notes · " : ""}
|
|
{cluster.nodeCount} propositions
|
|
</span>
|
|
</button>
|
|
))}
|
|
{clusters.length === 0 && (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
No clusters yet — a cluster appears once a session births its first proposition.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="mt-4 flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
Ladder
|
|
<span className="text-muted-foreground/80">0 · 1 · 2 · 3 · 4</span>
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={4}
|
|
step={1}
|
|
value={params.ladderRung}
|
|
data-testid="param-ladder"
|
|
onChange={(e) => onParam({ ladderRung: Number(e.target.value) as PracticeParams["ladderRung"] })}
|
|
className="h-1.5 w-full accent-primary"
|
|
/>
|
|
<span data-testid="param-ladder-level" className="text-xs">
|
|
<span className="font-medium">{LADDER_RUNGS[params.ladderRung].name}</span>
|
|
<span className="text-muted-foreground"> — {LADDER_RUNGS[params.ladderRung].definition}</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
Cue distance
|
|
<span className="text-muted-foreground/80">0 · 1 · 2</span>
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={2}
|
|
step={1}
|
|
value={params.cueDistance}
|
|
data-testid="param-cue"
|
|
onChange={(e) => onParam({ cueDistance: Number(e.target.value) as PracticeParams["cueDistance"] })}
|
|
className="h-1.5 w-full accent-primary"
|
|
/>
|
|
<span data-testid="param-cue-level" className="text-xs">
|
|
<span className="font-medium">{CUE_LEVELS[params.cueDistance].name}</span>
|
|
<span className="text-muted-foreground"> — {CUE_LEVELS[params.cueDistance].definition}</span>
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[11px] text-muted-foreground">Presentation</span>
|
|
<div className="flex gap-1" data-testid="param-presentation">
|
|
{(["theory", "application"] as const).map((pres) => (
|
|
<button
|
|
type="button"
|
|
key={pres}
|
|
data-testid={`param-presentation-${pres}`}
|
|
onClick={() => onParam({ presentation: pres })}
|
|
className={`flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
|
params.presentation === pres
|
|
? "border-primary bg-primary/10 font-medium text-foreground"
|
|
: "text-muted-foreground hover:bg-accent"
|
|
}`}
|
|
>
|
|
{PRESENTATIONS[pres].name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span data-testid="param-presentation-level" className="text-xs text-muted-foreground">
|
|
{PRESENTATIONS[params.presentation].definition}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{failure && (
|
|
<div data-testid="practice-dialog-error" className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
{failure.code === "empty_selection" ? "Nothing practicable is left in that cluster — the graph changed." : failure.error}
|
|
{failure.status === 503 && (
|
|
<button
|
|
type="button"
|
|
data-testid="practice-dialog-open-settings"
|
|
onClick={onOpenSettings}
|
|
className="ml-2 underline underline-offset-2"
|
|
>
|
|
Open settings
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-5 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={busy}
|
|
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="practice-dialog-create"
|
|
onClick={onRun}
|
|
disabled={!canRun}
|
|
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
|
|
>
|
|
{busy ? "Creating…" : "Create"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The stateful wrapper: fetches the cluster list, runs the create POST
|
|
* (nodeIds + the cluster's label as title + params), navigates on 202. */
|
|
export function PracticeDialog({ onClose }: { onClose: () => void }) {
|
|
const router = useRouter();
|
|
const { open: openSettings } = useSettingsDialog();
|
|
const [params, setParams] = useState<PracticeParams>({ ...DEFAULT_PRACTICE_PARAMS });
|
|
const [clusters, setClusters] = useState<GraphClusterGroup[] | null>(null);
|
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [failure, setFailure] = useState<PracticeFailure | null>(null);
|
|
|
|
// The picker's truth is the graph's clustering (fresh on open).
|
|
useEffect(() => {
|
|
let alive = true;
|
|
fetch("/api/graph/clusters", { cache: "no-store" })
|
|
.then((res) => res.json() as Promise<{ clusters: GraphClusterGroup[] }>)
|
|
.then((d) => {
|
|
if (alive) setClusters(d.clusters);
|
|
})
|
|
.catch(() => {
|
|
if (alive) setClusters([]);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
|
|
// 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 picked = (clusters ?? []).find((c) => c.key === selectedKey);
|
|
if (!picked || busy) return;
|
|
setBusy(true);
|
|
setFailure(null);
|
|
try {
|
|
const res = await fetch("/api/practice/sets", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
nodeIds: picked.nodeIds,
|
|
params,
|
|
title: picked.label ?? undefined,
|
|
}),
|
|
});
|
|
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);
|
|
}
|
|
}, [clusters, selectedKey, busy, params, router]);
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
|
onClick={() => {
|
|
if (!busy) onClose();
|
|
}}
|
|
>
|
|
<PracticeDialogBody
|
|
params={params}
|
|
onParam={(patch) => setParams((prev) => ({ ...prev, ...patch }))}
|
|
clusters={clusters ?? []}
|
|
selectedKey={selectedKey}
|
|
onSelect={setSelectedKey}
|
|
busy={busy}
|
|
failure={failure}
|
|
onOpenSettings={() => openSettings()}
|
|
onClose={onClose}
|
|
onRun={() => void run()}
|
|
/>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|