UI iteration 1: app shell + full LaTeX pipeline
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.
This commit is contained in:
@@ -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<typeof Streamdown>;
|
||||
// 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) => (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 }) {
|
||||
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-3 font-medium">{answer.questionText}</div>
|
||||
<div className="mb-3 font-medium">
|
||||
<MessageResponse>{answer.questionText}</MessageResponse>
|
||||
</div>
|
||||
|
||||
{answer.options && answer.options.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
@@ -61,7 +64,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
{!sel && key && "✓"}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(!sel && "text-muted-foreground")}>{opt.label}</span>
|
||||
<span className={cn(!sel && "text-muted-foreground")}>
|
||||
<MessageResponse>{opt.label}</MessageResponse>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -75,7 +80,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
{isQuiz && answer.explanation && (
|
||||
<div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Why: </span>
|
||||
{answer.explanation}
|
||||
<MessageResponse className="inline">{answer.explanation}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
{answer.note && <div className="mt-2 text-xs text-muted-foreground">Note: {answer.note}</div>}
|
||||
|
||||
+6
-7
@@ -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<TeacherUIMessage>({
|
||||
id: chatId,
|
||||
@@ -80,10 +79,8 @@ export function Chat({
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
|
||||
← Sessions
|
||||
</Button>
|
||||
<header className="flex items-center gap-3 border-b px-3 py-2">
|
||||
<SessionDrawer />
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||
{busy && (
|
||||
<Button variant="outline" size="sm" onClick={() => stop()}>
|
||||
@@ -105,7 +102,9 @@ export function Chat({
|
||||
<Message key={m.id} from={m.role}>
|
||||
<MessageContent>
|
||||
{m.role === "user" ? (
|
||||
<div className="whitespace-pre-wrap">{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}</div>
|
||||
<MessageResponse>
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
</MessageResponse>
|
||||
) : (
|
||||
m.parts.map((part, pi) => {
|
||||
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
|
||||
|
||||
+45
-45
@@ -2,11 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ChatSummary } from "@/src/store/chats";
|
||||
import { SessionDrawer } from "@/components/session-drawer";
|
||||
|
||||
export function Home({ chats }: { chats: ChatSummary[] }) {
|
||||
/**
|
||||
* Home — the knowledge graph is the main view (placeholder until the graph
|
||||
* pipeline lands), with the new-session input docked below it. Sessions live
|
||||
* in the hamburger drawer.
|
||||
*/
|
||||
export function Home() {
|
||||
const router = useRouter();
|
||||
const [topic, setTopic] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -29,50 +35,44 @@ export function Home({ chats }: { chats: ChatSummary[] }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col px-4 py-10">
|
||||
<h1 className="mb-1 text-2xl font-semibold">Learning Harness</h1>
|
||||
<p className="mb-8 text-sm text-muted-foreground">
|
||||
A teacher with a memory. Start a session on anything you want to understand.
|
||||
</p>
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center gap-2 px-3 py-2">
|
||||
<SessionDrawer />
|
||||
</header>
|
||||
|
||||
<form
|
||||
className="mb-10 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void start();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="What do you want to learn?"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" disabled={!topic.trim() || busy}>
|
||||
{busy ? "Starting…" : "Start"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-muted-foreground">Sessions</h2>
|
||||
{chats.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No sessions yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{chats.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/chat/${c.id}`)}
|
||||
className="rounded-lg border px-4 py-3 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="truncate font-medium">{c.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{/* Graph — the main view. Placeholder until the knowledge graph lands. */}
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
|
||||
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
|
||||
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
|
||||
<p className="max-w-sm text-sm">
|
||||
Every session you complete will map what you know — and where the edge of your
|
||||
understanding is — right here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</main>
|
||||
|
||||
{/* New session input */}
|
||||
<div className="px-4 pb-6">
|
||||
<form
|
||||
className="mx-auto flex w-full max-w-2xl gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void start();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="What do you want to learn?"
|
||||
autoFocus
|
||||
className="h-11"
|
||||
/>
|
||||
<Button type="submit" disabled={!topic.trim() || busy} className="h-11">
|
||||
{busy ? "Starting…" : "Start"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<QuestionData>(() => {
|
||||
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"}
|
||||
</div>
|
||||
<div className="mb-3 font-medium">{qd.question}</div>
|
||||
{qd.context && <div className="mb-3 text-sm text-muted-foreground">{qd.context}</div>}
|
||||
<div className="mb-3 font-medium">
|
||||
<MessageResponse>{qd.question}</MessageResponse>
|
||||
</div>
|
||||
{qd.context && (
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
<MessageResponse>{qd.context}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qd.mode !== "text" && (
|
||||
<div className="flex flex-col gap-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
|
||||
@@ -154,7 +163,7 @@ export function QuizCard({ toolName, toolCallId, input, onSubmit }: QuizCardProp
|
||||
{isSel && <span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>{qd.mode === "multi-select" ? "✓" : ""}</span>}
|
||||
</span>
|
||||
<span>
|
||||
{opt.label}
|
||||
<MessageResponse>{opt.label}</MessageResponse>
|
||||
{opt.description && <span className="block text-xs text-muted-foreground">{opt.description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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<ChatSummary[] | null>(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 (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" aria-label="Open sessions" onClick={() => setOpen(true)}>
|
||||
<MenuIcon className="size-5" />
|
||||
</Button>
|
||||
|
||||
{/* backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-black/50 transition-opacity",
|
||||
open ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* panel */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r bg-background transition-transform duration-200",
|
||||
open ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("/")}
|
||||
className="font-semibold tracking-tight hover:opacity-80"
|
||||
>
|
||||
Learning Harness
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" aria-label="Close sessions" onClick={() => setOpen(false)}>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{chats === null ? (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground">Loading…</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
|
||||
) : (
|
||||
chats.map((c) => {
|
||||
const active = pathname === `/chat/${c.id}`;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => go(`/chat/${c.id}`)}
|
||||
className={cn(
|
||||
"mb-0.5 w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
|
||||
active && "bg-accent font-medium",
|
||||
)}
|
||||
>
|
||||
<div className="truncate">{c.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{c.messageCount} messages · {new Date(c.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user