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
+91
View File
@@ -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)