# 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).