- Markdown.tsx never passed text as children to ReactMarkdown (valid JSX, compiled clean, rendered nothing) — the 'thinking shows, text doesn't' bug - toolResult entries (quiz/ask dispatch instructions for the LLM) leaked into the transcript as visible text; now never rendered - render smoke tests (renderToStaticMarkup, no DOM): pin markdown content, KaTeX, button-based quiz options, ask answers not graded, toolResult and [Quiz answer] hiding — negative-tested against the actual bugs - React default import in all .tsx: tsx test runner uses classic JSX transform (ignores tsconfig jsx option), Vite uses automatic runtime; explicit import works under both - katex css moved to main.tsx (tests can't import css) - verified live in browser: all assistant messages render, ask card has no grading marks, pending quiz re-arms on resume
91 lines
3.4 KiB
TypeScript
91 lines
3.4 KiB
TypeScript
/**
|
|
* 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 React, { 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>
|
|
);
|
|
}
|