terminal: snap to bottom after post-row mutations (#393)

#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.
This commit is contained in:
iris 2026-05-25 00:46:01 +02:00 committed by Mara
parent e9cce17828
commit 9975be9c08

View file

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