From 832c4f4cefb089c7944b777d1e6cdd1c3d516d24 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 29 Sep 2026 18:40:07 +0300 Subject: [PATCH] =?UTF-8?q?practice=20slice=205=20=E2=80=94=20UI:=20list?= =?UTF-8?q?=20page,=20exam=20page,=20creation=20dialog,=20sidebar=20entrie?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components/practice-dialog.tsx: portal dialog (delete-session shape), three dial rows (SettingSlider markup, anchor text as display: flashcard-competition / near the lesson-far transfer / theory- application, defaults 0.5), session picker (generic mode, /api/chats) or preselected lock (right-click path); 400 empty_selection renders 'This session has no propositions yet', 503 renders the server line + Open settings deep link (useSettingsDialog, zero-keys precedent). Pure body exported for SSR pins. components/practice-list.tsx: rows (title, date, ready/total problems, graded count, status word; destructive tint on error), click-through, delete affordance → portaled confirm (DeleteSetBody: no mode checkbox — a set has no preserve-graph question), 2s reveal-poll while any set is partitioning/generating. components/practice-exam.tsx: numbered problems; generating stubs (BrailleSpinner + 'Generating problem…', propositions never surface); ready statements/solutions/rubrics/ feedback as MessageResponse markdown with restoreMathEscapes at the display seam; per-problem textarea + useDictation instance with the DictationBar rendered only on box focus; one Submit answers POST per set; graded reveal (verdict chip, feedback, model solution, rubric) with read-only answer; polling while not ready OR grading-live (activity feed practice rows — dropped grades terminate the poll). app/practice/page.tsx (force-dynamic, listPracticeSets) + app/practice/ [id]/page.tsx (getPracticeSet + notFound). Sidebar: quiet Practice nav row under New session (GraduationCapIcon, active on /practice) + 'Practice this session's propositions' in the session right-click below the hairline, above CoverageMenuBlock — opens the dialog preselected. tests/render-practice.test.tsx — 15 pins: dials + anchors, picker selection gating, preselected lock, empty-selection line, 503 deep link, list rows/counts/empty, delete confirm + inline failure, exam generating (no proposition text, no answer boxes, no submit), ready (markdown statement, gated submit), graded reveal + read-only + sibling answerable, failed problem/set lines, math healing. 1129/1129, tsc 0, lint 0, build 0. --- app/practice/[id]/page.tsx | 17 ++ app/practice/page.tsx | 14 ++ components/practice-dialog.tsx | 272 +++++++++++++++++++++++++++++ components/practice-exam.tsx | 275 +++++++++++++++++++++++++++++ components/practice-list.tsx | 239 ++++++++++++++++++++++++++ components/sidebar.tsx | 41 ++++- tests/render-practice.test.tsx | 304 +++++++++++++++++++++++++++++++++ 7 files changed, 1161 insertions(+), 1 deletion(-) create mode 100644 app/practice/[id]/page.tsx create mode 100644 app/practice/page.tsx create mode 100644 components/practice-dialog.tsx create mode 100644 components/practice-exam.tsx create mode 100644 components/practice-list.tsx create mode 100644 tests/render-practice.test.tsx 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) => ( + + ))} + {(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, + ); +} 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 ( +
+