From 04a7f4c05d294363c71faecc0976865a5b127876 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 24 Aug 2026 18:24:57 -0400 Subject: [PATCH] =?UTF-8?q?fix(ui):=20thinking=20window=20no=20longer=20sc?= =?UTF-8?q?rolls=20=E2=80=94=20live=20320px=20view=20pinned=20to=20the=20s?= =?UTF-8?q?tream=20tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/20_sources_midstream_bug/00_phase.md | 122 ----- .../02_e2e_story_suite_commit.md | 76 --- .../01_disable_thinking_scroll.md | 57 --- frontend/assets/styles.css | 7 +- tests/e2e/mock_llm.py | 52 +- tests/e2e/test_follow_bottom_scroll.py | 3 +- tests/e2e/test_sources_midstream_bug.py | 6 +- tests/e2e/test_thinking_display.py | 13 +- tests/e2e/test_thinking_no_scroll.py | 455 ++++++++++++++++++ tests/unit/test_chat_persistence.py | 11 +- tests/unit/test_thinking_no_scroll.py | 95 ++++ 11 files changed, 618 insertions(+), 279 deletions(-) delete mode 100644 .agent/phases/todo/20_sources_midstream_bug/00_phase.md delete mode 100644 .agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md delete mode 100644 .agent/phases/todo/21_thinking_no_scroll/01_disable_thinking_scroll.md create mode 100644 tests/e2e/test_thinking_no_scroll.py create mode 100644 tests/unit/test_thinking_no_scroll.py diff --git a/.agent/phases/todo/20_sources_midstream_bug/00_phase.md b/.agent/phases/todo/20_sources_midstream_bug/00_phase.md deleted file mode 100644 index 06287da..0000000 --- a/.agent/phases/todo/20_sources_midstream_bug/00_phase.md +++ /dev/null @@ -1,122 +0,0 @@ -# Phase 20 — Sources Mid-Stream: an in-flight answer is not lost on navigation - -**Source:** `TODO.md` L3 — *"Clicking "sources" while chat is generating -clears chat and result will never show up"* -**Story:** `.agent/user_stories/sources-midstream.md` (created by task 02) -**Context:** `frontend/assets/app.js` — the phase-14 persistence block -(`STORAGE_KEY = "bor.chat.v1"`, `conversation`, `saveConversation`, -`rememberBrainTurn`), the turn state machine (`UI_STATE.thinking` / -`.streaming`), the streaming accumulators (`acc` / `thinkingAcc` / -`sawThinking`), and the phase-14 restore path; `frontend/index.html` -(`#nav-sources` link); `frontend/assets/header.js` (`clearChatStorage` — -the deliberate New-Chat clear, NOT this bug). - -## Objective -When the user leaves the chat page (the Sources nav link, the document -viewer, any link) while a turn is still in flight, the answer generated so -far must not vanish. Today the brain message is persisted only on `done`, -so navigating away aborts the stream and the partial answer is lost — the -user returns to their own question with no result, ever. After this phase, -returning to the chat shows the question **and** the partial answer that -had already streamed (rendered like any brain message, thinking block -restored if any). - -## Owner-confirmed (2026-08-24, roadmap A1) -1. **A partial answer is persisted as a plain brain message** — no - "(partial)" marker, no sources/suggestions (the turn is dead; the user - can re-ask for the full answer). -2. Navigation **before the first answer token** (pure thinking) persists - nothing brain-side: the question is restored, no empty/partial bubble. -3. The deliberate **New Chat** `clearChatStorage()` (sources/viewer pages) - is untouched — that clear is by design (phase 14/19). -4. No server-side resume (A10 stays stateless) and no - "leave page?" confirmation dialog. - -## Design -- **`app.js` — one new `pagehide` handler** (`window.addEventListener( - "pagehide", …)` — fires on navigate-away and bfcache store): - - Guard: only when a turn is in flight (current `uiState` is - `UI_STATE.thinking` or `UI_STATE.streaming`) **and** `acc` is - non-empty. - - Action: `rememberBrainTurn(acc, { thinking: thinkingAcc || - undefined })` — reuse the existing save-point helper, so the partial - text is stored raw (the restore path re-renders through the - escape-first markdown renderer; the phase-17 `thinking` field - restores the collapsed Thinking block). - - **Idempotency guard:** a turn-local `persistedOnLeave` flag so a - second `pagehide` (or bfcache store+restore churn) never appends the - same partial message twice. The `done` save point is unaffected - (navigation means the stream is dead; if the user returns via - bfcache the turn is already aborted by the unloading page). -- **Restore path:** unchanged — a stored partial message is a well-formed - brain message and renders exactly like a completed one (minus - sources/deflection, which it simply doesn't carry). -- **Non-goals:** no resume of the SSE stream, no API changes, no changes - to the New Chat buttons, sign-out, or the document-viewer back link. - -## Dependencies -- `14_chat_persistence` (complete) — `bor.chat.v1` shape, save points, - restore, and the `rememberBrainTurn` helper this phase reuses. -- `17_thinking_display` (complete) — the `thinking` field on persisted - brain messages and the `thinkingAcc` accumulator. -- `19_shared_header` (complete) — the `#nav-sources` link (admin-only) - the bug report clicks. -- `18_follow_bottom_scroll` (complete) — no overlap (scroll gating only). - -## Tasks -1. `01_persist_inflight_turn.md` — the `pagehide` partial-persistence - handler in `app.js` + source-level unit pins. -2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_sources_midstream_bug.py` - (the story gate, isolated), regression suites, story file, final - validation, the single atomic commit, phase move to `complete/`. - -## Locked decisions -- **A10 untouched** — API stays stateless; no resume. **A11 untouched** — - vanilla JS, no CDN. **A16 honored** — one new story E2E suite + - adapted regressions. No anchor changed. - -## Testing & Quality -- **Unit (source-level, new `tests/unit/test_sources_midstream.py`, - following the repo's source-pin pattern):** `app.js` registers a - `pagehide` listener; the guard references the in-flight `uiState` and a - non-empty `acc`; the partial path calls `rememberBrainTurn` with - `thinking: thinkingAcc || undefined`; a turn-local idempotency flag - exists; `STORAGE_KEY`/save-point comments updated to list the new - save point. -- **Integration:** none (no `app/` changes) — the `uv run pytest - --cov=app` number must stay at today's. -- **Coverage:** frontend-only; the >90% `app/` gate is unaffected, - re-run to prove it. -- **E2E:** `tests/e2e/test_sources_midstream_bug.py` (task 02), green - **in isolation** (prereq `podman compose up -d db`). -- **Lint/types:** `uv run ruff check . && uv run pyright` clean. - -## Completion Criteria -- [ ] Admin, mid-stream, clicks **Sources** → returns to `/`: the - question **and** the already-streamed partial answer are both - rendered; no error banner; `bor.chat.v1` holds the partial brain - message. -- [ ] Navigate away before the first token → back: question restored, - no empty/partial brain bubble. -- [ ] A completed turn is persisted exactly as before (sources, - deflection, suggestions intact). -- [ ] New Chat from the sources page still clears the conversation. -- [ ] `uv run pytest` green; `uv run pytest --cov=app - --cov-report=term-missing` ≥ today's number. -- [ ] `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov` - green in isolation; regressions green in isolation (one command - each): `test_chat_persistence.py`, `test_thinking_display.py`, - `test_shared_header.py`. -- [ ] `uv run ruff check . && uv run pyright` clean. -- [ ] UI Structure Check (AGENTS.md rule 5): no new UI surface — the - restored partial renders through the existing bubble/thinking - contract. -- [ ] `.agent/user_stories/sources-midstream.md` exists. -- [ ] One `--no-gpg-sign` commit (below); - `.agent/phases/todo/20_sources_midstream_bug/` moved to - `.agent/phases/complete/`. - -## Commit -```bash -git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return" -``` diff --git a/.agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md b/.agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md deleted file mode 100644 index 28933f1..0000000 --- a/.agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md +++ /dev/null @@ -1,76 +0,0 @@ -# Task 02 — E2E story suite, story file, validation, commit - -**Phase:** `20_sources_midstream_bug` · **Source:** `TODO.md` L3 - -## Objective -The story gate: `tests/e2e/test_sources_midstream_bug.py` proves the bug -is fixed end-to-end (navigate away mid-stream, come back, the partial -answer is there), plus the regression suites, the story file, final -validation, and the single atomic commit. - -## Work -1. `tests/e2e/test_sources_midstream_bug.py` (new — mirror - `test_chat_persistence.py`'s scaffolding: fixture import via - `_import_fixtures`/`_run_in_thread`, mock LLM on a thread, `login` - from `e2e.auth_helpers`). The mock LLM must stream **slowly enough** - that the turn is still in flight when the test navigates (reuse the - streaming pattern from `test_thinking_display.py` / `mock_llm.py`; - tune the per-chunk delay until the navigation lands mid-stream). - Tests (Playwright Mapping Rule — one per numbered scenario): - 1. `test_partial_answer_survives_sources_nav_midstream` — admin - (`login(page, app_url, next="/")`), send a question, wait for the - first streamed chunk to render (expect the first chunk's text in - the answer bubble), **click `#nav-sources`** (the actual nav link — - admin sees it), land on `/sources.html`, then `page.goto("/")`: - expect the question text AND the first-chunk text present, no - `role="alert"` banner. Read `localStorage` `bor.chat.v1`: the - messages contain a brain message whose text starts with the first - chunk. - 2. `test_no_orphan_brain_message_when_navigated_before_first_token` — - mock streams a `thinking` event, then a long pre-token pause; - navigate (direct `page.goto("/sources.html")` is fine here) during - the pause, return to `/`: the question is present, exactly one - user message and **zero** brain messages in both the DOM and - `bor.chat.v1`. - 3. `test_completed_turn_unaffected` — a turn that finishes normally - (`done`), then navigate to sources and back: full answer, sources - chips, and the done-metadata (sources array) intact in storage. - 4. `test_new_chat_still_clears_conversation` — regression: completed - turn → `/sources.html` → click the sources-page New Chat button → - lands on `/` with the empty state and `bor.chat.v1` removed. -2. `.agent/user_stories/sources-midstream.md` (new) — the short story - file matching the repo's story format (goal, the bug report verbatim - from `TODO.md` L3, the owner-confirmed A1 decisions from - `00_phase.md`, the E2E mapping table test-name → scenario). -3. Run the suite **in isolation** (prereq `podman compose up -d db`): - `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov`. -4. Regressions, in isolation, one command each (all must stay green): - - `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov` - - `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` - - `uv run pytest tests/e2e/test_shared_header.py -v --no-cov` -5. Final validation: `uv run pytest` green; `uv run pytest --cov=app - --cov-report=term-missing` ≥ today's number (>90% gate); - `uv run ruff check . && uv run pyright` clean. -6. **UI Structure Check** (AGENTS.md rule 5): the restored partial - renders through the existing bubble/thinking contract — no new - surface, no new ids, focus/contrast unchanged. -7. Write the phase report - (`.agent/reports/20_sources_midstream_bug/` — what changed, E2E - results, the manual-smoke note from task 01). -8. Commit (one atomic commit) and move the phase: - ```bash - git add -A .agent/ frontend/ tests/ - git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return" - mv .agent/phases/todo/20_sources_midstream_bug .agent/phases/complete/ - ``` - -## Testing & Quality -- Story suite green **in isolation**; the three regression suites green - in isolation; full unit+integration suite green; `app/` coverage at or - above today's number (>90%); ruff + pyright clean. - -## Completion Criteria -- [ ] `test_sources_midstream_bug.py` 4/4 in isolation. -- [ ] Regressions (persistence, thinking display, shared header) green. -- [ ] Story file + phase report exist. -- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`. diff --git a/.agent/phases/todo/21_thinking_no_scroll/01_disable_thinking_scroll.md b/.agent/phases/todo/21_thinking_no_scroll/01_disable_thinking_scroll.md deleted file mode 100644 index 0a7ba6c..0000000 --- a/.agent/phases/todo/21_thinking_no_scroll/01_disable_thinking_scroll.md +++ /dev/null @@ -1,57 +0,0 @@ -# Task 01 — styles.css: `.thinking-text` overflow hidden (no user scroll) - -**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4 — -*"Disable scroll in the thinking window. Users don't need to scroll back -through thinking, just see it live."* - -## Objective -One CSS property change makes the Thinking window a live-tail-only view: -`overflow-y: hidden` instead of `auto`, keeping the 320px clip. The -phase-17 JS bottom-pin (which keeps working under `overflow: hidden`) is -the sole scroller. - -## Work -1. `frontend/assets/styles.css` — in the phase-17 thinking block - section (~line 444): - ```css - details.thinking .thinking-text { - padding: 0 0.75rem 0.75rem; - color: var(--ink-soft); /* 6.9:1 on --surface */ - font-size: 0.875rem; - line-height: 1.55; - max-height: 320px; - overflow-y: hidden; /* no 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 */ - } - ``` - (Only the `overflow-y` value + comment change; every other declaration - stays byte-identical.) -2. `frontend/assets/app.js` — **no change expected.** Verify the pin is - intact: the streaming `thinking` branch still does - `textEl.scrollTop = textEl.scrollHeight` on every chunk (~line 886). - If (and only if) the pin were missing/broken, fix it — do not remove - or alter any other scrolling behavior. -3. `tests/unit/test_thinking_no_scroll.py` (new — repo source-pin - pattern): - - `styles.css`: the `details.thinking .thinking-text` rule contains - `overflow-y: hidden` and `max-height: 320px` (no `overflow-y: auto` - left in that rule). - - `app.js`: the bottom-pin line - `textEl.scrollTop = textEl.scrollHeight` is still present (the - live-tail mechanism). -4. Manual smoke (dev server): stream a long thinking turn; try to wheel / - drag / Tab+ArrowDown inside the Thinking block — it must not move; - the newest chunk is always the one visible at the bottom. - -## Testing & Quality -- `uv run pytest tests/unit/test_thinking_no_scroll.py -v` green. -- `uv run ruff check . && uv run pyright` clean. - -## Completion Criteria -- [ ] `.thinking-text` is `overflow-y: hidden`, `max-height: 320px`, with - the owner-choice comment. -- [ ] The JS bottom-pin is verified intact (no app.js diff unless the - pin was broken). -- [ ] Unit pins green; lint/types clean; manual smoke passed. diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 52b6637..a6ae545 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -439,7 +439,7 @@ details.thinking summary:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; } -/* The scratchpad is a scrollable, compact area (max-height keeps long +/* The scratchpad is a live-tail, compact area (max-height keeps long reasoning from pushing the answer off-screen while open). */ details.thinking .thinking-text { padding: 0 0.75rem 0.75rem; @@ -447,7 +447,10 @@ details.thinking .thinking-text { font-size: 0.875rem; line-height: 1.55; max-height: 320px; - overflow-y: auto; + 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 */ } /* The scratchpad is compact: tighten the renderer's paragraph/list margins. */ details.thinking .thinking-text p, diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 056d4ba..284a073 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -16,8 +16,9 @@ Implements just enough of the aipi surface: - user message containing ``pretend to think slowly`` -> 3s warm-up delay (used by the loading-feedback story). - user message containing ``think out loud`` -> the answer is preceded by - ~800 chars of deterministic ``reasoning_content`` chunks (the - thinking-display story, phase 17). + ~2 700 chars of deterministic ``reasoning_content`` chunks (the + thinking-display story, phase 17; lengthened in phase 21 so the + rendered scratchpad overflows the 320px ``.thinking-text`` window) - user message containing ``think out loud then hesitate`` -> the ``think out loud`` stream, then a 4s pause before the first content frame (the sources-midstream story, phase 20 — a deterministic @@ -167,13 +168,18 @@ def compose_answer(body: dict[str, Any]) -> str: def compose_thinking(body: dict[str, Any]) -> str: - """Deterministic reasoning scratchpad (thinking-display story, phase 17). + """Deterministic reasoning scratchpad (thinking-display story, phase 17; + lengthened in phase 21). - A fixed 4-line "Step 1… Step 4" template quoting the first ~60 chars - of the user question: unique per question, byte-stable across runs, - ~700–900 chars total (≈ 60–75 frames at the mock's 12-char/0.02s - pacing). The ``Step 2: Check my notes`` line fragment is what the E2E - assertions key off. + A fixed "Step 1… Step 4" template interleaved with a "Scratch" deep-dive + block, quoting the first ~60 chars of the user question: unique per + question, byte-stable across runs, ~2 700 chars total (≈ 230 frames at + the mock's 12-char/0.02s pacing). The length is deliberate (phase 21, + thinking-no-scroll story): rendered in the 320px ``.thinking-text`` + window it overflows by ~2x, so the live-tail clip and the no-user-scroll + contract are observable in E2E. The ``Step 2: Check my notes`` line + fragment (phase 17) and the ``nothing is invented`` tail (phase 20's + THINKING_TAIL) are what the E2E assertions key off — both are preserved. """ q = _user(body).strip()[:60] return ( @@ -183,6 +189,36 @@ def compose_thinking(body: dict[str, Any]) -> str: "Step 2: Check my notes for the closest match. The homelab kubernetes file " "is the obvious candidate, but I should also consider whether a deployments " "note covers the same ground better.\n" + "Scratch 1: the kubernetes file is organized by component — control plane, " + "worker nodes, ingress, storage — so I can map each part of the question to " + "a section instead of summarizing the whole file at once, and keep the " + "answer anchored to the structure the notes actually use.\n" + "Scratch 2: I should check whether the deployments note duplicates any of " + "that ground; if it does, I will prefer the homelab file because the " + "question is phrased around the cluster itself, and I will say which file " + "each fact came from so the citation is honest.\n" + "Scratch 3: versions and ports are the facts most likely to be stale in my " + "memory — the etcd backup schedule, the ingress controller port, the " + "registry mirror address — so I will re-read those lines verbatim before " + "writing a single one of them into the answer.\n" + "Scratch 4: if the answer needs a sequence, for example how a node joins the " + "cluster or how the load balancer fronts the control plane, I will keep the " + "order exactly as the notes write it rather than re-deriving it from general " + "kubernetes knowledge that may not match this setup.\n" + "Scratch 5: anything I cannot find in the notes — a host I do not recognize, " + "a version I am not sure about, a schedule I cannot place — gets left out of " + "the answer instead of guessed, because the honesty rule beats a longer " + "answer every single time.\n" + "Scratch 6: one more pass over the question wording to make sure I am " + "answering the cluster setup, not some other homelab topic that shares the " + "same vocabulary, and I will stay on the specific the question asked about.\n" + "Scratch 7: I will also verify that the file describes the current setup — " + "if the notes mention a migration from an older cluster, I should answer " + "from the post-migration section and not mix in the old host names or the " + "old port numbers that no longer apply.\n" + "Scratch 8: final shape check before I commit — short paragraphs, a few " + "bullets at most, the document path cited where the fact came from, and no " + "invented facts anywhere in the draft.\n" "Step 3: Re-read the relevant sections top to bottom so every specific — " "hosts, versions, ports, schedules — is exact as written rather than " "remembered, and note which document each fact comes from.\n" diff --git a/tests/e2e/test_follow_bottom_scroll.py b/tests/e2e/test_follow_bottom_scroll.py index a9b691c..ca42daf 100644 --- a/tests/e2e/test_follow_bottom_scroll.py +++ b/tests/e2e/test_follow_bottom_scroll.py @@ -76,7 +76,8 @@ NEAR_BOTTOM_PX = 200 #: 0.02s/frame pace) — the wide, deterministic window to scroll away in. LONG_QUESTION = "write a long answer about my kubernetes cluster" #: Phase-17 thinking prefix + the long-answer trigger: both mock triggers -#: fire independently (a ~1.3s reasoning stream, then the long answer). +#: fire independently (a ~4.5s reasoning stream — lengthened in phase 21 — +#: then the long answer). THINK_LONG_QUESTION = "think out loud — write a long answer about my kubernetes cluster" #: The mock long answer's unique final line (mock_llm.LONG_ANSWER_END) — #: proves the whole stream landed even while the viewport was at the top. diff --git a/tests/e2e/test_sources_midstream_bug.py b/tests/e2e/test_sources_midstream_bug.py index 16e848b..00c82ce 100644 --- a/tests/e2e/test_sources_midstream_bug.py +++ b/tests/e2e/test_sources_midstream_bug.py @@ -250,9 +250,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token( page.click("#send-btn") expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION) - # The mock streamed the ~800-char scratchpad (phase-17 thinking - # frames); wait until its tail is rendered — the 4s pre-content pause - # (SLOW_PRETOKEN_TRIGGER) is now running, so the navigation below + # Wait until the scratchpad's tail is rendered (phase-17 thinking body, + # ~2 700 chars / ≈4.5s, lengthened in phase 21) — the 4s pre-content + # pause (SLOW_PRETOKEN_TRIGGER) is now running, so the navigation below # lands inside pure thinking with a wide margin. thinking = page.locator(".msg.brain").last.locator("details.thinking") thinking.wait_for(state="attached", timeout=10_000) diff --git a/tests/e2e/test_thinking_display.py b/tests/e2e/test_thinking_display.py index af50293..2a322c0 100644 --- a/tests/e2e/test_thinking_display.py +++ b/tests/e2e/test_thinking_display.py @@ -18,10 +18,11 @@ Test → story mapping (Playwright Mapping Rule): 5. ``test_thinking_with_deflection`` Determinism note: the mock paces every SSE frame at 0.02s and the thinking -text is ~700–900 chars (≈ 60–75 frames ≈ 1.2–1.5s) before the first -content frame, so "attach → assert open" runs well inside the open window -on headless Chromium; all other assertions are made after the send button -re-enables (fully settled state). +text is ~2 700 chars (≈ 230 frames ≈ 4.5s — lengthened in phase 21 so the +scratchpad overflows the 320px window) before the first content frame, so +"attach → assert open" runs well inside the open window on headless +Chromium; all other assertions are made after the send button re-enables +(fully settled state). """ from __future__ import annotations @@ -139,8 +140,8 @@ def test_thinking_block_streams_open_then_collapses( # token — and is created OPEN. details = page.locator(".msg.brain").last.locator("details.thinking") details.wait_for(state="attached", timeout=10_000) - # The ~800-char thinking stream (≈1.3s) keeps the block open right - # after attach — assert while it is still streaming. + # The ~2 700-char thinking stream (≈4.5s, phase 21) keeps the block + # open right after attach — assert while it is still streaming. expect(details).to_have_attribute("open", "") expect(details.locator(".thinking-text")).not_to_have_text("") diff --git a/tests/e2e/test_thinking_no_scroll.py b/tests/e2e/test_thinking_no_scroll.py new file mode 100644 index 0000000..2bc6366 --- /dev/null +++ b/tests/e2e/test_thinking_no_scroll.py @@ -0,0 +1,455 @@ +"""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/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index 8514c37..6492975 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -234,8 +234,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 scrollable - scratchpad (ink-soft ≈6.9:1 on surface, 320px cap).""" + ≥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).""" css = _css() block = re.search(r"details\.thinking \{([\s\S]*?)\n\}", css) assert block, "styles.css must style details.thinking" @@ -251,8 +252,10 @@ def test_thinking_block_css_uses_phase08_tokens() -> None: assert "var(--brand-ink)" in sbody assert "cursor: pointer" in sbody text = re.search(r"details\.thinking \.thinking-text \{([\s\S]*?)\n\}", css) - assert text, "the .thinking-text scroll area must be styled" + assert text, "the .thinking-text live-tail area must be styled" tbody = text.group(1) assert "var(--ink-soft)" in tbody assert "max-height: 320px" in tbody - assert "overflow-y: auto" 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 diff --git a/tests/unit/test_thinking_no_scroll.py b/tests/unit/test_thinking_no_scroll.py new file mode 100644 index 0000000..da27681 --- /dev/null +++ b/tests/unit/test_thinking_no_scroll.py @@ -0,0 +1,95 @@ +"""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('
') + 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