All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
165 lines
7.1 KiB
Python
165 lines
7.1 KiB
Python
"""Unit: the "thinking scroll back" contract for the Thinking window
|
|
(phase 43, owner direction 2026-08-27, ``TODO.md`` L7, roadmap A2 —
|
|
reversing the phase-21 owner choice 2026-08-24).
|
|
|
|
The Thinking window is user-scrollable again: ``details.thinking
|
|
.thinking-text`` goes back to ``overflow-y: auto`` (the 320px clip is
|
|
kept, owner-confirmed). Wheel, drag, and keyboard move the window; the
|
|
autoscroll — the phase-17 bottom-pin (``textEl.scrollTop =
|
|
textEl.scrollHeight`` per thinking chunk) — is GATED: it follows the
|
|
live tail only while the user is pinned near the window's bottom (the
|
|
32px band, ``THINKING_NEAR_BOTTOM_PX``). Scrolling up pauses the
|
|
follow; returning to the bottom re-arms it (the check runs on every
|
|
chunk, by construction).
|
|
|
|
The gate is measured against the PRE-render geometry: the chunk's
|
|
re-render grows the window's content below the old bottom, so a
|
|
post-render reading measures the new chunk's height, not the user's
|
|
position — any chunk taller than the 32px band (a real model's
|
|
sentence, or a "\n\n" paragraph break) killed the follow at the first
|
|
2-newline gap. The pin state is captured into ``pinned`` BEFORE
|
|
``textEl.innerHTML = …`` and the pin line runs inside ``if (pinned)``
|
|
after it.
|
|
|
|
The browser behavior itself is E2E-covered
|
|
(tests/e2e/test_thinking_scroll.py, task 03); here we pin the CSS
|
|
value + the owner-direction comment, the exported band, the gate
|
|
function's math, and the pre-render capture + gated pin call — so a
|
|
silent regression (``overflow-y`` back to ``hidden``, band removed,
|
|
pin ungated, or the capture moved back after the re-render) is caught
|
|
without a browser.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
|
|
def _js() -> str:
|
|
return APP_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _css() -> str:
|
|
return STYLES_CSS.read_text(encoding="utf-8")
|
|
|
|
|
|
def _thinking_text_rule(css: str) -> str:
|
|
"""Body of the `details.thinking .thinking-text { ... }` rule."""
|
|
rule = re.search(
|
|
r"details\.thinking \.thinking-text \{([\s\S]*?)\n\}", css
|
|
)
|
|
assert rule, "styles.css must style details.thinking .thinking-text"
|
|
return rule.group(1)
|
|
|
|
|
|
def test_thinking_text_is_user_scrollable_window() -> None:
|
|
"""The window is user-scrollable again (wheel / drag / keyboard
|
|
move it — frozen-tail state) and keeps the fixed 320px clip."""
|
|
body = _thinking_text_rule(_css())
|
|
assert "overflow-y: auto" in body, "the window must be user-scrollable"
|
|
assert "max-height: 320px" in body, "the 320px clip must stay"
|
|
assert "overflow-y: hidden" not in body, "no live-tail-only clip remains"
|
|
assert "overflow-y: scroll" not in body
|
|
|
|
|
|
def test_thinking_text_carries_owner_direction_comment() -> None:
|
|
"""The owner-direction comment (2026-08-27, ``TODO.md`` L7) explains
|
|
the new contract: autoscroll follows the live tail only while the
|
|
user is pinned near the window's bottom — scrolling up pauses the
|
|
follow, returning to the bottom resumes it."""
|
|
body = _thinking_text_rule(_css())
|
|
assert "owner direction 2026-08-27" in body
|
|
assert "TODO.md L7" in body
|
|
|
|
|
|
def test_thinking_near_bottom_band_exported() -> None:
|
|
"""The 32px follow-the-tail band is exported (same unit-pinned
|
|
pattern as TURN_TIMEOUT_MS):
|
|
``export const THINKING_NEAR_BOTTOM_PX = 32;``."""
|
|
js = _js()
|
|
assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js
|
|
|
|
|
|
def test_is_thinking_near_bottom_band_math() -> None:
|
|
"""``isThinkingNearBottom(textEl)`` is the window-level band check:
|
|
the distance from the window's bottom
|
|
(``scrollHeight - scrollTop - clientHeight``) must be
|
|
``<= THINKING_NEAR_BOTTOM_PX`` — the "window bottom in view"
|
|
threshold."""
|
|
js = _js()
|
|
fn = re.search(
|
|
r"function isThinkingNearBottom\(textEl\) \{([\s\S]*?)\n\}", js
|
|
)
|
|
assert fn, "app.js must define isThinkingNearBottom(textEl)"
|
|
body = fn.group(1)
|
|
assert re.search(
|
|
r"textEl\.scrollHeight\s*-\s*textEl\.scrollTop\s*-\s*"
|
|
r"textEl\.clientHeight\s*<=\s*THINKING_NEAR_BOTTOM_PX",
|
|
body,
|
|
), "the band math must compare the bottom distance to the band"
|
|
|
|
|
|
def test_thinking_pin_is_gated_on_window_bottom() -> None:
|
|
"""The phase-17 pin is GATED on the user's pin state captured BEFORE
|
|
the re-render: ``const pinned = block.open &&
|
|
isThinkingNearBottom(textEl)`` sits above ``textEl.innerHTML =
|
|
…`` in the streaming thinking branch, and the pin line runs inside
|
|
``if (pinned)`` below it. Measuring after the update would read the
|
|
new chunk's rendered height instead of the user's position — the
|
|
"2-newline gap" regression. ``block.open`` stays in the capture so a
|
|
closed block (e.g. restored collapsed, phase 17) is never pinned,
|
|
and no direct ``if (block.open)`` gate remains anywhere in app.js."""
|
|
js = _js()
|
|
pin = "textEl.scrollTop = textEl.scrollHeight"
|
|
assert js.count(pin) == 1, "the bottom-pin must exist exactly once"
|
|
# The pin lives in the streaming thinking branch (before the delta
|
|
# branch).
|
|
thinking_idx = js.find('ev.type === "thinking"')
|
|
delta_idx = js.find('ev.type === "delta"')
|
|
assert -1 < thinking_idx < delta_idx
|
|
thinking_branch = js[thinking_idx:delta_idx]
|
|
capture = "block.open && isThinkingNearBottom(textEl)"
|
|
render = "textEl.innerHTML = renderMarkdown(currentRun.run.text)"
|
|
gate = "if (pinned)"
|
|
assert capture in thinking_branch, (
|
|
"the combined gate must be captured (block.open + the window band)"
|
|
)
|
|
assert gate in thinking_branch, "the pin must sit inside the captured gate"
|
|
# Pre-capture → re-render → gated pin, in exactly that order: the
|
|
# pin state is the user's PRE-render position, not the chunk's
|
|
# rendered height.
|
|
cap_idx = thinking_branch.find(capture)
|
|
render_idx = thinking_branch.find(render, cap_idx)
|
|
assert render_idx != -1 and render_idx > cap_idx, (
|
|
"the pin state must be measured BEFORE the re-render"
|
|
)
|
|
gate_idx = thinking_branch.find(gate, render_idx)
|
|
assert gate_idx != -1 and gate_idx > render_idx, (
|
|
"the pin must run AFTER the re-render, inside the captured gate"
|
|
)
|
|
pin_idx = thinking_branch.find(pin, gate_idx)
|
|
assert pin_idx != -1
|
|
assert thinking_branch.count(pin) == 1
|
|
# No direct `if (block.open)` gate may remain — the capture is the
|
|
# only gate left.
|
|
assert "if (block.open)" not in js, (
|
|
"no direct `if (block.open)` gate may remain — the capture is the gate"
|
|
)
|
|
|
|
|
|
def test_restore_path_renders_collapsed_block() -> None:
|
|
"""Phase 17 survives: the restore path renders the thinking block
|
|
COLLAPSED above the bubble (``block.open = false`` in
|
|
``renderStoredMessage``) — and with the pin gated on
|
|
``block.open``, a restored closed block is never auto-pinned."""
|
|
js = _js()
|
|
start = js.find("function renderStoredMessage(m) {")
|
|
assert start != -1, "app.js must keep the phase-14 restore renderer"
|
|
body = js[start : js.find("\n}", start)]
|
|
assert "createThinkingBlock()" in body
|
|
assert "block.open = false" in body, "restored blocks must be collapsed"
|