From ac664325e1d5e65f911490100552a58b831f0161 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 08:01:38 -0400 Subject: [PATCH] =?UTF-8?q?repl:=20sticky=20autoscroll=20=E2=80=94=20track?= =?UTF-8?q?=20bottom=20unless=20user=20scrolled=20up=20to=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming output now follows the bottom of the page when the user is already at the bottom, and stops doing so the moment they scroll up. Scrolling back down re-engages autoscroll. A 64px tolerance covers sub-pixel scroll positions and the sticky prompt-bar's offset. Mechanics: * Module-level scrollPinned flag, recomputed on every window scroll event (passive). * maybeAutoscroll() jumps to the bottom only when pinned. Called from attachStreaming's appendText / appendNewline so every chunk-text / chunk-eol arriving from the worker tracks. * renderAll still does an unconditional jump (it fires on user-initiated actions — send, tab switch, etc. — where the most-recent content is what they want) and re-pins scrollPinned afterward so the streaming follow-ups continue to track. Smoke-tested headlessly: streaming 30+ lines while at bottom keeps viewport at bottom; scrolling to top during streaming leaves scrollY=0 even as the doc grows to 2KB tall. --- wasm/dist-repl/repl.js | 30 ++++++++++++++++++++++++++++++ wasm/repl/repl.js | 30 ++++++++++++++++++++++++++++++ www/repl/repl.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index 948ab10..e72fc91 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -233,12 +233,14 @@ function attachStreaming(resultSpan, metaSpan, tier) { if (!t) return; pendingText += t; pendingLine.textContent = pendingText; + maybeAutoscroll(); }, appendNewline() { pendingLine.textContent = pendingText || " "; pendingText = ""; pendingLine = makeLine(); resultSpan.appendChild(pendingLine); + maybeAutoscroll(); }, finalize(r) { const text = r.output != null ? r.output : ""; @@ -515,11 +517,39 @@ function renderAll() { // requestAnimationFrame lets the just-mounted DOM contribute to // scrollHeight before we measure — otherwise on first load the page // sticks at the top because the transcript hasn't been laid out yet. + // renderAll fires on user-initiated actions (send, tab switch, new + // entry) where they expect the most-recent content, so the jump + // here is unconditional and resets scrollPinned to true so streamed + // follow-ups continue to autoscroll. requestAnimationFrame(() => { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + scrollPinned = true; }); } +// ─── Sticky autoscroll ────────────────────────────────────────────── +// During a long streaming eval the page should track the bottom UNLESS +// the user has deliberately scrolled up to read older output. We sample +// "is the viewport at the bottom" on every user scroll, then streamed +// appendText/appendNewline only force the scroll when scrollPinned is +// still true. A 64px tolerance covers fractional-pixel scroll positions +// and short header offsets — anything farther than that is treated as +// "user wanted to look back" and autoscroll stops until they return. +let scrollPinned = true; +const SCROLL_BOTTOM_TOLERANCE = 64; +function isViewportAtBottom() { + return (window.innerHeight + window.scrollY) >= + (document.documentElement.scrollHeight - SCROLL_BOTTOM_TOLERANCE); +} +function maybeAutoscroll() { + if (scrollPinned) { + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + } +} +window.addEventListener("scroll", () => { + scrollPinned = isViewportAtBottom(); +}, { passive: true }); + // ─── History navigation (readline-style up/down) ──────────────────── // Each tab owns its own history; the active tab's draft is preserved so // that walking back into history doesn't eat what the user was typing. diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 948ab10..e72fc91 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -233,12 +233,14 @@ function attachStreaming(resultSpan, metaSpan, tier) { if (!t) return; pendingText += t; pendingLine.textContent = pendingText; + maybeAutoscroll(); }, appendNewline() { pendingLine.textContent = pendingText || " "; pendingText = ""; pendingLine = makeLine(); resultSpan.appendChild(pendingLine); + maybeAutoscroll(); }, finalize(r) { const text = r.output != null ? r.output : ""; @@ -515,11 +517,39 @@ function renderAll() { // requestAnimationFrame lets the just-mounted DOM contribute to // scrollHeight before we measure — otherwise on first load the page // sticks at the top because the transcript hasn't been laid out yet. + // renderAll fires on user-initiated actions (send, tab switch, new + // entry) where they expect the most-recent content, so the jump + // here is unconditional and resets scrollPinned to true so streamed + // follow-ups continue to autoscroll. requestAnimationFrame(() => { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + scrollPinned = true; }); } +// ─── Sticky autoscroll ────────────────────────────────────────────── +// During a long streaming eval the page should track the bottom UNLESS +// the user has deliberately scrolled up to read older output. We sample +// "is the viewport at the bottom" on every user scroll, then streamed +// appendText/appendNewline only force the scroll when scrollPinned is +// still true. A 64px tolerance covers fractional-pixel scroll positions +// and short header offsets — anything farther than that is treated as +// "user wanted to look back" and autoscroll stops until they return. +let scrollPinned = true; +const SCROLL_BOTTOM_TOLERANCE = 64; +function isViewportAtBottom() { + return (window.innerHeight + window.scrollY) >= + (document.documentElement.scrollHeight - SCROLL_BOTTOM_TOLERANCE); +} +function maybeAutoscroll() { + if (scrollPinned) { + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + } +} +window.addEventListener("scroll", () => { + scrollPinned = isViewportAtBottom(); +}, { passive: true }); + // ─── History navigation (readline-style up/down) ──────────────────── // Each tab owns its own history; the active tab's draft is preserved so // that walking back into history doesn't eat what the user was typing. diff --git a/www/repl/repl.js b/www/repl/repl.js index 948ab10..e72fc91 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -233,12 +233,14 @@ function attachStreaming(resultSpan, metaSpan, tier) { if (!t) return; pendingText += t; pendingLine.textContent = pendingText; + maybeAutoscroll(); }, appendNewline() { pendingLine.textContent = pendingText || " "; pendingText = ""; pendingLine = makeLine(); resultSpan.appendChild(pendingLine); + maybeAutoscroll(); }, finalize(r) { const text = r.output != null ? r.output : ""; @@ -515,11 +517,39 @@ function renderAll() { // requestAnimationFrame lets the just-mounted DOM contribute to // scrollHeight before we measure — otherwise on first load the page // sticks at the top because the transcript hasn't been laid out yet. + // renderAll fires on user-initiated actions (send, tab switch, new + // entry) where they expect the most-recent content, so the jump + // here is unconditional and resets scrollPinned to true so streamed + // follow-ups continue to autoscroll. requestAnimationFrame(() => { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + scrollPinned = true; }); } +// ─── Sticky autoscroll ────────────────────────────────────────────── +// During a long streaming eval the page should track the bottom UNLESS +// the user has deliberately scrolled up to read older output. We sample +// "is the viewport at the bottom" on every user scroll, then streamed +// appendText/appendNewline only force the scroll when scrollPinned is +// still true. A 64px tolerance covers fractional-pixel scroll positions +// and short header offsets — anything farther than that is treated as +// "user wanted to look back" and autoscroll stops until they return. +let scrollPinned = true; +const SCROLL_BOTTOM_TOLERANCE = 64; +function isViewportAtBottom() { + return (window.innerHeight + window.scrollY) >= + (document.documentElement.scrollHeight - SCROLL_BOTTOM_TOLERANCE); +} +function maybeAutoscroll() { + if (scrollPinned) { + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + } +} +window.addEventListener("scroll", () => { + scrollPinned = isViewportAtBottom(); +}, { passive: true }); + // ─── History navigation (readline-style up/down) ──────────────────── // Each tab owns its own history; the active tab's draft is preserved so // that walking back into history doesn't eat what the user was typing.