diff --git a/app/practice/[id]/page.tsx b/app/practice/[id]/page.tsx
new file mode 100644
index 0000000..fb3aee6
--- /dev/null
+++ b/app/practice/[id]/page.tsx
@@ -0,0 +1,17 @@
+/**
+ * One exam page (slice 5): the set server-prefetched, notFound() on an
+ * unknown id; the client body owns polling (generation + grading),
+ * answering, and the reveal.
+ */
+import { notFound } from "next/navigation";
+import { getPracticeSet } from "@/src/server";
+import { PracticeExam } from "@/components/practice-exam";
+
+export const dynamic = "force-dynamic";
+
+export default async function Page({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const set = getPracticeSet(id);
+ if (!set) notFound();
+ return ;
+}
diff --git a/app/practice/page.tsx b/app/practice/page.tsx
new file mode 100644
index 0000000..8a41512
--- /dev/null
+++ b/app/practice/page.tsx
@@ -0,0 +1,14 @@
+/**
+ * The practice list page (slice 5): the set list, server-prefetched
+ * (force-dynamic — a static list would go stale between requests), the
+ * client body owns polling + dialogs. Reads through the server barrel
+ * only (app/ never touches src/store directly).
+ */
+import { listPracticeSets } from "@/src/server";
+import { PracticeListPage } from "@/components/practice-list";
+
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/components/practice-dialog.tsx b/components/practice-dialog.tsx
new file mode 100644
index 0000000..e5ea544
--- /dev/null
+++ b/components/practice-dialog.tsx
@@ -0,0 +1,272 @@
+"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) => (
+
onSelect(chat.id)}
+ className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
+ selectedId === chat.id ? "bg-accent" : ""
+ }`}
+ >
+ {chat.title}
+ {selectedId === chat.id && selected }
+
+ ))}
+ {(chats ?? []).length === 0 && (
+
No sessions yet.
+ )}
+
+ >
+ )}
+
+
+ {/* The three dials (SettingSlider's markup, anchors as display). */}
+
+ {PRACTICE_DIALS.map((dial) => (
+
+
+ {dial.label}
+ {dial.anchors}
+
+ onDial(dial.key, Number(e.target.value))}
+ className="h-1.5 w-full accent-primary"
+ />
+
+ ))}
+
+
+ {failure && (
+
+ {failure.code === "empty_selection" ? "This session has no propositions yet" : failure.error}
+ {failure.status === 503 && (
+
+ Open settings
+
+ )}
+
+ )}
+
+
+
+ Cancel
+
+
+ {busy ? "Creating…" : "Create"}
+
+
+
+ );
+}
+
+/** 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,
+ );
+}
diff --git a/components/practice-exam.tsx b/components/practice-exam.tsx
new file mode 100644
index 0000000..e6d72f9
--- /dev/null
+++ b/components/practice-exam.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+/**
+ * The exam page (practice slice 5): one problem set, numbered problems,
+ * markdown statements, a textarea + dictation per problem (the bar
+ * renders only on the FOCUSED box — thinking out loud is the primary
+ * answering mode), one submit for the set, and the post-grade reveal
+ * (verdict chip, feedback, model solution, rubric) under the box.
+ *
+ * DISPLAY-SEAM MATH HEALING: statements/solutions/rubrics/feedback ride
+ * the same streaming-args parse as quiz/ask strings — restoreMathEscapes
+ * heals them at render (the same helper the tool-part seam uses).
+ *
+ * POLLING: 2s while the set isn't ready (stubs → filled), and while a
+ * grading run is live for this set (activity feed's practice rows) with
+ * answered-but-ungraded problems outstanding. A settled page goes quiet
+ * (a dropped grade terminates honestly: no live row → no poll).
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { GradeVerdict, Problem, ProblemSet } from "@/src/core";
+import { restoreMathEscapes } from "@/src/core";
+import { MessageResponse } from "@/components/ai-elements/message";
+import { BrailleSpinner } from "@/components/braille-spinner";
+import { DictationBar, useDictation } from "@/components/dictation";
+
+/** The verdict chip's tone — plain, no invented color language. */
+export function verdictClass(verdict: GradeVerdict): string {
+ if (verdict === "solid") return "bg-green-500/15 text-green-500";
+ if (verdict === "partial") return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
+ return "bg-destructive/15 text-destructive";
+}
+
+/** One answerable problem: textarea + focus-gated dictation bar. */
+export function ProblemBox({
+ problem,
+ draft,
+ onDraft,
+}: {
+ problem: Pick;
+ draft: string;
+ onDraft: (text: string) => void;
+}) {
+ const [focused, setFocused] = useState(false);
+ // The dictation appends into THIS box only (per-box instance).
+ const dictation = useDictation({
+ append: (text) => onDraft(text),
+ });
+ return (
+
+
+ );
+}
+
+/** The exam page's pure body — exported for SSR pins. */
+export function PracticeExamBody({
+ set,
+ drafts,
+ onDraft,
+ onSubmit,
+ submitting,
+ submitError,
+ grading,
+}: {
+ set: ProblemSet;
+ drafts: Record;
+ onDraft: (problemId: string, text: string) => void;
+ onSubmit: () => void;
+ submitting: boolean;
+ submitError: string | null;
+ grading: boolean;
+}) {
+ const answerable = set.problems.filter((p) => !p.grade);
+ const anyDraft = answerable.some((p) => (drafts[p.id] ?? p.answer ?? "") !== "");
+ return (
+
+
+
{set.title}
+
+ {set.status === "partitioning" && "Partitioning the chains…"}
+ {set.status === "generating" && `Generating — ${set.problems.filter((p) => p.status === "ready").length}/${set.problems.length} problems ready`}
+ {set.status === "error" && (set.error ?? "Something went wrong")}
+ {set.status === "ready" &&
+ (grading ? "Grading your answers…" : `${set.problems.length} problems · ${set.problems.filter((p) => p.grade).length} graded`)}
+
+
+
+ {set.problems.map((problem, i) => (
+
+ Problem {i + 1}
+ {problem.status === "generating" ? (
+ // The stub: propositions NEVER surface (never-announce law).
+
+
+ Generating problem…
+
+ ) : problem.status === "error" ? (
+
+ This problem failed to generate{problem.error ? ` — ${problem.error}` : ""}.
+
+ ) : (
+ <>
+
+ {restoreMathEscapes(problem.statement)}
+
+ {problem.grade ? (
+ // Graded: the answer read-only, the reveal below.
+
+
+
+
+
+ {problem.grade.verdict}
+
+
+
+ {restoreMathEscapes(problem.grade.feedback)}
+
+
Model solution
+
+ {restoreMathEscapes(problem.solution)}
+
+
Rubric
+
+ {restoreMathEscapes(problem.rubric)}
+
+
+
+ ) : (
+ // Answerable: the box (graded problems lock above).
+
+
onDraft(problem.id, text)} />
+
+ )}
+ >
+ )}
+
+ ))}
+
+ {set.status === "ready" && answerable.length > 0 && (
+
+
+ {submitting ? "Submitting…" : "Submit answers"}
+
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+ )}
+
+ );
+}
+
+export function PracticeExam({ initial }: { initial: ProblemSet }) {
+ const [set, setSet] = useState(initial);
+ const [drafts, setDrafts] = useState>({});
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+ const [gradingLive, setGradingLive] = useState(false);
+ const mounted = useRef(true);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ const refetch = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/practice/sets/${initial.id}`, { cache: "no-store" });
+ if (!res.ok) return;
+ const d = (await res.json()) as { set: ProblemSet };
+ if (mounted.current) setSet(d.set);
+ } catch {
+ // transient poll failures keep the last snapshot
+ }
+ }, [initial.id]);
+
+ // The poll: while not ready (generation), or while a grading run is
+ // live for this set with outstanding ungraded answers. The activity
+ // feed is the grading-liveness truth — a dropped grade (no live row)
+ // settles the page instead of polling forever.
+ const pendingGrade = set.problems.some((p) => p.answer && !p.grade);
+ const needsPoll = set.status !== "ready" || (gradingLive && pendingGrade);
+ useEffect(() => {
+ if (!needsPoll) return;
+ let stopped = false;
+ const tick = async () => {
+ await refetch();
+ try {
+ const res = await fetch("/api/activity", { cache: "no-store" });
+ if (!res.ok) return;
+ const feed = (await res.json()) as { practice?: Array<{ id: string; kind: string }> };
+ if (!stopped && mounted.current) {
+ setGradingLive((feed.practice ?? []).some((row) => row.id === initial.id));
+ }
+ } catch {
+ // keep the last liveness read
+ }
+ };
+ void tick();
+ const t = setInterval(() => void tick(), 2000);
+ return () => {
+ stopped = true;
+ clearInterval(t);
+ };
+ }, [needsPoll, refetch, initial.id]);
+
+ const submit = useCallback(async () => {
+ if (submitting) return;
+ const answers = set.problems
+ .filter((p) => !p.grade)
+ .map((p) => ({ problemId: p.id, text: drafts[p.id] ?? p.answer ?? "" }))
+ .filter((a) => a.text.trim() !== "");
+ if (answers.length === 0) return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ const res = await fetch(`/api/practice/sets/${set.id}/answers`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ answers }),
+ });
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
+ setSubmitError(body.error ?? `Submit failed (${res.status})`);
+ setSubmitting(false);
+ return;
+ }
+ setSubmitting(false);
+ setGradingLive(true); // grades land via the poll
+ await refetch();
+ } catch {
+ setSubmitError("Submit failed — network error. Try again.");
+ setSubmitting(false);
+ }
+ }, [submitting, set, drafts, refetch]);
+
+ return (
+ setDrafts((prev) => ({ ...prev, [problemId]: text }))}
+ onSubmit={() => void submit()}
+ submitting={submitting}
+ submitError={submitError}
+ grading={gradingLive && pendingGrade}
+ />
+ );
+}
diff --git a/components/practice-list.tsx b/components/practice-list.tsx
new file mode 100644
index 0000000..aafcff4
--- /dev/null
+++ b/components/practice-list.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+/**
+ * The problem-set list (practice slice 5): rows of sets — title, date,
+ * status, problem/graded counts — click through to the exam page; a
+ * delete affordance in the delete-session dialog's shape; "New problem
+ * set" opens the creation dialog. Polls /api/practice/sets (2s, the
+ * graph-view reveal-poll idiom) while ANY set is still generating, so
+ * statuses live-update; a settled list stops polling.
+ */
+
+import { useCallback, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { createPortal } from "react-dom";
+import { PlusIcon, TrashIcon } from "lucide-react";
+import type { ProblemSet } from "@/src/core";
+import { PracticeDialog } from "@/components/practice-dialog";
+
+/** A set's status word — plain and honest, no invented progress. */
+export function setStatusWord(set: Pick): string {
+ return set.status === "partitioning" ? "partitioning" : set.status === "generating" ? "generating" : set.status;
+}
+
+/** The list's pure body — exported for SSR pins. Rows are buttons
+ * (click-through); the delete affordance is a nested icon button. */
+export function PracticeListBody({
+ sets,
+ onOpen,
+ onDelete,
+ onNew,
+}: {
+ sets: ProblemSet[];
+ onOpen: (id: string) => void;
+ onDelete: (set: ProblemSet) => void;
+ onNew: () => void;
+}) {
+ return (
+
+
+
Practice
+
+
+ New problem set
+
+
+
+ {sets.length === 0 && (
+
+ No problem sets yet — create one from a session.
+
+ )}
+
+
+ {sets.map((set) => {
+ const ready = set.problems.filter((p) => p.status === "ready").length;
+ const graded = set.problems.filter((p) => p.grade).length;
+ return (
+
+ onOpen(set.id)}
+ className="flex flex-1 items-center justify-between gap-4 rounded-xl border bg-card px-4 py-3 text-left transition-colors hover:bg-accent/50"
+ >
+
+ {set.title}
+
+ {set.createdAt.slice(0, 10)} · {set.problems.length > 0 ? `${ready}/${set.problems.length} problems` : "no problems yet"}
+ {graded > 0 ? ` · ${graded} graded` : ""}
+
+
+
+ {setStatusWord(set)}
+
+
+ onDelete(set)}
+ className="flex items-center rounded-xl border px-3 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
+ >
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+/** The delete confirm — the delete-session dialog's shape (no mode
+ * checkbox: a set has no preserve-graph question). Pure body + portal
+ * wrapper. */
+export function DeleteSetBody({
+ title,
+ busy,
+ failure,
+ onClose,
+ onRun,
+}: {
+ title: string;
+ busy: boolean;
+ failure: string | null;
+ onClose: () => void;
+ onRun: () => void;
+}) {
+ return (
+ e.stopPropagation()}
+ className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
+ >
+
Delete problem set?
+
{title}
+
+ The set and its problems are removed. Nothing in the graph or your sessions changes.
+
+ {failure && (
+
+ {failure}
+
+ )}
+
+
+ Cancel
+
+
+ {busy ? "Deleting…" : "Delete"}
+
+
+
+ );
+}
+
+export function PracticeListPage({ initialSets }: { initialSets: ProblemSet[] }) {
+ const router = useRouter();
+ const [sets, setSets] = useState(initialSets);
+ const [creating, setCreating] = useState(false);
+ const [deleting, setDeleting] = useState(null);
+ const [deleteBusy, setDeleteBusy] = useState(false);
+ const [deleteFailure, setDeleteFailure] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const res = await fetch("/api/practice/sets", { cache: "no-store" });
+ if (!res.ok) return;
+ const d = (await res.json()) as { sets: ProblemSet[] };
+ setSets(d.sets);
+ } catch {
+ // a transient failure on the poll keeps the last snapshot
+ }
+ }, []);
+
+ // The reveal poll: while any set is partitioning/generating, statuses
+ // live-update; a settled list goes quiet.
+ const anyRunning = sets.some((s) => s.status === "partitioning" || s.status === "generating");
+ useEffect(() => {
+ if (!anyRunning) return;
+ const t = setInterval(() => void refresh(), 2000);
+ return () => clearInterval(t);
+ }, [anyRunning, refresh]);
+
+ const runDelete = useCallback(async () => {
+ if (!deleting || deleteBusy) return;
+ setDeleteBusy(true);
+ setDeleteFailure(null);
+ try {
+ const res = await fetch(`/api/practice/sets/${deleting.id}`, { method: "DELETE" });
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
+ setDeleteFailure(body.error ?? `Delete failed (${res.status})`);
+ setDeleteBusy(false);
+ return;
+ }
+ setDeleting(null);
+ setDeleteBusy(false);
+ await refresh();
+ } catch {
+ setDeleteFailure("Delete failed — network error. Try again.");
+ setDeleteBusy(false);
+ }
+ }, [deleting, deleteBusy, refresh]);
+
+ return (
+ <>
+ router.push(`/practice/${id}`)}
+ onDelete={(set) => setDeleting(set)}
+ onNew={() => setCreating(true)}
+ />
+ {creating && setCreating(false)} />}
+ {deleting &&
+ createPortal(
+ {
+ if (!deleteBusy) setDeleting(null);
+ }}
+ >
+ setDeleting(null)}
+ onRun={() => void runDelete()}
+ />
+
,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index e1ea520..a536084 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -6,9 +6,10 @@ import { usePathname, useRouter } from "next/navigation";
import { MenuShell } from "@/components/menu-shell";
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
import type { ChatDeleteMode } from "@/src/core";
-import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
+import { GraduationCapIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
import { ActivitySection } from "@/components/activity-dock";
import { CoverageMenuBlock } from "@/components/extraction-coverage";
+import { PracticeDialog } from "@/components/practice-dialog";
import { LhMark } from "@/components/lh-mark";
import { BrailleSpinner } from "@/components/braille-spinner";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
@@ -397,6 +398,9 @@ function SessionsSidebar() {
const ctxAnchorRef = useRef(null);
const [renamingId, setRenamingId] = useState(null);
const [deleting, setDeleting] = useState(null);
+ // Practice slice 5 (2026-09-29): the right-click "Practice this session's
+ // propositions" opens the creation dialog PRESELECTED on that session.
+ const [practiceChat, setPracticeChat] = useState<{ chatId: string; title: string } | null>(null);
const openContext = (e: React.MouseEvent, chat: ChatListItem) => {
e.preventDefault(); // suppress the native menu
@@ -591,6 +595,21 @@ function SessionsSidebar() {
create a new session
+ {/* Practice (slice 5, 2026-09-29): the set list's entry — the same
+ quiet row language as New session, active-state lit while any
+ /practice route is open. GraduationCapIcon to match the dock. */}
+ router.push("/practice")}
+ className={`mx-3 mb-1 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider transition-colors hover:bg-accent hover:text-foreground ${
+ pathname.startsWith("/practice") ? "bg-accent text-foreground" : "text-muted-foreground"
+ }`}
+ >
+
+ Practice
+
+
Sessions
@@ -717,6 +736,21 @@ function SessionsSidebar() {
Delete…
+ {/* Practice (slice 5): the session's propositions → a problem set.
+ Sits ABOVE the coverage block — an action like Extract, not
+ management like Rename/Delete. */}
+ {
+ const target = chats?.find((c) => c.id === ctxChatId);
+ if (target) setPracticeChat({ chatId: target.id, title: target.title });
+ setCtxChatId(null);
+ }}
+ className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
+ >
+ Practice this session's propositions
+
)}
@@ -728,6 +762,11 @@ function SessionsSidebar() {
onClose={() => setDeleting(null)}
onDeleted={onDeleted}
/>
+ {/* The preselected practice dialog (slice 5) — portaled by the
+ component itself, here for state locality like the delete dialog. */}
+ {practiceChat && (
+ setPracticeChat(null)} />
+ )}
);
}
diff --git a/tests/render-practice.test.tsx b/tests/render-practice.test.tsx
new file mode 100644
index 0000000..9faa32e
--- /dev/null
+++ b/tests/render-practice.test.tsx
@@ -0,0 +1,304 @@
+import React from "react";
+(globalThis as Record).React = React;
+
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ DeleteSetBody,
+ PracticeListBody,
+ setStatusWord,
+} from "../components/practice-list.tsx";
+import {
+ PRACTICE_DIALS,
+ PracticeDialogBody,
+ type PracticeFailure,
+} from "../components/practice-dialog.tsx";
+import { PracticeExamBody } from "../components/practice-exam.tsx";
+import type { ChatListItem, Problem, ProblemSet } from "../src/core";
+
+/**
+ * The practice UI surfaces (slice 5, 2026-09-29): creation dialog (dials
+ * + session picker + preselected lock), list page (rows, delete flow),
+ * exam page states (generating stubs — propositions never surface —
+ * ready + answerable, graded reveal + lock). The pure bodies are pinned
+ * here (the render-suite discipline); the live fetch chains (polling,
+ * dictation, submit) are Playwright's beat.
+ */
+
+// ─── fixtures ────────────────────────────────────────────────────────────────
+
+const PARAMS = { searchSolutionRatio: 0.5, cueDistance: 0.5, presentation: 0.5 };
+
+const dialValues = {
+ searchSolutionRatio: 0.7,
+ cueDistance: 0.5,
+ presentation: 0.3,
+};
+
+function chats(): ChatListItem[] {
+ return [
+ { id: "chat_1", title: "Ethics lesson", updatedAt: "2026-09-29T10:00:00.000Z", createdAt: "2026-09-29T09:00:00.000Z", busy: false } as ChatListItem,
+ { id: "chat_2", title: "Physics", updatedAt: "2026-09-28T10:00:00.000Z", createdAt: "2026-09-28T09:00:00.000Z", busy: false } as ChatListItem,
+ ];
+}
+
+function stubProblem(id: string, chains: string[]): Problem {
+ return { id, chains, statement: "", solution: "", rubric: "", status: "generating" };
+}
+
+function readyProblem(id: string, chains: string[], over: Partial = {}): Problem {
+ return {
+ id,
+ chains,
+ statement: `Why does arguing presuppose **self-ownership**?`,
+ solution: "The model solution.",
+ rubric: "- names self-ownership",
+ status: "ready",
+ ...over,
+ };
+}
+
+function set(over: Partial = {}): ProblemSet {
+ return {
+ id: "ps_1",
+ title: "Ethics lesson",
+ createdAt: "2026-09-29T12:00:00.000Z",
+ params: PARAMS,
+ nodeIds: ["n1", "n3"],
+ propositions: {
+ n1: { name: "Self-ownership", statement: "Each person owns themself." },
+ n3: { name: "Argumentation", statement: "Arguing presupposes self-ownership." },
+ },
+ chains: [
+ { id: "n3", premises: ["n1"], conclusion: "n3" },
+ ],
+ status: "ready",
+ problems: [],
+ ...over,
+ };
+}
+
+// ─── the creation dialog ─────────────────────────────────────────────────────
+
+describe("PracticeDialogBody — creation", () => {
+ const base = {
+ values: dialValues,
+ onDial: () => {},
+ chats: chats(),
+ selectedId: null,
+ onSelect: () => {},
+ lockedTitle: null,
+ busy: false,
+ failure: null as PracticeFailure | null,
+ onOpenSettings: () => {},
+ onClose: () => {},
+ onRun: () => {},
+ };
+
+ it("renders the three dials with anchor labels", () => {
+ const html = renderToStaticMarkup( );
+ for (const dial of PRACTICE_DIALS) {
+ assert.ok(html.includes(`data-testid="dial-${dial.key}"`), `the ${dial.key} dial`);
+ assert.ok(html.includes(dial.anchors), `the ${dial.key} anchors`);
+ assert.ok(html.includes(dial.label));
+ }
+ });
+
+ it("session picker: titled rows, selection lights up, Create gated until picked", () => {
+ const html = renderToStaticMarkup( );
+ assert.ok(html.includes('data-testid="practice-dialog-sessions"'));
+ assert.ok(html.includes("Ethics lesson"));
+ assert.ok(html.includes('disabled=""'), "no session picked — Create disabled");
+
+ const picked = renderToStaticMarkup( );
+ assert.ok(picked.includes('data-testid="practice-session-chat_2"'));
+ assert.match(picked, /Physics<\/span>.*selected/s);
+ });
+
+ it("preselected mode: picker hidden, the session locked", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ assert.ok(html.includes('data-testid="practice-dialog-locked"'));
+ assert.ok(!html.includes('data-testid="practice-dialog-sessions"'));
+ assert.ok(!html.includes('disabled=""'), "preselected — Create enabled");
+ });
+
+ it("empty selection renders the honest inline line", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ assert.ok(html.includes('data-testid="practice-dialog-error"'));
+ assert.ok(html.includes("This session has no propositions yet"));
+ });
+
+ it("503 renders the server's line + the settings deep link", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ assert.ok(html.includes("No model configured"));
+ assert.ok(html.includes('data-testid="practice-dialog-open-settings"'));
+ });
+});
+
+// ─── the list page ───────────────────────────────────────────────────────────
+
+describe("PracticeListBody — the set list", () => {
+ it("rows: title, date, counts, status word; the delete affordance", () => {
+ const s = set({
+ status: "generating",
+ problems: [readyProblem("pp_1", ["n3"]), stubProblem("pp_2", ["n1"])],
+ });
+ const html = renderToStaticMarkup(
+ {}} onDelete={() => {}} onNew={() => {}} />,
+ );
+ assert.ok(html.includes('data-testid="practice-row-ps_1"'));
+ assert.ok(html.includes("Ethics lesson"));
+ assert.ok(html.includes("2026-09-29"));
+ assert.ok(html.includes("1/2 problems"));
+ assert.ok(html.includes('data-testid="practice-status-ps_1"'));
+ assert.ok(html.includes("generating"));
+ assert.ok(html.includes('data-testid="practice-delete-ps_1"'));
+ assert.ok(html.includes('data-testid="practice-new"'));
+ });
+
+ it("graded count appears; empty list states it honestly", () => {
+ const s = set({
+ problems: [
+ readyProblem("pp_1", ["n3"], { grade: { verdict: "solid", feedback: "f", gradedAt: "2026-09-29T13:00:00.000Z" } }),
+ readyProblem("pp_2", ["n1"]),
+ ],
+ });
+ const html = renderToStaticMarkup(
+ {}} onDelete={() => {}} onNew={() => {}} />,
+ );
+ assert.ok(html.includes("1 graded"));
+ const empty = renderToStaticMarkup(
+ {}} onDelete={() => {}} onNew={() => {}} />,
+ );
+ assert.ok(empty.includes('data-testid="practice-empty"'));
+ assert.ok(empty.includes("No problem sets yet"));
+ });
+
+ it("setStatusWord covers the four states", () => {
+ assert.equal(setStatusWord({ status: "partitioning" }), "partitioning");
+ assert.equal(setStatusWord({ status: "generating" }), "generating");
+ assert.equal(setStatusWord({ status: "ready" }), "ready");
+ assert.equal(setStatusWord({ status: "error" }), "error");
+ });
+});
+
+describe("DeleteSetBody — the delete confirm", () => {
+ it("the title, the plain-consequence copy, destructive Delete", () => {
+ const html = renderToStaticMarkup(
+ {}} onRun={() => {}} />,
+ );
+ assert.ok(html.includes('data-testid="delete-set-dialog"'));
+ assert.ok(html.includes("Delete problem set?"));
+ assert.ok(html.includes("Ethics lesson"));
+ assert.ok(html.includes("Nothing in the graph or your sessions changes."));
+ assert.ok(html.includes('data-testid="delete-set-run"'));
+ });
+
+ it("failures render inline", () => {
+ const html = renderToStaticMarkup(
+ {}} onRun={() => {}} />,
+ );
+ assert.ok(html.includes('data-testid="delete-set-error"'));
+ assert.ok(html.includes("Delete failed (409)"));
+ });
+});
+
+// ─── the exam page ───────────────────────────────────────────────────────────
+
+describe("PracticeExamBody — exam states", () => {
+ const examBase = {
+ drafts: {} as Record,
+ onDraft: () => {},
+ onSubmit: () => {},
+ submitting: false,
+ submitError: null as string | null,
+ grading: false,
+ };
+
+ it("generating: numbered stubs with the placeholder — propositions NEVER surface", () => {
+ const s = set({
+ status: "generating",
+ problems: [stubProblem("pp_1", ["n3"]), stubProblem("pp_2", ["n1"])],
+ });
+ const html = renderToStaticMarkup( );
+ assert.ok(html.includes("Problem 1"));
+ assert.ok(html.includes("Problem 2"));
+ assert.ok(html.includes('data-testid="generating-pp_1"'));
+ assert.ok(html.includes("Generating problem"));
+ // The never-announce law starts early: no proposition names/statements.
+ assert.ok(!html.includes("Self-ownership"));
+ assert.ok(!html.includes("Each person owns themself"));
+ assert.ok(!html.includes('data-testid="answer-pp_1"'), "no answer box for a stub");
+ // No submit while generating.
+ assert.ok(!html.includes('data-testid="practice-submit"'));
+ });
+
+ it("ready: the statement renders as markdown, the box is answerable, submit gated on a draft", () => {
+ const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
+ const gated = renderToStaticMarkup( );
+ assert.ok(gated.includes("self-ownership")); // markdown strong rendered
+ assert.ok(gated.includes('data-testid="answer-pp_1"'));
+ assert.ok(gated.includes('data-testid="practice-submit"'));
+ assert.ok(gated.includes('disabled=""'), "empty drafts — submit disabled");
+ const filled = renderToStaticMarkup( );
+ assert.ok(!filled.includes('data-testid="practice-submit-error"'));
+ });
+
+ it("graded: answer read-only, verdict chip + feedback + solution + rubric revealed", () => {
+ const s = set({
+ problems: [
+ readyProblem("pp_1", ["n3"], {
+ answer: "because arguing is intentional action",
+ grade: { verdict: "partial", feedback: "Half the target came to mind.", gradedAt: "2026-09-29T13:00:00.000Z" },
+ }),
+ readyProblem("pp_2", ["n1"]),
+ ],
+ });
+ const html = renderToStaticMarkup( );
+ // The locked answer.
+ assert.ok(html.includes("because arguing is intentional action"));
+ assert.ok(/readOnly/.test(html), "the graded answer is read-only");
+ // The reveal.
+ assert.ok(html.includes('data-testid="reveal-pp_1"'));
+ assert.ok(html.includes("partial"));
+ assert.ok(html.includes('data-testid="feedback-pp_1"'));
+ assert.ok(html.includes("Half the target came to mind."));
+ assert.ok(html.includes('data-testid="solution-pp_1"'));
+ assert.ok(html.includes("The model solution."));
+ assert.ok(html.includes('data-testid="rubric-pp_1"'));
+ assert.ok(html.includes("names self-ownership"));
+ // Ungraded sibling stays answerable.
+ assert.ok(html.includes('data-testid="answer-pp_2"'));
+ });
+
+ it("a failed problem says so; a failed set says so", () => {
+ const s = set({
+ status: "ready",
+ problems: [readyProblem("pp_1", ["n3"], { status: "error", error: "the problem generator never submitted a problem" })],
+ });
+ const html = renderToStaticMarkup( );
+ assert.ok(html.includes('data-testid="problem-error-pp_1"'));
+ assert.ok(html.includes("This problem failed to generate"));
+
+ const dead = set({ status: "error", error: "Aborted", problems: [] });
+ const deadHtml = renderToStaticMarkup( );
+ assert.ok(deadHtml.includes("Aborted"));
+ });
+
+ it("math healing rides the display seam (restoreMathEscapes at render)", () => {
+ // The streaming-args parse turns single-backslash LaTeX into control
+ // chars; the seam restores — the healed string must contain the slash.
+ const s = set({
+ problems: [readyProblem("pp_1", ["n3"], { statement: "Show that \\\\frac{a}{b} holds." })],
+ });
+ const html = renderToStaticMarkup( );
+ assert.ok(html.includes("frac"), "the LaTeX survives to render");
+ });
+});