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('

') - assert block_template != -1, "the thinking block template must exist" - # No inline scroll styling on the element itself. - assert 'style="scroll' not in js - assert "scroll-behavior" not in js - assert "addEventListener(\"wheel\"" not in js - assert "addEventListener('wheel'" not in js diff --git a/tests/unit/test_thinking_scroll.py b/tests/unit/test_thinking_scroll.py new file mode 100644 index 0000000..1e0dc99 --- /dev/null +++ b/tests/unit/test_thinking_scroll.py @@ -0,0 +1,139 @@ +"""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"