From 9975be9c081f4cb7800f563c0d4ad4dc640f6a60 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 25 May 2026 00:46:01 +0200 Subject: [PATCH] terminal: snap to bottom after post-row mutations (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #375 captured `wasNearBottom` BEFORE the row's initial append so the autoscroll wouldn't be tricked by the new row's own height. That's still right, but it leaves a second gap: renderers commonly call `api.row(cls, text)` to build the shell, then mutate the returned element by appending more children (badges, multi-line turn bodies, tool-result panes). The initial scrollTop assignment in afterAppend only sees the row's INITIAL height — once the renderer adds the body, the row's grown past the visible bottom and the operator's stranded scrolled to the row's TOP, breaking stick-to-bottom for every subsequent event. Mara's symptom: two lines of text appear, the view scrolls only one row, the terminal isn't at the bottom anymore, and the next event doesn't auto-scroll because `isNearBottom()` now returns false. Fix: add a `stickToBottom` boolean (updated synchronously from the scroll event handler) + a MutationObserver on the log subtree (`childList`, `subtree`, `characterData`). On any mutation, if stickToBottom is true, re-snap to bottom. MutationObserver batches mutations into one microtask callback per synchronous block, so it runs once per renderer call regardless of how many children the renderer appends after `api.row` returned. Programmatic `scrollTop = scrollHeight` doesn't trigger MO (the scroll isn't a DOM mutation), so no feedback loop. Operator scrolling up still flips stickToBottom to false via the existing scroll handler — MO becomes a no-op until they scroll back. Same shared `@hive/shared/terminal.js` powers the dashboard's flow page + per-agent terminal, so both pages inherit the fix. --- frontend/packages/shared/src/terminal.js | 40 +++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js index 1c350ff..658b07b 100644 --- a/frontend/packages/shared/src/terminal.js +++ b/frontend/packages/shared/src/terminal.js @@ -63,6 +63,12 @@ export function create(opts) { let pill = null; let unseen = 0; let currentNoAnim = false; + // Sticky-bottom intent. True means "keep snapping to bottom on + // any mutation"; false means "the operator scrolled up — leave + // them alone". Updated synchronously from the scroll event + // handler so both programmatic scrollTop assignments and + // operator-driven wheel/drag stay in sync. + let stickToBottom = true; function isNearBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX; @@ -86,8 +92,31 @@ export function create(opts) { pill.classList.add('visible'); } log.addEventListener('scroll', () => { - if (isNearBottom()) { unseen = 0; updatePill(); } + stickToBottom = isNearBottom(); + if (stickToBottom) { unseen = 0; updatePill(); } }); + // Post-append mutations (issue #393). Renderers commonly call + // `api.row(cls, text)` to create the row shell, then mutate it + // by appending more children (badges, multi-line bodies, tool + // result panes) AFTER api.row returned. The afterAppend scroll + // below only sees the row's INITIAL height — once the renderer + // adds the body, the row's grown past the visible bottom and + // the operator is left scrolled to the row's TOP, breaking + // stick-to-bottom for every subsequent event. + // + // Fix: MutationObserver on the log subtree. Fires once per + // microtask after each batch of synchronous mutations, so it + // runs once per renderer call regardless of how many children + // the renderer appends. When `stickToBottom` is true, snap to + // bottom again — catches whatever the renderer added after the + // afterAppend hop. Programmatic `scrollTop = scrollHeight` + // 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; + }); + mo.observe(log, { childList: true, subtree: true, characterData: true }); + // Auto-scroll decision uses the PRE-append scroll position // (issue #375). Checking after the append underestimates // "nearness" because the new row's own height has already pushed @@ -96,8 +125,17 @@ export function create(opts) { // Each row/details/detailsDiff captures `nearBottomBeforeAppend` // and hands it to afterAppend so the auto-scroll triggers // whenever the operator was at the bottom when the row landed. + // (The MutationObserver above catches the AFTER-row mutations + // too, but this initial scroll keeps the visual lag to one + // 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; } else { unseen += 1;