v1 web interface: dispatch-mode quiz/ask, Fastify+WS server, React frontend, deterministic test suite

- extensions/core/question-core.ts: pure quiz/ask logic (normalize/shuffle/resolve/grade/format) shared by both interfaces
- extensions/core/bridge.ts: in-process server<->extension bridge (answer/dismiss/re-arm)
- extensions/quiz-web.ts + ask-web.ts: web dispatch mode — tool returns immediately, graded answer arrives as next user message; sessions resumable mid-question via lh_question/lh_question_answered custom entries (never in LLM context)
- extensions/quiz.ts: refactored onto shared core (terminal blocking mode unchanged)
- src/server/index.ts: Fastify + WebSocket; REST (list/create/resume), entry sync (diff-based, all message kinds), streaming deltas, pending-question push
- web/: Vite + React — home view (graph placeholder, session list, new-session input), session view (markdown+KaTeX+mermaid transcript, quiz cards, streaming)
- src/testing/scripted-model.ts: deterministic fake LLM (scripted model runtime injected via factory overrides)
- tests/: 48 deterministic tests (unit: question-core + bridge; integration: full pi loop with scripted model incl. resume re-arm)
- scripts/e2e.ts: live smoke test (manual, real LLM, never a gate)
- teacher/system-prompt.md: end-your-turn rule for question tools
This commit is contained in:
Amos
2026-08-22 00:19:49 +03:00
parent 9a6af9697f
commit 25063b2a3e
32 changed files with 9717 additions and 143 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>learning harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ServerMessage, SessionSummary } from "../../src/shared/protocol.ts";
import { Home } from "./views/Home.tsx";
import { SessionView } from "./views/SessionView.tsx";
export interface LiveState {
connected: boolean;
entries: import("../../src/shared/protocol.ts").ClientEntry[];
pending: import("../../src/shared/protocol.ts").ClientQuestion | null;
liveText: string;
streaming: boolean;
error: string | null;
}
export function App() {
const [view, setView] = useState<"home" | "session">("home");
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [live, setLive] = useState<LiveState>({
connected: false,
entries: [],
pending: null,
liveText: "",
streaming: false,
error: null,
});
const wsRef = useRef<WebSocket | null>(null);
const liveTextRef = useRef("");
const refreshSessions = useCallback(async () => {
try {
const res = await fetch("/api/sessions");
const data = (await res.json()) as { sessions: SessionSummary[] };
setSessions(data.sessions ?? []);
} catch {
// server down — keep whatever we have
}
}, []);
useEffect(() => {
void refreshSessions();
}, [refreshSessions]);
// ─── WebSocket lifecycle ──────────────────────────────────────────────
const connect = useCallback(() => {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
wsRef.current = ws;
ws.onopen = () => setLive((s) => ({ ...s, connected: true, error: null }));
ws.onclose = () => setLive((s) => ({ ...s, connected: false }));
ws.onerror = () => setLive((s) => ({ ...s, connected: false, error: "connection error" }));
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data as string) as ServerMessage;
switch (msg.type) {
case "hello":
setLive((s) => ({ ...s, entries: [], pending: null, liveText: "", streaming: false }));
liveTextRef.current = "";
break;
case "backfill":
setLive((s) => ({ ...s, entries: msg.entries }));
break;
case "entry":
setLive((s) => ({ ...s, entries: [...s.entries, msg.entry] }));
if (msg.entry.type === "assistant") {
liveTextRef.current = "";
setLive((s) => ({ ...s, liveText: "" }));
}
break;
case "pending":
setLive((s) => ({ ...s, pending: msg.question }));
break;
case "delta":
liveTextRef.current += msg.delta;
setLive((s) => ({ ...s, liveText: liveTextRef.current }));
break;
case "streaming":
setLive((s) => ({ ...s, streaming: msg.value }));
break;
case "error":
setLive((s) => ({ ...s, error: msg.message }));
break;
}
};
}, []);
useEffect(() => {
if (view === "session") connect();
return () => {
wsRef.current?.close();
wsRef.current = null;
};
}, [view, connect]);
const send = useCallback((msg: unknown) => {
wsRef.current?.send(JSON.stringify(msg));
}, []);
// ─── actions ──────────────────────────────────────────────────────────
const startSession = useCallback(
async (topic: string) => {
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic }),
});
if (!res.ok) {
setLive((s) => ({ ...s, error: `failed to create session (${res.status})` }));
return;
}
setView("session");
void refreshSessions();
},
[refreshSessions],
);
const resumeSession = useCallback(
async (file: string) => {
const res = await fetch(`/api/sessions/${encodeURIComponent(file)}/resume`, { method: "POST" });
if (!res.ok) {
setLive((s) => ({ ...s, error: `failed to resume session (${res.status})` }));
return;
}
setView("session");
},
[],
);
const goHome = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
setView("home");
void refreshSessions();
}, [refreshSessions]);
if (view === "home") {
return (
<Home
sessions={sessions}
onStart={startSession}
onResume={resumeSession}
onRefresh={refreshSessions}
hasActive={live.connected}
/>
);
}
return (
<SessionView
live={live}
send={send}
onHome={goHome}
/>
);
}
+588
View File
@@ -0,0 +1,588 @@
/* learning harness — minimal v1 dark theme */
* {
box-sizing: border-box;
}
:root {
--bg: #14161a;
--bg-panel: #1b1e24;
--bg-raised: #22262e;
--border: #2e333d;
--text: #d8dce4;
--text-muted: #8b93a3;
--accent: #7aa2f7;
--success: #9ece6a;
--error: #f7768e;
--warning: #e0af68;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.55;
}
button {
font: inherit;
color: inherit;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
}
button:disabled {
opacity: 0.45;
cursor: default;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #10131a;
font-weight: 600;
}
button.ghost {
background: transparent;
}
button.icon-btn {
padding: 2px 8px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 16px;
}
button.icon-btn:hover {
color: var(--text);
}
input[type="text"],
textarea {
font: inherit;
color: var(--text);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 6px;
padding: 9px 12px;
outline: none;
}
input[type="text"]:focus,
textarea:focus {
border-color: var(--accent);
}
input[type="text"]:disabled,
textarea:disabled {
opacity: 0.5;
}
/* ─── home ─────────────────────────────────────────────────────── */
.home {
display: flex;
height: 100%;
}
.sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--bg-panel);
display: flex;
flex-direction: column;
}
.sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 0 8px 8px;
}
.session-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-radius: 6px;
padding: 9px 10px;
margin-bottom: 2px;
}
.session-item:hover {
background: var(--bg-raised);
}
.session-name {
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.empty {
color: var(--text-muted);
padding: 12px;
font-size: 13px;
}
.home-main {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px;
gap: 20px;
min-width: 0;
}
.graph-placeholder {
flex: 1;
border: 1px dashed var(--border);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-panel);
}
.graph-placeholder-inner {
text-align: center;
}
.graph-placeholder-title {
font-size: 17px;
font-weight: 600;
color: var(--text-muted);
}
.graph-placeholder-sub {
font-size: 13px;
color: var(--text-muted);
opacity: 0.7;
margin-top: 6px;
}
.new-session {
display: flex;
gap: 10px;
}
.new-session input {
flex: 1;
}
/* ─── session view ─────────────────────────────────────────────── */
.session {
display: flex;
flex-direction: column;
height: 100%;
}
.session-head {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.session-head-status {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.dot.on {
background: var(--accent);
animation: pulse 1.2s infinite;
}
.dot.ok {
background: var(--success);
}
.dot.q {
background: var(--warning);
}
.dot.off {
background: var(--text-muted);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.abort-btn {
border-color: var(--error);
color: var(--error);
background: transparent;
padding: 4px 12px;
font-size: 13px;
}
.transcript {
flex: 1;
overflow-y: auto;
padding: 20px 0;
}
.msg {
padding: 4px 24px;
margin-bottom: 14px;
}
.msg.user {
display: flex;
justify-content: flex-end;
}
.msg.user .msg-bubble {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 12px 12px 4px 12px;
padding: 10px 14px;
max-width: 72%;
white-space: pre-wrap;
}
.msg.assistant {
max-width: 820px;
}
.tool-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 12px;
font-size: 12.5px;
margin-bottom: 10px;
margin-right: 8px;
color: var(--text-muted);
}
.tool-name {
color: var(--accent);
font-weight: 600;
}
.tool-arg {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 420px;
}
.tool-result {
padding: 2px 24px;
font-size: 12.5px;
color: var(--text-muted);
font-style: italic;
max-width: 820px;
}
.composer {
display: flex;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid var(--border);
background: var(--bg-panel);
}
.composer input {
flex: 1;
}
.error-banner {
margin: 10px 24px;
padding: 9px 14px;
border: 1px solid var(--error);
border-radius: 6px;
color: var(--error);
font-size: 13px;
}
/* ─── markdown ─────────────────────────────────────────────────── */
.markdown p {
margin: 0 0 12px;
}
.markdown h1,
.markdown h2,
.markdown h3 {
margin: 20px 0 10px;
line-height: 1.3;
}
.markdown h1 {
font-size: 22px;
}
.markdown h2 {
font-size: 19px;
}
.markdown h3 {
font-size: 16.5px;
}
.markdown ul,
.markdown ol {
margin: 0 0 12px;
padding-left: 26px;
}
.markdown li {
margin-bottom: 4px;
}
.markdown code {
background: var(--bg-raised);
border-radius: 4px;
padding: 1px 5px;
font-size: 13px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.markdown pre {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
overflow-x: auto;
}
.markdown pre code {
background: none;
padding: 0;
}
.markdown blockquote {
border-left: 3px solid var(--border);
margin: 0 0 12px;
padding: 2px 14px;
color: var(--text-muted);
}
.markdown table {
border-collapse: collapse;
margin-bottom: 12px;
}
.markdown th,
.markdown td {
border: 1px solid var(--border);
padding: 6px 12px;
}
.markdown .katex-display {
overflow-x: auto;
padding: 4px 0;
}
.mermaid {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
display: flex;
justify-content: center;
overflow-x: auto;
}
.mermaid svg {
max-width: 100%;
height: auto;
}
/* ─── quiz cards ───────────────────────────────────────────────── */
.quiz-card {
max-width: 680px;
margin: 10px 24px 18px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
}
.quiz-card.quiz {
border-color: #3b4252;
}
.quiz-card.answered {
opacity: 0.92;
}
.quiz-kind {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
}
.quiz-card.ask .quiz-kind {
color: var(--warning);
}
.quiz-question {
font-size: 16px;
font-weight: 600;
}
.quiz-context {
color: var(--text-muted);
font-size: 13.5px;
margin-top: 6px;
}
.quiz-options {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.quiz-option {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
background: var(--bg-raised);
}
.quiz-option:hover {
border-color: var(--accent);
}
.quiz-option.selected {
border-color: var(--accent);
}
.quiz-option.static {
cursor: default;
background: transparent;
}
.quiz-option.static.correct {
border-color: var(--success);
}
.quiz-option.static.wrong {
border-color: var(--error);
}
.quiz-option input {
margin-top: 3px;
accent-color: var(--accent);
}
.quiz-option-label {
display: block;
}
.quiz-option-desc {
display: block;
color: var(--text-muted);
font-size: 12.5px;
margin-top: 2px;
}
.quiz-mark {
width: 18px;
flex-shrink: 0;
text-align: center;
font-weight: 700;
}
.quiz-option.static.correct .quiz-mark {
color: var(--success);
}
.quiz-option.static.wrong .quiz-mark {
color: var(--error);
}
.quiz-option.dont-know {
border-style: dashed;
color: var(--text-muted);
}
.quiz-option.dont-know.selected {
color: var(--warning);
border-color: var(--warning);
}
.quiz-other,
.quiz-text {
margin-top: 8px;
width: 100%;
}
.quiz-note {
margin-top: 10px;
width: 100%;
resize: vertical;
font-size: 13px;
}
.quiz-actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
.quiz-verdict {
margin-top: 12px;
font-weight: 700;
}
.quiz-verdict.correct {
color: var(--success);
}
.quiz-verdict.wrong {
color: var(--error);
}
.quiz-verdict.dont-know {
color: var(--warning);
}
.quiz-note-display {
margin-top: 8px;
color: var(--text-muted);
font-size: 13.5px;
}
.quiz-explanation {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
color: var(--text);
font-size: 14px;
}
.quiz-text-answer {
margin-top: 10px;
padding: 8px 12px;
background: var(--bg-raised);
border-radius: 6px;
}
+72
View File
@@ -0,0 +1,72 @@
import type { QuestionAnswerData } from "../../../src/shared/protocol.ts";
/**
* AnsweredCard — static rendering of a question that has been answered.
* All the grading data (correct indices, explanation) is in the answer record
* (it lived in a custom entry, which never entered LLM context).
*/
export function AnsweredCard({ data }: { data: QuestionAnswerData }) {
if (data.dismissed) {
return (
<div className="quiz-card answered dismissed">
<div className="quiz-kind">{data.kind === "quiz" ? "Quiz" : "Question"}</div>
<div className="quiz-question">{data.questionText}</div>
<div className="quiz-verdict dont-know">Skipped</div>
</div>
);
}
const selectedSet = new Set(data.answers.map((a) => a.index));
const correctSet = new Set(data.correctIndices ?? []);
return (
<div className={`quiz-card answered ${data.kind}`}>
<div className="quiz-kind">{data.kind === "quiz" ? "Quiz" : "Question"}</div>
<div className="quiz-question">{data.questionText}</div>
{data.options && data.options.length > 0 && (
<div className="quiz-options">
{data.options.map((opt) => {
const isSelected = selectedSet.has(opt.index);
const isKey = correctSet.has(opt.index);
let cls = "";
if (data.dontKnow) cls = isKey ? "correct" : "";
else if (isSelected && isKey) cls = "correct selected";
else if (isSelected) cls = "wrong";
else if (isKey) cls = "correct";
const mark = isSelected ? (isKey || data.dontKnow ? "✓" : "✗") : isKey ? "✓" : "";
return (
<div key={opt.index} className={`quiz-option static ${cls}`}>
<span className="quiz-mark">{mark}</span>
<span className="quiz-option-label">
{opt.index}. {opt.label}
</span>
</div>
);
})}
{data.other && (
<div className="quiz-option static">
<span className="quiz-mark">✓</span>
<span className="quiz-option-label">Other: {data.other}</span>
</div>
)}
</div>
)}
{(data.text || (!data.options?.length && !data.answers.length)) && data.text && (
<div className="quiz-text-answer">{data.text}</div>
)}
{data.other && !data.options?.length && <div className="quiz-text-answer">Other: {data.other}</div>}
{data.kind === "quiz" && (
<div className={`quiz-verdict ${data.dontKnow ? "dont-know" : data.correct ? "correct" : "wrong"}`}>
{data.dontKnow ? "I don't know" : data.correct ? "Correct" : "Incorrect"}
</div>
)}
{data.note && <div className="quiz-note-display">Note: {data.note}</div>}
{data.kind === "quiz" && data.explanation && (
<div className="quiz-explanation">{data.explanation}</div>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* GraphPlaceholder — the home view's main panel. Deliberately isolated: the
* real knowledge graph (territory map, domain clusters, frontier) is a
* separate research/planning phase and will replace ONLY this component.
*/
export function GraphPlaceholder() {
return (
<div className="graph-placeholder">
<div className="graph-placeholder-inner">
<div className="graph-placeholder-title">Knowledge graph</div>
<div className="graph-placeholder-sub">
Coming in phase 2 — the territory map, repairs, and unlock views.
</div>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { memo, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
/**
* Markdown — renders assistant prose with LaTeX (KaTeX) and mermaid diagrams
* (the teacher's Phase-2 dependency maps are mermaid code blocks).
*/
let mermaidPromise: Promise<typeof import("mermaid")> | null = null;
function getMermaid() {
if (!mermaidPromise) {
mermaidPromise = import("mermaid").then((m) => {
m.default.initialize({ startOnLoad: false, theme: "dark" });
return m;
});
}
return mermaidPromise;
}
function MermaidBlock({ code }: { code: string }) {
const ref = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const mermaid = await getMermaid();
const { svg } = await mermaid.default.render(`mmd-${Math.random().toString(36).slice(2)}`, code);
if (!cancelled && ref.current) ref.current.innerHTML = svg;
} catch (e) {
if (!cancelled) setError(String(e));
}
})();
return () => {
cancelled = true;
};
}, [code]);
if (error) {
return (
<pre className="mermaid-error">
<code>{code}</code>
</pre>
);
}
return <div className="mermaid" ref={ref} />;
}
export const Markdown = memo(function Markdown({ text }: { text: string }) {
return (
<div className="markdown">
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ className, children, ...props }) {
const lang = /language-(\w+)/.exec(className ?? "")?.[1];
const raw = String(children).replace(/\n$/, "");
if (lang === "mermaid") return <MermaidBlock code={raw} />;
return (
<code className={className} {...props}>
{children}
</code>
);
},
pre({ children }) {
const child = children as { props?: { className?: string } };
if (child?.props?.className?.includes("language-mermaid")) return <>{children}</>;
return <pre>{children}</pre>;
},
}}
/>
</div>
);
});
+155
View File
@@ -0,0 +1,155 @@
import { useMemo, useState } from "react";
import type { ClientQuestion } from "../../../src/shared/protocol.ts";
interface QuizCardProps {
question: ClientQuestion;
send: (msg: unknown) => void;
}
/**
* QuizCard — the interactive answer UI for a pending quiz/ask question.
* Mirrors the terminal quiz semantics: single/multi-select, automatic
* "I don't know" (quiz only), "Other" free text (ask only), optional note.
* Grading happens server-side; the graded card arrives afterwards as a
* questionAnswered entry.
*/
export function QuizCard({ question, send }: QuizCardProps) {
const multi = question.mode === "multi-select";
const isQuiz = question.kind === "quiz";
const isText = question.mode === "text";
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dontKnow, setDontKnow] = useState(false);
const [other, setOther] = useState("");
const [showOther, setShowOther] = useState(false);
const [text, setText] = useState("");
const [note, setNote] = useState("");
const canSubmit = useMemo(() => {
if (dontKnow) return true;
if (isText) return text.trim().length > 0;
if (showOther && other.trim()) return true;
return selected.size > 0;
}, [dontKnow, isText, text, showOther, other, selected]);
const toggle = (value: string) => {
setDontKnow(false);
setSelected((prev) => {
const next = new Set(multi ? prev : undefined);
if (multi) {
if (next.has(value)) next.delete(value);
else next.add(value);
} else {
next.clear();
next.add(value);
}
return next;
});
};
const submit = () => {
if (!canSubmit) return;
send({
type: "answer",
questionId: question.id,
dontKnow,
values: [...selected],
other: showOther && other.trim() ? other.trim() : undefined,
text: isText ? text.trim() : undefined,
note: note.trim() || undefined,
});
};
const dismiss = () => {
send({ type: "dismiss", questionId: question.id });
};
return (
<div className={`quiz-card ${isQuiz ? "quiz" : "ask"}`}>
<div className="quiz-kind">{isQuiz ? "Quiz" : "Question"}</div>
<div className="quiz-question">{question.question}</div>
{question.context && <div className="quiz-context">{question.context}</div>}
{isText ? (
<textarea
className="quiz-text"
placeholder="Type your answer…"
value={text}
rows={3}
onChange={(e) => setText(e.target.value)}
/>
) : (
<div className="quiz-options">
{question.options.map((opt) => (
<label key={opt.value} className={`quiz-option ${selected.has(opt.value) ? "selected" : ""}`}>
<input
type={multi ? "checkbox" : "radio"}
name={question.id}
checked={selected.has(opt.value)}
onChange={() => toggle(opt.value)}
/>
<span className="quiz-option-label">
{opt.label}
{opt.description && <span className="quiz-option-desc">{opt.description}</span>}
</span>
</label>
))}
{!isQuiz && (
<label className={`quiz-option ${showOther ? "selected" : ""}`}>
<input
type={multi ? "checkbox" : "radio"}
name={question.id}
checked={showOther}
onChange={() => {
setDontKnow(false);
setShowOther((v) => !v);
}}
/>
<span className="quiz-option-label">Other</span>
</label>
)}
{showOther && (
<input
type="text"
className="quiz-other"
placeholder="Your custom answer…"
value={other}
onChange={(e) => setOther(e.target.value)}
/>
)}
</div>
)}
{isQuiz && (
<label className={`quiz-option dont-know ${dontKnow ? "selected" : ""}`}>
<input
type="checkbox"
checked={dontKnow}
onChange={() => {
setDontKnow((v) => !v);
if (!dontKnow) setSelected(new Set());
}}
/>
<span className="quiz-option-label">I don&apos;t know</span>
</label>
)}
<textarea
className="quiz-note"
placeholder="Note (optional) — what you were thinking or unsure about"
value={note}
rows={2}
onChange={(e) => setNote(e.target.value)}
/>
<div className="quiz-actions">
<button className="primary" onClick={submit} disabled={!canSubmit}>
{dontKnow ? "I don't know" : "Submit"}
</button>
<button className="ghost" onClick={dismiss}>
Skip
</button>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";
import "./app.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+84
View File
@@ -0,0 +1,84 @@
import { useState } from "react";
import type { SessionSummary } from "../../../src/shared/protocol.ts";
import { GraphPlaceholder } from "../components/GraphPlaceholder.tsx";
interface HomeProps {
sessions: SessionSummary[];
onStart: (topic: string) => void;
onResume: (file: string) => void;
onRefresh: () => void;
hasActive: boolean;
}
function formatDate(iso: string): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
export function Home({ sessions, onStart, onResume, onRefresh }: HomeProps) {
const [topic, setTopic] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => {
const t = topic.trim();
if (!t || busy) return;
setBusy(true);
try {
await onStart(t);
} finally {
setBusy(false);
setTopic("");
}
};
return (
<div className="home">
<aside className="sidebar">
<div className="sidebar-head">
<span>Sessions</span>
<button className="icon-btn" title="Refresh" onClick={onRefresh}>
↻
</button>
</div>
<div className="session-list">
{sessions.length === 0 && <div className="empty">No sessions yet</div>}
{sessions.map((s) => (
<button key={s.id} className="session-item" onClick={() => onResume(s.file)}>
<div className="session-name">{s.name || s.preview || "Untitled"}</div>
<div className="session-meta">
{formatDate(s.updatedAt)} · {s.messageCount} msg{s.messageCount === 1 ? "" : "s"}
</div>
</button>
))}
</div>
</aside>
<main className="home-main">
<GraphPlaceholder />
<div className="new-session">
<input
type="text"
placeholder="What do you want to learn?"
value={topic}
disabled={busy}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
/>
<button onClick={() => void submit()} disabled={busy || !topic.trim()}>
{busy ? "…" : "Start"}
</button>
</div>
</main>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import type { ClientEntry } from "../../../src/shared/protocol.ts";
import type { LiveState } from "../App.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { QuizCard } from "../components/QuizCard.tsx";
import { AnsweredCard } from "../components/AnsweredCard.tsx";
interface SessionViewProps {
live: LiveState;
send: (msg: unknown) => void;
onHome: () => void;
}
/** User messages that are already rendered as answer cards — don't duplicate. */
const HIDDEN_USER_PREFIXES = ["[Quiz answer]", "[Answer to your question]"];
function EntryBlock({ entry }: { entry: ClientEntry }) {
switch (entry.type) {
case "user":
if (HIDDEN_USER_PREFIXES.some((p) => entry.text.startsWith(p))) return null;
return (
<div className="msg user">
<div className="msg-bubble">{entry.text}</div>
</div>
);
case "assistant":
return (
<div className="msg assistant">
{entry.toolCalls.map((tc, i) => (
<div key={i} className="tool-chip">
<span className="tool-name">{tc.name}</span>
{typeof tc.args.question === "string" && (
<span className="tool-arg"> {tc.args.question}</span>
)}
</div>
))}
{entry.text && <Markdown text={entry.text} />}
</div>
);
case "toolResult":
return <div className="tool-result">{entry.text}</div>;
case "question":
// Rendered by the pending-question flow below (cards need answer state);
// when answered, the questionAnswered entry renders instead.
return null;
case "questionAnswered":
return <AnsweredCard data={entry.data} />;
default:
return null;
}
}
export function SessionView({ live, send, onHome }: SessionViewProps) {
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [live.entries.length, live.liveText, live.pending?.id]);
const submit = () => {
const text = input.trim();
if (!text || live.streaming || live.pending !== null) return;
send({ type: "prompt", text });
setInput("");
};
return (
<div className="session">
<header className="session-head">
<button className="icon-btn" onClick={onHome} title="Back">
←
</button>
<div className="session-head-status">
{!live.connected ? (
<>
<span className="dot off" /> connecting…
</>
) : live.streaming ? (
<>
<span className="dot on" /> teaching…
</>
) : live.pending !== null ? (
<>
<span className="dot q" /> waiting for your answer
</>
) : (
<>
<span className="dot ok" /> ready
</>
)}
</div>
{live.streaming && (
<button className="abort-btn" onClick={() => send({ type: "abort" })}>
Stop
</button>
)}
</header>
<div className="transcript">
{live.entries.map((entry) => (
<EntryBlock key={entry.id} entry={entry} />
))}
{live.liveText && (
<div className="msg assistant">
<Markdown text={live.liveText} />
</div>
)}
{live.pending && (
<QuizCard
question={live.pending}
send={send}
/>
)}
{live.error && <div className="error-banner">{live.error}</div>}
<div ref={bottomRef} />
</div>
<div className="composer">
<input
type="text"
placeholder={
live.pending !== null
? "Answer the question above first…"
: live.streaming
? "Teaching…"
: "Reply to the teacher…"
}
value={input}
disabled={live.streaming || live.pending !== null}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
/>
<button onClick={submit} disabled={live.streaming || live.pending !== null || !input.trim()}>
Send
</button>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "../src/shared/protocol.ts"]
}