terminal: smooth-scroll to bottom instead of instant jump (#400)

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.
This commit is contained in:
iris 2026-05-25 01:24:49 +02:00 committed by Mara
commit 8305c0716a

View file

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