From 502e9e0e7088a45e35974fa051a12d14f0b399b7 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 25 May 2026 21:01:58 +0200 Subject: [PATCH] =?UTF-8?q?terminal:=20snappier=20snap-to-bottom=20?= =?UTF-8?q?=E2=80=94=20140ms=20rAF=20animation=20(#400=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mara: *can we make this really fast to not be a problem? like its okay if it is pretty fast*. `scrollTo({ behavior: 'smooth' })` runs ~500ms in Chromium/Firefox — "still smooth, but visibly slow." Swap it for a custom rAF loop with an ease-out cubic over 140ms. Distances under 24px short- circuit to instant — animating a 12px nudge is just jitter. Each call cancels the previous rAF before starting a new one, so a burst of mutations coalesces into one ride to the latest bottom instead of two animations fighting over scrollTop. Re-reads `scrollHeight - clientHeight` each frame so a renderer mutation landing mid-animation (the common case — `api.row` returned, then renderer appended badge + body) extends the destination smoothly rather than landing short. `smoothScrollingUntil` gate shrinks accordingly (140 + 80 = 220ms) — still protects the scroll handler from flipping stickToBottom on the intermediate scroll events the rAF fires. --- frontend/packages/shared/src/terminal.js | 84 +++++++++++++++++------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js index 314328b1..f7677805 100644 --- a/frontend/packages/shared/src/terminal.js +++ b/frontend/packages/shared/src/terminal.js @@ -50,6 +50,14 @@ // count=0); pages use it to set state flags from the replayed history. const NEAR_BOTTOM_PX = 48; +// Snap-to-bottom animation duration (#400 + mara feedback). Browser +// default `scrollTo({ behavior: 'smooth' })` runs ~500ms, which read +// as "still smooth, but visibly slow." 140ms with ease-out is fast +// enough to feel snap-y, slow enough that the row's destination +// reads as motion (not a jump). Distances under SCROLL_SNAP_PX +// short-circuit to instant — animating a 12px nudge is just jitter. +const SCROLL_ANIM_MS = 140; +const SCROLL_SNAP_PX = 24; export function create(opts) { const log = opts.logEl; @@ -70,39 +78,69 @@ export function create(opts) { // 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. + // our own animation is mid-flight (#400). The animation drives + // scrollTop with rAF, which fires a stream of scroll events as + // the position eases toward the target — the position passes + // through "not near bottom" before settling. 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. Set to the + // animation's nominal end + small headroom; each fresh snap + // re-arms it so back-to-back snaps stay gated. let smoothScrollingUntil = 0; + // rAF id for the current snap animation. Cancelled when a new + // snap starts so we never have two animations fighting over + // scrollTop. + let scrollAnimRaf = 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). + // Snap the log to the bottom with a brief eased animation + // (#400 + mara: snappier than the browser's default 500ms smooth + // scroll). Each call cancels the previous frame loop and starts a + // fresh one, so a burst of mutations coalesces into one ride to + // the latest bottom. Re-evaluates the target each frame so a + // renderer mutation landing mid-animation extends the destination + // without a visible jump. Falls back to instant scroll when + // `currentNoAnim` is true (backfill replay — operator never sees + // intermediate positions, animation is wasted frames) or when the + // remaining distance is under SCROLL_SNAP_PX. function snapToBottom(immediate) { stickToBottom = true; - if (immediate || currentNoAnim) { + if (scrollAnimRaf) { + cancelAnimationFrame(scrollAnimRaf); + scrollAnimRaf = 0; + } + const target = log.scrollHeight - log.clientHeight; + const start = log.scrollTop; + const distance = target - start; + if (immediate || currentNoAnim || distance <= SCROLL_SNAP_PX) { smoothScrollingUntil = 0; - log.scrollTop = log.scrollHeight; + log.scrollTop = target; return; } - smoothScrollingUntil = Date.now() + 800; - log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' }); + smoothScrollingUntil = Date.now() + SCROLL_ANIM_MS + 80; + const t0 = performance.now(); + const easeOut = (t) => 1 - Math.pow(1 - t, 3); + const step = (now) => { + const elapsed = now - t0; + const frac = Math.min(1, elapsed / SCROLL_ANIM_MS); + // Re-read target each frame so mutations landing mid-animation + // (the common case — a renderer appended badge / body bits + // after api.row returned) extend the destination smoothly + // rather than landing short. + const currentTarget = log.scrollHeight - log.clientHeight; + log.scrollTop = start + (currentTarget - start) * easeOut(frac); + if (frac < 1) { + scrollAnimRaf = requestAnimationFrame(step); + } else { + // Final exact settle on the as-of-now bottom. + log.scrollTop = log.scrollHeight - log.clientHeight; + scrollAnimRaf = 0; + } + }; + scrollAnimRaf = requestAnimationFrame(step); } function ensurePill() { if (pill) return pill;