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:
Amos
2026-08-22 00:57:16 +03:00
parent 25063b2a3e
commit 296cc1b7b6
14 changed files with 1485 additions and 441 deletions
+7 -6
View File
@@ -4,10 +4,10 @@ import { GraphPlaceholder } from "../components/GraphPlaceholder.tsx";
interface HomeProps {
sessions: SessionSummary[];
onStart: (topic: string) => void;
onResume: (file: string) => void;
onStart: (topic: string) => Promise<void>;
onOpen: (file: string) => void;
onRefresh: () => void;
hasActive: boolean;
error: string | null;
}
function formatDate(iso: string): string {
@@ -24,7 +24,7 @@ function formatDate(iso: string): string {
}
}
export function Home({ sessions, onStart, onResume, onRefresh }: HomeProps) {
export function Home({ sessions, onStart, onOpen, onRefresh, error }: HomeProps) {
const [topic, setTopic] = useState("");
const [busy, setBusy] = useState(false);
@@ -52,7 +52,7 @@ export function Home({ sessions, onStart, onResume, onRefresh }: HomeProps) {
<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)}>
<button key={s.id} className="session-item" onClick={() => onOpen(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"}
@@ -75,9 +75,10 @@ export function Home({ sessions, onStart, onResume, onRefresh }: HomeProps) {
}}
/>
<button onClick={() => void submit()} disabled={busy || !topic.trim()}>
{busy ? "…" : "Start"}
{busy ? "Starting…" : "Start"}
</button>
</div>
{error && <div className="error-banner">{error}</div>}
</main>
</div>
);