fix(chat): stop autoscrolling while a reply streams (owner direction)

TODO.md L5: "Get rid of the chat reply autoscroll, it's breaking things
like making it impossible for the user to scroll while a reply
generates." Owner direction 2026-08-27 (roadmap A1) revises the
phase-18 follow-the-bottom choice: the page NEVER auto-scrolls while a
turn streams. Kept (owner decision): the submit reveal (the user's own
message) and the one-shot phase-14 restore landing.

- frontend/assets/app.js: delete NEAR_BOTTOM_PX + isNearBottom;
  scrollReveal becomes the one unconditional scrollIntoView (still
  smooth, still "auto" under prefers-reduced-motion via SCROLL);
  addMessage(who, html, scroll = false) carries an explicit scroll
  intent — only the submit (", true") and the two restore landings
  scroll. The thinking/tool/delta handlers and the typing indicator
  drop their page-scroll calls; the thinking block's INTERNAL
  bottom-pin (textEl.scrollTop, phase 17 — reworked separately in
  phase 43) and the turn-end focus({ preventScroll: true }) survive.
- tests/unit/test_frontend_scroll.py: rewritten pin for the new
  contract — phase-18 gate absent, helper unconditional, explicit
  intent at submit/restore, no page-scroll call in the streaming
  handlers, typing bubble scroll-free, SCROLL reduced-motion intact.
- tests/unit/test_chat_persistence.py: restore-landing pin updated to
  the new signature (the old forced "auto" is gone; the landing
  rides the default SCROLL — noted at the call site).
- tests/e2e/test_no_reply_autoscroll.py (new, replaces the deleted
  test_follow_bottom_scroll.py): no autoscroll across >=10 samples
  (1px tolerance) during a long answer and during the thinking stream;
  submit-from-the-top still reveals the user message; the restore
  landing lands one-shot on the latest message and stays; long answer
  + sources and the collapsed thinking block persist and restore.

