phase: 166_hide_empty_answer_bubble
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**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:
2026-10-08 04:27:03 -04:00
parent b0c7aed613
commit ab9391d019
36 changed files with 2348 additions and 93 deletions
+39 -13
View File
@@ -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)