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:
Amos
2026-08-22 03:23:11 +03:00
parent a72c902d30
commit 46d71380fa
14 changed files with 302 additions and 72 deletions
+15 -6
View File
@@ -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>