diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 7bb2dee..922aed5 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -162,6 +162,22 @@ const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; const SCROLL = reducedMotion ? "auto" : "smooth"; +/* Thinking-window follow-the-tail contract (owner direction + * 2026-08-27, `TODO.md` L7): the scratchpad autoscrolls to its live + * tail only while the user is pinned near the window's bottom — + * the 32px band is the "window bottom in view" threshold. Scrolling + * up pauses the follow; returning to the bottom resumes it (the + * check runs on every chunk). Exported so the band is unit-pinned + * (same pattern as TURN_TIMEOUT_MS). */ +export const THINKING_NEAR_BOTTOM_PX = 32; + +function isThinkingNearBottom(textEl) { + return ( + textEl.scrollHeight - textEl.scrollTop - textEl.clientHeight <= + THINKING_NEAR_BOTTOM_PX + ); +} + /* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 — * revising the phase 18 follow-the-bottom choice): the page never * auto-scrolls while a turn streams. The only scroll call sites are @@ -955,9 +971,10 @@ async function handleSend(e) { const block = ensureThinkingBlock(wrap); const textEl = block.querySelector(".thinking-text"); textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe - if (block.open) { - // Pin the block's OWN stream (phase 17 — reworked in phase 43); - // the page never follows (phase 42, no reply autoscroll). + if (block.open && isThinkingNearBottom(textEl)) { + // Follow the live tail only while the user is pinned to the window + // bottom (owner direction 2026-08-27); a scrolled-up reader is + // never re-pinned — returning to the bottom re-arms the pin. textEl.scrollTop = textEl.scrollHeight; } } else if (ev.type === "tool") { diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 8939f32..726703c 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -638,10 +638,13 @@ details.thinking .thinking-text { font-size: 0.875rem; line-height: 1.55; max-height: 320px; - overflow-y: hidden; /* no user scroll back (owner choice 2026-08-24): - the window is a live tail only — the phase-17 - JS bottom-pin (scrollTop = scrollHeight per - chunk) is the sole scroller */ + overflow-y: auto; /* user-scrollable window (owner direction 2026-08-27, + TODO.md L7): autoscroll follows the live tail + only while the user is pinned near the window's + bottom — the phase-17 pin, gated in app.js (task + 02: THINKING_NEAR_BOTTOM_PX); scrolling up + pauses the follow, returning to the bottom + resumes it. */ } /* The scratchpad is compact: tighten the renderer's paragraph/list margins. */ details.thinking .thinking-text p, diff --git a/tests/e2e/test_thinking_no_scroll.py b/tests/e2e/test_thinking_no_scroll.py deleted file mode 100644 index 2bc6366..0000000 --- a/tests/e2e/test_thinking_no_scroll.py +++ /dev/null @@ -1,455 +0,0 @@ -"""Phase 21 E2E (Playwright, mock-only): the Thinking window is a live tail. - -Story: ``.agent/user_stories/thinking-no-scroll.md`` -Run in isolation (DB must be up: ``podman compose up -d db``): - - uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov - -MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported here — the real -``turbo`` decides its own reasoning length and pacing, and this story's -contract (fixed 320px clip, no user scroll back, programmatic pin to the -live tail) needs the deterministic mock's long, paced scratchpad. - -The whole functional change is one CSS property -(``details.thinking .thinking-text``: ``overflow-y: auto`` → ``hidden``); -``overflow: hidden`` still allows the phase-17 programmatic bottom-pin -(``scrollTop = scrollHeight`` per thinking chunk), which is the sole -scroller. This suite proves the browser behavior the unit pins -(``tests/unit/test_thinking_no_scroll.py``) can only pin at source level. - -Determinism note: phase 21 lengthened the mock's ``compose_thinking`` -body to ~2 700 chars (≈230 frames at the mock's 12-char/0.02s pacing ≈ -4.5s) so the rendered scratchpad overflows the 320px window by ~2x. -Tests 1–2 key off the mock's ``think out loud then hesitate`` trigger: -after the thinking stream ends there is a deterministic 4s pre-content -pause with the block still OPEN and no further pin frames — a frozen -live tail, the only state where a (regressed, working) user scroll would -persist and be observable. During live streaming the per-chunk re-pin -masks any user scroll within one frame (≈20ms), so that state is covered -by the tail-tracking invariant instead (test 2, sampled mid-stream). - -Test → story mapping (Playwright Mapping Rule): -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`` — after the 2nd-to-last and - the last thinking chunk the window is pinned to the tail and the last - chunk's text sits inside the visible rectangle. -3. ``test_thinking_window_css_contract`` — computed ``overflow-y`` is - ``hidden``, ``max-height`` is 320px, and the clip is real. -4. ``test_answer_bubble_still_scrollable`` — regression (phase 11): the - answer bubble's overflow is untouched and the page still scrolls. -5. ``test_restored_collapsed_thinking_unaffected`` — regression - (phase 17): a stored thinking turn restores a collapsed block. -""" -from __future__ import annotations - -import asyncio -import json -import re -from collections.abc import Iterator -from pathlib import Path -from threading import Thread -from typing import Any - -import pytest -from playwright.sync_api import Page, expect -from sqlalchemy import text - -from app.config import Settings -from app.db import SessionLocal -from app.rag.importer import ImportSummary, import_sources -from app.rag.llm import LLMClient -from e2e.mock_llm import compose_thinking - -REPO = Path(__file__).resolve().parents[2] -FIXTURES = REPO / "tests" / "fixtures" / "docs" - -#: Phase-17 trigger question (grounded turn, thinking + short answer). -THINK_QUESTION = "think out loud — how is my kubernetes cluster set up?" -#: Phase-20 hesitation trigger: the long phase-17/21 thinking stream, then -#: a deterministic 4s pause before the first content frame — a frozen -#: live tail with the block still open (the no-scroll test window). -HESITATE_QUESTION = "think out loud then hesitate — how is my kubernetes cluster set up?" -#: Phase-11 long-answer trigger (regression test 4). -LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer" -LONG_ANSWER_END = "LONG-ANSWER-END" -MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" -THINKING_FRAGMENT = "Step 2: Check my notes" -STORAGE_KEY = "bor.chat.v1" - -SELECTOR = ".msg.brain details.thinking .thinking-text" - - -async def _import_fixtures(mock_port: int) -> ImportSummary: - kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} - settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] - return await import_sources([FIXTURES], LLMClient(settings)) - - -def _run_in_thread(coro: Any) -> Any: - """Run a coroutine on a worker thread (Playwright owns the test loop).""" - box: dict[str, Any] = {} - - def runner() -> None: - try: - box["value"] = asyncio.run(coro) - except BaseException as e: # noqa: BLE001 — re-raised on the test thread - box["error"] = e - - t = Thread(target=runner) - t.start() - t.join() - if "error" in box: - raise box["error"] - return box["value"] - - -def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: - """Truncate the KB (and query log), then optionally re-import fixtures.""" - with SessionLocal() as db: - db.execute(text("TRUNCATE chunks, documents, query_log")) - db.commit() - if not seed: - return None - return _run_in_thread(_import_fixtures(mock_port)) - - -@pytest.fixture() -def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]: - """A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats), - truncated again on teardown (same fixture shape as the phase-17 suite).""" - summary = _reset_db(mock_llm, seed=True) - assert summary is not None and summary.added == 8 - yield - _reset_db(mock_llm, seed=False) - - -def send_and_wait(page: Page, question: str) -> None: - """Type into #message-input, submit via #composer, then wait until the - last brain message settles (send button re-enabled, label "Send").""" - page.fill("#message-input", question) - page.evaluate("() => document.querySelector('#composer').requestSubmit()") - expect(page.locator(".msg.user .bubble").last).to_contain_text(question) - # Thinking (~4.5s, phase 21) + answer land in a few seconds. - expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000) - expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) - expect(page.locator("#send-label")).to_have_text("Send") - - -def _scroll_sample(page: Page) -> dict[str, float]: - """scrollTop / scrollHeight / clientHeight of the live Thinking window.""" - return page.evaluate( - f"""() => {{ const el = document.querySelector('{SELECTOR}'); - return {{ top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight }}; }}""" - ) - - -def _at_tail(sample: dict[str, float]) -> bool: - """True when the window is pinned to the live tail: the bottom of the - content is visible (scrollTop clamped at scrollHeight - clientHeight, - within 1px — the phase-17 pin's effect).""" - return abs(sample["top"] - (sample["height"] - sample["client"])) <= 1 - - -def _wait_text_stable(page: Page, timeout_ms: int = 30_000) -> None: - """Wait until the scratchpad text stops growing for 300ms. - - The mock paces frames at 0.02s, so a 300ms still length means the - thinking stream has ended — with the hesitation trigger, the 4s - pre-content pause (block still open, no further pin frames) is then - running.""" - page.wait_for_function( - f"""() => {{ const el = document.querySelector('{SELECTOR}'); - if (!el) return false; - const len = el.innerText.length; - const now = performance.now(); - if (!window.__thinkProbe) window.__thinkProbe = {{ len, at: now }}; - const p = window.__thinkProbe; - if (len !== p.len) {{ p.len = len; p.at = now; return false; }} - return now - p.at >= 300; }}""", - timeout=timeout_ms, - ) - - -def _wait_text_contains(page: Page, marker: str, timeout_ms: int = 30_000) -> None: - """Wait until the rendered scratchpad (whitespace-insensitive) contains - ``marker`` — a deterministic probe for a given point in the stream.""" - page.wait_for_function( - f"""(tail) => {{ const el = document.querySelector('{SELECTOR}'); - return !!el && el.innerText.replace(/\\s+/g, '').includes(tail); }}""", - arg=marker, - timeout=timeout_ms, - ) - - -# --------------------------------------------------------------------------- -# 1. No user scroll back: wheel, drag, and keyboard on the frozen live tail -# do not move the window -# --------------------------------------------------------------------------- - - -def test_thinking_window_not_user_scrollable( - page: Page, app_url: str, seeded_kb: None -) -> None: - page.set_default_timeout(30_000) - page.goto(app_url) - page.fill("#message-input", HESITATE_QUESTION) - page.evaluate("() => document.querySelector('#composer').requestSubmit()") - expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) - - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) - text_el = details.locator(".thinking-text") - - # Premise: the long scratchpad overflows the 320px clip. - page.wait_for_function( - f"() => {{ const el = document.querySelector('{SELECTOR}');" - " return !!el && el.scrollHeight > el.clientHeight; }", - timeout=30_000, - ) - - # The thinking stream has ENDED (4s hesitation pause running): no more - # pin frames, the block is still open, and the tail is frozen — any - # user scroll would persist and be observable here. - _wait_text_stable(page) - expect(details).to_have_attribute("open", "") - expect(page.locator(".msg.brain .bubble").last).to_have_text("") - - # Precondition: the phase-17 pin left the window at the live tail. - before = _scroll_sample(page) - assert before["height"] > before["client"], "the window must overflow" - assert _at_tail(before), "the pin must have left the window at the tail" - - # Focus the window (a plain div is not focusable — el.focus() is a - # no-op; the keyboard presses below land on the page focus instead. - # Neither path may move the window). - text_el.evaluate("el => el.focus()") - box = text_el.bounding_box() - assert box - cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 - page.mouse.move(cx, cy) - - # Wheel back (up) — must not scroll the window. - page.mouse.wheel(0, -200) - # Keyboard: Home + ArrowUp — must not scroll the window. - page.keyboard.press("Home") - page.keyboard.press("ArrowUp") - page.keyboard.press("ArrowUp") - # Mouse drag over the window — must not scroll the window - # (there is no scrollbar to grab with overflow hidden). - page.mouse.down() - page.mouse.move(cx, cy - 100, steps=5) - page.mouse.up() - # Wheel again, then let a would-be (regressed) scroll settle. - page.mouse.wheel(0, -200) - page.wait_for_timeout(150) - - # Still inside the pure-thinking window (the assertions below are only - # meaningful while the block is open and no content frame has landed). - expect(details).to_have_attribute("open", "") - after = _scroll_sample(page) - assert abs(after["top"] - before["top"]) <= 1, ( - f"user scroll moved the window: {before['top']} -> {after['top']}" - ) - assert _at_tail(after), "the window must still show the live tail" - - -# --------------------------------------------------------------------------- -# 2. Live tail tracking: the per-chunk pin keeps the window glued to the -# newest content (sampled at the 2nd-to-last and last chunks), and the -# last chunk's text is inside the visible rectangle -# --------------------------------------------------------------------------- - - -def test_thinking_window_tracks_live_tail( - page: Page, app_url: str, seeded_kb: None -) -> None: - page.set_default_timeout(30_000) - page.goto(app_url) - page.fill("#message-input", HESITATE_QUESTION) - page.evaluate("() => document.querySelector('#composer').requestSubmit()") - expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) - - details = page.locator(".msg.brain").last.locator("details.thinking") - details.wait_for(state="attached", timeout=10_000) - - # The exact deterministic scratchpad the mock will stream, sliced the - # same way the mock's _sse_stream does (12-char chunks). - expected = compose_thinking( - {"messages": [{"role": "user", "content": HESITATE_QUESTION}]} - ) - pieces = re.findall(r".{1,12}", expected, re.S) - ws = re.sub(r"\s+", "", "".join(pieces)) - #: 12 rendered chars ending at the 2nd-to-last chunk. - marker_second_last = re.sub(r"\s+", "", "".join(pieces[:-1]))[-12:] - #: 12 rendered chars at the very end (the last chunk). - marker_last = ws[-12:] - - # During the stream: once the 2nd-to-last chunk has landed, the - # window is pinned to the tail (the invariant holds at EVERY chunk; - # the pin runs per chunk while the block is open). - _wait_text_contains(page, marker_second_last) - assert _at_tail(_scroll_sample(page)), "not at the tail after chunk N-1" - - # After the last chunk: the 4s hesitation pause holds this state with - # the block still open — sample the tail pin, then the geometry. - _wait_text_contains(page, marker_last) - _wait_text_stable(page) - expect(details).to_have_attribute("open", "") - sample = _scroll_sample(page) - assert _at_tail(sample), f"not at the tail after the last chunk: {sample}" - - # Geometry: the last chunk's text (the final text node of the - # scratchpad) renders INSIDE the visible rectangle, and a hit-test at - # the box's bottom lands inside .thinking-text. - geo = page.evaluate( - f"""() => {{ const el = document.querySelector('{SELECTOR}'); - const box = el.getBoundingClientRect(); - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); - let last = null; - while (walker.nextNode()) last = walker.currentNode; - const range = document.createRange(); - range.selectNodeContents(last); - const r = range.getBoundingClientRect(); - const hit = document.elementFromPoint(box.left + 10, box.bottom - 5); - return {{ - nodeVisible: r.bottom > box.top && r.top < box.bottom, - nodeBottomInBox: r.bottom <= box.bottom + 1, - hitInside: hit ? el.contains(hit) : false, - }}; }}""" - ) - assert geo["nodeVisible"], "the last chunk's text is outside the window" - assert geo["nodeBottomInBox"], "the last chunk's text is clipped off the bottom" - assert geo["hitInside"], "a hit-test at the box bottom missed .thinking-text" - - -# --------------------------------------------------------------------------- -# 3. CSS contract: overflow-y hidden, 320px max-height, and the clip is real -# --------------------------------------------------------------------------- - - -def test_thinking_window_css_contract( - page: Page, app_url: str, seeded_kb: None -) -> None: - page.set_default_timeout(30_000) - page.goto(app_url) - send_and_wait(page, THINK_QUESTION) - - details = page.locator(".msg.brain").last.locator("details.thinking") - expect(details).not_to_have_attribute("open") # auto-collapsed - details.locator("summary").click() # open for measurement - expect(details).to_have_attribute("open", "") - - style = page.evaluate( - f"""() => {{ const el = document.querySelector('{SELECTOR}'); - const cs = getComputedStyle(el); - return {{ overflowY: cs.overflowY, maxHeight: cs.maxHeight, - scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }}; }}""" - ) - assert style["overflowY"] == "hidden", "the window must not be user-scrollable" - assert style["maxHeight"] == "320px", "the 320px clip must stay" - # The clip is real, not cosmetic: the long scratchpad overflows it. - assert style["scrollHeight"] > style["clientHeight"] - - -# --------------------------------------------------------------------------- -# 4. Regression (phase 11): the answer bubble is untouched — a long answer -# still grows the page, and normal (user) scrolling of the answer works -# --------------------------------------------------------------------------- - - -def test_answer_bubble_still_scrollable( - page: Page, app_url: str, seeded_kb: None -) -> None: - page.set_default_timeout(45_000) - page.goto(app_url) - page.fill("#message-input", LONG_QUESTION) - page.click("#send-btn") - expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION) - - # The ~900-word answer streams to completion (phase-11 contract). - bubble = page.locator(".msg.brain .bubble").last - expect(bubble).to_contain_text(LONG_ANSWER_END, timeout=60_000) - expect(page.locator("#send-btn")).to_be_enabled() - - # The answer bubble keeps its existing overflow (phase 21 only touched - # .thinking-text) — it is NOT the hidden clip. - overflow_y = page.evaluate( - "() => { const els = document.querySelectorAll('.msg.brain .bubble');" - " return getComputedStyle(els[els.length - 1]).overflowY; }" - ) - assert overflow_y != "hidden", "the answer bubble must keep its scroll behavior" - - # A long answer grows the PAGE — and the page still scrolls normally. - state0 = page.evaluate( - "() => ({ y: window.scrollY, sh: document.documentElement.scrollHeight," - " ch: window.innerHeight })" - ) - assert state0["sh"] > state0["ch"], "the long answer must make the page scrollable" - - box = bubble.bounding_box() - assert box - viewport = page.viewport_size - assert viewport # the conftest `page` fixture is fixed at 1280x800 - # A point in the visible lower part of the answer area (the page is - # pinned at the bottom, so the bubble's lower edge is in the viewport). - mx = box["x"] + box["width"] / 2 - my = max(50.0, min(box["y"] + box["height"] - 60.0, viewport["height"] - 100)) - hit = page.evaluate( - "([x, y]) => { const e = document.elementFromPoint(x, y);" - " return e ? e.tagName + '.' + String(e.className) : 'none'; }", - [mx, my], - ) - page.mouse.move(mx, my) - # Headless Chromium applies wheel scrolling through an async momentum - # pipeline — let each gesture settle before reading the position. - page.mouse.wheel(0, -400) # wheel up: away from the newest content - page.wait_for_timeout(500) - y_up = page.evaluate("() => window.scrollY") - page.mouse.wheel(0, 400) # wheel back down - page.wait_for_timeout(500) - y_down = page.evaluate("() => window.scrollY") - assert y_up < state0["y"] - 100, ( - f"the page must scroll up on wheel (wheel over {hit!r}): " - f"y {state0['y']} -> {y_up}" - ) - assert y_down > y_up, f"the page must scroll back down on wheel: {y_up} -> {y_down}" - - -# --------------------------------------------------------------------------- -# 5. Regression (phase 17): a stored thinking turn restores a COLLAPSED -# block with its full text (replicates the phase-17 reload pin — the -# restore path is untouched by phase 21, where overflow is moot) -# --------------------------------------------------------------------------- - - -def test_restored_collapsed_thinking_unaffected( - page: Page, app_url: str, seeded_kb: None -) -> None: - page.set_default_timeout(30_000) - page.goto(app_url) - send_and_wait(page, THINK_QUESTION) - - details = page.locator(".msg.brain").last.locator("details.thinking") - expect(details).not_to_have_attribute("open") # auto-collapsed - captured = details.locator(".thinking-text").text_content() - assert captured - - page.reload() - expect(page.locator("#empty-state")).to_be_hidden() - - restored = page.locator(".msg.brain").last.locator("details.thinking") - expect(restored).to_have_count(1) - expect(restored).not_to_have_attribute("open") # restored COLLAPSED - expect(restored.locator(".thinking-text")).to_have_text(captured) - - # Opening the restored block still shows the full scratchpad, and the - # answer + persistence are intact. - restored.locator("summary").click() - expect(restored).to_have_attribute("open", "") - expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT) - expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER) - raw = json.loads(page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')"))[ - "messages" - ][1]["thinking"] - assert re.sub(r"\s+", "", raw) == re.sub(r"\s+", "", captured) diff --git a/tests/e2e/test_thinking_scroll.py b/tests/e2e/test_thinking_scroll.py new file mode 100644 index 0000000..f34a4f0 --- /dev/null +++ b/tests/e2e/test_thinking_scroll.py @@ -0,0 +1,703 @@ +"""Phase 43 E2E (Playwright, mock-only): the Thinking window scrolls again. + +Story: ``.agent/user_stories/thinking-scroll-back.md`` +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_thinking_scroll.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported here — the real +``turbo`` decides its own reasoning length and pacing, and this story's +contract (320px clip, follow-while-pinned at the window bottom, pause on +scroll-up, resume on return) needs the deterministic mock's long, paced +scratchpad (phase-21 length: ``compose_thinking`` ≈ 2 700 chars ≈ 4.5s of +12-char/0.02s frames, overflowing the window by ~2x). + +This suite REPLACES the deleted phase-21 ``test_thinking_no_scroll.py`` +(owner direction 2026-08-27, ``TODO.md`` L7, roadmap A2 — the 2026-08-24 +no-scroll choice is reversed): ``details.thinking .thinking-text`` is +user-scrollable again (``overflow-y: auto``, 320px clip kept), and the +phase-17 bottom-pin is GATED in app.js (``THINKING_NEAR_BOTTOM_PX = 32``) +— the window follows the live tail only while the user is pinned near +its bottom; scrolling up pauses the follow, returning resumes it. + +Determinism notes: +* Tests 1–4 key off the mock's ``think out loud then hesitate`` trigger + (phase 20): the thinking stream, then a deterministic 4s pre-content + pause with the block still OPEN and no further pin frames — a frozen + live tail (test 1) plus generous, stable mid-stream windows (tests + 2–4 sample while the block is provably open and the bubble empty). +* Per-chunk pin: the gate re-runs on EVERY thinking frame; the user is + never re-pinned once scrolled up (distance > 32px only grows as the + content grows), and setting ``scrollTop`` back to the bottom re-arms + it on the very next chunk (a 12-char frame renders ≤ one line ≈ 22px, + always inside the 32px band — so "at the bottom" survives the whole + stream, which is exactly what test 2 samples). +* User-scroll gestures: wheel scrolling is 1:1 pixel-deterministic on + headless Chromium 151, and keyboard scrolling is exercised after + making the scratchpad focusable (``tabindex="0"`` + a real click — the + plain div is not keyboard-focusable by design; the attribute is test + scaffolding for the gesture, not app state). The mouse-drag gesture is + performed literally; headless Chromium's OVERLAY scrollbars are not + grabbable by synthetic mouse events (verified against a bare + overflow-auto probe page), so the drag assertion is that the window + stays where the user put it (no snap back to the live tail) — wheel + and keyboard are the proven movers. +* Mid-stream scroll-up/down (tests 3–4) is programmatic + (``el.scrollTop = …``), the phase-18 convention for simulating the + user's scroll-away: the pin is checked per frame in JS, so the value + set is exactly what the gate sees. + +Test → story mapping (Playwright Mapping Rule): +1. ``test_thinking_window_user_scrollable`` — frozen live tail (4s + hesitation): wheel up, ``Home``, mouse-drag up — ``scrollTop`` + moves; the window shows earlier content. +2. ``test_thinking_window_follows_while_pinned`` — live stream: at the + bottom, after the 2nd-to-last and the last chunk the window is + pinned to the tail (±1px); the last chunk's text renders inside the + visible rectangle. +3. ``test_thinking_window_stops_on_scroll_up`` — mid-stream: scrolled + up ~half the window, ``scrollTop`` is stable (±1px) over the next + ≥5 chunks — no re-pin. +4. ``test_thinking_window_resumes_on_return`` — from the paused state, + ``scrollTop`` back to the bottom: on the next chunk the window is + re-pinned to the tail (±1px). +5. ``test_thinking_window_css_contract`` — computed ``overflow-y`` is + ``auto``, ``max-height`` is 320px, and the clip is real. +6. ``test_answer_bubble_still_scrollable`` — regression (phase 11): + long answer, the page scrolls, the bubble's overflow is untouched. +7. ``test_restored_collapsed_thinking_unaffected`` — regression + (phase 17): a settled thinking turn reloads collapsed with full text. +""" +from __future__ import annotations + +import asyncio +import json +import re +from collections.abc import Iterator +from pathlib import Path +from threading import Thread +from typing import Any + +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from e2e.mock_llm import compose_thinking + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" + +#: Phase-17 trigger question (grounded turn, thinking + short answer). +THINK_QUESTION = "think out loud — how is my kubernetes cluster set up?" +#: Phase-20 hesitation trigger: the long phase-17/21 thinking stream, then +#: a deterministic 4s pause before the first content frame — a frozen +#: live tail with the block still open (the user-scroll test window). +HESITATE_QUESTION = "think out loud then hesitate — how is my kubernetes cluster set up?" +#: Phase-11 long-answer trigger (regression test 6). +LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer" +LONG_ANSWER_END = "LONG-ANSWER-END" +MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" +#: Line fragment the mock's deterministic scratchpad must carry (phase 17 +#: convention, mock_llm.compose_thinking). +THINKING_FRAGMENT = "Step 2: Check my notes" +STORAGE_KEY = "bor.chat.v1" + +SELECTOR = ".msg.brain details.thinking .thinking-text" + +#: The story's "within 1px" tolerance for tail-pin claims. +TAIL_TOL = 1 + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread (Playwright owns the test loop).""" + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (and query log), then optionally re-import fixtures.""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log")) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +@pytest.fixture() +def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]: + """A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats), + truncated again on teardown (same fixture shape as the phase-17/21 + suites).""" + summary = _reset_db(mock_llm, seed=True) + assert summary is not None and summary.added == 8 + yield + _reset_db(mock_llm, seed=False) + + +def send_and_wait(page: Page, question: str) -> None: + """Type into #message-input, submit via #composer, then wait until the + last brain message settles (send button re-enabled, label "Send").""" + page.fill("#message-input", question) + page.evaluate("() => document.querySelector('#composer').requestSubmit()") + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + # The mock streams at 0.02s/chunk, so thinking + answer land in a few + # seconds — 30s is generous on headless Chromium. + expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000) + expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) + expect(page.locator("#send-label")).to_have_text("Send") + + +def submit(page: Page, question: str) -> None: + """Submit a question and confirm the user bubble landed (the caller + then drives the mid-stream assertions itself).""" + page.fill("#message-input", question) + page.evaluate("() => document.querySelector('#composer').requestSubmit()") + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + + +def _scroll_sample(page: Page) -> dict[str, float]: + """scrollTop / scrollHeight / clientHeight of the Thinking window.""" + return page.evaluate( + f"""() => {{ const el = document.querySelector('{SELECTOR}'); + return {{ top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight }}; }}""" + ) + + +def _at_tail(sample: dict[str, float]) -> bool: + """True when the window is pinned to the live tail: the bottom of the + content is visible (``scrollTop`` clamped at ``scrollHeight - + clientHeight``, within ``TAIL_TOL`` — the gated pin's effect).""" + return abs(sample["top"] - (sample["height"] - sample["client"])) <= TAIL_TOL + + +def _wait_text_stable(page: Page, timeout_ms: int = 30_000) -> None: + """Wait until the scratchpad text stops growing for 300ms. + + The mock paces frames at 0.02s, so a 300ms still length means the + thinking stream has ended — with the hesitation trigger, the 4s + pre-content pause (block still open, no further pin frames) is then + running.""" + page.wait_for_function( + f"""() => {{ const el = document.querySelector('{SELECTOR}'); + if (!el) return false; + const len = el.innerText.length; + const now = performance.now(); + if (!window.__thinkProbe) window.__thinkProbe = {{ len, at: now }}; + const p = window.__thinkProbe; + if (len !== p.len) {{ p.len = len; p.at = now; return false; }} + return now - p.at >= 300; }}""", + timeout=timeout_ms, + ) + + +def _wait_text_contains(page: Page, marker: str, timeout_ms: int = 30_000) -> None: + """Wait until the rendered scratchpad (whitespace-insensitive) contains + ``marker`` — a deterministic probe for a given point in the stream.""" + page.wait_for_function( + f"""(tail) => {{ const el = document.querySelector('{SELECTOR}'); + return !!el && el.innerText.replace(/\\s+/g, '').includes(tail); }}""", + arg=marker, + timeout=timeout_ms, + ) + + +def _full_text_len() -> int: + """Whitespace-stripped length of the mock's FULL scratchpad for the + hesitation question (what the rendered innerText will reach at the + end of the thinking stream).""" + expected = compose_thinking( + {"messages": [{"role": "user", "content": HESITATE_QUESTION}]} + ) + return len(re.sub(r"\s+", "", expected)) + + +def _rendered_len(page: Page) -> int: + """Whitespace-stripped length of the rendered scratchpad (same + measure as ``_full_text_len`` — innerText adds nothing: the + scratchpad renders as one
with
line breaks, no escaping).
+ """
+ return page.evaluate(
+ f"""() => document.querySelector('{SELECTOR}')
+ .innerText.replace(/\\s+/g, '').length"""
+ )
+
+
+def _first_text_node(page: Page) -> dict[str, Any]:
+ """Rect of the scratchpad's FIRST text node vs the window's box — the
+ "earlier content is visible" probe (the mock's text starts with
+ ``Step 1: Read the question carefully``)."""
+ return page.evaluate(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ const box = el.getBoundingClientRect();
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ const first = walker.nextNode();
+ const range = document.createRange();
+ range.selectNodeContents(first);
+ const r = range.getBoundingClientRect();
+ return {{ nodeText: first ? first.textContent.slice(0, 8) : "",
+ nodeTop: r.top, boxTop: box.top, boxBottom: box.bottom }}; }}"""
+ )
+
+
+# ---------------------------------------------------------------------------
+# 1. User scroll restored: wheel, keyboard (Home), and mouse-drag on the
+# frozen live tail (4s hesitation) move / hold the window; earlier
+# content becomes visible
+# ---------------------------------------------------------------------------
+
+
+def test_thinking_window_user_scrollable(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ submit(page, HESITATE_QUESTION)
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
+
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ details.wait_for(state="attached", timeout=10_000)
+
+ # Premise: the long scratchpad overflows the 320px clip (the story's
+ # "window" is only real once it clips).
+ page.wait_for_function(
+ f"() => {{ const el = document.querySelector('{SELECTOR}');"
+ " return !!el && el.scrollHeight > el.clientHeight; }",
+ timeout=30_000,
+ )
+
+ # The thinking stream has ENDED (4s hesitation pause running): no more
+ # pin frames, the block is still open, and the tail is frozen — any
+ # user scroll persists and is observable here.
+ _wait_text_stable(page)
+ expect(details).to_have_attribute("open", "")
+ expect(page.locator(".msg.brain .bubble").last).to_have_text("")
+
+ # Precondition: the gated pin left the window at the live tail.
+ before = _scroll_sample(page)
+ assert before["height"] > before["client"], "the window must overflow"
+ assert _at_tail(before), "the pin must have left the window at the tail"
+
+ box = details.locator(".thinking-text").bounding_box()
+ assert box
+ cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
+ page.mouse.move(cx, cy)
+
+ # Wheel back (up) — the window (not the page) must scroll; headless
+ # Chromium applies the delta 1:1 (verified: 150 -> exactly 150px).
+ WHEEL_UP = 160
+ page.mouse.wheel(0, -WHEEL_UP)
+ page.wait_for_timeout(200)
+ wheeled = _scroll_sample(page)
+ moved = min(WHEEL_UP, before["top"]) # clamped at the window's top
+ assert wheeled["top"] <= before["top"] - moved + TAIL_TOL, (
+ f"wheel up did not scroll the window: {before['top']} -> {wheeled['top']}"
+ )
+ assert wheeled["top"] < before["top"], "the window must have scrolled up"
+
+ # Keyboard: the plain div is not keyboard-focusable by design — make
+ # it so (test scaffolding), give it REAL focus with a click, and press
+ # Home: the window must jump to its top (proven 1:1 on headless
+ # Chromium 151: click + Home -> 0, End -> maxScroll).
+ details.locator(".thinking-text").evaluate(
+ "el => { el.setAttribute('tabindex', '0'); }"
+ )
+ page.mouse.click(cx, cy)
+ page.keyboard.press("Home")
+ page.wait_for_timeout(200)
+ homed = _scroll_sample(page)
+ assert homed["top"] <= TAIL_TOL, f"Home must land the window at its top: {homed}"
+
+ # Earlier content is visible: the scratchpad's FIRST line ("Step 1…")
+ # now sits at the top of the visible window (it was off-screen while
+ # pinned to the tail).
+ first = _first_text_node(page)
+ assert first["nodeText"].startswith("Step 1"), "the first line must be Step 1"
+ assert first["boxTop"] - 1 <= first["nodeTop"] < first["boxTop"] + 60, (
+ f"the earlier content must be visible at the window top: {first}"
+ )
+
+ # Mouse drag up: the literal gesture over the window. Headless
+ # Chromium's overlay scrollbars are not grabbable by synthetic mouse
+ # events (bare overflow-auto probe: wheel + click/keys move the
+ # window; a thumb/track drag never does — no classic scrollbar is
+ # rendered, and `::-webkit-scrollbar` does not force one), so the
+ # contract proven here is that the drag leaves the window EXACTLY
+ # where the user put it — no snap back to the live tail (the
+ # ungated-pin regression would re-yank it). Wheel + keyboard above
+ # are the proven movers.
+ page.mouse.move(cx, cy)
+ page.mouse.down()
+ page.mouse.move(cx, cy - 80, steps=5)
+ page.mouse.up()
+ page.wait_for_timeout(200)
+ after = _scroll_sample(page)
+ assert abs(after["top"] - homed["top"]) <= TAIL_TOL, (
+ f"a mouse drag must not move the window from the user's position: "
+ f"{homed['top']} -> {after['top']}"
+ )
+ assert not _at_tail(after), "the window must stay scrolled up (no re-pin)"
+
+ # Still inside the pure-thinking window.
+ expect(details).to_have_attribute("open", "")
+ expect(page.locator(".msg.brain .bubble").last).to_have_text("")
+
+
+# ---------------------------------------------------------------------------
+# 2. Follow while pinned: at the window's bottom, the gated pin keeps the
+# window glued to the live tail — sampled at the 2nd-to-last and the
+# last chunk — and the last chunk's text renders inside the window
+# ---------------------------------------------------------------------------
+
+
+def test_thinking_window_follows_while_pinned(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ submit(page, HESITATE_QUESTION)
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
+
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ details.wait_for(state="attached", timeout=10_000)
+
+ # The exact deterministic scratchpad the mock will stream, sliced the
+ # same way the mock's _sse_stream does (12-char chunks). The user
+ # never scrolls: at the bottom, every 12-char frame renders at most
+ # one line (≈22px — single
,
line breaks), always inside the
+ # 32px band, so the gate passes on EVERY frame and the pin holds.
+ expected = compose_thinking(
+ {"messages": [{"role": "user", "content": HESITATE_QUESTION}]}
+ )
+ pieces = re.findall(r".{1,12}", expected, re.S)
+ ws = re.sub(r"\s+", "", "".join(pieces))
+ #: 12 rendered chars ending at the 2nd-to-last chunk.
+ marker_second_last = re.sub(r"\s+", "", "".join(pieces[:-1]))[-12:]
+ #: 12 rendered chars at the very end (the last chunk).
+ marker_last = ws[-12:]
+
+ # During the stream: once the 2nd-to-last chunk has landed, the window
+ # is pinned to the tail (the invariant holds at EVERY chunk while the
+ # user is at the bottom).
+ _wait_text_contains(page, marker_second_last)
+ assert _at_tail(_scroll_sample(page)), "not at the tail after chunk N-1"
+
+ # After the last chunk: the 4s hesitation pause holds this state with
+ # the block still open — sample the tail pin, then the geometry.
+ _wait_text_contains(page, marker_last)
+ _wait_text_stable(page)
+ expect(details).to_have_attribute("open", "")
+ expect(page.locator(".msg.brain .bubble").last).to_have_text("")
+ sample = _scroll_sample(page)
+ assert _at_tail(sample), f"not at the tail after the last chunk: {sample}"
+
+ # Geometry: the last chunk's text (the final text node of the
+ # scratchpad) renders INSIDE the visible rectangle, and a hit-test at
+ # the box's bottom lands inside .thinking-text.
+ geo = page.evaluate(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ const box = el.getBoundingClientRect();
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ let last = null;
+ while (walker.nextNode()) last = walker.currentNode;
+ const range = document.createRange();
+ range.selectNodeContents(last);
+ const r = range.getBoundingClientRect();
+ const hit = document.elementFromPoint(box.left + 10, box.bottom - 5);
+ return {{
+ nodeVisible: r.bottom > box.top && r.top < box.bottom,
+ nodeBottomInBox: r.bottom <= box.bottom + 1,
+ hitInside: hit ? el.contains(hit) : false,
+ }}; }}"""
+ )
+ assert geo["nodeVisible"], "the last chunk's text is outside the window"
+ assert geo["nodeBottomInBox"], "the last chunk's text is clipped off the bottom"
+ assert geo["hitInside"], "a hit-test at the box bottom missed .thinking-text"
+
+
+# ---------------------------------------------------------------------------
+# 3. Paused on scroll-up: mid-stream, scrolled up ~half the window, the
+# gated pin never re-pins — scrollTop is stable across ≥5 chunks
+# ---------------------------------------------------------------------------
+
+
+def test_thinking_window_stops_on_scroll_up(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ submit(page, HESITATE_QUESTION)
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
+
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ details.wait_for(state="attached", timeout=10_000)
+
+ # Mid-stream: the window has REAL headroom (≥100px of hidden content
+ # — the first overflowing frame, a few px, is not the story's
+ # "window") and the (untouched, pinned) window is at the tail — the
+ # user now scrolls up ~half the window.
+ page.wait_for_function(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ return !!el && el.scrollHeight - el.clientHeight >= 100; }}""",
+ timeout=30_000,
+ )
+ sample = _scroll_sample(page)
+ assert _at_tail(sample), "the pin must hold at the tail before the scroll-up"
+ total = _full_text_len()
+ grown = _rendered_len(page)
+ assert total - grown >= 84, "the stream must have ≥5 chunks left mid-stream"
+
+ top0 = page.evaluate(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2);
+ return el.scrollTop; }}"""
+ )
+ assert top0 >= 40, "half the window must be a real scroll-up"
+ len0 = _rendered_len(page)
+
+ # Over the NEXT ≥5 CHUNKS (84 chars = 7 × 12) the window must not
+ # move: scrolled-up readers are never re-pinned (the distance from
+ # the bottom only grows as content grows).
+ page.wait_for_function(
+ f"""(minLen) => {{ const el = document.querySelector('{SELECTOR}');
+ return !!el && el.innerText.length >= minLen; }}""",
+ arg=len0 + 84,
+ timeout=15_000,
+ )
+ after = _scroll_sample(page)
+ assert abs(after["top"] - top0) <= TAIL_TOL, (
+ f"the window must hold still over ≥5 chunks (no re-pin): "
+ f"{top0} -> {after['top']}"
+ )
+
+ # Still inside the pure-thinking window (the stability claim is only
+ # meaningful while the block is open and no content frame has landed).
+ expect(details).to_have_attribute("open", "")
+ expect(page.locator(".msg.brain .bubble").last).to_have_text("")
+
+
+# ---------------------------------------------------------------------------
+# 4. Resumes on return: from the paused state, scrollTop back to the
+# bottom — on the NEXT chunk the window is re-pinned to the tail
+# ---------------------------------------------------------------------------
+
+
+def test_thinking_window_resumes_on_return(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ submit(page, HESITATE_QUESTION)
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
+
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ details.wait_for(state="attached", timeout=10_000)
+
+ # Mid-stream: real window headroom (≥100px) + pinned tail (test-3
+ # premises).
+ page.wait_for_function(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ return !!el && el.scrollHeight - el.clientHeight >= 100; }}""",
+ timeout=30_000,
+ )
+ sample = _scroll_sample(page)
+ assert _at_tail(sample), "the pin must hold at the tail before the scroll-up"
+ total = _full_text_len()
+ grown = _rendered_len(page)
+ assert total - grown >= 96, "the stream must have enough chunks left to pause + resume"
+
+ # Scroll up ~half the window and confirm the PAUSED state (≥5 chunks,
+ # stable within 1px — the same invariant as test 3).
+ top0 = page.evaluate(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2);
+ return el.scrollTop; }}"""
+ )
+ assert top0 >= 40, "half the window must be a real scroll-up"
+ len0 = page.evaluate(f"() => document.querySelector('{SELECTOR}').innerText.length")
+ page.wait_for_function(
+ f"""(minLen) => {{ const el = document.querySelector('{SELECTOR}');
+ return !!el && el.innerText.length >= minLen; }}""",
+ arg=len0 + 84,
+ timeout=15_000,
+ )
+ assert abs(_scroll_sample(page)["top"] - top0) <= TAIL_TOL, (
+ "precondition: the window must be paused (test-3 invariant)"
+ )
+ len1 = _rendered_len(page)
+
+ # Return to the bottom: the gate re-arms on the very next chunk (the
+ # distance from the bottom is 0, and a 12-char frame renders ≤ one
+ # line — always inside the 32px band), so the pin fires immediately.
+ page.evaluate(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ el.scrollTop = el.scrollHeight; }}"""
+ )
+ page.wait_for_function(
+ f"""(minLen) => {{ const el = document.querySelector('{SELECTOR}');
+ return !!el && el.innerText.length > minLen; }}""",
+ arg=len1,
+ timeout=15_000,
+ )
+ after = _scroll_sample(page)
+ assert _at_tail(after), f"the window must be re-pinned on the next chunk: {after}"
+ assert after["top"] > top0, "the window must have returned toward the tail"
+
+ # Still inside the pure-thinking window.
+ expect(details).to_have_attribute("open", "")
+ expect(page.locator(".msg.brain .bubble").last).to_have_text("")
+
+
+# ---------------------------------------------------------------------------
+# 5. CSS contract: overflow-y auto (user scroll restored), 320px clip
+# kept, and the clip is real
+# ---------------------------------------------------------------------------
+
+
+def test_thinking_window_css_contract(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ send_and_wait(page, THINK_QUESTION)
+
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ expect(details).not_to_have_attribute("open") # auto-collapsed
+ details.locator("summary").click() # open for measurement
+ expect(details).to_have_attribute("open", "")
+
+ style = page.evaluate(
+ f"""() => {{ const el = document.querySelector('{SELECTOR}');
+ const cs = getComputedStyle(el);
+ return {{ overflowY: cs.overflowY, maxHeight: cs.maxHeight,
+ scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }}; }}"""
+ )
+ assert style["overflowY"] == "auto", "the window must be user-scrollable again"
+ assert style["maxHeight"] == "320px", "the 320px clip must stay"
+ # The clip is real, not cosmetic: the long scratchpad overflows it.
+ assert style["scrollHeight"] > style["clientHeight"]
+
+
+# ---------------------------------------------------------------------------
+# 6. Regression (phase 11): the answer bubble is untouched — a long answer
+# still grows the page, and normal (user) scrolling of the answer works
+# ---------------------------------------------------------------------------
+
+
+def test_answer_bubble_still_scrollable(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(45_000)
+ page.goto(app_url)
+ page.fill("#message-input", LONG_QUESTION)
+ page.click("#send-btn")
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
+
+ # The ~900-word answer streams to completion (phase-11 contract).
+ bubble = page.locator(".msg.brain .bubble").last
+ expect(bubble).to_contain_text(LONG_ANSWER_END, timeout=60_000)
+ expect(page.locator("#send-btn")).to_be_enabled()
+
+ # The answer bubble keeps its existing overflow (this phase only
+ # touched .thinking-text) — it is NOT a clipped scroll window.
+ overflow_y = page.evaluate(
+ "() => { const els = document.querySelectorAll('.msg.brain .bubble');"
+ " return getComputedStyle(els[els.length - 1]).overflowY; }"
+ )
+ assert overflow_y != "hidden", "the answer bubble must keep its scroll behavior"
+
+ # A long answer grows the PAGE — and the page still scrolls normally
+ # (phase 42: no autoscroll — the page sits wherever the user left it,
+ # so move it to the bottom first (phase-18 convention for simulating
+ # the user's scroll); the user's wheel then moves it up and down).
+ state0 = page.evaluate(
+ "() => { window.scrollTo(0, document.documentElement.scrollHeight);"
+ " return { y: window.scrollY, sh: document.documentElement.scrollHeight,"
+ " ch: window.innerHeight }; }"
+ )
+ assert state0["sh"] > state0["ch"], "the long answer must make the page scrollable"
+ assert state0["y"] > 100, "the page must have scrolled to its bottom"
+
+ box = bubble.bounding_box()
+ assert box
+ viewport = page.viewport_size
+ assert viewport # the conftest `page` fixture is fixed at 1280x800
+ # A point in the visible lower part of the answer area (the page is
+ # at the bottom, so the bubble's lower edge is in the viewport).
+ mx = box["x"] + box["width"] / 2
+ my = max(50.0, min(box["y"] + box["height"] - 60.0, viewport["height"] - 100))
+ page.mouse.move(mx, my)
+ # Headless Chromium applies wheel scrolling through an async momentum
+ # pipeline — let each gesture settle before reading the position.
+ page.mouse.wheel(0, -400) # wheel up: away from the newest content
+ page.wait_for_timeout(500)
+ y_up = page.evaluate("() => window.scrollY")
+ page.mouse.wheel(0, 400) # wheel back down
+ page.wait_for_timeout(500)
+ y_down = page.evaluate("() => window.scrollY")
+ assert y_up < state0["y"] - 100, (
+ f"the page must scroll up on wheel: y {state0['y']} -> {y_up}"
+ )
+ assert y_down > y_up, f"the page must scroll back down on wheel: {y_up} -> {y_down}"
+
+
+# ---------------------------------------------------------------------------
+# 7. Regression (phase 17): a stored thinking turn restores a COLLAPSED
+# block with its full text (the restore path is untouched by this
+# phase — with the pin gated on `block.open`, a restored closed block
+# is never auto-pinned)
+# ---------------------------------------------------------------------------
+
+
+def test_restored_collapsed_thinking_unaffected(
+ page: Page, app_url: str, seeded_kb: None
+) -> None:
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+ send_and_wait(page, THINK_QUESTION)
+
+ details = page.locator(".msg.brain").last.locator("details.thinking")
+ expect(details).not_to_have_attribute("open") # auto-collapsed
+ captured = details.locator(".thinking-text").text_content()
+ assert captured
+
+ page.reload()
+ expect(page.locator("#empty-state")).to_be_hidden()
+
+ restored = page.locator(".msg.brain").last.locator("details.thinking")
+ expect(restored).to_have_count(1)
+ expect(restored).not_to_have_attribute("open") # restored COLLAPSED
+ expect(restored.locator(".thinking-text")).to_have_text(captured)
+
+ # Opening the restored block still shows the full scratchpad, and the
+ # answer + persistence are intact.
+ restored.locator("summary").click()
+ expect(restored).to_have_attribute("open", "")
+ expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+ raw = json.loads(page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')"))[
+ "messages"
+ ][1]["thinking"]
+ assert re.sub(r"\s+", "", raw) == re.sub(r"\s+", "", captured)
diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py
index 2a0d6d5..8c49809 100644
--- a/tests/unit/test_chat_persistence.py
+++ b/tests/unit/test_chat_persistence.py
@@ -240,9 +240,9 @@ def test_restore_renders_collapsed_thinking_block() -> None:
def test_thinking_block_css_uses_phase08_tokens() -> None:
"""Phase 17 styling (Phase-08 tokens, WCAG AA): the block frame, the
- ≥44px summary control (brand-ink ≈8.7:1 on surface) and the live-tail
- scratchpad (ink-soft ≈6.9:1 on surface, 320px cap; phase 21 removed the
- user scroll — owner choice 2026-08-24)."""
+ ≥44px summary control (brand-ink ≈8.7:1 on surface) and the scratchpad
+ (ink-soft ≈6.9:1 on surface, 320px cap; user-scrollable again since
+ phase 43 — owner direction 2026-08-27, TODO.md L7)."""
css = _css()
block = re.search(r"details\.thinking \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style details.thinking"
@@ -262,6 +262,7 @@ def test_thinking_block_css_uses_phase08_tokens() -> None:
tbody = text.group(1)
assert "var(--ink-soft)" in tbody
assert "max-height: 320px" in tbody
- # Phase 21: no user scroll back — the window is a live tail only.
- assert "overflow-y: hidden" in tbody
- assert "overflow-y: auto" not in tbody
+ # Phase 43 (owner direction 2026-08-27): user-scrollable window again;
+ # the phase-17 bottom-pin (gated in app.js) is the autoscroll.
+ assert "overflow-y: auto" in tbody
+ assert "overflow-y: hidden" not in tbody
diff --git a/tests/unit/test_frontend_scroll.py b/tests/unit/test_frontend_scroll.py
index c678c7e..2c7f070 100644
--- a/tests/unit/test_frontend_scroll.py
+++ b/tests/unit/test_frontend_scroll.py
@@ -40,10 +40,15 @@ def _fn_body(js: str, name: str) -> str:
def test_phase18_gate_is_gone() -> None:
"""The follow-the-bottom machinery (phase 18) is removed by owner
- direction 2026-08-27: no band constant, no gate function, and no
- document-scroller measurement left anywhere in app.js."""
+ direction 2026-08-27: no page-level band constant, no gate
+ function, and no document-scroller measurement left anywhere in
+ app.js. (The phase-43 window-level THINKING_NEAR_BOTTOM_PX /
+ isThinkingNearBottom pair is a different band — the thinking
+ window's, not the page's.)"""
js = _js()
- assert "NEAR_BOTTOM_PX" not in js, "the 200px band constant must be gone"
+ assert not re.search(r"(? 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_live_tail_clip() -> None:
- """The window stays the fixed 320px clip (owner-confirmed: no
- auto-height growth) but is NO LONGER user-scrollable."""
- body = _thinking_text_rule(_css())
- assert "max-height: 320px" in body, "the 320px clip must stay"
- assert "overflow-y: hidden" in body, "the window must not scroll"
- assert "overflow-y: auto" not in body, "no user-scrollable window remains"
- assert "overflow-y: scroll" not in body
-
-
-def test_thinking_text_carries_owner_choice_comment() -> None:
- """The owner-choice comment explains WHY the window is a live tail —
- the phase-17 JS bottom-pin is the sole scroller."""
- body = _thinking_text_rule(_css())
- assert "owner choice 2026-08-24" in body
- assert "live tail" in body
- assert "sole scroller" in body
-
-
-def test_js_bottom_pin_intact_and_sole_scroller() -> None:
- """The live-tail mechanism (phase 17) must survive phase 21 untouched:
- the streaming `thinking` branch pins `textEl.scrollTop =
- textEl.scrollHeight` per chunk, and it is the ONLY scrollTop
- assignment in app.js (no new user-facing scroll code was added to the
- window)."""
- js = _js()
- pin = "textEl.scrollTop = textEl.scrollHeight"
- assert js.count(pin) == 1, "the bottom-pin must exist exactly once"
- # It lives in the streaming thinking branch (before the delta branch),
- # inside the `block.open` guard so closed blocks are not scrolled.
- 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]
- assert pin in thinking_branch
- assert "if (block.open)" in thinking_branch
-
-
-def test_no_js_change_to_thinking_scroll_behavior() -> None:
- """Phase 21 is CSS-only: nothing else in app.js touches the
- .thinking-text scroll (no scroll-behavior, no wheel/touch handlers, no
- scrollIntoView on the block — the page-level reveal stays the
- phase-18 scrollReveal, which is not a .thinking-text scroller)."""
- js = _js()
- block_template = js.find('