phase: 166_hide_empty_answer_bubble
**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.
- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
- `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
- `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
- `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
This commit is contained in:
@@ -270,12 +270,28 @@ def test_no_autoscroll_during_long_answer(
|
||||
# Phase 48: in-flight detection rides the Stop label (the button is
|
||||
# the enabled Stop control while a turn runs — it is no longer
|
||||
# disabled, so the disabled-state proxy is gone).
|
||||
#
|
||||
# WHAT IS MEASURED (2026-10-08): the VISUAL position of the text the
|
||||
# user is reading — the first answer bubble's top relative to the
|
||||
# viewport — not the raw scrollY. Chromium's native scroll anchoring
|
||||
# legitimately compensates scrollY when layout above the viewport
|
||||
# changes (at settle the Edit/Retry buttons re-target to the newest
|
||||
# turn, so the FIRST turn's meta row collapses 44px→0): the reader
|
||||
# sees nothing move, but a bare scrollY-constancy check would flag
|
||||
# the compensating adjustment itself. A real up-hop (the regression
|
||||
# this suite pins — the old block:"end" submit jump, programmatic
|
||||
# follow-scrolls) moves the marker just the same, so the contract is
|
||||
# asserted strictly, and correctly, on what the user perceives.
|
||||
marker_top = """() => {
|
||||
const el = document.querySelector('.msg.brain .bubble');
|
||||
return el ? el.getBoundingClientRect().top : null;
|
||||
}"""
|
||||
samples: list[float] = []
|
||||
mid_stream = 0
|
||||
deadline = time.monotonic() + 40
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.25)
|
||||
samples.append(scroll_state(page)["y"])
|
||||
samples.append(page.evaluate(marker_top))
|
||||
if page.locator("#send-label").inner_text() == "Stop":
|
||||
mid_stream += 1
|
||||
if len(samples) >= 10 and page.locator("#send-label").inner_text() == "Send":
|
||||
@@ -283,7 +299,7 @@ def test_no_autoscroll_during_long_answer(
|
||||
# queued behind the stream may move the page either).
|
||||
for _ in range(3):
|
||||
time.sleep(0.3)
|
||||
samples.append(scroll_state(page)["y"])
|
||||
samples.append(page.evaluate(marker_top))
|
||||
break
|
||||
else:
|
||||
raise AssertionError("the long turn did not settle within 40s")
|
||||
@@ -291,8 +307,8 @@ def test_no_autoscroll_during_long_answer(
|
||||
|
||||
spread = max(samples) - min(samples)
|
||||
assert spread <= STABLE_PX, (
|
||||
f"the viewport moved {spread:.1f}px while the user was scrolled up "
|
||||
"(no-reply-autoscroll contract)"
|
||||
f"the page moved {spread:.1f}px under the user's reading position "
|
||||
"while they were scrolled up (no-reply-autoscroll contract)"
|
||||
)
|
||||
|
||||
# The whole answer still landed (off-screen below — by design).
|
||||
@@ -318,13 +334,17 @@ def test_no_autoscroll_during_thinking(
|
||||
# The thinking turn: the submit reveals the user's message (the kept
|
||||
# app scroll) ...
|
||||
submit(page, THINKING_QUESTION)
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# 690fd98 refinement: ONE details.reasoning container per turn (the
|
||||
# live per-run blocks are plain divs that never fold); the container
|
||||
# is created OPEN and auto-folds on settle — same view-stability
|
||||
# contract phase 17 pinned, one level up.
|
||||
details = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
expect(details).to_have_attribute("open", "") # created open (phase 17)
|
||||
# ... and, once the reasoning stream is clearly running ...
|
||||
page.wait_for_function(
|
||||
"() => { const el = document.querySelector('details.thinking .thinking-text');"
|
||||
" return !!el && el.innerText.length > 300; }",
|
||||
"() => { const els = document.querySelectorAll('details.reasoning .thinking-text');"
|
||||
" let n = 0; els.forEach((e) => { n += e.innerText.length; }); return n > 300; }",
|
||||
timeout=30_000,
|
||||
)
|
||||
# ... let the submit's smooth reveal settle, then the user goes up.
|
||||
@@ -339,7 +359,7 @@ def test_no_autoscroll_during_thinking(
|
||||
time.sleep(0.3)
|
||||
state = page.evaluate(
|
||||
"""() => {
|
||||
const block = document.querySelector('details.thinking');
|
||||
const block = document.querySelector('details.reasoning');
|
||||
const wrap = block ? block.closest('.msg.brain') : null;
|
||||
const el = wrap ? wrap.querySelector('.bubble') : null;
|
||||
return { y: window.scrollY,
|
||||
@@ -366,7 +386,9 @@ def test_no_autoscroll_during_thinking(
|
||||
# the point of the story).
|
||||
wait_settled(page)
|
||||
assert abs(scroll_state(page)["y"]) <= STABLE_PX
|
||||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
# Settled: the container auto-folded; its last run holds the tail.
|
||||
assert not details.get_attribute("open")
|
||||
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
|
||||
@@ -484,9 +506,12 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
|
||||
# (phase 17: open while streaming, closed from the first delta on).
|
||||
submit(page, THINKING_QUESTION)
|
||||
wait_settled(page)
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# 690fd98 refinement: settled turns carry ONE details.reasoning
|
||||
# container (auto-collapsed by settle, phase-17 contract) holding the
|
||||
# plain per-run blocks — the per-run details.thinking is gone live.
|
||||
details = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(details).not_to_have_attribute("open")
|
||||
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(details.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
# Persistence: four messages, the thinking text stored raw — and
|
||||
@@ -508,9 +533,10 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
|
||||
expect(page.locator(".msg.brain .bubble")).to_have_count(2)
|
||||
expect(page.locator(".msg.brain .bubble").first).to_contain_text(LONG_ANSWER_END)
|
||||
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
||||
restored = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
# Restore writes the SAME container shape (segments-based restore).
|
||||
restored = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(restored).not_to_have_attribute("open")
|
||||
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(restored.locator(".thinking-text").last).to_contain_text(THINKING_FRAGMENT)
|
||||
wait_settled(page)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user