Files
learnharness/components/practice-exam.tsx
T
amosblomqvist 155d065fd1 practice: the chain table — every reasoning chain in one selectable overlay (owner-approved plan, 2026-09-29)
- core formatChainLine: names + arrow one-liner, singleton marker, pure
- graph-view Chains button (chrome row, empty-graph hidden) opens the
  overlay: fuzzy search (names/aliases/statements), collapsible chain →
  member → statement rows, birth-session + created columns, no
  understanding-levels column
- pinned Selected section derives the agents' actual input live
  (chainsForSelection over the selection; premise without its
  conclusion shows as the singleton it becomes)
- Generate opens the creation dialog in CUSTOM mode: selection summary
  + dated default title, posts {nodeIds, params, note}; cluster mode
  untouched on /practice
- live-verified on the owner's dev server: 219 chains, derived preview,
  search-never-filters-Selected, dialog handoff (selection pass-through
  bug caught + fixed en route), Esc/backdrop dismiss, zero writes
- practice UI files re-normalized to tabs (my earlier prettier runs had
  drifted them to the 2-space default; whitespace-only)

17 render/unit pins added; gates: tsc 0, 1174/1174, lint 0, build 0
2026-09-29 22:41:52 +03:00

439 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 poll 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 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 { BrailleSpinner } from "@/components/braille-spinner";
import { DictationBar, useDictation } from "@/components/dictation";
import { SidebarToggle } from "@/components/sidebar";
/** 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 poll's activity feed). */
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. */}
<header className="lh-drag flex h-15 items-center gap-3 border-b border-border px-3">
<SidebarToggle />
<div
className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight"
data-testid="practice-exam-title"
>
{set.title}
</div>
</header>
<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 — keeps the poll alive across refetches.
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'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 instead of polling forever.
const pendingGrade = set.problems.some(
(p) => p.answer !== undefined && !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) {
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));
setGradingLive(pids.length > 0);
}
} catch {
// keep the last liveness read
}
};
void tick();
const t = setInterval(() => void tick(), 2000);
return () => {
stopped = true;
clearInterval(t);
};
}, [needsPoll, refetch, initial.id]);
// 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 poll.
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);
setGradingLive(true); // the grade lands via the poll
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}
/>
);
}