diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md index 054f771a..c0000b7e 100644 --- a/docs/web-ui/shape.md +++ b/docs/web-ui/shape.md @@ -121,7 +121,15 @@ the smooth animation eases through positions that are technically `smoothScrollingUntil` timestamp gates the scroll handler — set to the animation end + ~80ms headroom, re-armed on each fresh snap. Programmatic `scrollTop` writes (the animation's per-frame update) -fire scroll events that the gate swallows. +fire scroll events that the gate swallows. One caveat: if live events +arrive faster than the animation window (< 220ms apart), the +MutationObserver keeps re-firing `snapToBottom()` which perpetually +re-arms the gate — trapping the operator at the bottom with no way +to scroll up or trigger `loadMore()`. Safety valve: even inside the +guard the handler checks `isNearBottom()`; if the operator has +scrolled away from the bottom, `stickToBottom` is immediately forced +false. The MO's `if (stickToBottom)` check then stops re-arming +snaps, and the gate expires within ≤220ms. **Post-append `MutationObserver`.** Renderers commonly call `api.row(cls, text)` to create the row shell then append more diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js index aefb0223..aa13d612 100644 --- a/frontend/packages/shared/src/terminal.js +++ b/frontend/packages/shared/src/terminal.js @@ -158,12 +158,29 @@ export function create(opts) { pill.classList.add('visible'); } log.addEventListener('scroll', () => { - // Swallow scroll events during smooth-snap animations — see - // docs/web-ui.md::Shared terminal pane (Mid-animation scroll-event - // guard). - if (Date.now() < smoothScrollingUntil) return; - stickToBottom = isNearBottom(); + // Sticky-bottom intent tracking. Outside an animation this is + // straightforward — stickToBottom = isNearBottom(). During a + // smooth-snap animation we swallow most of the event to avoid a + // feedback loop (programmatic scrollTop changes → scroll events → + // new snap → cancels current rAF), but we MUST still let the user + // break out of sticky mode: if the user scrolls away from the + // bottom while a snap animation is in flight, honour that intent + // immediately so the MutationObserver stops re-firing snapToBottom() + // and the animation-guard window can expire naturally. Without this, + // live events arriving < 220ms apart permanently block scroll-to-top + // and loadMore() never fires. See docs/web-ui.md::Shared terminal + // pane (Mid-animation scroll-event guard). + const inAnim = Date.now() < smoothScrollingUntil; + const nearBottom = isNearBottom(); + if (!inAnim) { + stickToBottom = nearBottom; + } else if (!nearBottom) { + // User scrolled up during an animation — break sticky mode so the + // MO stops calling snapToBottom() and the gate expires. + stickToBottom = false; + } if (stickToBottom) { unseen = 0; updatePill(); } + if (inAnim) return; // Auto-fetch older history when the user scrolls near the top — no // click required; the load-more pill stays as a visual indicator. if (rootLog.scrollTop <= LOAD_MORE_SCROLL_PX && histHasMore && !histLoading) {