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 pill = null;
|
||||||
let unseen = 0;
|
let unseen = 0;
|
||||||
let currentNoAnim = false;
|
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() {
|
function isNearBottom() {
|
||||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||||
|
|
@ -86,8 +92,31 @@ export function create(opts) {
|
||||||
pill.classList.add('visible');
|
pill.classList.add('visible');
|
||||||
}
|
}
|
||||||
log.addEventListener('scroll', () => {
|
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
|
// Auto-scroll decision uses the PRE-append scroll position
|
||||||
// (issue #375). Checking after the append underestimates
|
// (issue #375). Checking after the append underestimates
|
||||||
// "nearness" because the new row's own height has already pushed
|
// "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`
|
// Each row/details/detailsDiff captures `nearBottomBeforeAppend`
|
||||||
// and hands it to afterAppend so the auto-scroll triggers
|
// and hands it to afterAppend so the auto-scroll triggers
|
||||||
// whenever the operator was at the bottom when the row landed.
|
// 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) {
|
function afterAppend(wasNearBottom) {
|
||||||
if (currentNoAnim || 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;
|
log.scrollTop = log.scrollHeight;
|
||||||
} else {
|
} else {
|
||||||
unseen += 1;
|
unseen += 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue