From 8305c0716a08cbb6375ec8f5abd0e26914c51c9a Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 25 May 2026 01:24:49 +0200 Subject: [PATCH] terminal: smooth-scroll to bottom instead of instant jump (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mara: "New scroll down behavior works, but jumps instead of scroll." The autoscroll path in `afterAppend` + the MutationObserver re-snap + the tail-pill click handler all set `log.scrollTop = log.scrollHeight` — instant jump. Reads as jerky on long mutations where the row height grew a lot between the initial append and the body fill. Switch to `log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' })` via a new `snapToBottom()` helper. All three call sites (afterAppend, MO callback, pill click) route through it. ## Smooth-scroll vs `stickToBottom` flicker `behavior: 'smooth'` fires a stream of scroll events as the position eases toward the target. Without guarding, the scroll handler reads the intermediate position, fails `isNearBottom()`, flips `stickToBottom` to false — the next MO callback then skips the snap and strands the operator mid-scroll. Add a `smoothScrollingUntil` timestamp gate: every `snapToBottom()` call (without `immediate`) re-arms it to `now + 800ms` (~Chromium / Firefox smooth-scroll animation duration + headroom). The scroll handler ignores events while the gate is active. Bursts of writes coalesce into one smooth ride to the latest bottom rather than a sequence of half-cancelled animations. ## Backfill replay `currentNoAnim` is true during history backfill — the operator never sees the intermediate positions there, so smooth scroll is just wasted animation. `snapToBottom(immediate=true)` (and the noAnim branch in afterAppend) falls back to instant scroll for that path. Same shared `@hive/shared/terminal.js` is used by the dashboard + per-agent terminal + flow page; all three inherit the change. --- frontend/packages/shared/src/terminal.js | 49 +++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js index 658b07b2..314328b1 100644 --- a/frontend/packages/shared/src/terminal.js +++ b/frontend/packages/shared/src/terminal.js @@ -69,16 +69,47 @@ export function create(opts) { // handler so both programmatic scrollTop assignments and // operator-driven wheel/drag stay in sync. let stickToBottom = true; + // Guards scroll-event-handler from misreading the position while + // our own smooth-scroll animation is mid-flight (#400). When we + // initiate `scrollTo({ behavior: 'smooth' })`, the browser fires + // a stream of scroll events as it eases toward the target — the + // position passes through "not near bottom" before settling at + // bottom. Without this gate, the scroll handler flips + // `stickToBottom` to false mid-animation, which then causes the + // MutationObserver to skip the next snap and leaves the operator + // stranded mid-scroll. The gate is released on a timeout sized + // to outlast the smooth-scroll animation (~500ms in Chromium / + // Firefox; 800ms gives comfortable headroom). Each fresh snap + // re-arms the timer so back-to-back snaps stay gated. + let smoothScrollingUntil = 0; function isNearBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; } + // Smooth-scroll the log to the bottom (#400 — replaces the + // instant `scrollTop = scrollHeight` jumps with eased animation). + // Each call re-arms the smooth-scrolling gate so back-to-back + // burst writes coalesce into one smooth ride to the new bottom + // instead of a sequence of half-cancelled animations. Falls back + // to instant scroll when `currentNoAnim` is true (backfill replay + // — the operator never sees the intermediate positions so the + // animation is just wasted frames + jitter). + function snapToBottom(immediate) { + stickToBottom = true; + if (immediate || currentNoAnim) { + smoothScrollingUntil = 0; + log.scrollTop = log.scrollHeight; + return; + } + smoothScrollingUntil = Date.now() + 800; + log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' }); + } function ensurePill() { if (pill) return pill; pill = document.createElement('button'); pill.type = 'button'; pill.className = 'tail-pill'; - pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; }); + pill.addEventListener('click', () => snapToBottom()); pillAnchor.appendChild(pill); return pill; } @@ -92,6 +123,12 @@ export function create(opts) { pill.classList.add('visible'); } log.addEventListener('scroll', () => { + // Mid-smooth-scroll: ignore the intermediate scroll events. The + // gate releases when the animation has had time to settle (or + // when the next snap re-arms it). Without this, easing toward + // bottom would flip `stickToBottom` false partway and the next + // MO callback would skip the snap. + if (Date.now() < smoothScrollingUntil) return; stickToBottom = isNearBottom(); if (stickToBottom) { unseen = 0; updatePill(); } }); @@ -113,7 +150,7 @@ export function create(opts) { // assignments don't re-trigger MO (the scroll itself isn't a // DOM mutation), so no feedback loop. const mo = new MutationObserver(() => { - if (stickToBottom) log.scrollTop = log.scrollHeight; + if (stickToBottom) snapToBottom(); }); mo.observe(log, { childList: true, subtree: true, characterData: true }); @@ -130,13 +167,7 @@ export function create(opts) { // frame instead of one microtask + frame.) function afterAppend(wasNearBottom) { if (currentNoAnim || wasNearBottom) { - // Re-arm stickToBottom before the scroll — the assignment - // fires a scroll event which sets it via isNearBottom(), - // but doing it eagerly here makes the next renderer - // mutation's MO callback deterministic even if the scroll - // event hasn't fired yet. - stickToBottom = true; - log.scrollTop = log.scrollHeight; + snapToBottom(); } else { unseen += 1; updatePill();