# Story: Thinking Display **Phase:** `17_thinking_display` · **E2E:** `tests/e2e/test_thinking_display.py` ## Narrative As **a user**, my local reasoning model *thinks* before it answers — and today those 10–30 seconds are pure silence. I want to *see* Brain think: its reasoning streaming live, tucked away once the answer starts — so long turns feel transparent instead of frozen. - **Given** I submit a question the model reasons about - **When** the turn streams reasoning ahead of (or around) the answer - **Then** a collapsible "Thinking" block streams open above the answer bubble, auto-collapses on the first answer token, and stays user-toggleable afterwards — while turns that emit no reasoning render exactly as before. ## Acceptance criteria 1. Turns whose model stream carries reasoning show a "Thinking" block (collapsible, above the answer bubble) that streams open and auto-collapses on the first answer token; always user-toggleable afterwards. 2. Turns without reasoning render exactly as before (no block, no layout shift). 3. Thinking-without-answer (reasoning exhausted the token budget) shows the existing empty-answer fallback with the thinking block preserved. 4. Thinking persists with the message (phase 14) and restores collapsed after reload; "New chat" clears it with everything else. 5. Deflected turns show the amber bubble + "Maybe try" chips alongside the thinking block (honesty gate untouched). 6. A stream that dies mid-thinking/mid-answer ends in the error state (retry hint) — never a silent half bubble. 7. `BOR_STREAM_THINKING=0` suppresses `thinking` events server-side (log line still counts `thinking_chars`). ## UI Visualization & Structure - **DOM contract** (built dynamically — `index.html` is unchanged): inside `.msg-body`, **before** `.bubble`: ```html
Thinking
``` The block is created **open** at the first `thinking` event and auto-collapses at the first answer token; a late/interleaved `thinking` event only appends to the block — it never reopens once the answer started. Restored messages (phase 14) render the block **collapsed**. - **Styling (Phase-08 tokens):** `details.thinking` — `--surface` background, 1px `--line` border, 3px `--brand-soft` left accent, `--radius-sm`; `summary` — `--brand-ink` on surface (≈**8.7:1**), 0.9rem, **min-height 44px** touch target, CSS chevron `▸` rotating 90° when open (0.15s transform, stills under `prefers-reduced-motion`), `:focus-visible` 3px `--brand` outline; `.thinking-text` — `--ink-soft` on surface (≈**6.9:1**), 0.875rem/1.55, **max-height 320px** with `overflow-y: auto` (long scratchpads scroll instead of pushing the answer off-screen) and tightened paragraph/list margins. - **Accessibility:** native `
/` — a real, keyboard-focusable control with native open/closed announcements. **No live region on the scratchpad** (announcing every reasoning chunk would be hostile to screen readers); the existing `#send-status` region announces turn state as before. - **Feedback contract:** while reasoning streams, the block *is* the visible "thinking" feedback (the typing dots are removed); the UI state stays `thinking` (button disabled with "Thinking…", status text unchanged). The 120s pre-token guard clears on the first `thinking` **or** `delta` event. - **Stream-drop guard:** a stream that ends without a `done` event after at least one thinking/delta frame settles into the error state ("The stream ended before my answer finished — try again?") — never a silent half bubble. ## Playwright Mapping Rule **Test Scenario → `tests/e2e/test_thinking_display.py`** (mock LLM, `"think out loud"` trigger): 1. `test_thinking_block_streams_open_then_collapses` — submit `THINK_QUESTION`. Assert `details.thinking` inside the last `.msg.brain` attaches within 10s (it appears at the first `thinking` event); immediately after attach, assert it is open (the mock's ~800-char thinking stream gives a multi-second open window — see the suite's determinism note) and `.thinking-text` is non-empty; once `.bubble` text is non-empty, assert the block is **closed**; at settle: `.thinking-text` contains `Step 2: Check my notes`, the bubble contains the mock's deterministic answer sentence, `.source-chip` count ≥ 1, send button re-enabled. 2. `test_thinking_toggle_after_done` — after a settled `THINK_QUESTION` turn, the block is closed; click `summary` → `details[open]` and the full thinking text is visible; click again → closed. (Real keyboard-focusable control.) 3. `test_thinking_restored_after_reload` — settle a `THINK_QUESTION` turn; capture the thinking text; `page.reload()`; the restored conversation contains the brain message with a **closed** `details.thinking` whose `.thinking-text` matches the captured text, and the answer bubble + source chips are intact (phase-14 restore path). 4. `test_no_thinking_block_without_trigger` — submit `PLAIN_QUESTION`; at settle: `page.locator("details.thinking")` count is 0 (a model that doesn't think renders exactly as before — no layout regression). 5. `test_thinking_with_deflection` — submit `THINK_DEFLECT_QUESTION`; at settle: the brain message has `.is-deflected`, a `.maybe-try` group with chips, and a closed `details.thinking` whose text contains `Step 2: Check my notes` (thinking and the honesty gate coexist).