fix(chat): stop the submit up-hop and keep the thinking pin alive across paragraph breaks
- 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user