feat(ui): chat auto-scrolls only while pinned to the bottom — submitting reveals your message, scrolling up holds the viewport

This commit is contained in:
2026-08-24 10:53:04 -04:00
parent b16deb2b1d
commit bc0158f858
5 changed files with 558 additions and 12 deletions
+49 -9
View File
@@ -50,6 +50,16 @@
* announced through a polite live region (#steering-announcer), and the
* panel + count badge update on every change.
*
* 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.
*
* All DOM ids match frontend/index.html.
*/
@@ -99,6 +109,28 @@ 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;
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" });
}
}
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
* Every cited document opens in the viewer, in a NEW tab. All query
* values are percent-encoded: real paths contain slashes and sometimes
@@ -327,8 +359,11 @@ const BRAIN_AVATAR =
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 ---------- */
function addMessage(who, html, scrollBehavior = SCROLL) {
/* ---------- 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) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
@@ -338,7 +373,7 @@ function addMessage(who, html, scrollBehavior = SCROLL) {
<div class="bubble">${html}</div>
</div>`;
messagesEl.appendChild(wrap);
wrap.scrollIntoView({ behavior: scrollBehavior, block: "end" });
scrollReveal(wrap, scrollBehavior, force);
return wrap;
}
@@ -356,7 +391,7 @@ function addTyping() {
</div>
</div>`;
messagesEl.appendChild(wrap);
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
scrollReveal(wrap);
}
function removeTyping() {
@@ -670,11 +705,14 @@ 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.
if (m.who === "user") {
addMessage("user", renderMarkdown(m.text), "auto");
addMessage("user", renderMarkdown(m.text), "auto", true);
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), "auto");
const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true);
if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble.
const block = ensureThinkingBlock(wrap);
@@ -855,7 +893,7 @@ async function handleSend(e) {
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open) {
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
scrollReveal(wrap); // page follows only while pinned (phase 18)
}
} else if (ev.type === "delta") {
acc += ev.text || "";
@@ -863,7 +901,7 @@ async function handleSend(e) {
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
scrollReveal(wrap); // page follows only while pinned (phase 18)
} else if (ev.type === "done") {
sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed
@@ -928,7 +966,9 @@ async function handleSend(e) {
stopThinkingClock();
try { res?.body?.cancel(); } catch { /* stream already closed */ }
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
input.focus();
// Phase 18: focus back for the next question, but never move the
// viewport — a user reading earlier content stays where they are.
input.focus({ preventScroll: true });
}
}