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;