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:
@@ -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 (
|
||||
<div className={`thinking ${streaming ? "streaming" : ""}`}>
|
||||
<button type="button" className="thinking-toggle" onClick={() => setOpen((v) => !v)}>
|
||||
{streaming ? <span className="thinking-pulse">Thinking…</span> : <span>Thought process</span>}
|
||||
{!streaming && <span className="thinking-caret">{open ? "▾" : "▸"}</span>}
|
||||
</button>
|
||||
{open && <div className="thinking-body">{text}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── tool calls ─────────────────────────────────────────────────────────────
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
quiz: "asked a quiz",
|
||||
ask_user_question: "asked a question",
|
||||
};
|
||||
|
||||
export function ToolChips({ toolCalls }: { toolCalls: ClientToolCall[] }) {
|
||||
if (toolCalls.length === 0) return null;
|
||||
return (
|
||||
<div className="tool-chips">
|
||||
{toolCalls.map((tc, i) => (
|
||||
<div key={i} className="tool-chip">
|
||||
<span className="tool-name">{TOOL_LABELS[tc.name] ?? tc.name}</span>
|
||||
{typeof tc.args.question === "string" && tc.args.question && (
|
||||
<span className="tool-arg"> {tc.args.question}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── user ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function UserMessage({ text, status }: { text: string; status?: "sending" | "failed" }) {
|
||||
return (
|
||||
<div className={`msg user ${status === "failed" ? "failed" : status === "sending" ? "sending" : ""}`}>
|
||||
<div className="msg-bubble">{text}</div>
|
||||
{status === "failed" && <div className="msg-status error">failed to send</div>}
|
||||
{status === "sending" && <div className="msg-status">sending…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── assistant ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function AssistantMessage({
|
||||
text,
|
||||
thinking,
|
||||
toolCalls,
|
||||
}: {
|
||||
text: string;
|
||||
thinking?: string;
|
||||
toolCalls: ClientToolCall[];
|
||||
}) {
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
<ThinkingBlock text={thinking ?? ""} />
|
||||
<ToolChips toolCalls={toolCalls} />
|
||||
{text && <Markdown text={text} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user