"""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 gate is measured against the PRE-render geometry: the chunk's re-render grows the window's content below the old bottom, so a post-render reading measures the new chunk's height, not the user's position — any chunk taller than the 32px band (a real model's sentence, or a "\n\n" paragraph break) killed the follow at the first 2-newline gap. The pin state is captured into ``pinned`` BEFORE ``textEl.innerHTML = …`` and the pin line runs inside ``if (pinned)`` after it. 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 pre-render capture + gated pin call — so a silent regression (``overflow-y`` back to ``hidden``, band removed, pin ungated, or the capture moved back after the re-render) is caught 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 GATED on the user's pin state captured BEFORE the re-render: ``const pinned = block.open && isThinkingNearBottom(textEl)`` sits above ``textEl.innerHTML = …`` in the streaming thinking branch, and the pin line runs inside ``if (pinned)`` below it. Measuring after the update would read the new chunk's rendered height instead of the user's position — the "2-newline gap" regression. ``block.open`` stays in the capture so a closed block (e.g. restored collapsed, phase 17) is never pinned, and no direct ``if (block.open)`` gate remains anywhere in app.js.""" js = _js() pin = "textEl.scrollTop = textEl.scrollHeight" assert js.count(pin) == 1, "the bottom-pin must exist exactly once" # 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] capture = "block.open && isThinkingNearBottom(textEl)" render = "textEl.innerHTML = renderMarkdown(thinkingAcc)" gate = "if (pinned)" assert capture in thinking_branch, ( "the combined gate must be captured (block.open + the window band)" ) assert gate in thinking_branch, "the pin must sit inside the captured gate" # Pre-capture → re-render → gated pin, in exactly that order: the # pin state is the user's PRE-render position, not the chunk's # rendered height. cap_idx = thinking_branch.find(capture) render_idx = thinking_branch.find(render, cap_idx) assert render_idx != -1 and render_idx > cap_idx, ( "the pin state must be measured BEFORE the re-render" ) gate_idx = thinking_branch.find(gate, render_idx) assert gate_idx != -1 and gate_idx > render_idx, ( "the pin must run AFTER the re-render, inside the captured gate" ) pin_idx = thinking_branch.find(pin, gate_idx) assert pin_idx != -1 assert thinking_branch.count(pin) == 1 # No direct `if (block.open)` gate may remain — the capture is the # only gate left. assert "if (block.open)" not in js, ( "no direct `if (block.open)` gate may remain — the capture is the gate" ) 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"