Files
ducoterra dbf2af26c6 refactor(agents): migrate .agent/ planning tree to .agents/
Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
2026-09-05 10:57:07 -04:00

5.5 KiB
Raw Permalink Blame History

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:
    <details class="thinking" open>
      <summary>Thinking</summary>
      <div class="thinking-text"></div>
    </details>
    
    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 <details>/<summary> — 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).