From 296cc1b7b6e946dbda1d9fd50db77c002fa4ab0c Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 00:57:16 +0300 Subject: [PATCH] v2 web interface: fix delivery races, real URLs, optimistic echo, thinking stream, button-based quiz UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- README.md | 2 +- src/server/index.ts | 389 +++++++++++++++++----------- src/shared/protocol.ts | 5 +- src/testing/scripted-model.ts | 14 +- tests/server.test.ts | 330 +++++++++++++++++++++++ web/src/App.tsx | 191 ++++++-------- web/src/app.css | 206 +++++++++++++++ web/src/components/AnsweredCard.tsx | 52 ++-- web/src/components/Message.tsx | 90 +++++++ web/src/components/QuizCard.tsx | 159 +++++++----- web/src/components/Transcript.tsx | 43 +++ web/src/useSession.ts | 191 ++++++++++++++ web/src/views/Home.tsx | 13 +- web/src/views/SessionView.tsx | 241 +++++++++++------ 14 files changed, 1485 insertions(+), 441 deletions(-) create mode 100644 tests/server.test.ts create mode 100644 web/src/components/Message.tsx create mode 100644 web/src/components/Transcript.tsx create mode 100644 web/src/useSession.ts diff --git a/README.md b/README.md index 48be3f7..ab7e46a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ npm run graph # ledger stats Three layers, LLM only at the top: 1. **Unit** (`tests/question-core.test.ts`, `tests/bridge.test.ts`) — pure logic: grading, exact-set matching, option shuffling/index resolution, answer formatting, pending-question re-arm, the bridge state machine. No pi, no network. -2. **Scripted integration** (`tests/integration-quiz.test.ts`) — the full pi agent loop with real extensions and real session persistence, but the LLM replaced by a scripted fake model (`src/testing/scripted-model.ts`). Deterministically verifies the quiz dispatch contract: question persisted → tool returns immediately → agent ends turn → answer graded → `[Quiz answer]` user message → agent continues → resume re-arms pending questions. +2. **Scripted integration** (`tests/integration-quiz.test.ts`, `tests/server.test.ts`) — the full pi agent loop with real extensions and real session persistence, but the LLM replaced by a scripted fake model (`src/testing/scripted-model.ts`). Deterministically verifies the quiz dispatch contract: question persisted → tool returns immediately → agent ends turn → answer graded → `[Quiz answer]` user message → agent continues → resume re-arms pending questions. 3. **Live smoke** (`scripts/e2e.ts`) — real model over real WebSocket. For verifying actual model *behavior* (does it follow the end-your-turn convention?) — which is a prompt-engineering question, verified by using the app, not by CI. ## Layout diff --git a/src/server/index.ts b/src/server/index.ts index f110991..b514356 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,13 +3,22 @@ * pi SDK (web dispatch mode: quiz/ask return immediately, answers arrive as * user messages — nothing blocks, so sessions can be exited/resumed mid-question). * - * v1 scope (deliberately minimal): - * - one active session at a time - * - REST: list/create/resume sessions - * - WS: entry stream (backfill + live), streaming deltas, pending question, - * prompt / answer / dismiss / abort from the client + * Delivery model (the part that must never double-send or drop): + * - ONE append-only entry log per active session (`entryLog`), plus a cursor + * (`syncedCount`) into the session manager's entries. + * - Every entry enters the log exactly once — either at activation (seeded + * from history, no broadcast) or live via syncEntries (broadcast to all + * connected sockets). + * - A socket connecting at ANY time receives the whole log as backfill. + * There is no per-socket marking and no window in which an entry can be + * both backfilled and broadcast — the duplication race from v1 is + * structurally impossible now. * - * NOT wired yet: extraction pipeline (post-session), graph queries. + * - Streaming: thinking and text deltas are forwarded as they happen; + * ephemeral (a socket that connects mid-turn gets `streaming: true` in its + * snapshot and the entries when they land). + * + * v1 scope: one active session at a time; extraction pipeline NOT wired yet. */ import fs from "node:fs"; @@ -18,10 +27,10 @@ import Fastify from "fastify"; import fastifyWebsocket from "@fastify/websocket"; import fastifyStatic from "@fastify/static"; import { - AgentSessionRuntime, + type AgentSessionEvent, + type AgentSessionRuntimeDiagnostic, createAgentSessionRuntime, SessionManager, - type AgentSessionEvent, } from "@earendil-works/pi-coding-agent"; import { PATHS } from "../../config.ts"; import { buildTeacherRuntimeFactory } from "../teacher/create-session.ts"; @@ -36,25 +45,36 @@ import { import { getBridge } from "../../extensions/core/bridge.ts"; import type { ClientEntry, ServerMessage, SessionSummary } from "../shared/protocol.ts"; -const PORT = Number(process.env.LH_PORT ?? 8787); - -// ─── active session state ─────────────────────────────────────────────────── - -interface ActiveSession { - runtime: AgentSessionRuntime; - sessionId: string; +/** What the server needs from a teacher runtime (real or scripted). */ +export interface RuntimeLike { + session: { + subscribe(listener: (evt: AgentSessionEvent) => void): () => void; + dispose(): void; + isStreaming: boolean; + prompt(text: string): Promise; + abort(): unknown; + setSessionName(name: string): unknown; + sessionManager: SessionManager; + }; + diagnostics: readonly AgentSessionRuntimeDiagnostic[]; } -let active: ActiveSession | null = null; -const sockets = new Set(); +export type RuntimeFactory = (options: { + cwd: string; + agentDir: string; + sessionManager: SessionManager; +}) => Promise; -function broadcast(msg: ServerMessage): void { - const raw = JSON.stringify(msg); - for (const ws of sockets) { - if (ws.readyState === ws.OPEN) ws.send(raw); - } +export interface ServerOptions { + /** TCP port; 0 = pick a free port (tests). Default: LH_PORT or 8787. */ + port?: number; + /** Teacher runtime factory — inject a scripted one in tests. */ + runtimeFactory?: RuntimeFactory; } +const defaultRuntimeFactory: RuntimeFactory = (options) => + createAgentSessionRuntime(buildTeacherRuntimeFactory("web"), options) as unknown as Promise; + // ─── entry serialization ──────────────────────────────────────────────────── function extractText(content: unknown[]): string { @@ -64,6 +84,15 @@ function extractText(content: unknown[]): string { .join(""); } +function extractThinking(content: unknown[]): string { + return content + .filter( + (c): c is { type: "thinking"; thinking: string } => (c as { type?: string }).type === "thinking", + ) + .map((c) => c.thinking) + .join("\n"); +} + function serializeEntry(entry: { type: string; id: string; @@ -84,7 +113,14 @@ function serializeEntry(entry: { (c as { type?: string }).type === "toolCall", ) .map((c) => ({ name: c.name, args: (c.arguments ?? {}) as Record })); - return { type: "assistant", id: entry.id, text: extractText(m.content ?? []), toolCalls }; + const thinking = extractThinking(m.content ?? []); + return { + type: "assistant", + id: entry.id, + text: extractText(m.content ?? []), + thinking: thinking || undefined, + toolCalls, + }; } if (m.role === "toolResult") { return { type: "toolResult", id: entry.id, toolName: m.toolName ?? "", text: extractText(m.content ?? []) }; @@ -104,115 +140,6 @@ function serializeEntry(entry: { return { type: "meta", id: entry.id, kind: entry.type }; } -function currentEntries(): Array<{ type: string; customType?: string; data?: unknown }> { - if (!active) return []; - return active.runtime.session.sessionManager.getEntries(); -} - -function broadcastPending(): void { - broadcast({ type: "pending", question: findPendingQuestion(currentEntries()) }); -} - -// ─── session lifecycle ────────────────────────────────────────────────────── - -// ─── entry synchronization ──────────────────────────────────────────────────────────── -// `entry_appended` in the session event stream only fires for EXTENSION-appended -// custom entries — NOT for messages pi appends itself (user prompts, assistant -// messages, tool results). So instead of relying on it, we diff the session -// manager's entries against what we have already broadcast, on every session -// event. Sessions are small; this is cheap and can not miss anything. - -const broadcastEntryIds = new Set(); - -function syncEntries(): void { - if (!active) return; - const entries = active.runtime.session.sessionManager.getEntries(); - for (const entry of entries) { - if (broadcastEntryIds.has(entry.id)) continue; - broadcastEntryIds.add(entry.id); - const client = serializeEntry(entry as never); - if (client) broadcast({ type: "entry", entry: client }); - if ( - entry.type === "custom" && - (entry.customType === QUESTION_ENTRY || entry.customType === QUESTION_ANSWERED_ENTRY) - ) { - broadcastPending(); - } - } -} - -function handleSessionEvent(evt: AgentSessionEvent): void { - if (!active) return; - switch (evt.type) { - case "message_update": { - if (evt.assistantMessageEvent?.type === "text_delta") { - broadcast({ type: "delta", delta: evt.assistantMessageEvent.delta }); - } - break; - } - case "agent_start": - broadcast({ type: "streaming", value: true }); - break; - case "agent_settled": - syncEntries(); - broadcast({ type: "streaming", value: false }); - break; - } - // Catch every appended entry (messages + custom) regardless of event type. - syncEntries(); -} - -async function activate(sessionManager: SessionManager): Promise { - if (active) { - active.runtime.session.dispose(); - active = null; - } - const createRuntime = buildTeacherRuntimeFactory("web"); - const runtime = await createAgentSessionRuntime(createRuntime, { - cwd: PATHS.root, - agentDir: PATHS.agentDir, - sessionManager, - }); - for (const d of runtime.diagnostics) { - if (d.type === "error") console.error(`[teacher] ${d.message}`); - } - runtime.session.subscribe(handleSessionEvent); - active = { runtime, sessionId: sessionManager.getSessionId() }; - broadcastEntryIds.clear(); - // Notify every client of the switch + backfill. - const rawEntries = currentEntries(); - const entries = rawEntries - .map((e) => serializeEntry(e as never)) - .filter((e): e is ClientEntry => e !== null); - // Everything backfilled here is marked as already-broadcast so a later - // syncEntries() does not re-send the session history as live entries. - for (const e of rawEntries) broadcastEntryIds.add((e as unknown as { id: string }).id); - broadcast({ type: "hello", sessionId: active.sessionId }); - broadcast({ type: "backfill", entries }); - broadcastPending(); - broadcast({ type: "streaming", value: runtime.session.isStreaming }); - return active; -} - -async function createSession(topic: string): Promise { - const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions); - const result = await activate(sessionManager); - // Name the session after the topic (shown in the session list). - const name = topic.trim().slice(0, 60) || "New session"; - result.runtime.session.setSessionName(name); - // Fire the first prompt (don't await — the client follows along via WS). - void result.runtime.session.prompt(topic).catch((err) => { - console.error("[teacher] prompt failed:", err); - broadcast({ type: "error", message: String(err) }); - }); - return result; -} - -async function resumeSession(file: string): Promise { - const sessionManager = SessionManager.open(path.join(PATHS.sessions, file), PATHS.sessions, PATHS.root); - return activate(sessionManager); -} - // ─── session listing (reads data/sessions/*.jsonl) ────────────────────────── function listSessions(): SessionSummary[] { @@ -263,7 +190,141 @@ function listSessions(): SessionSummary[] { // ─── server ───────────────────────────────────────────────────────────────── -async function main(): Promise { +export async function startServer(options: ServerOptions = {}): Promise<{ + port: number; + close: () => Promise; +}> { + const runtimeFactory = options.runtimeFactory ?? defaultRuntimeFactory; + + // Active session state. `entryLog` is the single source of truth for what + // clients have been able to see; `syncedCount` is how many session entries + // it reflects. Both reset together on activation. + interface ActiveSession { + runtime: RuntimeLike; + sessionId: string; + file: string; + } + let active: ActiveSession | null = null; + let unsubscribeEvents: (() => void) | null = null; + let entryLog: ClientEntry[] = []; + let syncedCount = 0; + + const sockets = new Set(); + + function broadcast(msg: ServerMessage): void { + const raw = JSON.stringify(msg); + for (const ws of sockets) { + if (ws.readyState === ws.OPEN) ws.send(raw); + } + } + + function broadcastPending(): void { + if (!active) return; + const pending = findPendingQuestion(active.runtime.session.sessionManager.getEntries()); + broadcast({ type: "pending", question: pending }); + } + + /** Append every not-yet-logged session entry to the log (broadcasting each). */ + function syncEntries(): void { + if (!active) return; + const entries = active.runtime.session.sessionManager.getEntries(); + for (; syncedCount < entries.length; syncedCount++) { + const entry = entries[syncedCount]!; + const client = serializeEntry(entry as never); + if (client) { + entryLog.push(client); + broadcast({ type: "entry", entry: client }); + } + if ( + entry.type === "custom" && + ((entry as { customType?: string }).customType === QUESTION_ENTRY || + (entry as { customType?: string }).customType === QUESTION_ANSWERED_ENTRY) + ) { + broadcastPending(); + } + } + } + + function handleSessionEvent(evt: AgentSessionEvent): void { + if (!active) return; + switch (evt.type) { + case "message_update": { + const e = evt.assistantMessageEvent; + if (e?.type === "text_delta") { + broadcast({ type: "delta", kind: "text", delta: e.delta }); + } else if (e?.type === "thinking_delta") { + broadcast({ type: "delta", kind: "thinking", delta: e.delta }); + } + break; + } + case "agent_start": + broadcast({ type: "streaming", value: true }); + break; + case "agent_settled": + broadcast({ type: "streaming", value: false }); + break; + } + syncEntries(); + } + + async function activate(sessionManager: SessionManager): Promise { + if (unsubscribeEvents) { + unsubscribeEvents(); + unsubscribeEvents = null; + } + if (active) { + active.runtime.session.dispose(); + active = null; + } + const runtime = await runtimeFactory({ + cwd: PATHS.root, + agentDir: PATHS.agentDir, + sessionManager, + }); + for (const d of runtime.diagnostics) { + if (d.type === "error") console.error(`[teacher] ${d.message}`); + } + unsubscribeEvents = runtime.session.subscribe(handleSessionEvent); + const file = path.basename(sessionManager.getSessionFile() ?? ""); + active = { runtime, sessionId: sessionManager.getSessionId(), file }; + + // Seed the log from history WITHOUT broadcasting: sockets that connect + // later get it via their connect-backfill; sockets already connected get + // it via the hello+backfill below. Either way, exactly once. + entryLog = sessionManager + .getEntries() + .map((e) => serializeEntry(e as never)) + .filter((e): e is ClientEntry => e !== null); + syncedCount = sessionManager.getEntries().length; + + broadcast({ type: "hello", sessionId: active.sessionId }); + broadcast({ type: "backfill", entries: entryLog }); + broadcastPending(); + broadcast({ type: "streaming", value: runtime.session.isStreaming }); + return active; + } + + async function createSession(topic: string): Promise { + const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions); + const result = await activate(sessionManager); + // Name the session after the topic (shown in the session list). + const name = topic.trim().slice(0, 60) || "New session"; + result.runtime.session.setSessionName(name); + // Fire the first prompt (don't await — clients follow along via WS). + void result.runtime.session.prompt(topic).catch((err) => { + console.error("[teacher] prompt failed:", err); + broadcast({ type: "error", message: String(err) }); + }); + return result; + } + + async function resumeSession(file: string): Promise { + const sessionManager = SessionManager.open(path.join(PATHS.sessions, file), PATHS.sessions, PATHS.root); + return activate(sessionManager); + } + + // ─── HTTP ──────────────────────────────────────────────────────────────── + const app = Fastify({ logger: false }); await app.register(fastifyWebsocket); @@ -289,7 +350,7 @@ async function main(): Promise { const topic = (req.body?.topic ?? "").trim(); if (!topic) return reply.code(400).send({ error: "topic is required" }); const result = await createSession(topic); - return { id: result.sessionId }; + return { id: result.sessionId, file: result.file }; }); app.post<{ Params: { file: string } }>("/api/sessions/:file/resume", async (req, reply) => { @@ -300,34 +361,31 @@ async function main(): Promise { } try { const result = await resumeSession(file); - return { id: result.sessionId }; + return { id: result.sessionId, file: result.file }; } catch (err) { return reply.code(500).send({ error: String(err) }); } }); + // ─── WebSocket ─────────────────────────────────────────────────────────── + app.get("/ws", { websocket: true }, (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); - // Initial state snapshot. - socket.send( - JSON.stringify({ type: "hello", sessionId: active?.sessionId ?? null } satisfies ServerMessage), - ); + // Initial snapshot: everything the session has produced so far. + socket.send(JSON.stringify({ type: "hello", sessionId: active?.sessionId ?? null } satisfies ServerMessage)); + socket.send(JSON.stringify({ type: "backfill", entries: entryLog } satisfies ServerMessage)); if (active) { - const entries = currentEntries() - .map((e) => serializeEntry(e as never)) - .filter((e): e is ClientEntry => e !== null); - socket.send(JSON.stringify({ type: "backfill", entries } satisfies ServerMessage)); - socket.send( - JSON.stringify({ type: "pending", question: findPendingQuestion(currentEntries()) } satisfies ServerMessage), - ); socket.send( JSON.stringify({ - type: "streaming", - value: active.runtime.session.isStreaming, + type: "pending", + question: findPendingQuestion(active.runtime.session.sessionManager.getEntries()), } satisfies ServerMessage), ); + socket.send( + JSON.stringify({ type: "streaming", value: active.runtime.session.isStreaming } satisfies ServerMessage), + ); } socket.on("message", (raw: Buffer) => { @@ -348,7 +406,7 @@ async function main(): Promise { if (!text) return; // One question at a time: don't accept prompts while a question // is waiting to be answered. - if (findPendingQuestion(currentEntries())) { + if (findPendingQuestion(session.sessionManager.getEntries())) { socket.send( JSON.stringify({ type: "error", @@ -395,11 +453,32 @@ async function main(): Promise { }); }); - await app.listen({ port: PORT, host: "127.0.0.1" }); - console.log(`learning-harness web server: http://127.0.0.1:${PORT}`); + const port = options.port ?? Number(process.env.LH_PORT ?? 8787); + await app.listen({ port, host: "127.0.0.1" }); + const actualPort = (app.server.address() as { port: number }).port; + + return { + port: actualPort, + close: async () => { + if (unsubscribeEvents) unsubscribeEvents(); + if (active) active.runtime.session.dispose(); + for (const ws of sockets) ws.close(); + sockets.clear(); + await app.close(); + }, + }; } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +// ─── entrypoint ───────────────────────────────────────────────────────────── + +async function main(): Promise { + const server = await startServer(); + console.log(`learning-harness web server: http://127.0.0.1:${server.port}`); +} + +if (process.argv[1] && process.argv[1].endsWith("server/index.ts")) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index f68c22d..5a412cf 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -16,7 +16,7 @@ export interface ClientToolCall { export type ClientEntry = | { type: "user"; id: string; text: string } - | { type: "assistant"; id: string; text: string; toolCalls: ClientToolCall[] } + | { type: "assistant"; id: string; text: string; thinking?: string; toolCalls: ClientToolCall[] } | { type: "toolResult"; id: string; toolName: string; text: string } | { type: "question"; id: string; data: ClientQuestion } | { type: "questionAnswered"; id: string; data: QuestionAnswerData } @@ -36,6 +36,7 @@ export interface SessionSummary { export interface NewSessionResponse { id: string; + file: string; } // ─── WS protocol ──────────────────────────────────────────────────────────── @@ -45,7 +46,7 @@ export type ServerMessage = | { type: "backfill"; entries: ClientEntry[] } | { type: "entry"; entry: ClientEntry } | { type: "pending"; question: ClientQuestion | null } - | { type: "delta"; delta: string } + | { type: "delta"; kind: "thinking" | "text"; delta: string } | { type: "streaming"; value: boolean } | { type: "error"; message: string }; diff --git a/src/testing/scripted-model.ts b/src/testing/scripted-model.ts index 95e960c..ed79406 100644 --- a/src/testing/scripted-model.ts +++ b/src/testing/scripted-model.ts @@ -21,9 +21,10 @@ export interface ScriptedToolCall { arguments: Record; } -/** One scripted assistant turn: tool calls and/or final text. */ +/** One scripted assistant turn: reasoning, tool calls, and/or final text. */ export interface ScriptedTurn { text?: string; + thinking?: string; toolCalls?: ScriptedToolCall[]; } @@ -140,6 +141,17 @@ export function createScriptedRuntime(script: ScriptedTurn[]): ScriptedRuntime { // "start" stream.push({ type: "start", partial: { ...msg, content: [] } }); + // thinking (reasoning) — split into two deltas so accumulation is tested + if (turn.thinking !== undefined && turn.thinking.length > 0) { + content.push({ type: "thinking", thinking: turn.thinking }); + const idx = content.length - 1; + stream.push({ type: "thinking_start", contentIndex: idx, partial: { ...msg, content: [...content] } }); + const mid = Math.ceil(turn.thinking.length / 2); + stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(0, mid), partial: { ...msg, content: [...content] } }); + stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(mid), partial: { ...msg, content: [...content] } }); + stream.push({ type: "thinking_end", contentIndex: idx, content: turn.thinking, partial: { ...msg, content: [...content] } }); + } + // tool calls for (const tc of turn.toolCalls ?? []) { const toolCall = { diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..12d2160 --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,330 @@ +/** + * Integration test — the web server with a SCRIPTED model runtime. + * + * Real Fastify server, real WebSocket protocol, real web extensions and + * bridge, real session files — the only fake is the LLM. This is the test + * layer that catches exactly the class of bugs reported from the browser: + * + * - exactly-once entry delivery (no duplicated user messages, ever — + * including sockets that connect after the turn already happened) + * - thinking + text deltas streamed live + * - quiz answer flow over WS + * - multiple sockets seeing the same history + * + * Deterministic: zero tokens, zero variance, runs in milliseconds. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { createAgentSessionRuntime } from "@earendil-works/pi-coding-agent"; +import { PATHS } from "../config.ts"; +import { buildTeacherRuntimeFactory } from "../src/teacher/create-session.ts"; +import { createScriptedRuntime } from "../src/testing/scripted-model.ts"; +import { startServer } from "../src/server/index.ts"; +import type { ServerMessage } from "../src/shared/protocol.ts"; + +const QUIZ_ARGS = { + question: "Which is closest to the Sun?", + options: [ + { label: "Mercury", value: "mercury" }, + { label: "Venus", value: "venus" }, + ], + correctAnswer: "mercury", + explanation: "Mercury is the innermost planet.", +}; + +let port: number; +let closeServer: () => Promise; + +/** The script for the NEXT session the server activates (tests swap it). */ +let currentScript: import("../src/testing/scripted-model.ts").ScriptedTurn[] = [ + // Turn 1: think, then call quiz. + { + thinking: "I should probe the learner's prior knowledge first.", + toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }], + }, + // Turn 2 (after the tool result): end turn. + { text: "Take a shot." }, + // Turn 3 (after the [Quiz answer] user message): wrap up. + { text: "Right — Mercury it is. Lesson over." }, +]; + +before(async () => { + const runtimeFactory = async (options: { + cwd: string; + agentDir: string; + sessionManager: import("@earendil-works/pi-coding-agent").SessionManager; + }) => { + const scripted = createScriptedRuntime(currentScript); + return createAgentSessionRuntime( + buildTeacherRuntimeFactory("web", { model: scripted.model, modelRuntime: scripted.modelRuntime }), + options, + ) as never; + }; + const server = await startServer({ port: 0, runtimeFactory }); + port = server.port; + closeServer = server.close; +}); + +after(async () => { + await closeServer(); + // The tests created real session files — remove them so the learner's + // data/sessions stays clean. + for (const file of createdFiles) { + try { + await fs.rm(path.join(PATHS.sessions, file), { force: true }); + } catch { + // best effort + } + } +}); + +/** Session files this test created (cleaned up in after()). */ +const createdFiles: string[] = []; + +async function createTestSession(topic: string): Promise<{ id: string; file: string }> { + const res = await fetch(`http://127.0.0.1:${port}/api/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ topic }), + }); + assert.ok(res.ok, `create failed: ${res.status}`); + const created = (await res.json()) as { id: string; file: string }; + assert.ok(created.file.endsWith(".jsonl"), "create returns the session file"); + createdFiles.push(created.file); + return created; +} + +// ─── WS test helper ───────────────────────────────────────────────────────── + +interface Collected { + ws: WebSocket; + msgs: ServerMessage[]; +} + +async function openSocket(): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const msgs: ServerMessage[] = []; + const opened = new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws connect failed")); + }); + ws.onmessage = (ev) => { + msgs.push(JSON.parse(String(ev.data)) as ServerMessage); + }; + await opened; + return { ws, msgs }; +} + +function waitMsg( + c: Collected, + pred: (m: ServerMessage) => boolean, + what: string, + timeoutMs = 5000, +): Promise { + const found = c.msgs.find(pred); + if (found) return Promise.resolve(found); + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + const m = c.msgs.find(pred); + if (m) { + clearInterval(interval); + clearTimeout(timer); + resolve(m); + } + }, 15); + const timer = setTimeout(() => { + clearInterval(interval); + reject(new Error(`timeout waiting for ${what}`)); + }, timeoutMs); + }); +} + +function waitSilence(c: Collected, settleMs = 300): Promise { + return new Promise((resolve) => { + let last = c.msgs.length; + const interval = setInterval(() => { + if (c.msgs.length === last) { + clearInterval(interval); + resolve(); + } + last = c.msgs.length; + }, settleMs / 3); + }); +} + +/** Every entry id a socket has seen (backfill + live), in order. */ +function seenEntryIds(msgs: ServerMessage[]): string[] { + const ids: string[] = []; + for (const m of msgs) { + if (m.type === "backfill") ids.push(...m.entries.map((e) => e.id)); + if (m.type === "entry") ids.push(m.entry.id); + } + return ids; +} + +function seenUserTexts(msgs: ServerMessage[]): string[] { + const texts: string[] = []; + for (const m of msgs) { + if (m.type === "backfill") texts.push(...m.entries.filter((e) => e.type === "user").map((e) => e.text)); + if (m.type === "entry" && m.entry.type === "user") texts.push(m.entry.text); + } + return texts; +} + +// ─── the test ─────────────────────────────────────────────────────────────── + +describe("web server protocol (scripted model)", () => { + it("delivers every entry exactly once, streams thinking, and handles the answer flow", async () => { + const TOPIC = "Teach me the solar system."; + + // Connect BEFORE creating the session so we see every live event. + const c1 = await openSocket(); + + // ── create the session ──────────────────────────────────────────── + const created = await createTestSession(TOPIC); + assert.ok(created.file.endsWith(".jsonl")); + + // ── the first turn: thinking delta, quiz presented, agent settles ── + const thinkingDelta = await waitMsg( + c1, + (m) => m.type === "delta" && m.kind === "thinking", + "thinking delta", + ); + assert.equal((thinkingDelta as { delta: string }).delta.length > 0, true); + + const pendingMsg = await waitMsg( + c1, + (m) => m.type === "pending" && m.question !== null, + "pending question", + ); + const question = (pendingMsg as { question: { id: string; question: string; kind: string } }).question; + assert.equal(question.kind, "quiz"); + // No leak in the client payload. + const pendingJson = JSON.stringify(pendingMsg); + assert.ok(!pendingJson.includes("Mercury is the innermost"), "no explanation leak to client"); + + await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after quiz turn"); + + // The topic user message must have arrived EXACTLY once. + await waitSilence(c1); + const topicCount = seenUserTexts(c1.msgs).filter((t) => t === TOPIC).length; + assert.equal(topicCount, 1, "topic message delivered exactly once (the v1 duplication bug)"); + const ids1 = seenEntryIds(c1.msgs); + assert.equal(ids1.length, new Set(ids1).size, "no duplicate entry ids on socket 1"); + + // ── answer the quiz ─────────────────────────────────────────────── + c1.ws.send(JSON.stringify({ type: "answer", questionId: question.id, values: ["mercury"] })); + + await waitMsg( + c1, + (m) => m.type === "entry" && m.entry.type === "questionAnswered", + "questionAnswered entry", + ); + const answerUserMsg = await waitMsg( + c1, + (m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.startsWith("[Quiz answer]"), + "[Quiz answer] user entry", + ); + assert.match((answerUserMsg as { entry: { text: string } }).entry.text, /Result: CORRECT/); + + // Pending cleared after answering. + await waitMsg(c1, (m) => m.type === "pending" && m.question === null, "pending cleared"); + // Agent continues after the answer and settles. + await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after answer"); + await waitSilence(c1); + + const ids1b = seenEntryIds(c1.msgs); + assert.equal(ids1b.length, new Set(ids1b).size, "still no duplicate entry ids after answering"); + + // ── a SECOND socket connects late: same history, exactly once ───── + const c2 = await openSocket(); + await waitMsg(c2, (m) => m.type === "backfill", "socket 2 backfill"); + await waitSilence(c2); + + const ids2 = seenEntryIds(c2.msgs); + assert.equal(ids2.length, new Set(ids2).size, "no duplicate entry ids on socket 2"); + assert.deepEqual([...ids2].sort(), [...ids1b].sort(), "late socket sees the same history"); + + // Thinking survives in the persisted assistant entry. + const assistantEntry = c2.msgs + .flatMap((m) => (m.type === "backfill" ? m.entries : [])) + .find((e) => e.type === "assistant" && (e as { thinking?: string }).thinking); + assert.ok(assistantEntry, "persisted assistant entry carries thinking"); + assert.match((assistantEntry as { thinking: string }).thinking, /probe the learner/); + + // ── follow-up prompt: both sockets get it exactly once ──────────── + c1.ws.send(JSON.stringify({ type: "prompt", text: "thanks, that's enough for now" })); + await waitMsg( + c1, + (m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.includes("enough for now"), + "follow-up user entry on socket 1", + ); + await waitMsg( + c2, + (m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.includes("enough for now"), + "follow-up user entry on socket 2", + ); + await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after follow-up"); + await waitSilence(c1); + await waitSilence(c2); + + for (const [name, c] of [ + ["socket1", c1], + ["socket2", c2], + ] as const) { + const ids = seenEntryIds(c.msgs); + assert.equal(ids.length, new Set(ids).size, `no duplicate entry ids on ${name}`); + const texts = seenUserTexts(c.msgs); + assert.equal(texts.filter((t) => t === TOPIC).length, 1, `${name}: topic once`); + assert.equal(texts.filter((t) => t.includes("enough for now")).length, 1, `${name}: follow-up once`); + } + + // ── the pending-question guard ──────────────────────────────────── + // (No pending question now, so a prompt must NOT be rejected:) + // verified implicitly above — the follow-up prompt went through. + + c1.ws.close(); + c2.ws.close(); + }); + + it("rejects prompts while a question is pending", async () => { + currentScript = [ + { toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }] }, + { text: "Take a shot." }, + ]; + await createTestSession("Teach me something with a quiz."); + const c = await openSocket(); + const pendingMsg = await waitMsg( + c, + (m) => m.type === "pending" && m.question !== null, + "pending question", + ); + const question = (pendingMsg as { question: { id: string } }).question; + c.ws.send(JSON.stringify({ type: "prompt", text: "hello?" })); + const err = await waitMsg(c, (m) => m.type === "error", "guard rejection"); + assert.match((err as { message: string }).message, /Answer the pending question/); + + // Clean up: dismiss so subsequent state is clean. + c.ws.send(JSON.stringify({ type: "dismiss", questionId: question.id })); + await waitMsg( + c, + (m) => m.type === "entry" && m.entry.type === "questionAnswered", + "dismissal recorded", + ); + c.ws.close(); + }); + + it("lists sessions including the created ones", async () => { + const res = await fetch(`http://127.0.0.1:${port}/api/sessions`); + assert.ok(res.ok); + const data = (await res.json()) as { sessions: Array<{ file: string; name?: string }> }; + assert.ok(data.sessions.length >= 2, "at least the two sessions from this test"); + assert.ok( + data.sessions.some((s) => s.name === "Teach me the solar system."), + "session named after its topic", + ); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 81f6356..e7f98ff 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,31 +1,45 @@ +/** + * App — URL-routed shell. + * + * / home (session list, graph placeholder, new-session input) + * /s/ teaching session (own URL → deep links, refresh, back button) + * + * History API navigation (no router dependency). The server only ever has ONE + * active session, so we track which file we last activated this SPA lifetime: + * a session view mounts "already active" only for that file; anything else + * resumes via POST before connecting. + */ + import { useCallback, useEffect, useRef, useState } from "react"; -import type { ServerMessage, SessionSummary } from "../../src/shared/protocol.ts"; +import type { NewSessionResponse, 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; +type Route = { view: "home" } | { view: "session"; file: string }; + +function parseRoute(): Route { + const m = location.pathname.match(/^\/s\/([^/]+)\/?$/); + if (m && m[1]) return { view: "session", file: decodeURIComponent(m[1]) }; + return { view: "home" }; } export function App() { - const [view, setView] = useState<"home" | "session">("home"); + const [route, setRoute] = useState(parseRoute); const [sessions, setSessions] = useState([]); - const [live, setLive] = useState({ - connected: false, - entries: [], - pending: null, - liveText: "", - streaming: false, - error: null, - }); + const [homeError, setHomeError] = useState(null); + /** Session file last activated on the server during this SPA lifetime. */ + const activeFileRef = useRef(null); - const wsRef = useRef(null); - const liveTextRef = useRef(""); + useEffect(() => { + const onPop = () => setRoute(parseRoute()); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + const navigate = useCallback((to: string) => { + history.pushState(null, "", to); + setRoute(parseRoute()); + }, []); const refreshSessions = useCallback(async () => { try { @@ -38,116 +52,61 @@ export function App() { }, []); 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"); - }, - [], - ); + if (route.view === "home") void refreshSessions(); + }, [route, refreshSessions]); const goHome = useCallback(() => { - wsRef.current?.close(); - wsRef.current = null; - setView("home"); - void refreshSessions(); - }, [refreshSessions]); + navigate("/"); + }, [navigate]); - if (view === "home") { + const startSession = useCallback( + async (topic: string) => { + setHomeError(null); + try { + const res = await fetch("/api/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ topic }), + }); + if (!res.ok) throw new Error(`failed to create session (${res.status})`); + const data = (await res.json()) as NewSessionResponse; + activeFileRef.current = data.file; + navigate(`/s/${encodeURIComponent(data.file)}`); + } catch (err) { + setHomeError(String(err instanceof Error ? err.message : err)); + } + }, + [navigate], + ); + + const openSession = useCallback( + (file: string) => { + // SessionView resumes the file itself if it isn't the active one. + navigate(`/s/${encodeURIComponent(file)}`); + }, + [navigate], + ); + + if (route.view === "home") { return ( ); } + return ( { + activeFileRef.current = file; + }} onHome={goHome} /> ); diff --git a/web/src/app.css b/web/src/app.css index 8995129..7df8dbd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -586,3 +586,209 @@ textarea:disabled { background: var(--bg-raised); border-radius: 6px; } + +/* ─── v2: transcript wrap + stick-to-bottom ─────────────────────────── */ + +.transcript-wrap { + flex: 1; + position: relative; + min-height: 0; + display: flex; + flex-direction: column; +} + +.transcript-wrap .transcript { + flex: 1; +} + +.jump-latest { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text); + font-size: 12.5px; + padding: 6px 14px; + cursor: pointer; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); + z-index: 5; +} +.jump-latest:hover { + border-color: var(--accent); +} + +/* ─── v2: message status (optimistic echo) ──────────────────────────── */ + +.msg.user.sending .msg-bubble { + opacity: 0.55; +} +.msg.user.failed .msg-bubble { + border-color: var(--error); + opacity: 0.8; +} +.msg-status { + font-size: 11.5px; + color: var(--text-muted); + align-self: flex-end; + margin: 4px 6px 0 0; +} +.msg-status.error { + color: var(--error); +} + +/* ─── v2: thinking blocks ───────────────────────────────────────────── */ + +.thinking { + margin-bottom: 10px; +} +.thinking-toggle { + background: none; + border: none; + color: var(--text-muted); + font-size: 12.5px; + cursor: pointer; + padding: 2px 0; + display: inline-flex; + gap: 6px; + align-items: center; +} +.thinking-toggle:hover { + color: var(--text); +} +.thinking-caret { + font-size: 10px; +} +.thinking-body { + border-left: 2px solid var(--border); + padding: 4px 0 4px 12px; + margin-top: 6px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + max-height: 320px; + overflow-y: auto; +} +.thinking.streaming .thinking-body { + animation: thinking-pulse 1.6s ease-in-out infinite; +} +.thinking-pulse { + animation: thinking-pulse 1.6s ease-in-out infinite; +} +@keyframes thinking-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +/* ─── v2: live streaming text ───────────────────────────────────────── */ + +.live-text { + position: relative; +} +.stream-cursor { + display: inline-block; + width: 8px; + height: 15px; + background: var(--accent); + margin-left: 2px; + animation: cursor-blink 1s step-end infinite; + vertical-align: text-bottom; +} +@keyframes cursor-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +/* ─── v2: button-based quiz options ─────────────────────────────────── */ + +.quiz-option { + display: flex; + width: 100%; + text-align: left; + align-items: flex-start; + gap: 10px; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + background: var(--bg-raised); + font: inherit; + color: inherit; + transition: border-color 0.12s ease; +} +.quiz-option:hover { + border-color: var(--accent); +} +.quiz-option.selected { + border-color: var(--accent); +} + +.quiz-marker { + flex: none; + width: 15px; + height: 15px; + border-radius: 50%; + border: 1.5px solid var(--border); + margin-top: 3px; + position: relative; + transition: border-color 0.12s ease; + background: transparent; +} +.quiz-option.multi .quiz-marker { + border-radius: 4px; +} +.quiz-option.selected .quiz-marker { + border-color: var(--accent); +} +.quiz-option.selected .quiz-marker::after { + content: ""; + position: absolute; + inset: 3px; + border-radius: 50%; + background: var(--accent); +} +.quiz-option.multi.selected .quiz-marker::after { + border-radius: 1px; +} + +.quiz-option.dont-know { + border-style: dashed; + color: var(--text-muted); +} +.quiz-option.dont-know.selected { + color: var(--text); +} + +/* answered cards: option rows are static divs — keep old marker sizing */ +.quiz-option.static .quiz-marker { + width: 15px; + height: 15px; + margin-top: 2px; +} + +/* ─── v2: boot screen / reconnecting ────────────────────────────────── */ + +.boot-screen { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 14px; +} +.boot-error { + color: var(--error); +} diff --git a/web/src/components/AnsweredCard.tsx b/web/src/components/AnsweredCard.tsx index 3d07930..d55dfd9 100644 --- a/web/src/components/AnsweredCard.tsx +++ b/web/src/components/AnsweredCard.tsx @@ -2,9 +2,12 @@ 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). + * + * QUIZ answers are graded (correct/incorrect marks, explanation). + * ASK answers are NOT graded — selections are highlighted, no ✓/✗, no verdict + * (the v1 bug: ask answers were run through quiz grading and showed as wrong). */ + export function AnsweredCard({ data }: { data: QuestionAnswerData }) { if (data.dismissed) { return ( @@ -16,25 +19,41 @@ export function AnsweredCard({ data }: { data: QuestionAnswerData }) { ); } + const isQuiz = data.kind === "quiz"; const selectedSet = new Set(data.answers.map((a) => a.index)); - const correctSet = new Set(data.correctIndices ?? []); + const correctSet = new Set(isQuiz ? (data.correctIndices ?? []) : []); return (
-
{data.kind === "quiz" ? "Quiz" : "Question"}
+
{isQuiz ? "Quiz" : "Question"}
{data.questionText}
{data.options && data.options.length > 0 && (
{data.options.map((opt) => { const isSelected = selectedSet.has(opt.index); - const isKey = correctSet.has(opt.index); + const isKey = isQuiz && 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 ? "✓" : ""; + let mark = ""; + if (isQuiz) { + if (data.dontKnow) { + cls = isKey ? "correct" : ""; + mark = isKey ? "✓" : ""; + } else if (isSelected && isKey) { + cls = "correct selected"; + mark = "✓"; + } else if (isSelected) { + cls = "wrong"; + mark = "✗"; + } else if (isKey) { + cls = "correct"; + mark = "✓"; + } + } else { + // ask: no grading — just show what was selected + cls = isSelected ? "selected" : ""; + mark = isSelected ? "●" : ""; + } return (
{mark} @@ -45,26 +64,23 @@ export function AnsweredCard({ data }: { data: QuestionAnswerData }) { ); })} {data.other && ( -
- ✓ +
+ {isQuiz ? "✓" : "●"} Other: {data.other}
)}
)} - {(data.text || (!data.options?.length && !data.answers.length)) && data.text && ( -
{data.text}
- )} - {data.other && !data.options?.length &&
Other: {data.other}
} + {data.text &&
{data.text}
} - {data.kind === "quiz" && ( + {isQuiz && (
{data.dontKnow ? "I don't know" : data.correct ? "Correct" : "Incorrect"}
)} {data.note &&
Note: {data.note}
} - {data.kind === "quiz" && data.explanation && ( + {isQuiz && data.explanation && (
{data.explanation}
)}
diff --git a/web/src/components/Message.tsx b/web/src/components/Message.tsx new file mode 100644 index 0000000..a8043a8 --- /dev/null +++ b/web/src/components/Message.tsx @@ -0,0 +1,90 @@ +/** + * Message components — one entry of the transcript. + * + * Assistant messages render as: thinking (collapsible) → tool chips → text. + * Thinking is streamed live in the session view (expanded while streaming, + * collapses when the message lands) and persisted in the entry, so it + * survives reloads and resumes. + */ + +import { useEffect, useState } from "react"; +import type { ClientToolCall } from "../../../src/shared/protocol.ts"; +import { Markdown } from "./Markdown.tsx"; + +// ─── thinking ─────────────────────────────────────────────────────────────── + +export function ThinkingBlock({ text, streaming }: { text: string; streaming?: boolean }) { + const [open, setOpen] = useState(false); + + // Auto-collapse when the stream finishes (expanded-while-live pattern). + useEffect(() => { + if (streaming) setOpen(true); + else setOpen(false); + }, [streaming]); + + if (!text) return null; + return ( +
+ + {open &&
{text}
} +
+ ); +} + +// ─── tool calls ───────────────────────────────────────────────────────────── + +const TOOL_LABELS: Record = { + quiz: "asked a quiz", + ask_user_question: "asked a question", +}; + +export function ToolChips({ toolCalls }: { toolCalls: ClientToolCall[] }) { + if (toolCalls.length === 0) return null; + return ( +
+ {toolCalls.map((tc, i) => ( +
+ {TOOL_LABELS[tc.name] ?? tc.name} + {typeof tc.args.question === "string" && tc.args.question && ( + {tc.args.question} + )} +
+ ))} +
+ ); +} + +// ─── user ─────────────────────────────────────────────────────────────────── + +export function UserMessage({ text, status }: { text: string; status?: "sending" | "failed" }) { + return ( +
+
{text}
+ {status === "failed" &&
failed to send
} + {status === "sending" &&
sending…
} +
+ ); +} + +// ─── assistant ────────────────────────────────────────────────────────────── + +export function AssistantMessage({ + text, + thinking, + toolCalls, +}: { + text: string; + thinking?: string; + toolCalls: ClientToolCall[]; +}) { + return ( +
+ + + {text && } +
+ ); +} diff --git a/web/src/components/QuizCard.tsx b/web/src/components/QuizCard.tsx index d4f4f99..04ae79f 100644 --- a/web/src/components/QuizCard.tsx +++ b/web/src/components/QuizCard.tsx @@ -1,3 +1,16 @@ +/** + * QuizCard — the interactive answer UI for a pending quiz/ask question. + * + * Options are BUTTONS with a custom marker: React state is the single source + * of truth for selection (no native radio/checkbox inputs fighting it — that + * desync was the v1 selection bug). One click anywhere on the row selects. + * + * Semantics mirror the terminal quiz: + * - single-select: exactly one of {options, Other (ask), I-don't-know (quiz)} + * - multi-select: toggle options; I-don't-know/Other are exclusive toggles + * - text mode (ask): free-form answer + */ + import { useMemo, useState } from "react"; import type { ClientQuestion } from "../../../src/shared/protocol.ts"; @@ -6,55 +19,64 @@ interface QuizCardProps { 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>(new Set()); + // Selection state — the ONLY source of truth. + const [selected, setSelected] = useState([]); const [dontKnow, setDontKnow] = useState(false); + const [otherSelected, setOtherSelected] = useState(false); const [other, setOther] = useState(""); - const [showOther, setShowOther] = useState(false); const [text, setText] = useState(""); const [note, setNote] = useState(""); + const pickOption = (value: string) => { + setDontKnow(false); + if (multi) { + setSelected((prev) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value])); + } else { + setOtherSelected(false); + setSelected([value]); + } + }; + + const pickOther = () => { + if (multi) { + setOtherSelected((v) => !v); + } else { + setDontKnow(false); + setSelected([]); + setOtherSelected(true); + } + }; + + const pickDontKnow = () => { + if (dontKnow) { + setDontKnow(false); + } else { + setDontKnow(true); + setSelected([]); + setOtherSelected(false); + } + }; + 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; - }); - }; + if (otherSelected && other.trim()) return true; + return selected.length > 0; + }, [dontKnow, isText, text, otherSelected, other, selected]); const submit = () => { if (!canSubmit) return; send({ type: "answer", questionId: question.id, - dontKnow, - values: [...selected], - other: showOther && other.trim() ? other.trim() : undefined, + dontKnow: dontKnow || undefined, + values: selected.length > 0 ? selected : undefined, + other: otherSelected && other.trim() ? other.trim() : undefined, text: isText ? text.trim() : undefined, note: note.trim() || undefined, }); @@ -79,41 +101,45 @@ export function QuizCard({ question, send }: QuizCardProps) { onChange={(e) => setText(e.target.value)} /> ) : ( -
- {question.options.map((opt) => ( - - ))} +
+ {question.options.map((opt) => { + const isSelected = selected.includes(opt.value); + return ( + + ); + })} {!isQuiz && ( -