fix(chat): stop the submit up-hop and keep the thinking pin alive across paragraph breaks
Build and Push Containers / build-and-push-app (push) Successful in 3m55s
Build and Push Containers / build-and-push-db (push) Successful in 13s

- scrollReveal lands at the document bottom (window.scrollTo) instead of
  scrollIntoView({ block: 'end' }): the old alignment sat above the
  in-flow composer, so every Enter hopped the page up by the
  composer+footer height and pushed the composer below the fold.
- The thinking window's pin state is now captured BEFORE the re-render
  (const pinned = block.open && isThinkingNearBottom(textEl)): the
  post-render distance read the new chunk's rendered height, not the
  user's position, so any chunk taller than the 32px band (real-model
  deltas, '\n\n' paragraph breaks) killed the follow at the first
  2-newline gap.
- Mock LLM: new 'think in paragraphs' trigger (scratchpad with real
  blank-line breaks, 60-char frames) — the 12-char mock frames never
  rendered past the band, which is why the bug survived the E2E gates.
- E2E (both verified red against the old code):
  test_submit_does_not_hop_up, test_thinking_window_follows_across_paragraph_breaks.
- Unit source-marker tests updated to the new contracts.
This commit is contained in:
2026-08-28 17:10:02 -04:00
parent 03d26255c6
commit 3a404eb161
6 changed files with 365 additions and 55 deletions
+79
View File
@@ -38,6 +38,13 @@ Test → story mapping (Playwright Mapping Rule):
3. ``test_submit_reveals_user_message``
4. ``test_restore_landing_one_shot``
5. ``test_answer_content_intact``
6. ``test_submit_does_not_hop_up`` — regression (2026-08-29, owner
report): submitting from the document bottom must NOT pull the page
up. The old ``scrollIntoView({ block: "end" })`` reveal aligned the
message's bottom to the viewport bottom — which sits above the
in-flow composer — so every Enter hopped the page up by the
composer+footer height and pushed the composer below the fold. The
reveal now lands at the document bottom (composer stays in view).
"""
from __future__ import annotations
@@ -494,3 +501,75 @@ def test_answer_content_intact(page: Page, app_url: str, seeded_kb: None) -> Non
expect(restored).not_to_have_attribute("open")
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
wait_settled(page)
# ---------------------------------------------------------------------------
# 6. Regression (2026-08-29, owner report): pressing Enter to send must
# not scroll the page UP. The user is at the document bottom (having
# read the answer), sends a new question; the reveal may only move
# the viewport DOWN (the new message grows the page) — the old
# block:"end" alignment hopped it up by the composer+footer height
# and pushed the composer below the fold on every submit.
# ---------------------------------------------------------------------------
def test_submit_does_not_hop_up(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
# A settled long answer (the document overflows the 800px viewport),
# and the user has scrolled to the very bottom to read it — the
# real-world position for the next Enter.
submit(page, LONG_QUESTION)
wait_settled(page)
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
page.wait_for_timeout(150) # let the (user) scroll land
state = scroll_state(page)
assert state["sh"] > state["ch"], "a long answer must make the document scrollable"
assert state["y"] + state["ch"] >= state["sh"] - STABLE_PX, "at the bottom"
y0 = state["y"]
# Send the next question from the bottom. Sample the viewport from
# the moment the user bubble lands through the smooth reveal: the
# page must never move ABOVE where the user left it (no up-hop), and
# the reveal may only settle at or BELOW the starting position
# (the new message grows the page — a downward reveal). The old
# block:"end" alignment settled ~170px (composer+footer) ABOVE it.
page.fill("#message-input", SHORT_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(SHORT_QUESTION)
samples: list[float] = []
prev: float | None = None
deadline = time.monotonic() + 10
while True:
y = scroll_state(page)["y"]
samples.append(y)
if prev is not None and abs(y - prev) <= STABLE_PX:
break # the reveal has settled (the only scroll in flight)
prev = y
if time.monotonic() >= deadline:
raise AssertionError("the submit reveal did not settle within timeout")
time.sleep(0.1)
assert min(samples) >= y0 - STABLE_PX, (
f"the submit hopped the page UP (y0={y0:.0f}, min={min(samples):.0f}) — "
"the reveal must land at the document bottom, never above the user"
)
assert samples[-1] >= y0 - STABLE_PX, (
f"the reveal settled ABOVE where the user was (y0={y0:.0f}, "
f"settled={samples[-1]:.0f}) — it must land at or below the start"
)
# The user's own message is revealed in view. (The composer sits in
# view at the settled position too — the reveal lands at the document
# bottom — but it is not asserted here: phase 42 (no page autoscroll)
# lets the in-flight reply push it down afterwards, so its exact box
# is a timing race, not a contract.)
assert user_message_in_view(page), "the submit must reveal the user's message"
# And the turn completes normally (nothing about the reveal changed
# the never-stale contract).
wait_settled(page)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)