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();