"""Unit: the "thinking scroll back" contract for the Thinking window (phase 43, owner direction 2026-08-27, ``TODO.md`` L7, roadmap A2 — reversing the phase-21 owner choice 2026-08-24). The Thinking window is user-scrollable again: ``details.thinking .thinking-text`` goes back to ``overflow-y: auto`` (the 320px clip is kept, owner-confirmed). Wheel, drag, and keyboard move the window; the autoscroll — the phase-17 bottom-pin (``textEl.scrollTop = textEl.scrollHeight`` per thinking chunk) — is GATED: it follows the live tail only while the user is pinned near the window's bottom (the 32px band, ``THINKING_NEAR_BOTTOM_PX``). Scrolling up pauses the follow; returning to the bottom re-arms it (the check runs on every chunk, by construction). The browser behavior itself is E2E-covered (tests/e2e/test_thinking_scroll.py, task 03); here we pin the CSS value + the owner-direction comment, the exported band, the gate function's math, and the gated pin call — so a silent regression (``overflow-y`` back to ``hidden``, band removed, pin ungated) is catched without a browser. """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _thinking_text_rule(css: str) -> str: """Body of the `details.thinking .thinking-text { ... }` rule.""" rule = re.search( r"details\.thinking \.thinking-text \{([\s\S]*?)\n\}", css ) assert rule, "styles.css must style details.thinking .thinking-text" return rule.group(1) def test_thinking_text_is_user_scrollable_window() -> None: """The window is user-scrollable again (wheel / drag / keyboard move it — frozen-tail state) and keeps the fixed 320px clip.""" body = _thinking_text_rule(_css()) assert "overflow-y: auto" in body, "the window must be user-scrollable" assert "max-height: 320px" in body, "the 320px clip must stay" assert "overflow-y: hidden" not in body, "no live-tail-only clip remains" assert "overflow-y: scroll" not in body def test_thinking_text_carries_owner_direction_comment() -> None: """The owner-direction comment (2026-08-27, ``TODO.md`` L7) explains the new contract: autoscroll follows the live tail only while the user is pinned near the window's bottom — scrolling up pauses the follow, returning to the bottom resumes it.""" body = _thinking_text_rule(_css()) assert "owner direction 2026-08-27" in body assert "TODO.md L7" in body def test_thinking_near_bottom_band_exported() -> None: """The 32px follow-the-tail band is exported (same unit-pinned pattern as TURN_TIMEOUT_MS): ``export const THINKING_NEAR_BOTTOM_PX = 32;``.""" js = _js() assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js def test_is_thinking_near_bottom_band_math() -> None: """``isThinkingNearBottom(textEl)`` is the window-level band check: the distance from the window's bottom (``scrollHeight - scrollTop - clientHeight``) must be ``<= THINKING_NEAR_BOTTOM_PX`` — the "window bottom in view" threshold.""" js = _js() fn = re.search( r"function isThinkingNearBottom\(textEl\) \{([\s\S]*?)\n\}", js ) assert fn, "app.js must define isThinkingNearBottom(textEl)" body = fn.group(1) assert re.search( r"textEl\.scrollHeight\s*-\s*textEl\.scrollTop\s*-\s*" r"textEl\.clientHeight\s*<=\s*THINKING_NEAR_BOTTOM_PX", body, ), "the band math must compare the bottom distance to the band" def test_thinking_pin_is_gated_on_window_bottom() -> None: """The phase-17 pin is now GATED: the pin line sits inside ``if (block.open && isThinkingNearBottom(textEl))`` in the streaming thinking branch — the window follows the live tail only while the user is pinned near its bottom, and ``block.open`` stays in the gate so a closed block (e.g. restored collapsed, phase 17) is never pinned. No unconditional ``if (block.open) { … pin … }`` remains anywhere in app.js (the exact old gate string is gone).""" js = _js() pin = "textEl.scrollTop = textEl.scrollHeight" assert js.count(pin) == 1, "the bottom-pin must exist exactly once" # Surviving task-01 assertion: the pin lives in the streaming # thinking branch (before the delta branch). thinking_idx = js.find('ev.type === "thinking"') delta_idx = js.find('ev.type === "delta"') assert -1 < thinking_idx < delta_idx thinking_branch = js[thinking_idx:delta_idx] gate = "if (block.open && isThinkingNearBottom(textEl))" assert gate in thinking_branch, "the pin must be behind the combined gate" # The pin line follows the gate (inside it) — the only pin in the # branch is the gated one. gate_idx = thinking_branch.find(gate) pin_idx = thinking_branch.find(pin, gate_idx) assert pin_idx != -1 assert thinking_branch.count(pin) == 1 # The old unconditional gate is gone from the whole file: no # `if (block.open)` (closed paren) — the combined condition is the # only gate left. assert "if (block.open)" not in js, ( "no unconditional `if (block.open)` pin may remain" ) def test_restore_path_renders_collapsed_block() -> None: """Phase 17 survives: the restore path renders the thinking block COLLAPSED above the bubble (``block.open = false`` in ``renderStoredMessage``) — and with the pin gated on ``block.open``, a restored closed block is never auto-pinned.""" js = _js() start = js.find("function renderStoredMessage(m) {") assert start != -1, "app.js must keep the phase-14 restore renderer" body = js[start : js.find("\n}", start)] assert "ensureThinkingBlock(wrap)" in body assert "block.open = false" in body, "restored blocks must be collapsed"