Files
learnharness/components/practice-exam.tsx
T

446 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* The exam page (practice slice 5, one-attempt redesign 2026-09-29):
* 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 ATTEMPT: each
* problem has its OWN submit button; once submitted the box locks and
* the MODEL SOLUTION reveals instantly (rubric collapsed by default).
* The grade (score 0–10 + brief reasoning) lands via the events channel and
* updates live — no reload.
*
* TOP BAR (owner, UI round 2026-09-29): the chat pattern — an h-15
* lh-drag header in flow above the scroller, the sidebar reopen toggle
* first (it clears the traffic lights via .lh-closed-toggle), the set
* title truncating beside it. The levels + status stay in the body.
*
* DISPLAY-SEAM MATH HEALING: statements/solutions/rubrics/reasoning 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 practice rows are
* keyed setId:grade:problemId — the LIVE rows name exactly which
* problems' grades are pending). A settled page goes quiet; a dropped
* grade shows "grade interrupted" honestly.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useServerEvent, useServerResync } from "@/components/server-events";
import type { Problem, ProblemSet } from "@/src/core";
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
import { restoreMathEscapes } from "@/src/core";
import { MessageResponse } from "@/components/ai-elements/message";
import { TopBar } from "@/components/top-bar";
import { BrailleSpinner } from "@/components/braille-spinner";
import { DictationBar, useDictation } from "@/components/dictation";
/** One answerable problem: textarea + focus-gated dictation bar. */
export function ProblemBox({
problem,
draft,
onDraft,
}: {
problem: Pick<Problem, "id">;
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 (
<div
className="flex flex-col gap-1.5"
data-testid={`problem-box-${problem.id}`}
>
<textarea
data-testid={`answer-${problem.id}`}
value={draft}
rows={5}
placeholder="Think out loud — dictation or typing, both fine."
onChange={(e) => onDraft(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="w-full resize-y rounded-lg border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{focused && <DictationBar dictation={dictation} />}
</div>
);
}
/** The exam page's pure body — exported for SSR pins. */
export function PracticeExamBody({
set,
drafts,
onDraft,
onSubmit,
submittingId,
submitErrors,
liveGradeIds,
}: {
set: ProblemSet;
drafts: Record<string, string>;
onDraft: (problemId: string, text: string) => void;
/** Per-problem submit (owner 2026-09-29): ONE attempt per problem. */
onSubmit: (problemId: string) => void;
/** The problem whose submit POST is in flight. */
submittingId: string | null;
/** Per-problem submit failures, keyed by problem id. */
submitErrors: Record<string, string>;
/** Problems whose grade rows are LIVE (the activity feed read). */
liveGradeIds: ReadonlySet<string>;
}) {
return (
<div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col">
{/* The top bar (owner, UI round 2026-09-29): the new-session
pattern — h-15, lh-drag (the Electron window-drag surface, so
the traffic lights never cover content), the sidebar reopen
toggle first, the set title truncating beside it. */}
<TopBar>
<div
className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight"
data-testid="practice-exam-title"
>
{set.title}
</div>
</TopBar>
<div className="relative flex min-h-0 flex-1">
<div
className="h-full min-w-0 flex-1 overflow-y-auto"
data-testid="practice-exam-scroll"
>
<div
className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6"
data-testid="practice-exam"
>
<div className="flex flex-col gap-1">
<p
className="text-xs text-muted-foreground/80"
data-testid="practice-exam-levels"
>
{LADDER_RUNGS[set.params.ladderRung].name} ·{" "}
{CUE_LEVELS[set.params.cueDistance].name} ·{" "}
{PRESENTATIONS[set.params.presentation].name}
</p>
<p
className="text-sm text-muted-foreground"
data-testid="practice-exam-status"
>
{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" &&
(liveGradeIds.size > 0
? "Grading your answers…"
: `${set.problems.length} problems · ${set.problems.filter((p) => p.grade).length} graded`)}
</p>
</div>
{set.problems.map((problem, i) => (
<section
key={problem.id}
className="rounded-xl border bg-card p-4"
data-testid={`problem-${problem.id}`}
>
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Problem {i + 1}
</h2>
{problem.status === "generating" ? (
// The stub: propositions NEVER surface (never-announce law).
<div
className="flex items-center gap-2 text-sm text-muted-foreground"
data-testid={`generating-${problem.id}`}
>
<BrailleSpinner className="size-4" />
Generating problem…
</div>
) : problem.status === "error" ? (
<p
className="text-sm text-destructive"
data-testid={`problem-error-${problem.id}`}
>
This problem failed to generate
{problem.error ? ` — ${problem.error}` : ""}.
</p>
) : (
<>
<div className="lh-prose text-sm">
<MessageResponse mode="static">
{restoreMathEscapes(problem.statement)}
</MessageResponse>
</div>
{problem.answer === undefined ? (
// ANSWERABLE (owner 2026-09-29): the box + its OWN submit
// button — one attempt, submitted the moment it's clicked.
<div className="flex flex-col items-start gap-2">
<div className="w-full">
<ProblemBox
problem={problem}
draft={drafts[problem.id] ?? ""}
onDraft={(text) => onDraft(problem.id, text)}
/>
</div>
<button
type="button"
data-testid={`practice-submit-${problem.id}`}
onClick={() => onSubmit(problem.id)}
disabled={
set.status !== "ready" ||
(drafts[problem.id] ?? "").trim() === "" ||
submittingId !== null
}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{submittingId === problem.id
? "Submitting…"
: "Submit answer"}
</button>
{submitErrors[problem.id] && (
<p
data-testid={`practice-submit-error-${problem.id}`}
className="text-sm text-destructive"
>
{submitErrors[problem.id]}
</p>
)}
</div>
) : (
// SUBMITTED (owner 2026-09-29): the answer locks, the
// model solution reveals INSTANTLY, the rubric stays
// collapsed, and the grade lands live when it lands.
<div className="mt-4 flex flex-col gap-3">
<textarea
data-testid={`answer-${problem.id}`}
value={problem.answer}
readOnly
rows={5}
className="w-full resize-y rounded-lg border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"
/>
<div
className="rounded-lg border bg-background p-3"
data-testid={`reveal-${problem.id}`}
>
{problem.grade ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline gap-2">
<span
data-testid={`grade-${problem.id}`}
className="font-mono text-sm font-medium"
>
{problem.grade.score}/10
</span>
</div>
<div
className="lh-prose text-sm"
data-testid={`reasoning-${problem.id}`}
>
<MessageResponse mode="static">
{restoreMathEscapes(
problem.grade.reasoning,
)}
</MessageResponse>
</div>
</div>
) : liveGradeIds.has(problem.id) ? (
<div
className="flex items-center gap-2 text-sm text-muted-foreground"
data-testid={`grading-${problem.id}`}
>
<BrailleSpinner className="size-4" />
Grading…
</div>
) : (
<p
className="text-sm text-muted-foreground"
data-testid={`grade-interrupted-${problem.id}`}
>
Grade interrupted — the answer stands.
</p>
)}
<div className="mt-3 text-xs uppercase tracking-wide text-muted-foreground">
Model solution
</div>
<div
className="lh-prose text-sm"
data-testid={`solution-${problem.id}`}
>
<MessageResponse mode="static">
{restoreMathEscapes(problem.solution)}
</MessageResponse>
</div>
<details
className="mt-3"
data-testid={`rubric-${problem.id}`}
>
<summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground">
Rubric
</summary>
<div className="mt-2 lh-prose text-sm">
<MessageResponse mode="static">
{restoreMathEscapes(problem.rubric)}
</MessageResponse>
</div>
</details>
</div>
</div>
)}
</>
)}
</section>
))}
</div>
</div>
</div>
</div>
</div>
);
}
export function PracticeExam({ initial }: { initial: ProblemSet }) {
const [set, setSet] = useState<ProblemSet>(initial);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [submitErrors, setSubmitErrors] = useState<Record<string, string>>({});
// Which problems' grade rows are LIVE (activity feed) — drives the
// per-problem "Grading…" spinners and the status line.
const [liveGradeIds, setLiveGradeIds] = useState<Set<string>>(new Set());
// True from a successful submit until the feed shows no live grade
// rows for this set — kept fresh across refetches (S7: the tick).
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 fetch failures keep the last snapshot
}
}, [initial.id]);
// PUSH (S7, 2026-10-02): the 2s grading-liveness interval is RETIRED —
// the practice registry announces every grade-row transition
// (activity-changed), and tick() reads exactly that liveness, so it
// fires when truth moved: debounced (the S4 law) + on mount + on
// resync. Ungated by design: a fresh grade registration must be seen
// even from a fully-settled page (the old needsPoll gate would have
// missed it — gradingLive fed the gate, and registration is what
// flips it). The activity feed's practice rows are ROW-KEYED
// (setId:grade:problemId) — the live rows name exactly which grades
// are pending; a dropped grade (no live row) settles the page.
const tick = useCallback(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 (mounted.current) {
const prefix = `${initial.id}:grade:`;
const pids = (feed.practice ?? [])
.filter((row) => row.id.startsWith(prefix))
.map((row) => row.id.slice(prefix.length));
setLiveGradeIds(new Set(pids));
}
} catch {
// keep the last liveness read
}
}, [refetch, initial.id]);
const tickRef = useRef(tick);
tickRef.current = tick;
const tickDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
void tickRef.current();
return () => {
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
};
}, []);
useServerEvent("activity-changed", () => {
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
tickDebounceRef.current = setTimeout(() => {
tickDebounceRef.current = undefined;
void tickRef.current();
}, 250);
});
useServerResync(() => void tickRef.current());
// ONE attempt per problem (owner 2026-09-29): the submit posts THAT
// problem's answer alone; the server refuses a second submission
// forever after. On success the box locks (answer persisted) and the
// reveal shows instantly; the grade arrives via the events channel.
const submit = useCallback(
async (problemId: string) => {
if (submittingId !== null) return;
const p = set.problems.find((q) => q.id === problemId);
if (!p || p.answer !== undefined) return; // done problems never resubmit
const text = drafts[problemId] ?? "";
if (text.trim() === "") return;
setSubmittingId(problemId);
setSubmitErrors((prev) => {
const next = { ...prev };
delete next[problemId];
return next;
});
try {
const res = await fetch(`/api/practice/sets/${set.id}/answers`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ answers: [{ problemId, text }] }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
};
setSubmitErrors((prev) => ({
...prev,
[problemId]: body.error ?? `Submit failed (${res.status})`,
}));
setSubmittingId(null);
return;
}
setSubmittingId(null);
// The grade lands via the events channel: the grade row's register
// announces activity-changed (S7), which triggers tick() above.
await refetch();
} catch {
setSubmitErrors((prev) => ({
...prev,
[problemId]: "Submit failed — network error. Try again.",
}));
setSubmittingId(null);
}
},
[submittingId, set, drafts, refetch],
);
return (
<PracticeExamBody
set={set}
drafts={drafts}
onDraft={(problemId, text) =>
setDrafts((prev) => ({ ...prev, [problemId]: text }))
}
onSubmit={(problemId) => void submit(problemId)}
submittingId={submittingId}
submitErrors={submitErrors}
liveGradeIds={liveGradeIds}
/>
);
}