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
+45 -20
View File
@@ -12,12 +12,22 @@ live tail only while the user is pinned near the window's bottom (the
follow; returning to the bottom re-arms it (the check runs on every
chunk, by construction).
The gate is measured against the PRE-render geometry: the chunk's
re-render grows the window's content below the old bottom, so a
post-render reading measures the new chunk's height, not the user's
position — any chunk taller than the 32px band (a real model's
sentence, or a "\n\n" paragraph break) killed the follow at the first
2-newline gap. The pin state is captured into ``pinned`` BEFORE
``textEl.innerHTML = …`` and the pin line runs inside ``if (pinned)``
after it.
The browser behavior itself is E2E-covered
(tests/e2e/test_thinking_scroll.py, task 03); here we pin the CSS
value + the owner-direction comment, the exported band, the gate
function's math, and the gated pin call — so a silent regression
(``overflow-y`` back to ``hidden``, band removed, pin ungated) is
catched without a browser.
function's math, and the pre-render capture + gated pin call — so a
silent regression (``overflow-y`` back to ``hidden``, band removed,
pin ungated, or the capture moved back after the re-render) is caught
without a browser.
"""
from __future__ import annotations
@@ -94,35 +104,50 @@ def test_is_thinking_near_bottom_band_math() -> None:
def test_thinking_pin_is_gated_on_window_bottom() -> None:
"""The phase-17 pin is now GATED: the pin line sits inside
``if (block.open && isThinkingNearBottom(textEl))`` in the
streaming thinking branch — the window follows the live tail only
while the user is pinned near its bottom, and ``block.open`` stays
in the gate so a closed block (e.g. restored collapsed, phase 17)
is never pinned. No unconditional ``if (block.open) { … pin … }``
remains anywhere in app.js (the exact old gate string is gone)."""
"""The phase-17 pin is GATED on the user's pin state captured BEFORE
the re-render: ``const pinned = block.open &&
isThinkingNearBottom(textEl)`` sits above ``textEl.innerHTML =
…`` in the streaming thinking branch, and the pin line runs inside
``if (pinned)`` below it. Measuring after the update would read the
new chunk's rendered height instead of the user's position — the
"2-newline gap" regression. ``block.open`` stays in the capture so a
closed block (e.g. restored collapsed, phase 17) is never pinned,
and no direct ``if (block.open)`` gate remains anywhere in app.js."""
js = _js()
pin = "textEl.scrollTop = textEl.scrollHeight"
assert js.count(pin) == 1, "the bottom-pin must exist exactly once"
# Surviving task-01 assertion: the pin lives in the streaming
# thinking branch (before the delta branch).
# The pin lives in the streaming thinking branch (before the delta
# branch).
thinking_idx = js.find('ev.type === "thinking"')
delta_idx = js.find('ev.type === "delta"')
assert -1 < thinking_idx < delta_idx
thinking_branch = js[thinking_idx:delta_idx]
gate = "if (block.open && isThinkingNearBottom(textEl))"
assert gate in thinking_branch, "the pin must be behind the combined gate"
# The pin line follows the gate (inside it) — the only pin in the
# branch is the gated one.
gate_idx = thinking_branch.find(gate)
capture = "block.open && isThinkingNearBottom(textEl)"
render = "textEl.innerHTML = renderMarkdown(thinkingAcc)"
gate = "if (pinned)"
assert capture in thinking_branch, (
"the combined gate must be captured (block.open + the window band)"
)
assert gate in thinking_branch, "the pin must sit inside the captured gate"
# Pre-capture → re-render → gated pin, in exactly that order: the
# pin state is the user's PRE-render position, not the chunk's
# rendered height.
cap_idx = thinking_branch.find(capture)
render_idx = thinking_branch.find(render, cap_idx)
assert render_idx != -1 and render_idx > cap_idx, (
"the pin state must be measured BEFORE the re-render"
)
gate_idx = thinking_branch.find(gate, render_idx)
assert gate_idx != -1 and gate_idx > render_idx, (
"the pin must run AFTER the re-render, inside the captured gate"
)
pin_idx = thinking_branch.find(pin, gate_idx)
assert pin_idx != -1
assert thinking_branch.count(pin) == 1
# The old unconditional gate is gone from the whole file: no
# `if (block.open)` (closed paren) — the combined condition is the
# No direct `if (block.open)` gate may remain — the capture is the
# only gate left.
assert "if (block.open)" not in js, (
"no unconditional `if (block.open)` pin may remain"
"no direct `if (block.open)` gate may remain — the capture is the gate"
)