From 46d71380fa67bfe183da60aa19ec37178fd6cd08 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 03:23:11 +0300 Subject: [PATCH] UI iteration 1: app shell + full LaTeX pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout: - Home = knowledge-graph placeholder (main view) with new-session input docked below; session list moved out of home into a hamburger drawer - SessionDrawer: slide-over session navigation (backdrop/ESC close, active highlight, fetches /api/chats on open) on both home and chat headers - Chat header loses the back button — the drawer is the navigation Math (inline $ was silently not rendering): - @streamdown/math defaults singleDollarTextMath:false — now createMathPlugin({ singleDollarTextMath: true }) in message + reasoning - katex.min.css imported in layout (streamdown does not inject plugin styles; display math only looked right via browser MathML fallback) - QuizCard/AnsweredCard route question/options/explanation through MessageResponse; user chat bubbles render markdown+math too - Currency hazard: micromark-extension-math@3.1 lacks pandoc's closing-digit rule, so bare '$5 and $10' parses as math — defense is at the source: system prompt now instructs escaped \$5 / 'USD' for money Hydration fix (React #418): - QuizCard shuffled options with Math.random() — server/client orders diverged. shuffleOptions now takes an injectable RNG; QuizCard passes seededRandom(toolCallId) (mulberry32/FNV-1a in question-core). Same question → same stable order across reloads; still varies per question. Tests: 53 pass (5 new math-rendering pins incl. fenced-vs-one-line $$ behavior and the escaped-currency contract). tsc + next build clean. Verified live in browser: inline/display math in text + cards, drawer open/navigate/active-highlight/ESC, no console errors, no font 404s. --- app/layout.tsx | 1 + app/page.tsx | 5 +- components/ai-elements/message.tsx | 11 ++- components/ai-elements/reasoning.tsx | 11 ++- components/answered-card.tsx | 11 ++- components/chat.tsx | 13 ++- components/home.tsx | 90 ++++++++++---------- components/quiz-card.tsx | 21 +++-- components/session-drawer.tsx | 118 +++++++++++++++++++++++++++ package-lock.json | 1 + package.json | 1 + src/agent/system-prompt.md | 2 + src/core/question-core.ts | 29 ++++++- tests/render-cards.test.tsx | 60 ++++++++++++++ 14 files changed, 302 insertions(+), 72 deletions(-) create mode 100644 components/session-drawer.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 34e07e7..13e1bb9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import "katex/dist/katex.min.css"; import "./globals.css"; export const metadata: Metadata = { diff --git a/app/page.tsx b/app/page.tsx index 518248c..2acb7bd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,5 @@ -import { listChats } from "@/src/store/chats"; import { Home } from "@/components/home"; -export const dynamic = "force-dynamic"; - export default function HomePage() { - return ; + return ; } diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index f75eded..aa6c625 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -14,7 +14,7 @@ import { import { cn } from "@/lib/utils"; import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; -import { math } from "@streamdown/math"; +import { createMathPlugin } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; import type { UIMessage } from "ai"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; @@ -324,7 +324,14 @@ export type MessageResponseProps = ComponentProps; // Type-only version skew: @streamdown/code bundles shiki@3 types while // streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its // own highlighter); the cast papers over the .d.ts mismatch. -const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig; +// singleDollarTextMath: the teacher prompt instructs $...$ inline math (the +// LLM-native convention); remark-math's default here is false. +const streamdownPlugins = { + cjk, + code, + math: createMathPlugin({ singleDollarTextMath: true }), + mermaid, +} as unknown as PluginConfig; export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index 765521f..b0ab48b 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -9,7 +9,7 @@ import { import { cn } from "@/lib/utils"; import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; -import { math } from "@streamdown/math"; +import { createMathPlugin } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; import { BrainIcon, ChevronDownIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; @@ -204,8 +204,13 @@ export type ReasoningContentProps = ComponentProps< children: string; }; -// See message.tsx — same streamdown plugin type skew. -const streamdownPlugins = { cjk, code, math, mermaid } as unknown as PluginConfig; +// See message.tsx — same streamdown plugin type skew + math config. +const streamdownPlugins = { + cjk, + code, + math: createMathPlugin({ singleDollarTextMath: true }), + mermaid, +} as unknown as PluginConfig; export const ReasoningContent = memo( ({ className, children, ...props }: ReasoningContentProps) => ( diff --git a/components/answered-card.tsx b/components/answered-card.tsx index f75bfed..b5dae79 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -1,6 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; +import { MessageResponse } from "@/components/ai-elements/message"; import type { QuestionAnswerData } from "@/src/core/question-core"; /** @@ -36,7 +37,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { incorrect ))} -
{answer.questionText}
+
+ {answer.questionText} +
{answer.options && answer.options.length > 0 && (
@@ -61,7 +64,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { {!sel && key && "✓"} )} - {opt.label} + + {opt.label} +
); })} @@ -75,7 +80,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { {isQuiz && answer.explanation && (
Why: - {answer.explanation} + {answer.explanation}
)} {answer.note &&
Note: {answer.note}
} diff --git a/components/chat.tsx b/components/chat.tsx index d1396e6..5fdb759 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { @@ -26,6 +25,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { QuizCard } from "@/components/quiz-card"; import { AnsweredCard } from "@/components/answered-card"; +import { SessionDrawer } from "@/components/session-drawer"; import type { TeacherUIMessage } from "@/src/agent/ui-message"; import type { QuestionAnswerData } from "@/src/core/question-core"; import { findPendingToolCall } from "@/src/core/pending"; @@ -46,7 +46,6 @@ export function Chat({ initialMessages: TeacherUIMessage[]; initialPrompt?: string; }) { - const router = useRouter(); const { messages, sendMessage, addToolOutput, status, error, stop } = useChat({ id: chatId, @@ -80,10 +79,8 @@ export function Chat({ return (
-
- +
+
{title}
{busy && ( - - -

Sessions

- {chats.length === 0 ? ( -

No sessions yet.

- ) : ( -
- {chats.map((c) => ( - - ))} + {/* Graph — the main view. Placeholder until the knowledge graph lands. */} +
+
+ +
Knowledge graph
+

+ Every session you complete will map what you know — and where the edge of your + understanding is — right here. +

- )} -
+ + + {/* New session input */} +
+
{ + e.preventDefault(); + void start(); + }} + > + setTopic(e.target.value)} + placeholder="What do you want to learn?" + autoFocus + className="h-11" + /> + +
+
+
); } diff --git a/components/quiz-card.tsx b/components/quiz-card.tsx index 6ee8446..03c119b 100644 --- a/components/quiz-card.tsx +++ b/components/quiz-card.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { MessageResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; import type { AskToolInput, QuizToolInput } from "@/src/agent/tools"; import { @@ -9,6 +10,7 @@ import { coerceCorrectAnswer, normalizeOptions, resolveCorrect, + seededRandom, shuffleOptions, type AnswerRequest, type QuestionAnswerData, @@ -37,8 +39,9 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); // Build the canonical QuestionData once per question. Quiz options are - // shuffled so position never leaks correctness; the display order is - // snapshotted into the answer record at submit time. + // deterministically shuffled (seeded by toolCallId) so position never leaks + // correctness AND server/client render the same order (hydration-safe); + // the display order is snapshotted into the answer record at submit time. const qd = useMemo(() => { const quizInput = kind === "quiz" ? (input as QuizToolInput) : null; const askInput = kind === "ask" ? (input as AskToolInput) : null; @@ -50,7 +53,7 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp : "single-select" : (askInput?.mode ?? (askInput?.options?.length ? (multi ? "multi-select" : "single-select") : "text")); const options = normalizeOptions((quizInput?.options ?? askInput?.options ?? []) as { label: string; value?: string }[]); - const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options) : options; + const ordered = kind === "quiz" && options.length > 0 ? shuffleOptions(options, seededRandom(toolCallId)) : options; const q: QuestionData = { id: toolCallId, kind, @@ -125,8 +128,14 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp {kind === "quiz" ? "Quiz" : "Question"} {qd.mode === "multi-select" && " · select all that apply"}
-
{qd.question}
- {qd.context &&
{qd.context}
} +
+ {qd.question} +
+ {qd.context && ( +
+ {qd.context} +
+ )} {qd.mode !== "text" && (
@@ -154,7 +163,7 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp {isSel && {qd.mode === "multi-select" ? "✓" : ""}} - {opt.label} + {opt.label} {opt.description && {opt.description}} diff --git a/components/session-drawer.tsx b/components/session-drawer.tsx new file mode 100644 index 0000000..4e83e24 --- /dev/null +++ b/components/session-drawer.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { MenuIcon, XIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ChatSummary } from "@/src/store/chats"; + +/** + * The app's primary navigation: a hamburger that opens the session list. + * Sessions only — settings and other chrome will dock here later. + * Renders the toggle button (sits in the page header) plus the slide-over. + */ +export function SessionDrawer() { + const [open, setOpen] = useState(false); + const [chats, setChats] = useState(null); + const router = useRouter(); + const pathname = usePathname(); + + const load = useCallback(async () => { + try { + const res = await fetch("/api/chats", { cache: "no-store" }); + const data = (await res.json()) as { chats: ChatSummary[] }; + setChats(data.chats); + } catch { + setChats([]); + } + }, []); + + useEffect(() => { + if (open) void load(); + }, [open, load]); + + // ESC closes; body scroll locks while open. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("keydown", onKey); + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + }, [open]); + + const go = (href: string) => { + setOpen(false); + router.push(href); + }; + + return ( + <> + + + {/* backdrop */} +
setOpen(false)} + /> + + {/* panel */} + + + ); +} diff --git a/package-lock.json b/package-lock.json index 9d1ca27..b0d0fba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "embla-carousel-react": "^8.6.0", + "katex": "^0.16.47", "lucide-react": "^1.33.0", "media-chrome": "^4.19.2", "motion": "^13.1.1", diff --git a/package.json b/package.json index de1f053..941ae65 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "embla-carousel-react": "^8.6.0", + "katex": "^0.16.47", "lucide-react": "^1.33.0", "media-chrome": "^4.19.2", "motion": "^13.1.1", diff --git a/src/agent/system-prompt.md b/src/agent/system-prompt.md index e718943..cc621b7 100644 --- a/src/agent/system-prompt.md +++ b/src/agent/system-prompt.md @@ -182,6 +182,8 @@ Whenever math notation is involved — explanations, questions, quiz options and If LaTeX can be used, it should be. Write $f(x) = x^2$, not `f(x) = x^2`. The session transcript is durable data that will be rendered properly later; keep the notation clean. +One hazard: a bare `$` starts math. NEVER write currency as `$5` — write `\$5` (escaped) or `5 USD`. This applies to quiz questions, options, and explanations too. + ## Session shape — one more thing Every session you run produces the artifact from which the learner's knowledge graph is built: the quiz outcomes, the dependency map, the propositions you teach. Teach in clean, explicit units — each node stated crisply (e.g. "**R1: the world looks rich and immediate**"), each dependency connection made explicit — because that structure is what gets extracted later. Vague, hedged, mushy node statements can't be extracted; crisp ones can. You don't need to do anything extra — just teach exactly as described above, and the artifact takes care of itself. diff --git a/src/core/question-core.ts b/src/core/question-core.ts index da00eb4..f539d9b 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -115,10 +115,35 @@ export function normalizeOptions( /** Fisher-Yates over a copy. correctAnswer is keyed by value, so display * order can be shuffled safely before resolving indices. */ -export function shuffleOptions(options: QuizOption[]): QuizOption[] { +// ─── options ──────────────────────────────────────────────────────────────── + +/** mulberry32 seeded by a string — deterministic shuffle for hydration-safe rendering. */ +export function seededRandom(seed: string): () => number { + let h = 2166136261; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + let a = h >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Fisher–Yates with an injectable RNG. Pass `seededRandom(toolCallId)` in + * rendered components: Math.random() shuffles differently on server vs client + * and breaks hydration (React #418). A per-toolCallId seed keeps the order + * stable across reloads while still varying it per question. + */ +export function shuffleOptions(options: QuizOption[], rand: () => number = Math.random): QuizOption[] { const out = [...options]; for (let i = out.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + const j = Math.floor(rand() * (i + 1)); const a = out[i]!; const b = out[j]!; out[i] = b; diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index a29ac82..0462e16 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -15,6 +15,7 @@ import React from "react"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { renderToStaticMarkup } from "react-dom/server"; +import { MessageResponse } from "../components/ai-elements/message.tsx"; import { QuizCard } from "../components/quiz-card.tsx"; import { AnsweredCard } from "../components/answered-card.tsx"; import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; @@ -102,3 +103,62 @@ describe("render smoke tests", () => { assert.match(html, /skipped/); }); }); + +describe("math rendering (streamdown + KaTeX)", () => { + it("renders INLINE math ($...$) — the LLM-native convention", () => { + const html = renderToStaticMarkup({"The map $f(x) = x^2$ is smooth."}); + assert.ok(html.includes("katex"), `inline math should render as KaTeX, got: ${html.slice(0, 300)}`); + }); + + it("renders display math ($$ fenced on own lines — the prompt contract)", () => { + const html = renderToStaticMarkup({"$$\n\\int_0^1 x^2\\,dx = \\tfrac{1}{3}\n$$"}); + assert.ok(html.includes("katex-display"), "fenced display math should render as display KaTeX"); + }); + + it("one-line $$...$$ degrades to inline (micromark behavior — prompt forbids it)", () => { + // micromark treats a one-line $$...$$ inside a paragraph as 2-dollar + // inline mathText, not flow math. The system prompt instructs the fenced + // form, so the model never emits this; pin the behavior so a renderer + // swap can't silently change it. + const html = renderToStaticMarkup({"$$\\int_0^1 x^2\\,dx$$"}); + assert.ok(html.includes("katex") && !html.includes("katex-display"), "one-liner should render inline"); + }); + + it("does NOT eat escaped currency (\\$5 stays text — the prompt contract)", () => { + // micromark-extension-math@3.1 lacks pandoc's closing-digit rule, so bare + // "$5 and $10" would parse as math. The defense is at the source: the + // system prompt instructs the teacher to escape currency (\$5 / 5 USD). + const html = renderToStaticMarkup({"It costs \\$5 and \\$10 more."}); + assert.ok(!html.includes("katex"), "escaped currency must not parse as math"); + assert.match(html, /\$5/); + }); + + it("quiz cards render math in question, options, and explanation", () => { + const mathQuiz = { + question: "What is $\\frac{dy}{dx}$ of $y = x^2$?", + options: [{ label: "$2x$" }, { label: "$x^2$" }, { label: "$x$" }], + correctAnswer: "$2x$", + explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.", + }; + const pendingHtml = renderToStaticMarkup( + {}} />, + ); + assert.ok(pendingHtml.includes("katex"), "pending card: question/options math renders"); + + const options = normalizeOptions(mathQuiz.options); + const qd: QuestionData = { + id: "m1", + kind: "quiz", + question: mathQuiz.question, + mode: "single-select", + options, + correctIndices: resolveCorrect(coerceCorrectAnswer(mathQuiz.correctAnswer), options).indices, + explanation: mathQuiz.explanation, + createdAt: new Date().toISOString(), + }; + const answeredHtml = renderToStaticMarkup( + , + ); + assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders"); + }); + });