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:
parent
e9cce17828
commit
9975be9c08
1 changed files with 39 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue