Files
multica/packages/views/common/task-transcript/transcript-follow.test.ts
T
07bc8bc6bc MUL-6739 fix(chat): keep the viewport at the live end while replies stream (#7612)
* fix(chat): keep the chat viewport at the live end while a reply streams (TIM-55)

Virtuoso's `followOutput` only fires when the item COUNT changes. A streaming
assistant turn is one row that keeps growing, so nothing followed it and the
reply ran off the bottom of the fold. Virtuoso's own SIZE_INCREASED recovery is
armed for 100ms after a count change, which a multi-minute stream leaves behind
on its first token.

The composer is the same failure from the other side: it grows with the draft
(and banners/queue appear above it), shrinking the list's viewport while
scrollTop stays put, so the tail slides out of view behind it.

The list now drives its own bottom-stick off a ResizeObserver watching both the
scroll container and the content inside it: while the reader is at the live end,
any content growth or viewport shrink re-pins to the bottom; scrolling away
releases the pin until they come back.

`followOutput` drops to "auto" — the stick pins instantly, so a smooth glide
started on a count change was only going to be cancelled by the next chunk.

* refactor(chat): collapse the bottom-stick controller into pure helpers

The stateful BottomStick factory carried one bit of state (pinned), which
now lives in the hook's ref. The decisions stay as pure functions
(isAtLiveEnd, bottomPinTarget) so the node-side matrix tests survive, and
HTMLElement satisfies ScrollMetrics structurally, removing the readMetrics
adapter. Pin-state transitions (release, re-engagement) are now hook
behavior and move to the DOM suite.

* refactor(chat): trim auto-scroll comments

* fix(chat): preserve scroll-away intent during streaming

* fix(chat): preserve pin through live-end clamps

* fix(chat): judge scroll-away intent from input via the shared live-end latch

Review feedback on #7612: releasing the follow on any 1px upward move
regressed the base's 120px forgiveness and left no recovery path, and the
repo already ships a latch that solves reader-intent-vs-system-displacement
for the newest-first transcript. The latch's state machine is direction
agnostic, so the chat list now reuses it: generalized to createLiveEndFollow
(fed away-positive input deltas and distance-from-bottom), wired to
wheel/touch/key/pointer input mirroring the transcript dialog. Sub-threshold
nudges keep following, accumulated input past FOLLOW_EDGE_THRESHOLD releases,
clamps and streaming never count as intent — no direction sensing, no 1px
special case, one shared threshold both directions.

Also from review: content growth now arrives through Virtuoso's own
totalListHeightChanged instead of a wrapper div, the #6697 auto-vs-smooth
rationale is restored, and followOutput has direct coverage for a released
follow while Virtuoso still reports atBottom.

* docs(chat): make auto-scroll comments standalone

* fix(chat): stage scroll input until the surface's own scroll confirms it

Review feedback on #7612: the latch credited every wheel/touch/key event
that reached the container, including input the list never consumed (a
nested code-block scroller, a conversation too short to scroll) — releasing
the follow with no scroll event to ever re-engage it. The keydown target
guard had the mirror problem: the container is not focusable, so paging
from a focused row control scrolled the list with no input recorded, and
the reader was pinned back over their own keypress.

input() now stages intent and only the surface's own scroll promotes it
past the threshold; resize-driven displacement (content growth, composer
changes) can never promote, and unconsumed intent expires with its gesture.
That lets the chat keydown wiring drop the target guard entirely: a key a
control consumed never scrolls the list, so it never releases, while one
the browser answered does. Home/End now stage intent like the other keys
instead of disengaging unconditionally.

* fix(chat): let the surface's scroll supply the release magnitude

Review feedback on #7612: releasing on input magnitude inside one 300ms
burst made release unreachable at reading pace (discrete wheel notches
reset the accumulator every gesture, and a Firefox notch is exactly the
threshold), and gating the pin on input freshness let a reply's final chunk
land inside the window and park unpinned forever.

Input now only opens a per-gesture budget; each scroll attributes the
displacement the surface actually took, capped by that budget, so a system
shift inside a gesture (a transcript prepend) can never count as reader
intent. Attributed displacement accumulates until the reader returns to
the live end — it survives both gesture boundaries (reading-pace notches
release) and pins (persistent sub-threshold attempts against a fast stream
eventually win). The pin verdict no longer consults the intent window at
all, so displacement is corrected immediately with no timer for a final
event to hide behind.

Also from review: Shift+Space pages up, so it now stages away-intent in
the chat list and toward-intent in the transcript dialog, where the
unsigned handling predated this PR.

* fix(chat): attribute confirmed touch momentum

* test(chat): cover active touch pin guard

* fix(chat): cap confirmed non-touch motion by its unspent claim

Confirming a scroll opened a motion window that any same-direction
displacement could extend for 300ms, with no source or magnitude
attached. A transcript prepend landing in that window was credited to
the reader: 30px of real movement, still inside the edge zone, released
the follow permanently — the #5921 case the latch exists to prevent.

Touch keeps coasting freely, since momentum is not proportional to
finger travel. Every other source may now only continue for the claim
the surface has not spent yet, so a wheel notch whose scroll animates
across frames still counts while a system shift finds nothing to draw
on.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): make carry survive fractional frames and overlapping notches

