fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# Story: Thinking No Scroll (live-tail Thinking window)
|
||||
|
||||
**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4 ·
|
||||
**E2E:** `tests/e2e/test_thinking_no_scroll.py`
|
||||
|
||||
## Bug report (verbatim, `TODO.md` L4)
|
||||
|
||||
> "Disable scroll in the thinking window. Users don't need to scroll back
|
||||
> through thinking, just see it live."
|
||||
|
||||
## Narrative
|
||||
|
||||
As **a user**, the Thinking block is a *scratchpad, not a transcript*:
|
||||
while Brain reasons I just want to watch the newest lines appear — not
|
||||
scroll back through everything it already thought. The block should
|
||||
always show the **live tail** of the reasoning stream, in its fixed
|
||||
320px window, with no way to scroll it back.
|
||||
|
||||
- **Given** a turn streams a long reasoning body (longer than the 320px
|
||||
window)
|
||||
- **When** I try to scroll back through the window — mouse wheel, mouse
|
||||
drag, or keyboard — while it streams (or while it sits open before the
|
||||
first token)
|
||||
- **Then** the window does not move: it stays glued to the live tail,
|
||||
pinned there by the phase-17 per-chunk bottom-pin (the sole scroller).
|
||||
|
||||
## Acceptance criteria
|
||||
1. With a long thinking stream, wheel / mouse-drag / keyboard on
|
||||
`.thinking-text` do **not** move it; the visible content is always the
|
||||
live tail (pinned at the bottom, within 1px, after each chunk).
|
||||
2. Computed style of `.thinking-text`: `overflow-y: hidden`,
|
||||
`max-height: 320px` — the 320px clip stays (owner-confirmed: no
|
||||
auto-height growth, no "↓ more" affordance).
|
||||
3. A long **answer** bubble is untouched: it keeps its existing scroll
|
||||
behavior (phase 11) — phase 21 only changed `.thinking-text`.
|
||||
4. Restored (collapsed) Thinking blocks are untouched (phase 14/17
|
||||
restore path): they render collapsed with their text, where overflow
|
||||
is moot.
|
||||
5. No new surface: the block keeps its summary chevron, focus-visible
|
||||
ring, aria contract, and the reduced-motion stillness (styles.css
|
||||
`prefers-reduced-motion` block).
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap A2)
|
||||
1. **Keep the 320px clip** — "just see it live" means the window stays a
|
||||
fixed 320px viewport showing the newest lines; no auto-height growth,
|
||||
no "↓ more" affordance.
|
||||
2. **The answer bubble is untouched** — final answers keep their existing
|
||||
scroll behavior (phase 11 long answers).
|
||||
3. **Restored (collapsed) Thinking blocks are untouched** — the phase-14
|
||||
restore renders them collapsed, where overflow is moot.
|
||||
|
||||
## UI Visualization & Structure
|
||||
- **The whole functional change is one CSS property**
|
||||
(`frontend/assets/styles.css`, phase-17 thinking section):
|
||||
`details.thinking .thinking-text` — `overflow-y: auto` →
|
||||
`overflow-y: hidden` (with the owner-choice comment: *no user scroll
|
||||
back (owner choice 2026-08-24): the window is a live tail only — the
|
||||
JS bottom-pin is the sole scroller*). `max-height: 320px` and every
|
||||
other declaration stay byte-identical.
|
||||
- **Why this works:** `overflow: hidden` disables *user* scrolling
|
||||
(wheel/drag/keyboard) but still permits *programmatic* scrolling — so
|
||||
the phase-17 bottom-pin (`textEl.scrollTop = textEl.scrollHeight` on
|
||||
every `thinking` chunk, `frontend/assets/app.js`) keeps the window
|
||||
glued to the live tail with **no JS change**.
|
||||
- **Non-goals:** no change to the answer bubble, the collapsed restore
|
||||
state, the summary/chevron, or the auto-collapse on the first delta
|
||||
(phase 17).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_thinking_no_scroll.py`** (mock LLM;
|
||||
phase 21 lengthened `mock_llm.compose_thinking` to ~2 700 chars ≈ 4.5s
|
||||
of paced `reasoning_content` frames so the scratchpad overflows the
|
||||
320px window; tests 1–2 use the phase-20 `think out loud then hesitate`
|
||||
trigger — after the stream ends there is a deterministic 4s pre-content
|
||||
pause with the block still open and no further pin frames, a frozen live
|
||||
tail where any (regressed) user scroll would persist and be observable):
|
||||
1. `test_thinking_window_not_user_scrollable` — wait until the window
|
||||
overflows and the thinking stream ends (text stable; block still
|
||||
open, pre-token). Assert the pin left it at the tail; then focus
|
||||
`.thinking-text`, wheel up ×2, `Home` + `ArrowUp`, mouse-drag up:
|
||||
`scrollTop` must not move (within 1px) and the window must still show
|
||||
the live tail.
|
||||
2. `test_thinking_window_tracks_live_tail` — while the stream is live:
|
||||
once the 2nd-to-last chunk has landed, the window is pinned to the
|
||||
tail; after the last chunk (held by the 4s pause) it is still pinned,
|
||||
the last chunk's text (the final text node) renders inside the
|
||||
visible rectangle, and a hit-test at the box's bottom lands inside
|
||||
`.thinking-text`.
|
||||
3. `test_thinking_window_css_contract` — computed style: `overflow-y` is
|
||||
`hidden`, `max-height` is `320px`, and the clip is real
|
||||
(`scrollHeight > clientHeight` for the long scratchpad).
|
||||
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — the
|
||||
long-answer mock question streams to completion; the answer bubble's
|
||||
computed `overflow-y` is not `hidden`, the page is scrollable, and
|
||||
wheel up/down over the answer moves the page scroll both ways.
|
||||
5. `test_restored_collapsed_thinking_unaffected` (regression, phase 17)
|
||||
— a settled thinking turn is reloaded; the restored conversation
|
||||
renders the Thinking block **collapsed** with its full text (matching
|
||||
the persisted raw text), and the answer bubble is intact.
|
||||
Reference in New Issue
Block a user