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:
+34
-11
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user