fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
# Task 02 — E2E story suite, story file, validation, commit
|
||||||
|
|
||||||
|
**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The story gate: `tests/e2e/test_thinking_no_scroll.py` proves the window
|
||||||
|
can't be user-scrolled but always tracks the live tail, plus regressions,
|
||||||
|
story file, final validation, and the single atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_thinking_no_scroll.py` (new — reuse
|
||||||
|
`test_thinking_display.py`'s mock-LLM streaming scaffolding; the mock
|
||||||
|
must stream a **long** thinking body, in many chunks, so
|
||||||
|
`.thinking-text` overflow exceeds its 320px box). Tests:
|
||||||
|
1. `test_thinking_window_not_user_scrollable` — open the block, wait
|
||||||
|
until `scrollHeight > clientHeight`; focus `.thinking-text`
|
||||||
|
(`el.focus()`), dispatch mouse wheel over it
|
||||||
|
(`page.mouse.wheel(0, -200)` after moving the mouse over the
|
||||||
|
element) and press `Home`/`ArrowUp`: `scrollTop` must not decrease
|
||||||
|
(assert `scrollTop` unchanged within 1px between actions).
|
||||||
|
2. `test_thinking_window_tracks_live_tail` — while chunks stream,
|
||||||
|
after the 2nd-to-last and last chunk:
|
||||||
|
`scrollTop === scrollHeight` (within 1px) — the visible window is
|
||||||
|
the live tail; the **last** chunk's text is within the visible
|
||||||
|
rectangle (its offsetTop + scrollTop geometry check, or
|
||||||
|
`elementFromPoint` at the box's bottom).
|
||||||
|
3. `test_thinking_window_css_contract` — computed style of
|
||||||
|
`.thinking-text`: `overflow-y === "hidden"`,
|
||||||
|
`max-height === "320px"`.
|
||||||
|
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — a
|
||||||
|
long answer (use the long-answer mock from
|
||||||
|
`test_long_answers.py`): the answer bubble is still
|
||||||
|
user-scrollable (scrollTop moves on wheel) and
|
||||||
|
`overflow-y` is not `hidden` there.
|
||||||
|
5. `test_restored_collapsed_thinking_unaffected` (regression,
|
||||||
|
phase 17) — a turn with stored `thinking`, reload: the collapsed
|
||||||
|
Thinking block renders with its text (existing pin from
|
||||||
|
`test_thinking_display.py` — replicate, don't duplicate the file).
|
||||||
|
2. `.agent/user_stories/thinking-no-scroll.md` (new) — story file per
|
||||||
|
the repo format: goal, the bug report verbatim from `TODO.md` L4, the
|
||||||
|
owner-confirmed A2 decisions from `00_phase.md`, E2E mapping table.
|
||||||
|
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
|
||||||
|
`uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov`.
|
||||||
|
4. Regressions, in isolation, one command each:
|
||||||
|
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`
|
||||||
|
- `uv run pytest tests/e2e/test_long_answers.py -v --no-cov`
|
||||||
|
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
|
||||||
|
--cov-report=term-missing` ≥ today's number (>90% gate);
|
||||||
|
`uv run ruff check . && uv run pyright` clean.
|
||||||
|
6. **UI Structure Check** (AGENTS.md rule 5): no new surface; the block
|
||||||
|
keeps its summary chevron, focus-visible ring, aria-live/label
|
||||||
|
contract, and the reduced-motion stillness (styles.css ~line 686).
|
||||||
|
7. Write the phase report (`.agent/reports/21_thinking_no_scroll/`).
|
||||||
|
8. Commit (one atomic commit) and move the phase:
|
||||||
|
```bash
|
||||||
|
git add -A .agent/ frontend/ tests/
|
||||||
|
git commit --no-gpg-sign -m "fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail"
|
||||||
|
mv .agent/phases/todo/21_thinking_no_scroll .agent/phases/complete/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Story suite green **in isolation**; both regression suites green in
|
||||||
|
isolation; full unit+integration suite green; `app/` coverage at or
|
||||||
|
above today's number (>90%); ruff + pyright clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `test_thinking_no_scroll.py` 5/5 in isolation.
|
||||||
|
- [ ] Regressions (thinking display, long answers) green in isolation.
|
||||||
|
- [ ] Story file + phase report exist.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Phase 21 — Thinking Window: No Scroll Back, Just Live
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Date:** 2026-08-25
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Disable user scrolling in the Thinking window so it always shows the live tail
|
||||||
|
of the reasoning stream in a fixed 320px clip.
|
||||||
|
|
||||||
|
## What was implemented
|
||||||
|
|
||||||
|
### Task 01 — CSS change + unit pins (completed by prior agent)
|
||||||
|
- `frontend/assets/styles.css`: `details.thinking .thinking-text` changed from
|
||||||
|
`overflow-y: auto` to `overflow-y: hidden` (with owner-choice comment).
|
||||||
|
- `tests/unit/test_thinking_no_scroll.py`: source-level pins for CSS value,
|
||||||
|
comment, and JS bottom-pin integrity.
|
||||||
|
|
||||||
|
### Task 02 — E2E story suite, story file, validation, commit (completed this run)
|
||||||
|
- `tests/e2e/test_thinking_no_scroll.py` (5 tests):
|
||||||
|
1. `test_thinking_window_not_user_scrollable` — wheel/drag/keyboard on the
|
||||||
|
frozen live tail do not move the window.
|
||||||
|
2. `test_thinking_window_tracks_live_tail` — per-chunk pin keeps the window
|
||||||
|
glued to the tail; last chunk's text is inside the visible rectangle.
|
||||||
|
3. `test_thinking_window_css_contract` — computed `overflow-y` is `hidden`,
|
||||||
|
`max-height` is `320px`, clip is real.
|
||||||
|
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — answer
|
||||||
|
bubble overflow is untouched, page scrolls normally.
|
||||||
|
5. `test_restored_collapsed_thinking_unaffected` (regression, phase 17) —
|
||||||
|
stored thinking restores collapsed with full text.
|
||||||
|
- `.agent/user_stories/thinking-no-scroll.md` — story file with bug report,
|
||||||
|
narrative, acceptance criteria, owner-confirmed decisions, and Playwright
|
||||||
|
mapping.
|
||||||
|
|
||||||
|
## Test / lint / coverage results
|
||||||
|
| Check | Command | Result |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| E2E story suite | `uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov` | **5/5 passed** |
|
||||||
|
| Regression: thinking display | `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` | **5/5 passed** |
|
||||||
|
| Regression: long answers | `uv run pytest tests/e2e/test_long_answers.py -v --no-cov` | **2/2 passed** |
|
||||||
|
| Full suite | `uv run pytest --no-cov` | **319 passed** |
|
||||||
|
| Coverage | `uv run pytest --cov=app --cov-report=term-missing` | **99%** (gate: >90%) |
|
||||||
|
| Lint | `uv run ruff check .` | **All checks passed** |
|
||||||
|
| Types | `uv run pyright` | **0 errors** |
|
||||||
|
|
||||||
|
## Notable decisions
|
||||||
|
- No JS changes: the phase-17 bottom-pin (`textEl.scrollTop = textEl.scrollHeight`)
|
||||||
|
already works under `overflow-y: hidden`, so the CSS-only change is sufficient.
|
||||||
|
- The E2E tests use the mock's `think out loud then hesitate` trigger (phase 20)
|
||||||
|
to create a 4s frozen window where user scroll would be observable if regressed.
|
||||||
|
- The long thinking body (~2,700 chars, lengthened in phase 21) overflows the
|
||||||
|
320px window by ~2x, making the live-tail clip contract E2E-observable.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None. All changes follow LOCKED DECISIONS A11 (no CDN, pure CSS), A16 (one E2E
|
||||||
|
suite per story), and the owner-confirmed roadmap A2 decisions.
|
||||||
@@ -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