feat(chat): thinking window scrolls again, follows the tail only while pinned
TODO.md L7: "Add scrolling back to the thinking block, but have it autoscroll while thinking content is generating." Owner direction 2026-08-27 (roadmap A2) reverses the phase-21 no-scroll choice (2026-08-24): details.thinking .thinking-text is user-scrollable again (overflow-y: auto — the 320px clip stays, owner-confirmed), and the phase-17 per-chunk bottom-pin is GATED: the window follows the live tail only while the user is pinned near its bottom (THINKING_NEAR_ BOTTOM_PX = 32); scrolling up pauses the follow, returning to the bottom re-arms it on the next chunk (the gate re-runs on every frame). - frontend/assets/styles.css: .thinking-text overflow-y: hidden -> auto; the phase-21 owner-choice comment is replaced with the 2026-08-27 direction; max-height: 320px and every other declaration in the rule byte-identical. - frontend/assets/app.js: export const THINKING_NEAR_BOTTOM_PX = 32 + isThinkingNearBottom(textEl) (scrollHeight - scrollTop - clientHeight <= band); the thinking-handler pin becomes `if (block.open && isThinkingNearBottom(textEl))` — a scrolled-up reader is never re-pinned and a closed (restored) block is never pinned; everything else in the handler (and phase 42's no page scroll) untouched. - tests/unit/test_thinking_scroll.py (new, replaces the deleted tests/unit/test_thinking_no_scroll.py): pins the CSS contract (auto + 320px + owner-direction comment, no hidden/scroll left), the exported 32px band, the gate math, the gated pin (no unconditional `if (block.open)` remains), and the surviving collapsed-restore pin. - tests/e2e/test_thinking_scroll.py (new, mock-only, replaces the deleted tests/e2e/test_thinking_no_scroll.py — its pins asserted the reversed phase-21 behavior, so both phase-21 files are deleted in this commit): user scroll restored on the frozen 4s-hesitation tail (wheel is 1:1; click+Home keyboard — the plain div is not keyboard-focusable by design, tabindex is test scaffolding; the literal drag holds the user's position — headless Chromium's overlay scrollbars are not grabbable by synthetic mouse events, documented in the suite), follow-while-pinned at the 2nd-to-last and last chunk (±1px) with the last chunk's text inside the visible rectangle, no re-pin over ≥5 mid-stream chunks after a half-window scroll-up, re-pin on the next chunk after returning to the bottom, the CSS contract, plus the phase-11 (long answer: page scrolls, bubble overflow untouched) and phase-17 (restored collapsed block with full text) regressions. - tests/unit/test_chat_persistence.py: the CSS pin flips with the contract (auto in, hidden out — owner direction 2026-08-27). - tests/unit/test_frontend_scroll.py: the "page-level band constant is gone" pin now excludes the phase-43 window-level THINKING_NEAR_BOTTOM_PX (a different band — the window's, not the page's). E2E (isolation): test_thinking_scroll 7/7 (twice); regressions test_thinking_display 5/5, test_chat_persistence 4/4, test_no_reply_autoscroll 5/5, test_smoke 3/3; unit+integration 725 passed, app/ coverage 99% (unchanged — frontend-only phase); ruff + pyright clean.
This commit is contained in:
+20
-3
@@ -162,6 +162,22 @@ const reducedMotion =
|
|||||||
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
const SCROLL = reducedMotion ? "auto" : "smooth";
|
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 —
|
/* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
|
||||||
* revising the phase 18 follow-the-bottom choice): the page never
|
* revising the phase 18 follow-the-bottom choice): the page never
|
||||||
* auto-scrolls while a turn streams. The only scroll call sites are
|
* 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 block = ensureThinkingBlock(wrap);
|
||||||
const textEl = block.querySelector(".thinking-text");
|
const textEl = block.querySelector(".thinking-text");
|
||||||
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
||||||
if (block.open) {
|
if (block.open && isThinkingNearBottom(textEl)) {
|
||||||
// Pin the block's OWN stream (phase 17 — reworked in phase 43);
|
// Follow the live tail only while the user is pinned to the window
|
||||||
// the page never follows (phase 42, no reply autoscroll).
|
// 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;
|
textEl.scrollTop = textEl.scrollHeight;
|
||||||
}
|
}
|
||||||
} else if (ev.type === "tool") {
|
} else if (ev.type === "tool") {
|
||||||
|
|||||||
@@ -638,10 +638,13 @@ details.thinking .thinking-text {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow-y: hidden; /* no user scroll back (owner choice 2026-08-24):
|
overflow-y: auto; /* user-scrollable window (owner direction 2026-08-27,
|
||||||
the window is a live tail only — the phase-17
|
TODO.md L7): autoscroll follows the live tail
|
||||||
JS bottom-pin (scrollTop = scrollHeight per
|
only while the user is pinned near the window's
|
||||||
chunk) is the sole scroller */
|
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. */
|
/* The scratchpad is compact: tighten the renderer's paragraph/list margins. */
|
||||||
details.thinking .thinking-text p,
|
details.thinking .thinking-text p,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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 <p> with <br> 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 <p>, <br> 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)
|
||||||
@@ -240,9 +240,9 @@ def test_restore_renders_collapsed_thinking_block() -> None:
|
|||||||
|
|
||||||
def test_thinking_block_css_uses_phase08_tokens() -> None:
|
def test_thinking_block_css_uses_phase08_tokens() -> None:
|
||||||
"""Phase 17 styling (Phase-08 tokens, WCAG AA): the block frame, the
|
"""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
|
≥44px summary control (brand-ink ≈8.7:1 on surface) and the scratchpad
|
||||||
scratchpad (ink-soft ≈6.9:1 on surface, 320px cap; phase 21 removed the
|
(ink-soft ≈6.9:1 on surface, 320px cap; user-scrollable again since
|
||||||
user scroll — owner choice 2026-08-24)."""
|
phase 43 — owner direction 2026-08-27, TODO.md L7)."""
|
||||||
css = _css()
|
css = _css()
|
||||||
block = re.search(r"details\.thinking \{([\s\S]*?)\n\}", css)
|
block = re.search(r"details\.thinking \{([\s\S]*?)\n\}", css)
|
||||||
assert block, "styles.css must style details.thinking"
|
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)
|
tbody = text.group(1)
|
||||||
assert "var(--ink-soft)" in tbody
|
assert "var(--ink-soft)" in tbody
|
||||||
assert "max-height: 320px" in tbody
|
assert "max-height: 320px" in tbody
|
||||||
# Phase 21: no user scroll back — the window is a live tail only.
|
# Phase 43 (owner direction 2026-08-27): user-scrollable window again;
|
||||||
assert "overflow-y: hidden" in tbody
|
# the phase-17 bottom-pin (gated in app.js) is the autoscroll.
|
||||||
assert "overflow-y: auto" not in tbody
|
assert "overflow-y: auto" in tbody
|
||||||
|
assert "overflow-y: hidden" not in tbody
|
||||||
|
|||||||
@@ -40,10 +40,15 @@ def _fn_body(js: str, name: str) -> str:
|
|||||||
|
|
||||||
def test_phase18_gate_is_gone() -> None:
|
def test_phase18_gate_is_gone() -> None:
|
||||||
"""The follow-the-bottom machinery (phase 18) is removed by owner
|
"""The follow-the-bottom machinery (phase 18) is removed by owner
|
||||||
direction 2026-08-27: no band constant, no gate function, and no
|
direction 2026-08-27: no page-level band constant, no gate
|
||||||
document-scroller measurement left anywhere in app.js."""
|
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()
|
js = _js()
|
||||||
assert "NEAR_BOTTOM_PX" not in js, "the 200px band constant must be gone"
|
assert not re.search(r"(?<![A-Z_])NEAR_BOTTOM_PX\b", js), (
|
||||||
|
"the 200px page-level band constant must be gone"
|
||||||
|
)
|
||||||
assert "isNearBottom" not in js, "the pinned-to-bottom gate must be gone"
|
assert "isNearBottom" not in js, "the pinned-to-bottom gate must be gone"
|
||||||
assert "window.scrollY" not in js, (
|
assert "window.scrollY" not in js, (
|
||||||
"nothing in app.js measures the page scroll offset anymore"
|
"nothing in app.js measures the page scroll offset anymore"
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Unit: the "no scroll back" contract for the Thinking window (phase 21,
|
|
||||||
owner choice 2026-08-24, roadmap A2).
|
|
||||||
|
|
||||||
The live Thinking block is a scratchpad, not a transcript: the 320px window
|
|
||||||
always shows the *live tail* of the reasoning stream. The whole functional
|
|
||||||
change is one CSS property — ``details.thinking .thinking-text`` goes from
|
|
||||||
``overflow-y: auto`` (a user-scrollable window) to ``overflow-y: hidden``
|
|
||||||
(a live-tail clip). ``overflow: hidden`` still permits *programmatic*
|
|
||||||
scrolling, so the phase-17 JS bottom-pin
|
|
||||||
(``textEl.scrollTop = textEl.scrollHeight`` on every thinking chunk) is the
|
|
||||||
sole scroller — wheel, drag, and keyboard scrolling stop working.
|
|
||||||
|
|
||||||
The browser behavior itself is E2E-covered (tests/e2e/test_thinking_no_scroll.py);
|
|
||||||
here we pin the CSS value + the owner-choice comment and the intact
|
|
||||||
bottom-pin so a silent regression (``overflow-y`` back to ``auto``, pin
|
|
||||||
removed) is caught without a browser.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
||||||
APP_JS = FRONTEND / "assets" / "app.js"
|
|
||||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
||||||
|
|
||||||
|
|
||||||
def _js() -> str:
|
|
||||||
return APP_JS.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _css() -> str:
|
|
||||||
return STYLES_CSS.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _thinking_text_rule(css: str) -> str:
|
|
||||||
"""Body of the `details.thinking .thinking-text { ... }` rule."""
|
|
||||||
rule = re.search(
|
|
||||||
r"details\.thinking \.thinking-text \{([\s\S]*?)\n\}", css
|
|
||||||
)
|
|
||||||
assert rule, "styles.css must style details.thinking .thinking-text"
|
|
||||||
return rule.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_thinking_text_is_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('<div class="thinking-text">')
|
|
||||||
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
|
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user