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:
@@ -67,6 +67,14 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
long answer, the page scrolls, the bubble's overflow is untouched.
|
||||
7. ``test_restored_collapsed_thinking_unaffected`` — regression
|
||||
(phase 17): a settled thinking turn reloads collapsed with full text.
|
||||
8. ``test_thinking_window_follows_across_paragraph_breaks`` —
|
||||
regression (2026-08-29, owner report): a 2-newline gap (a real
|
||||
"\n\n" paragraph break) must not stop the follow — the pin state is
|
||||
measured PRE-render in app.js, so a chunk taller than the 32px band
|
||||
(the mock streams this question at 60-char frames — a single frame
|
||||
renders several lines, a real-model-sized delta) cannot kill the pin.
|
||||
The 12-char suites above cannot catch this: a 12-char frame renders
|
||||
at most one line (≈22px), always inside the band.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -104,6 +112,10 @@ MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
#: Line fragment the mock's deterministic scratchpad must carry (phase 17
|
||||
#: convention, mock_llm.compose_thinking).
|
||||
THINKING_FRAGMENT = "Step 2: Check my notes"
|
||||
#: 2026-08-29 regression trigger (mock_llm.THINK_PARAS_TRIGGER): the
|
||||
#: scratchpad WITH real "\n\n" paragraph breaks, streamed at 60-char
|
||||
#: frames (mock_llm.THINK_PARAS_CHUNK — real-model-sized deltas).
|
||||
PARAS_QUESTION = "think in paragraphs — how is my kubernetes cluster set up?"
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
|
||||
SELECTOR = ".msg.brain details.thinking .thinking-text"
|
||||
@@ -701,3 +713,82 @@ def test_restored_collapsed_thinking_unaffected(
|
||||
"messages"
|
||||
][1]["thinking"]
|
||||
assert re.sub(r"\s+", "", raw) == re.sub(r"\s+", "", captured)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Regression (2026-08-29, owner report): a 2-newline gap (a real
|
||||
# "\n\n" paragraph break) must not stop the follow. The old code
|
||||
# measured the window's bottom distance AFTER the re-render — where it
|
||||
# reads the new chunk's rendered height, not the user's position — so
|
||||
# any frame taller than the 32px band (a real model's sentence, a
|
||||
# paragraph break) killed the pin permanently. The fix measures the
|
||||
# pin state BEFORE the re-render; this suite's 60-char mock frames
|
||||
# guarantee multiple over-band frames land before the stream ends.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_thinking_window_follows_across_paragraph_breaks(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
submit(page, PARAS_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(PARAS_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Two preconditions, in one poll: the window is REAL (content past
|
||||
# the 320px clip — a "window" only exists once it clips) AND the
|
||||
# first real paragraph break has rendered (>=2 <p> children — the
|
||||
# mock's paragraph scratchpad breaks after scratchpad lines 2 and 6;
|
||||
# break 1 lands well before the clip fills, so both hold together).
|
||||
# By here, several over-band 60-char frames have landed — the old
|
||||
# post-render reading is dead long ago.
|
||||
page.wait_for_function(
|
||||
f"() => {{ const el = document.querySelector('{SELECTOR}');"
|
||||
" return !!el && el.scrollHeight > el.clientHeight &&"
|
||||
" el.querySelectorAll('p').length >= 2; }",
|
||||
timeout=10_000,
|
||||
)
|
||||
len_overflow = page.evaluate(
|
||||
f"() => document.querySelector('{SELECTOR}').innerText.length"
|
||||
)
|
||||
# One more full frame past the clip edge, then measure — the follow
|
||||
# must still be pinned to the live tail.
|
||||
page.wait_for_function(
|
||||
f"(minLen) => {{ const el = document.querySelector('{SELECTOR}');"
|
||||
" return !!el && el.innerText.length >= minLen; }",
|
||||
arg=len_overflow + 60,
|
||||
timeout=15_000,
|
||||
)
|
||||
|
||||
# Atomic sample (one evaluate — no frame can land between the
|
||||
# preconditions and the measurement): still inside the pure-thinking
|
||||
# window (block open, no answer token yet), the window overflows, a
|
||||
# paragraph break is present, and the pin survived it.
|
||||
sample = page.evaluate(
|
||||
f"""() => {{ const el = document.querySelector('{SELECTOR}');
|
||||
const block = document.querySelector('details.thinking');
|
||||
const wrap = block ? block.closest('.msg.brain') : null;
|
||||
const bubble = wrap ? wrap.querySelector('.bubble') : null;
|
||||
return {{ top: el.scrollTop, height: el.scrollHeight,
|
||||
client: el.clientHeight,
|
||||
p: el.querySelectorAll('p').length,
|
||||
open: !!(block && block.open),
|
||||
bubble: bubble ? bubble.innerText.length : 0 }}; }}"""
|
||||
)
|
||||
assert sample["p"] >= 2, "a real paragraph break must be in the scratchpad"
|
||||
assert sample["open"], "the sample must land while the block is open"
|
||||
assert sample["bubble"] == 0, "no answer token may have landed yet"
|
||||
assert sample["height"] > sample["client"], "the window must overflow"
|
||||
assert _at_tail(sample), (
|
||||
f"the pin must survive the paragraph break: {sample}"
|
||||
)
|
||||
|
||||
# The turn settles; the scratchpad text past the break is intact and
|
||||
# the answer landed.
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
expect(details.locator(".thinking-text")).to_contain_text("Step 3")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
Reference in New Issue
Block a user