Two ordinary wheel sequences still reached the pin.

An exact `moved <= motionCarry` rejected the last frame of an unevenly
split animation by about one ulp, because carry is spent in fractional
steps and drifts from the surface's own arithmetic. The comparison now
allows a tolerance scaled to the amount at hand; a real system shift is
pixels, not ulps.

A notch arriving while the previous one was still animating was never
confirmed: the continuation branch spent the old carry and returned, so
endInputFrame() discarded the new claim and the rest of the reader's own
scroll was pinned back. Same-direction scroll now folds a fresh claim
into the carry first, on both the away and toward paths.

Co-authored-by: multica-agent <[email protected]>

* test(chat): make the toward-fold regression actually exercise the branch

The case started by scrolling 300px away, which released the follow — so
the pin verdict was false no matter what the toward fold did. Park the
viewport out at 300px under a held pointer instead, which suppresses the
pin without attributing anything, and the reader is still following when
the notches land. Removing the fold now fails this test.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-28 17:59:28 +08:00

329 lines
12 KiB
TypeScript

// @vitest-environment node
import { describe, it, expect } from "vitest";
import { createLiveEndFollow, FOLLOW_EDGE_THRESHOLD } from "./transcript-follow";
function makeFollow(startAt = 0) {
let t = startAt;
const follow = createLiveEndFollow(() => t);
const tick = (ms: number) => {
t += ms;
};
follow.setActive(true);
return { follow, tick };
}
describe("createLiveEndFollow", () => {
it("starts following and pins system displacement back to the live end", () => {
const { follow, tick } = makeFollow();
tick(1000);
// A prepend-anchoring shift: no user input at all.
expect(follow.onScroll(500)).toBe(true);
expect(follow.isFollowing()).toBe(true);
});
it("a system shift inside the reader's gesture is pinned, not blamed on them", () => {
// Regression: absolute-position heuristics dropped the latch here — the
// user nudged 30px (still inside the zone), then a flush pushed the
// viewport past the threshold while their input was still fresh. Only
// 30px of the movement is attributable to the reader; the rest is the
// system's and is corrected immediately.
const { follow, tick } = makeFollow();
tick(1000);
follow.input(30);
tick(200); // inside the intent window
expect(follow.onScroll(500)).toBe(true); // pin the system's 470px back
expect(follow.isFollowing()).toBe(true);
});
it("a system shift after a confirmed scroll is pinned, not treated as continued motion", () => {
// Regression: confirming a small scroll opened a motion window that any
// same-direction displacement could extend, so a prepend landing inside it
// was credited to the reader and released the follow — from 30px of real
// movement, still well inside the edge zone.
const { follow, tick } = makeFollow();
tick(1000);
follow.input(30);
follow.onScroll(30); // the surface honours it: reader motion, confirmed
follow.endInputFrame();
tick(100); // still inside the settle window
expect(follow.onScroll(230)).toBe(true); // pin the system's 200px back
expect(follow.isFollowing()).toBe(true);
});
it("keeps following a notch whose scroll animates across several frames", () => {
// The wiring drops the claim after one frame, so a browser-animated wheel
// scroll spends the rest through the motion it already confirmed. Capping
// that at the unspent claim is what keeps the case above from counting.
const { follow, tick } = makeFollow();
tick(1000);
follow.input(100);
tick(16);
expect(follow.onScroll(40)).toBe(false);
follow.endInputFrame();
tick(16);
expect(follow.onScroll(80)).toBe(false);
tick(16);
expect(follow.onScroll(100)).toBe(false);
expect(follow.isFollowing()).toBe(true); // 100px is inside the zone
});
it("keeps following when an animated notch lands on uneven fractional frames", () => {
// Carry is spent in fractional steps, so its running total drifts from the
// surface's own arithmetic. An exact comparison rejected the last frame by
// about one ulp and pinned a perfectly ordinary wheel scroll back.
const { follow, tick } = makeFollow();
tick(1000);
follow.input(100);
tick(16);
expect(follow.onScroll(7.29)).toBe(false);
follow.endInputFrame();
tick(16);
expect(follow.onScroll(22.86528)).toBe(false);
tick(16);
expect(follow.onScroll(100)).toBe(false);
});
it("confirms a second notch that arrives while the first is still animating", () => {
// The overlapping claim is confirmed by this same-direction scroll; without
// folding it into the carry, endInputFrame() dropped it and the rest of the
// reader's own scroll was pinned back.
const { follow, tick } = makeFollow();
tick(1000);
follow.input(100);
tick(16);
follow.onScroll(40);
follow.input(100); // second notch, first is still animating
tick(16);
follow.onScroll(80);
follow.endInputFrame();
tick(16);
expect(follow.onScroll(120)).toBe(false);
tick(16);
follow.onScroll(160);
expect(follow.isFollowing()).toBe(false); // 160px of their own scrolling
});
it("folds an overlapping notch on the way back to the live end too", () => {
// The reader must still be FOLLOWING for the pin verdict to mean anything:
// park the viewport out at 300px under a held pointer, which suppresses the
// pin without attributing anything, then walk back with two notches.
const { follow, tick } = makeFollow();
tick(1000);
follow.pointerDown(false);
follow.onScroll(300);
follow.pointerUp();
expect(follow.isFollowing()).toBe(true);
follow.input(-100);
tick(16);
expect(follow.onScroll(260)).toBe(false);
follow.input(-100); // second notch back, first still animating
tick(16);
expect(follow.onScroll(220)).toBe(false);
follow.endInputFrame();
tick(16);
// Without the toward fold the second notch is gone and this pins.
expect(follow.onScroll(180)).toBe(false);
});
it("releases when touch momentum carries a confirmed flick past the threshold", () => {
const { follow } = makeFollow();
follow.touchStart();
follow.input(80);
expect(follow.onScroll(80)).toBe(false);
follow.touchEnd();
expect(follow.onScroll(160)).toBe(false);
expect(follow.isFollowing()).toBe(false);
});
it("counts the surface's fractional touch displacement", () => {
const { follow } = makeFollow();
follow.touchStart();
follow.input(80);
expect(follow.onScroll(80.5)).toBe(false);
expect(follow.isFollowing()).toBe(true);
follow.touchEnd();
expect(follow.onScroll(120.25)).toBe(false);
expect(follow.isFollowing()).toBe(false);
});
it("does not pin unattributed displacement during an active touch", () => {
const { follow } = makeFollow();
follow.touchStart();
expect(follow.onScroll(300)).toBe(false);
});
it("releases for wheel notches spaced past the intent window", () => {
// A discrete mouse wheel at reading pace: one notch per event, far more
// than the window apart. Attributed displacement accumulates across
// gestures, so pacing and device units cannot defeat the threshold.
const { follow, tick } = makeFollow();
follow.input(100);
expect(follow.onScroll(100)).toBe(false); // notch 1: 100px taken
tick(500);
follow.input(100);
expect(follow.onScroll(200)).toBe(false); // notch 2: 200px taken, released
expect(follow.isFollowing()).toBe(false);
});
it("accumulates displacement across pins during a fast stream", () => {
// Sub-threshold notches each answered by a chunk that pins the reader
// back: the taken displacement survives the pins, so persistence wins
// instead of losing every round.
const { follow, tick } = makeFollow();
follow.input(60);
expect(follow.onScroll(60)).toBe(false);
tick(400);
expect(follow.onResize(240)).toBe(true); // chunk pins back
follow.input(60);
expect(follow.onScroll(60)).toBe(false);
tick(400);
expect(follow.onResize(240)).toBe(true); // chunk pins back again
follow.input(60);
follow.onScroll(60); // 180px taken in total
expect(follow.isFollowing()).toBe(false);
});
it("pins displacement that lands inside the intent window", () => {
// The final chunk of a reply has a whole window to land in after any
// small nudge; the pin decision must not hide behind a timer, because
// no later event exists to re-evaluate it.
const { follow } = makeFollow();
follow.input(2);
expect(follow.onScroll(2)).toBe(false); // 2px nudge, confirmed
expect(follow.onResize(182)).toBe(true); // still inside the window: pin now
expect(follow.isFollowing()).toBe(true);
});
it("re-engages a reader who walks themselves back to the live end", () => {
const { follow } = makeFollow();
follow.input(200);
follow.onScroll(200); // released
expect(follow.isFollowing()).toBe(false);
follow.input(-200);
expect(follow.onScroll(0)).toBe(false);
expect(follow.isFollowing()).toBe(true);
});
it("disengages on accumulated user input once the surface's scroll confirms it", () => {
const { follow } = makeFollow();
follow.input(80);
follow.input(80); // cumulative 160 > threshold — but only staged so far
expect(follow.isFollowing()).toBe(true);
expect(follow.onScroll(160)).toBe(false); // the surface moved: released, no pin
expect(follow.isFollowing()).toBe(false);
expect(follow.onScroll(400)).toBe(false); // no pinning once disengaged
});
it("input the surface never consumed does not release, and never blocks the pin", () => {
// A wheel over a nested scroller (or a list too short to scroll) bubbles
// to the container without moving it: no scroll ever attributes it.
const { follow } = makeFollow();
follow.input(300);
expect(follow.isFollowing()).toBe(true);
expect(follow.onResize(180)).toBe(true); // growth pins straight away
expect(follow.isFollowing()).toBe(true);
});
it("pins a system shift after the surface left input unconsumed", () => {
const { follow, tick } = makeFollow();
follow.input(300);
follow.endInputFrame();
tick(100);
expect(follow.onScroll(200)).toBe(true);
expect(follow.isFollowing()).toBe(true);
});
it("a new gesture does not inherit unconsumed intent from an old one", () => {
const { follow, tick } = makeFollow();
follow.input(300); // never confirmed by a scroll
tick(1000);
follow.input(60); // fresh gesture, sub-threshold on its own
expect(follow.onScroll(60)).toBe(false);
expect(follow.isFollowing()).toBe(true);
});
it("a mixed-direction gesture counts its net movement, not its pushes", () => {
const { follow } = makeFollow();
follow.input(100);
follow.input(-90);
follow.input(100);
// The surface only net-moved 110: attribution is capped by movement.
expect(follow.onScroll(110)).toBe(false);
expect(follow.isFollowing()).toBe(true);
});
it("a stale gesture's budget does not attribute a later system shift", () => {
const { follow, tick } = makeFollow();
follow.input(100); // never confirmed
tick(1000);
expect(follow.onScroll(300)).toBe(true); // whole shift is the system's: pin
follow.input(100); // fresh gesture: 100 <= threshold on its own
expect(follow.isFollowing()).toBe(true);
});
it("re-engages when the viewport returns to the live-end zone", () => {
const { follow } = makeFollow();
follow.input(FOLLOW_EDGE_THRESHOLD + 1);
follow.onScroll(FOLLOW_EDGE_THRESHOLD + 1);
expect(follow.isFollowing()).toBe(false);
follow.onAtEdgeChange(true);
expect(follow.isFollowing()).toBe(true);
});
it("scrollbar drag past the threshold disengages", () => {
const { follow } = makeFollow();
follow.pointerDown(true);
expect(follow.onScroll(80)).toBe(false); // still in zone
expect(follow.isFollowing()).toBe(true);
follow.onScroll(200);
expect(follow.isFollowing()).toBe(false);
follow.pointerUp();
});
it("never pins while the mouse is held on row content (text selection autoscroll)", () => {
// Regression: selection-drag autoscroll has no wheel/touch input; pinning
// during it made text below the fold unselectable.
const { follow, tick } = makeFollow();
tick(1000);
follow.pointerDown(false); // mousedown on a row, not the scrollbar
expect(follow.onScroll(600)).toBe(false);
expect(follow.isFollowing()).toBe(true); // content drag is not scroll intent
follow.pointerUp();
expect(follow.onScroll(600)).toBe(true); // released: enforcement resumes
});
it("explicit disengage (segment navigation) stops the pinning", () => {
const { follow, tick } = makeFollow();
follow.disengage();
tick(1000);
expect(follow.isFollowing()).toBe(false);
expect(follow.onScroll(300)).toBe(false);
});
it("reset re-engages and clears held-pointer state", () => {
const { follow, tick } = makeFollow();
follow.pointerDown(false);
follow.input(500);
follow.reset();
tick(1000);
expect(follow.isFollowing()).toBe(true);
expect(follow.onScroll(50)).toBe(true); // mouseHeld cleared by reset
});
it("is fully inert when inactive (chronological or completed task)", () => {
const { follow, tick } = makeFollow();
follow.setActive(false);
tick(1000);
expect(follow.isFollowing()).toBe(false);
expect(follow.onScroll(500)).toBe(false);
follow.input(500); // ignored
follow.setActive(true);
expect(follow.isFollowing()).toBe(true); // latch untouched while inactive
});
});