feat(chat): stream model thinking over SSE and show it in a collapsible block

This commit is contained in:
2026-08-24 09:52:27 -04:00
parent cbc263a4b2
commit b16deb2b1d
18 changed files with 1045 additions and 63 deletions
+111 -16
View File
@@ -14,16 +14,28 @@
* • thinking — pre-token: typing dots + disabled "Thinking…" button;
* after 10s the indicator's aria-label shows elapsed
* seconds so screen-reader users are never left guessing.
* Phase 17: while the model streams reasoning (`thinking`
* SSE events), the live collapsible Thinking block IS the
* visible feedback (it replaces the typing dots; the UI
* state stays "thinking" — button still disabled,
* "Thinking…") and the 120s guard clears on the first
* thinking *or* delta event.
* • streaming — the first delta removes the dots and appends live into
* the answer bubble; the button stays busy until `done`.
* the answer bubble (auto-collapsing the Thinking block,
* phase 17); the button stays busy until `done`.
* • error — red banner (role="alert") with an actionable retry hint;
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
* streams, so the button can never sit zombified.
* streams and the sawDone guard (phase 17) catches a
* stream that dies after frames but before `done`, so the
* button can never sit zombified.
*
* Conversation persistence (phase 14) makes the chat a durable LOCAL
* session: the message list (raw text + turn metadata) lives in
* localStorage under the versioned key `bor.chat.v1` and is re-rendered on
* load — refresh, tab close, and a trip to Sources never lose it. A10 is
* load — refresh, tab close, and a trip to Sources never lose it. Phase
* 17: a brain record may carry an optional `thinking` field — the
* collapsed Thinking block is restored with it; records without it (old
* sessions) restore exactly as before, so no version bump. A10 is
* untouched: the API stays stateless, nothing is stored server-side.
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
* state.
@@ -77,6 +89,10 @@ const SEND_STATUS = Object.freeze({
const TYPING_LABEL = "Brain of Reese is thinking";
const ERROR_HINT = "Try again — if this persists, check the LLM is reachable.";
/* A turn with no answer content (an empty stream, or reasoning that
exhausted max_tokens — phase 17) still renders a bubble, and this exact
text is what gets persisted: what the user saw is what is stored. */
const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?";
/* Calm, don't remove: smooth scrolling is the one motion JS controls. */
const reducedMotion =
@@ -347,6 +363,33 @@ function removeTyping() {
document.querySelector("#typing-indicator")?.remove();
}
/* ---------- thinking block (phase 17) ----------
* The model's reasoning streams into a collapsible <details> block ABOVE
* the answer bubble: created OPEN on the first `thinking` event,
* auto-collapsed when the first answer token lands, and user-toggleable
* afterwards (native <details>/<summary> — a real focusable control).
* ensureThinkingBlock is idempotent (returns the existing block if any);
* closeThinkingBlock never reopens a block once the answer has started,
* so a late/interleaved `thinking` event only appends to the closed text. */
function ensureThinkingBlock(wrap) {
let block = wrap.querySelector(".thinking");
if (!block) {
block = document.createElement("details");
block.className = "thinking";
block.open = true;
block.innerHTML =
`<summary>Thinking</summary><div class="thinking-text"></div>`;
const body = wrap.querySelector(".msg-body");
body.insertBefore(block, body.querySelector(".bubble"));
}
return block;
}
function closeThinkingBlock(wrap) {
const block = wrap?.querySelector?.(".thinking");
if (block) block.open = false; // idempotent; no-op without a block
}
/* ---------- suggestions (shared chip component, phase 05) ----------
*
* One component, two homes: the onboarding row in the empty state and the
@@ -555,7 +598,8 @@ function appendMaybeTry(wrap, suggestions) {
* lives in localStorage under a versioned key; a format bump = clean start:
*
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions? }] }
* sources?, deflected?, suggestions?,
* thinking? }] }
*
* Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user
@@ -631,6 +675,12 @@ function renderStoredMessage(m) {
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), "auto");
if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble.
const block = ensureThinkingBlock(wrap);
block.open = false;
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
}
if (m.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
@@ -647,8 +697,11 @@ function restoreConversation() {
for (const m of conversation) renderStoredMessage(m);
}
/* Brain message save point (on `done`): raw accumulated text only. An
empty stream keeps the "…" placeholder that was actually rendered. */
/* Brain message save point (on `done`): raw accumulated text + metadata.
Phase 17: meta.thinking is optional — `undefined` drops the key from
the JSON, so turns without thinking persist exactly as before. An empty
answer keeps the fallback/"…" text that was actually rendered — what
the user saw is what is stored. */
function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta });
saveConversation();
@@ -754,11 +807,16 @@ async function handleSend(e) {
let acc = "";
let res = null;
let aborted = false; // the 120s guard already took the turn to error
// Phase 17 (thinking display): turn-local reasoning state.
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
let sawThinking = false; // did any `thinking` frame arrive this turn?
let sawDone = false; // did the stream end with a `done` event?
try {
// thinking = pre-token: dots + busy button. The guard is armed so a
// hung stream can never leave the button zombified; it clears on the
// first delta (entering "streaming") and on every terminal transition.
// first thinking OR delta event (phase 17) and on every terminal
// transition.
setUiState(UI_STATE.thinking);
armTurnTimeout(() => {
aborted = true;
@@ -781,16 +839,34 @@ async function handleSend(e) {
}
await readSSE(res, (ev) => {
if (aborted) return;
if (ev.type === "delta") {
acc += ev.text || "";
if (!wrap) {
// First token: dots out, live bubble in; the button stays busy.
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "");
if (ev.type === "thinking") {
// Phase 17: model reasoning — stream it live into the collapsible
// Thinking block. No setUiState here: the UI state stays
// "thinking" (button still disabled with "Thinking…", #send-status
// unchanged) — the live block simply replaces the typing dots as
// the visible feedback.
thinkingAcc += ev.text || "";
sawThinking = true;
clearTurnTimeout(); // the stream is alive — as the first delta says
if (!wrap) wrap = addMessage("brain", "");
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingBlock(wrap);
const textEl = block.querySelector(".thinking-text");
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open) {
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
}
} else if (ev.type === "delta") {
acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
} else if (ev.type === "done") {
sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
@@ -801,9 +877,18 @@ async function handleSend(e) {
}
appendSources(wrap, ev.sources);
appendTuneButton(wrap); // every completed brain bubble is tunable
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata).
rememberBrainTurn(acc, {
// complete (raw text + the done metadata; phase 17: + optional
// thinking — `undefined` drops the key from the JSON).
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
deflected: !!ev.deflected,
sources: ev.sources,
suggestions: ev.suggestions,
@@ -812,8 +897,18 @@ async function handleSend(e) {
throw new Error(ev.detail || "Something went wrong on my side.");
}
});
// Stream-drop guard (phase 17): frames arrived but no `done` event —
// the connection died mid-turn. Say so; never settle silently into
// idle with a half bubble. The zero-frame case falls through to the
// existing empty-answer fallback below.
if (!sawDone && !aborted && (acc || thinkingAcc)) {
setUiState(
UI_STATE.error,
"The stream ended before my answer finished — try again?"
);
}
if (!aborted && !wrap) {
const fallback = "Hmm — that came back empty. Ask me again?";
const fallback = EMPTY_ANSWER_FALLBACK;
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
rememberBrainTurn(fallback, {}); // persist what the user actually saw
+54
View File
@@ -401,6 +401,58 @@ body::after {
border-color: var(--accent-line);
}
/* Collapsible "Thinking" block (phase 17): the model's reasoning streams
open ABOVE the answer bubble, auto-collapses when the answer starts, and
stays user-toggleable (native <details>/<summary> — a real focusable
control: >=44px target, :focus-visible via the global rule). Summary
text is brand-ink on surface ≈8.7:1; the scratchpad body is ink-soft on
surface ≈6.9:1 — both AA. */
details.thinking {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--brand-soft);
border-radius: var(--radius-sm);
margin: 0 0 0.5rem;
overflow: hidden;
}
details.thinking summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
min-height: 44px;
color: var(--brand-ink); /* 8.7:1 on --surface */
font-size: 0.9rem;
cursor: pointer;
list-style: none;
}
details.thinking summary::-webkit-details-marker { display: none; }
/* CSS chevron: ▸ rotates 90° when open (transition stills under
prefers-reduced-motion — see the reduced-motion block below). */
details.thinking summary::before {
content: "▸";
display: inline-block;
transition: transform 0.15s ease;
}
details.thinking[open] summary::before { transform: rotate(90deg); }
details.thinking summary:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* The scratchpad is a scrollable, compact area (max-height keeps long
reasoning from pushing the answer off-screen while open). */
details.thinking .thinking-text {
padding: 0 0.75rem 0.75rem;
color: var(--ink-soft); /* 6.9:1 on --surface */
font-size: 0.875rem;
line-height: 1.55;
max-height: 320px;
overflow-y: auto;
}
/* The scratchpad is compact: tighten the renderer's paragraph/list margins. */
details.thinking .thinking-text p,
details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.msg-meta {
font-size: 0.75rem;
color: var(--ink-soft);
@@ -631,6 +683,8 @@ body::after {
}
@media (prefers-reduced-motion: reduce) {
.typing span { animation: none; opacity: 0.7; }
/* Phase 17 thinking block: the chevron stills (no rotation motion). */
details.thinking summary::before { transition: none; }
}
/* ---------- Empty state & suggestions ---------- */