TODO.md L7: "Add scrolling back to the thinking block, but have it
autoscroll while thinking content is generating." Owner direction
2026-08-27 (roadmap A2) reverses the phase-21 no-scroll choice
(2026-08-24): details.thinking .thinking-text is user-scrollable again
(overflow-y: auto — the 320px clip stays, owner-confirmed), and the
phase-17 per-chunk bottom-pin is GATED: the window follows the live
tail only while the user is pinned near its bottom (THINKING_NEAR_
BOTTOM_PX = 32); scrolling up pauses the follow, returning to the
bottom re-arms it on the next chunk (the gate re-runs on every frame).
- frontend/assets/styles.css: .thinking-text overflow-y: hidden ->
auto; the phase-21 owner-choice comment is replaced with the
2026-08-27 direction; max-height: 320px and every other declaration
in the rule byte-identical.
- frontend/assets/app.js: export const THINKING_NEAR_BOTTOM_PX = 32 +
isThinkingNearBottom(textEl) (scrollHeight - scrollTop -
clientHeight <= band); the thinking-handler pin becomes
`if (block.open && isThinkingNearBottom(textEl))` — a scrolled-up
reader is never re-pinned and a closed (restored) block is never
pinned; everything else in the handler (and phase 42's no page
scroll) untouched.
- tests/unit/test_thinking_scroll.py (new, replaces the deleted
tests/unit/test_thinking_no_scroll.py): pins the CSS contract (auto
+ 320px + owner-direction comment, no hidden/scroll left), the
exported 32px band, the gate math, the gated pin (no unconditional
`if (block.open)` remains), and the surviving collapsed-restore pin.
- tests/e2e/test_thinking_scroll.py (new, mock-only, replaces the
deleted tests/e2e/test_thinking_no_scroll.py — its pins asserted the
reversed phase-21 behavior, so both phase-21 files are deleted in
this commit): user scroll restored on the frozen 4s-hesitation tail
(wheel is 1:1; click+Home keyboard — the plain div is not
keyboard-focusable by design, tabindex is test scaffolding; the
literal drag holds the user's position — headless Chromium's
overlay scrollbars are not grabbable by synthetic mouse events,
documented in the suite), follow-while-pinned at the 2nd-to-last
and last chunk (±1px) with the last chunk's text inside the visible
rectangle, no re-pin over ≥5 mid-stream chunks after a
half-window scroll-up, re-pin on the next chunk after returning to
the bottom, the CSS contract, plus the phase-11 (long answer: page
scrolls, bubble overflow untouched) and phase-17 (restored
collapsed block with full text) regressions.
- tests/unit/test_chat_persistence.py: the CSS pin flips with the
contract (auto in, hidden out — owner direction 2026-08-27).
- tests/unit/test_frontend_scroll.py: the "page-level band constant is
gone" pin now excludes the phase-43 window-level
THINKING_NEAR_BOTTOM_PX (a different band — the window's, not the
page's).
E2E (isolation): test_thinking_scroll 7/7 (twice); regressions
test_thinking_display 5/5, test_chat_persistence 4/4,
test_no_reply_autoscroll 5/5, test_smoke 3/3; unit+integration 725
passed, app/ coverage 99% (unchanged — frontend-only phase);
ruff + pyright clean.
TODO.md L5: "Get rid of the chat reply autoscroll, it's breaking things
like making it impossible for the user to scroll while a reply
generates." Owner direction 2026-08-27 (roadmap A1) revises the
phase-18 follow-the-bottom choice: the page NEVER auto-scrolls while a
turn streams. Kept (owner decision): the submit reveal (the user's own
message) and the one-shot phase-14 restore landing.
- frontend/assets/app.js: delete NEAR_BOTTOM_PX + isNearBottom;
scrollReveal becomes the one unconditional scrollIntoView (still
smooth, still "auto" under prefers-reduced-motion via SCROLL);
addMessage(who, html, scroll = false) carries an explicit scroll
intent — only the submit (", true") and the two restore landings
scroll. The thinking/tool/delta handlers and the typing indicator
drop their page-scroll calls; the thinking block's INTERNAL
bottom-pin (textEl.scrollTop, phase 17 — reworked separately in
phase 43) and the turn-end focus({ preventScroll: true }) survive.
- tests/unit/test_frontend_scroll.py: rewritten pin for the new
contract — phase-18 gate absent, helper unconditional, explicit
intent at submit/restore, no page-scroll call in the streaming
handlers, typing bubble scroll-free, SCROLL reduced-motion intact.
- tests/unit/test_chat_persistence.py: restore-landing pin updated to
the new signature (the old forced "auto" is gone; the landing
rides the default SCROLL — noted at the call site).
- tests/e2e/test_no_reply_autoscroll.py (new, replaces the deleted
test_follow_bottom_scroll.py): no autoscroll across >=10 samples
(1px tolerance) during a long answer and during the thinking stream;
submit-from-the-top still reveals the user message; the restore
landing lands one-shot on the latest message and stays; long answer
+ sources and the collapsed thinking block persist and restore.
E2E (isolation): test_no_reply_autoscroll.py 5/5; regressions
test_chat_rag 3/3, test_thinking_display 5/5,
test_chat_persistence 4/4, test_long_answers 2/2, test_smoke 3/3;
unit+integration 723 passed, app/ coverage 99%; ruff + pyright clean.