terminal: smooth-scroll to bottom instead of instant jump (#400)
Mara: "New scroll down behavior works, but jumps instead of scroll."
The autoscroll path in `afterAppend` + the MutationObserver re-snap
+ the tail-pill click handler all set `log.scrollTop = log.scrollHeight`
— instant jump. Reads as jerky on long mutations where the row
height grew a lot between the initial append and the body fill.
Switch to `log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' })`
via a new `snapToBottom()` helper. All three call sites (afterAppend,
MO callback, pill click) route through it.
## Smooth-scroll vs `stickToBottom` flicker
`behavior: 'smooth'` fires a stream of scroll events as the position
eases toward the target. Without guarding, the scroll handler reads
the intermediate position, fails `isNearBottom()`, flips
`stickToBottom` to false — the next MO callback then skips the snap
and strands the operator mid-scroll.
Add a `smoothScrollingUntil` timestamp gate: every `snapToBottom()`
call (without `immediate`) re-arms it to `now + 800ms` (~Chromium /
Firefox smooth-scroll animation duration + headroom). The scroll
handler ignores events while the gate is active. Bursts of writes
coalesce into one smooth ride to the latest bottom rather than a
sequence of half-cancelled animations.
## Backfill replay
`currentNoAnim` is true during history backfill — the operator never
sees the intermediate positions there, so smooth scroll is just
wasted animation. `snapToBottom(immediate=true)` (and the noAnim
branch in afterAppend) falls back to instant scroll for that path.
Same shared `@hive/shared/terminal.js` is used by the dashboard +
per-agent terminal + flow page; all three inherit the change.
This commit is contained in:
parent
c015f62764
commit
8305c0716a
1 changed files with 40 additions and 9 deletions
|
|
@ -69,16 +69,47 @@ export function create(opts) {
|
|||
// handler so both programmatic scrollTop assignments and
|
||||
// operator-driven wheel/drag stay in sync.
|
||||
let stickToBottom = true;
|
||||
// Guards scroll-event-handler from misreading the position while
|
||||
// our own smooth-scroll animation is mid-flight (#400). When we
|
||||
// initiate `scrollTo({ behavior: 'smooth' })`, the browser fires
|
||||
// a stream of scroll events as it eases toward the target — the
|
||||
// position passes through "not near bottom" before settling at
|
||||
// bottom. Without this gate, the scroll handler flips
|
||||
// `stickToBottom` to false mid-animation, which then causes the
|
||||
// MutationObserver to skip the next snap and leaves the operator
|
||||
// stranded mid-scroll. The gate is released on a timeout sized
|
||||
// to outlast the smooth-scroll animation (~500ms in Chromium /
|
||||
// Firefox; 800ms gives comfortable headroom). Each fresh snap
|
||||
// re-arms the timer so back-to-back snaps stay gated.
|
||||
let smoothScrollingUntil = 0;
|
||||
|
||||
function isNearBottom() {
|
||||
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
// Smooth-scroll the log to the bottom (#400 — replaces the
|
||||
// instant `scrollTop = scrollHeight` jumps with eased animation).
|
||||
// Each call re-arms the smooth-scrolling gate so back-to-back
|
||||
// burst writes coalesce into one smooth ride to the new bottom
|
||||
// instead of a sequence of half-cancelled animations. Falls back
|
||||
// to instant scroll when `currentNoAnim` is true (backfill replay
|
||||
// — the operator never sees the intermediate positions so the
|
||||
// animation is just wasted frames + jitter).
|
||||
function snapToBottom(immediate) {
|
||||
stickToBottom = true;
|
||||
if (immediate || currentNoAnim) {
|
||||
smoothScrollingUntil = 0;
|
||||
log.scrollTop = log.scrollHeight;
|
||||
return;
|
||||
}
|
||||
smoothScrollingUntil = Date.now() + 800;
|
||||
log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
function ensurePill() {
|
||||
if (pill) return pill;
|
||||
pill = document.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = 'tail-pill';
|
||||
pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; });
|
||||
pill.addEventListener('click', () => snapToBottom());
|
||||
pillAnchor.appendChild(pill);
|
||||
return pill;
|
||||
}
|
||||
|
|
@ -92,6 +123,12 @@ export function create(opts) {
|
|||
pill.classList.add('visible');
|
||||
}
|
||||
log.addEventListener('scroll', () => {
|
||||
// Mid-smooth-scroll: ignore the intermediate scroll events. The
|
||||
// gate releases when the animation has had time to settle (or
|
||||
// when the next snap re-arms it). Without this, easing toward
|
||||
// bottom would flip `stickToBottom` false partway and the next
|
||||
// MO callback would skip the snap.
|
||||
if (Date.now() < smoothScrollingUntil) return;
|
||||
stickToBottom = isNearBottom();
|
||||
if (stickToBottom) { unseen = 0; updatePill(); }
|
||||
});
|
||||
|
|
@ -113,7 +150,7 @@ export function create(opts) {
|
|||
// 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;
|
||||
if (stickToBottom) snapToBottom();
|
||||
});
|
||||
mo.observe(log, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
|
|
@ -130,13 +167,7 @@ export function create(opts) {
|
|||
// 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;
|
||||
snapToBottom();
|
||||
} else {
|
||||
unseen += 1;
|
||||
updatePill();
|
||||
|
|
|
|||
Loading…
Reference in a new issue