E2E (isolation): test_no_reply_autoscroll.py 5/5; regressions
test_chat_rag 3/3, test_thinking_display 5/5,
test_chat_persistence 4/4, test_long_answers 2/2, test_smoke 3/3;
unit+integration 723 passed, app/ coverage 99%; ruff + pyright clean.
This commit is contained in:
2026-08-28 00:29:25 -04:00
parent 6cf1df9bf2
commit 7c6763319b
5 changed files with 673 additions and 489 deletions
+46 -46
View File
@@ -70,15 +70,17 @@
* (announceSteering()) through the module. Note text is always rendered
* with textContent (XSS-safe) in both places.
*
* Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only
* while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
* the composer zone — the textarea auto-grows to 192px plus the button
* row — so "the composer is in view" counts as pinned: submitting from
* the composer reveals your own message, and the answer follows token by
* token while you stay pinned. Once you scroll up to read earlier
* content, nothing drags the viewport back down for the rest of the turn
* (thinking or answer). scrollReveal(wrap) is the single scroll gate;
* `force` is reserved for the one-shot phase-14 restore landing.
* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
* revising the phase 18 follow-the-bottom choice): the page NEVER
* auto-scrolls while a turn streams — no thinking, tool, or delta frame
* 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
* 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.
*
* Document modal (phase 26): a source chip opens the cited document in
* the almost-fullscreen modal overlay (assets/document-modal.js) on the
@@ -160,26 +162,17 @@ const reducedMotion =
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const SCROLL = reducedMotion ? "auto" : "smooth";
/* Follow-the-bottom scroll contract (phase 18, owner choice
* 2026-08-23): the page auto-scrolls only while the user is pinned
* at the bottom — the 200px band covers the composer zone (the
* textarea auto-grows to 192px + the button row), i.e. "the
* composer is in view". Exported so the band is unit-pinned (same
* pattern as TURN_TIMEOUT_MS). */
export const NEAR_BOTTOM_PX = 200;
/* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
* revising the phase 18 follow-the-bottom choice): the page never
* auto-scrolls while a turn streams. The only scroll call sites are
* the user submit (reveal my message) and the phase-14 restore
* landing (one-shot, load-time). */
function isNearBottom() {
const bottom =
document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
return bottom <= NEAR_BOTTOM_PX;
}
/* The ONE scroll call site in this file. `force` is used only by
* the phase-14 restore landing (one-shot, load-time). */
function scrollReveal(wrap, behavior = SCROLL, force = false) {
if (force || isNearBottom()) {
wrap.scrollIntoView({ behavior, block: "end" });
}
/* The ONE scrollIntoView 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. */
function scrollReveal(wrap, behavior = SCROLL) {
wrap.scrollIntoView({ behavior, block: "end" });
}
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
@@ -339,10 +332,12 @@ const USER_AVATAR =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
/* ---------- messages ----------
* Scroll is conditional (phase 18): addMessage reveals through
* scrollReveal — only when the user is pinned to the bottom, or when
* forced (the one-shot phase-14 restore landing). */
function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
* Scroll is explicit intent (phase 42, no reply autoscroll): addMessage
* scrolls only when the caller passes `scroll = true` — the user submit
* (reveal my message) and the phase-14 restore landing. The streaming
* path (thinking / tool / delta) creates bubbles with the default
* (scroll = false): the page never follows a turn. */
function addMessage(who, html, scroll = false) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
@@ -352,7 +347,7 @@ function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
<div class="bubble">${html}</div>
</div>`;
messagesEl.appendChild(wrap);
scrollReveal(wrap, scrollBehavior, force);
if (scroll) scrollReveal(wrap);
return wrap;
}
@@ -370,7 +365,7 @@ function addTyping() {
</div>
</div>`;
messagesEl.appendChild(wrap);
scrollReveal(wrap);
// No page scroll (phase 42): a typing bubble must not yank the viewport.
}
function removeTyping() {
@@ -765,14 +760,17 @@ export function clearStoredConversation() {
}
function renderStoredMessage(m) {
// Phase 18: the restore landing is the only `force`d scroll — one-shot,
// non-smooth, so a restored conversation lands on its latest message
// (phase-14 behavior preserved) without smooth-scrolling through it.
// Phase-14 restore landing (kept by the phase-42 direction): the
// one-shot load-time scroll — scroll=true so a restored conversation
// lands on its latest message. The new addMessage(who, html, scroll)
// signature has no per-call behavior override, so the landing rides
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
// instead of the old forced "auto" — noted per the phase-42 task.
if (m.who === "user") {
addMessage("user", renderMarkdown(m.text), "auto", true);
addMessage("user", renderMarkdown(m.text), true);
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true);
const wrap = addMessage("brain", renderMarkdown(m.text), true);
if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble.
const block = ensureThinkingBlock(wrap);
@@ -893,7 +891,7 @@ async function handleSend(e) {
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
addMessage("user", renderMarkdown(text));
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
@@ -958,8 +956,9 @@ async function handleSend(e) {
const textEl = block.querySelector(".thinking-text");
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open) {
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
scrollReveal(wrap); // page follows only while pinned (phase 18)
// Pin the block's OWN stream (phase 17 — reworked in phase 43);
// the page never follows (phase 42, no reply autoscroll).
textEl.scrollTop = textEl.scrollHeight;
}
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
@@ -988,14 +987,14 @@ async function handleSend(e) {
?.setAttribute("aria-label", toolStatus);
}
appendToolLine(wrap, name, argument);
scrollReveal(wrap); // page follows only while pinned (phase 18)
// No page scroll (phase 42): tool lines never yank the viewport.
} else if (ev.type === "delta") {
acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
scrollReveal(wrap); // page follows only while pinned (phase 18)
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed
@@ -1062,8 +1061,9 @@ async function handleSend(e) {
stopThinkingClock();
cancelStream(res); // the reader lock is released — no unhandled rejection
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
// Phase 18: focus back for the next question, but never move the
// viewport — a user reading earlier content stays where they are.
// Focus back for the next question, but never move the viewport —
// the page never auto-scrolls (phase 42), so a user reading earlier
// content stays where they are.
input.focus({ preventScroll: true });
}
}