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
+34 -11
View File
@@ -76,8 +76,10 @@
* moves the viewport, so scrolling up to read earlier content holds for
* the rest of the turn. The only scroll call sites are user intent: the
* submit (your own message is revealed) and the phase-14 restore landing
* (one-shot, load-time). scrollReveal(wrap) is the one scrollIntoView in
* this file; addMessage(who, html, scroll) carries the intent. The
* (one-shot, load-time). scrollReveal(wrap) is the one page scroll in
* this file — a document-BOTTOM landing, not a message-bottom alignment
* (its comment explains why block:"end" hopped the page up on submit);
* addMessage(who, html, scroll) carries the intent. The
* thinking block's internal bottom-pin (textEl.scrollTop, phase 17 —
* reworked separately in phase 43) pins the block's own clip, not the
* page, and is untouched here.
@@ -167,8 +169,10 @@ const SCROLL = reducedMotion ? "auto" : "smooth";
* tail only while the user is pinned near the window's bottom —
* the 32px band is the "window bottom in view" threshold. Scrolling
* up pauses the follow; returning to the bottom resumes it (the
* check runs on every chunk). Exported so the band is unit-pinned
* (same pattern as TURN_TIMEOUT_MS). */
* check runs on every chunk — against the PRE-render geometry: a
* post-render reading measures the new chunk's height, not the user's
* position, and the follow died at the first \"\n\n\" paragraph break).
* Exported so the band is unit-pinned (same pattern as TURN_TIMEOUT_MS). */
export const THINKING_NEAR_BOTTOM_PX = 32;
function isThinkingNearBottom(textEl) {
@@ -184,11 +188,20 @@ function isThinkingNearBottom(textEl) {
* the user submit (reveal my message) and the phase-14 restore
* landing (one-shot, load-time). */
/* The ONE scrollIntoView in this file — unconditional (unit-pinned):
/* The ONE page scroll in this file — unconditional (unit-pinned):
* scrollReveal scrolls whenever it is called, so a page scroll can only
* ever happen from those two user-intent call sites. */
* ever happen from those two user-intent call sites. It lands at the
* DOCUMENT BOTTOM, not on the message's own bottom edge: the composer
* and footer sit below the message in flow, so the old
* scrollIntoView({ block: "end" }) aligned the message's bottom to the
* viewport bottom — ABOVE the document bottom — and hopped the page UP
* by the composer+footer height on every submit (pushing the composer
* below the fold). At the document bottom the revealed message sits in
* view with the composer right under it. `wrap` is the revealed
* element, kept in the signature so the call sites read as intent. */
function scrollReveal(wrap, behavior = SCROLL) {
wrap.scrollIntoView({ behavior, block: "end" });
void wrap;
window.scrollTo({ top: document.documentElement.scrollHeight, behavior });
}
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
@@ -970,11 +983,21 @@ async function handleSend(e) {
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingBlock(wrap);
const textEl = block.querySelector(".thinking-text");
// Pin state is measured BEFORE the re-render: a chunk taller
// than the 32px band — any paragraph break ("\n\n") or a few
// lines of text, which is exactly what the real model streams —
// grows the window's content below the old bottom, so measuring
// the distance AFTER the update reads the chunk's height, not
// the user's position, and the follow died at the first
// 2-newline gap. Pre-update, the distance is where the user
// actually is.
const pinned = block.open && isThinkingNearBottom(textEl);
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open && isThinkingNearBottom(textEl)) {
// Follow the live tail only while the user is pinned to the window
// bottom (owner direction 2026-08-27); a scrolled-up reader is
// never re-pinned — returning to the bottom re-arms the pin.
if (pinned) {
// Follow the live tail only while the user was pinned to the
// window bottom before this chunk (owner direction
// 2026-08-27); a scrolled-up reader is never re-pinned —
// returning to the bottom re-arms the pin on the next chunk.
textEl.scrollTop = textEl.scrollHeight;
}
} else if (ev.type === "tool") {