v2 web interface: fix delivery races, real URLs, optimistic echo, thinking stream, button-based quiz UI
Root causes of the v1 browser bugs (all fixed + covered by deterministic tests): - duplicated messages: per-socket snapshot + global broadcast marking raced; replaced with a single append-only entry log (exactly-once per socket, structurally) — covered by tests/server.test.ts - delayed/missing echo of own messages: optimistic append with sending/failed status, reconciled by text when the real entry arrives - no thinking shown: thinking deltas streamed live (kind: thinking) and persisted in assistant entries so they survive reloads - ask answers graded as wrong: AnsweredCard no longer applies quiz grading to ask answers (no ✓/✗, no verdict) - broken option selection: native radio/checkbox inputs fighting React state replaced with button-based options (single source of truth, one-click select) - no URLs: History API routing — / home, /s/<file> per session (deep links, refresh, back button); session view resumes its file on mount - lost connection = dead UI: exponential-backoff reconnect with full resync - stick-to-bottom transcript with jump-to-latest pill Server: startServer() with injectable runtime factory (scripted model in tests); createSession returns file for routing; scripted model gained thinking turns. 51 deterministic tests green.
This commit is contained in:
+166
-75
@@ -1,47 +1,46 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ClientEntry } from "../../../src/shared/protocol.ts";
|
||||
import type { LiveState } from "../App.tsx";
|
||||
/**
|
||||
* SessionView — the teaching session at /s/<file>.
|
||||
*
|
||||
* 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 {
|
||||
live: LiveState;
|
||||
send: (msg: unknown) => void;
|
||||
file: string;
|
||||
active: boolean;
|
||||
onActivated: (file: string) => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/** User messages that are already rendered as answer cards — don't duplicate. */
|
||||
/** 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: ClientEntry }) {
|
||||
function EntryBlock({ entry }: { entry: DisplayEntry }) {
|
||||
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>
|
||||
);
|
||||
return <UserMessage text={entry.text} status={entry.status} />;
|
||||
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>
|
||||
);
|
||||
return <AssistantMessage text={entry.text} thinking={entry.thinking} toolCalls={entry.toolCalls} />;
|
||||
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.
|
||||
// Rendered by the pending-question flow; when answered, the
|
||||
// questionAnswered entry renders instead.
|
||||
return null;
|
||||
case "questionAnswered":
|
||||
return <AnsweredCard data={entry.data} />;
|
||||
@@ -50,21 +49,117 @@ function EntryBlock({ entry }: { entry: ClientEntry }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionView({ live, send, onHome }: SessionViewProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(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<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [live.entries.length, live.liveText, live.pending?.id]);
|
||||
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 (
|
||||
<div className="session">
|
||||
<header className="session-head">
|
||||
<button className="icon-btn" onClick={onHome} title="Back">
|
||||
←
|
||||
</button>
|
||||
<div className="session-head-status">
|
||||
{boot === "error" ? (
|
||||
<span className="boot-error">{bootError}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="dot on" /> opening session…
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="boot-screen">{boot === "error" ? bootError : "Opening session…"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SessionLive onHome={onHome} />;
|
||||
}
|
||||
|
||||
// ─── live phase ─────────────────────────────────────────────────────────────
|
||||
|
||||
const resumePromises = new Map<string, Promise<void>>();
|
||||
|
||||
function resumeOnce(file: string): Promise<void> {
|
||||
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 || live.streaming || live.pending !== null) return;
|
||||
send({ type: "prompt", text });
|
||||
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 (
|
||||
<div className="session">
|
||||
<header className="session-head">
|
||||
@@ -72,70 +167,66 @@ export function SessionView({ live, send, onHome }: SessionViewProps) {
|
||||
←
|
||||
</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
|
||||
</>
|
||||
)}
|
||||
<span className={`dot ${state.status === "open" ? (state.streaming ? "on" : "ok") : "off"}`} />{" "}
|
||||
{statusLabel}
|
||||
</div>
|
||||
{live.streaming && (
|
||||
<button className="abort-btn" onClick={() => send({ type: "abort" })}>
|
||||
{state.streaming && (
|
||||
<button className="abort-btn" onClick={sendAbort}>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="transcript">
|
||||
{live.entries.map((entry) => (
|
||||
<Transcript scrollToken={scrollToken}>
|
||||
{state.entries.map((entry) => (
|
||||
<EntryBlock key={entry.id} entry={entry} />
|
||||
))}
|
||||
{live.liveText && (
|
||||
<div className="msg assistant">
|
||||
<Markdown text={live.liveText} />
|
||||
{(state.liveThinking || state.liveText) && (
|
||||
<div className="msg assistant live">
|
||||
<ThinkingBlock text={state.liveThinking} streaming={state.streaming} />
|
||||
{state.liveText && (
|
||||
<div className="live-text">
|
||||
<Markdown text={state.liveText} />
|
||||
{state.streaming && <span className="stream-cursor" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{live.pending && (
|
||||
{state.pending && (
|
||||
<QuizCard
|
||||
question={live.pending}
|
||||
send={send}
|
||||
question={state.pending}
|
||||
send={(msg) => {
|
||||
if (msg && typeof msg === "object" && (msg as { type?: string }).type === "answer") {
|
||||
sendAnswer(msg as Record<string, unknown>);
|
||||
} else if (msg && typeof msg === "object" && (msg as { type?: string }).type === "dismiss") {
|
||||
sendDismiss(String((msg as { questionId?: string }).questionId ?? ""));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{live.error && <div className="error-banner">{live.error}</div>}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{state.error && <div className="error-banner">{state.error}</div>}
|
||||
</Transcript>
|
||||
|
||||
<div className="composer">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
live.pending !== null
|
||||
? "Answer the question above first…"
|
||||
: live.streaming
|
||||
? "Teaching…"
|
||||
: "Reply to the teacher…"
|
||||
}
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
disabled={live.streaming || live.pending !== null}
|
||||
disabled={inputDisabled}
|
||||
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>
|
||||
{state.streaming ? (
|
||||
<button className="abort" onClick={sendAbort}>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={submit} disabled={inputDisabled || !input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user