/** * SessionView — the teaching session at /s/. * * Two phases: * 1. boot — if this file isn't the server's active session, POST resume * first ("Opening session…"); fresh page loads and cross-session * navigation land here. * 2. live — useSession owns the WS + state machine; this component renders * the transcript (entries + live streaming block + pending quiz card) * and the composer. */ import { useEffect, useState } from "react"; import type { DisplayEntry, SessionState } from "../useSession.ts"; import { useSession } from "../useSession.ts"; import { Markdown } from "../components/Markdown.tsx"; import { AssistantMessage, ThinkingBlock, ToolChips, UserMessage } from "../components/Message.tsx"; import { QuizCard } from "../components/QuizCard.tsx"; import { AnsweredCard } from "../components/AnsweredCard.tsx"; import { Transcript } from "../components/Transcript.tsx"; interface SessionViewProps { file: string; active: boolean; onActivated: (file: string) => void; onHome: () => void; } /** User messages already rendered as answer cards — don't duplicate them. */ const HIDDEN_USER_PREFIXES = ["[Quiz answer]", "[Answer to your question]"]; function EntryBlock({ entry }: { entry: DisplayEntry }) { switch (entry.type) { case "user": if (HIDDEN_USER_PREFIXES.some((p) => entry.text.startsWith(p))) return null; return ; case "assistant": return ; case "toolResult": return
{entry.text}
; case "question": // Rendered by the pending-question flow; when answered, the // questionAnswered entry renders instead. return null; case "questionAnswered": return ; default: return null; } } export function SessionView({ file, active, onActivated, onHome }: SessionViewProps) { // ── boot: make sure the server has this session active ───────────────── const [boot, setBoot] = useState<"resuming" | "ready" | "error">(active ? "ready" : "resuming"); const [bootError, setBootError] = useState(null); useEffect(() => { if (active) { setBoot("ready"); return; } let cancelled = false; resumeOnce(file) .then(() => { if (cancelled) return; onActivated(file); setBoot("ready"); }) .catch((err) => { if (cancelled) return; setBootError(String(err instanceof Error ? err.message : err)); setBoot("error"); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [file, active]); if (boot !== "ready") { return (
{boot === "error" ? ( {bootError} ) : ( <> opening session… )}
{boot === "error" ? bootError : "Opening session…"}
); } return ; } // ─── live phase ───────────────────────────────────────────────────────────── const resumePromises = new Map>(); function resumeOnce(file: string): Promise { let p = resumePromises.get(file); if (!p) { p = fetch(`/api/sessions/${encodeURIComponent(file)}/resume`, { method: "POST" }) .then((res) => { if (!res.ok) throw new Error(`could not open session (${res.status})`); }) // don't cache failures — a remount should be able to retry .catch((err) => { resumePromises.delete(file); throw err; }); resumePromises.set(file, p); } return p; } function SessionLive({ onHome }: { onHome: () => void }) { const { state, sendPrompt, sendAnswer, sendDismiss, sendAbort } = useSession(); const [input, setInput] = useState(""); const submit = () => { const text = input.trim(); if (!text || state.streaming || state.pending !== null || state.status !== "open") return; sendPrompt(text); setInput(""); }; const inputDisabled = state.streaming || state.pending !== null || state.status !== "open"; const placeholder = state.status !== "open" ? "Reconnecting…" : state.pending !== null ? "Answer the question above first…" : state.streaming ? "Teaching…" : "Reply to the teacher…"; const statusLabel = state.status === "connecting" ? "connecting…" : state.status === "reconnecting" ? "reconnecting…" : state.streaming ? "teaching…" : state.pending !== null ? "waiting for your answer" : "ready"; const scrollToken = state.entries.length + state.liveText.length + state.liveThinking.length + (state.pending?.id ?? ""); return (
{" "} {statusLabel}
{state.streaming && ( )}
{state.entries.map((entry) => ( ))} {(state.liveThinking || state.liveText) && (
{state.liveText && (
{state.streaming && }
)}
)} {state.pending && ( { if (msg && typeof msg === "object" && (msg as { type?: string }).type === "answer") { sendAnswer(msg as Record); } else if (msg && typeof msg === "object" && (msg as { type?: string }).type === "dismiss") { sendDismiss(String((msg as { questionId?: string }).questionId ?? "")); } }} /> )} {state.error &&
{state.error}
}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submit(); }} /> {state.streaming ? ( ) : ( )}
); }