Files
multica/packages/views/common/task-transcript/transcript-follow.ts
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

285 lines
12 KiB
TypeScript

// Live-end follow latch, shared by the newest-first transcript (#5921) and the
// bottom-anchored chat list (chat/components/stick-to-bottom.ts).
//
// Both surfaces have the same problem: the system moves the viewport on its
// own (prepend anchoring in the transcript; streaming growth, composer
// resizes and the resulting scroll clamps in the chat list), so "am I at the
// live end right now" cannot distinguish a reader who scrolled away from a
// viewport the system displaced. This latch keeps that distinction:
//
// - Input alone never releases. It opens a claim for the current rendering
// frame, and only the surface's own scroll can confirm reader-driven motion.
// The wiring discards unused claims after that frame, so a later system shift
// cannot inherit input consumed by a nested scroller.
// - Confirmed motion uses the surface's displacement, not raw input magnitude.
// Continued same-direction movement inside the settle window remains reader
// motion, including touch momentum and fractional drag frames.
// - Attributed displacement ACCUMULATES until the reader returns to the live
// end; it does not expire with the gesture and pins do not erase it. A
// trackpad flick and five discrete wheel notches at reading pace both cross
// the same threshold, and repeated sub-threshold attempts against a fast
// stream eventually win instead of being re-pinned forever.
// - While following, displacement not attributed to the reader is pinned
// straight back to the live end (`onScroll` / `onResize` return the
// verdict) — immediately, with no intent-window timer for a final event to
// hide behind — but never during an active touch or while the mouse is held
// down (text selection autoscroll must not be fought).
// - Arriving back within the edge zone re-engages the follow.
//
// The state machine is direction-agnostic: callers feed it away-positive
// input deltas and the current distance from THEIR live end (`scrollTop` for
// the newest-first transcript, distance-from-bottom for the chat list).
//
// Pure state machine so the decision table is unit-testable; each surface
// owns wiring it to DOM events.
// Forgiving "at the live end" zone: within this distance of the live edge the
// reader counts as following. The chat list also passes this to Virtuoso as
// `atBottomThreshold`, so both judges of "at the bottom" agree.
export const FOLLOW_EDGE_THRESHOLD = 120;
// Maximum gap between an input and its confirming scroll, or between scrolls
// in confirmed motion. DOM wiring normally clears unconfirmed input sooner,
// after the next animation frame.
const MOTION_SETTLE_WINDOW_MS = 300;
// WheelEvent.deltaMode 1 (lines) / 2 (pages) conversion.
export const LINE_SCROLL_PX = 40;
export interface LiveEndFollow {
/** Whether the surface is live; everything is inert while inactive. */
setActive(active: boolean): void;
/** New list instance (task/sort/filter change): back to following. */
reset(): void;
isFollowing(): boolean;
/** Explicit navigation away from the live end (e.g. segment click). */
disengage(): void;
/**
* User scroll input in px; positive = away from the live end. Opens a
* budget the surface's own scroll can attribute displacement against.
*/
input(delta: number): void;
/** Discards input the surface did not consume during its rendering frame. */
endInputFrame(): void;
/** An active touch prevents pinning while drag displacement is confirmed. */
touchStart(): void;
/** Ends the held state; confirmed momentum may continue within the settle window. */
touchEnd(): void;
/** Mousedown inside the scroller; `onScroller` = on the element itself (scrollbar). */
pointerDown(onScroller: boolean): void;
pointerUp(): void;
onAtEdgeChange(atEdge: boolean): void;
/**
* The surface itself scrolled. Confirms staged input or continues recent
* reader motion; returns whether to pin the viewport back to the live end.
*/
onScroll(distance: number): boolean;
/**
* The live end moved without a scroll (content grew, viewport resized).
* Never attributes displacement to the reader; returns whether to pin back.
*/
onResize(distance: number): boolean;
}
export function createLiveEndFollow(now: () => number = () => Date.now()): LiveEndFollow {
let active = false;
let following = true;
// Unspent input claims for the current frame, split by direction. A claim
// becomes reader motion only when the surface confirms matching movement.
let awayBudget = 0;
let towardBudget = 0;
let lastInputAt = -MOTION_SETTLE_WINDOW_MS;
// Displacement the reader actually took, confirmed scroll by scroll. Does
// not expire and survives pins: repeated sub-threshold attempts against a
// fast stream accumulate into a release instead of losing every round.
let awayTaken = 0;
let lastDistance = 0;
let mouseHeld = false;
let scrollbarDrag = false;
let touchHeld = false;
let motionDirection: -1 | 0 | 1 = 0;
let lastMotionAt = -MOTION_SETTLE_WINDOW_MS;
// What set the current motion running, and how much of the claim behind it
// the surface has not spent yet. Touch coasts past its claim (momentum is
// not proportional to finger travel); everything else may only continue
// for what the reader actually asked for, so a system shift arriving inside
// the window has nothing to draw on.
let motionSource: "touch" | "input" | null = null;
let motionCarry = 0;
const inputFresh = () => now() - lastInputAt < MOTION_SETTLE_WINDOW_MS;
const motionFresh = () => now() - lastMotionAt < MOTION_SETTLE_WINDOW_MS;
// Carry is spent in fractional steps, so its running total drifts from the
// surface's own arithmetic by a few ulps. Scaled to the amount at hand, a
// gap this small is rounding — the system moving the viewport is pixels.
const withinCarry = (amount: number) =>
amount <= motionCarry + Math.max(1, motionCarry) * 1e-9;
const pinVerdict = (distance: number): boolean =>
following && !mouseHeld && !scrollbarDrag && !touchHeld && distance > 0;
return {
setActive(a: boolean) {
active = a;
},
reset() {
following = true;
awayBudget = 0;
towardBudget = 0;
awayTaken = 0;
lastDistance = 0;
mouseHeld = false;
scrollbarDrag = false;
touchHeld = false;
motionDirection = 0;
motionSource = null;
motionCarry = 0;
lastMotionAt = -MOTION_SETTLE_WINDOW_MS;
},
isFollowing: () => active && following,
disengage() {
if (active) following = false;
},
input(delta: number) {
if (!active) return;
// A fresh gesture opens fresh budgets: a claim the surface never
// honored must not linger into a later gesture.
if (!inputFresh()) {
awayBudget = 0;
towardBudget = 0;
}
lastInputAt = now();
if (delta > 0) awayBudget += delta;
else towardBudget -= delta;
},
endInputFrame() {
awayBudget = 0;
towardBudget = 0;
},
touchStart() {
if (active) touchHeld = true;
},
touchEnd() {
touchHeld = false;
},
pointerDown(onScroller: boolean) {
if (!active) return;
mouseHeld = true;
if (onScroller) scrollbarDrag = true;
},
pointerUp() {
mouseHeld = false;
scrollbarDrag = false;
},
onAtEdgeChange(atEdge: boolean) {
if (!active || !atEdge) return;
following = true;
awayBudget = 0;
towardBudget = 0;
awayTaken = 0;
motionDirection = 0;
motionSource = null;
motionCarry = 0;
},
onScroll(distance: number): boolean {
if (!active) return false;
const moved = distance - lastDistance;
lastDistance = distance;
// A scrollbar drag is fully user-controlled: absolute position is the
// user's displacement, so the plain threshold applies.
if (scrollbarDrag) {
if (distance > FOLLOW_EDGE_THRESHOLD) following = false;
return false;
}
// Motion already confirmed in this direction continues without a new
// claim. Touch coasts freely — momentum is not proportional to finger
// travel. Every other source is capped by the claim the surface has not
// spent yet, so a system shift landing inside the window (a transcript
// prepend) finds nothing to draw on and falls through to the pin.
if (moved > 0 && motionDirection === 1 && (touchHeld || motionFresh())) {
// A notch arriving mid-animation is confirmed by this same-direction
// scroll, so fold it in: the frame that ends the claim would otherwise
// discard it and pin the rest of the reader's own scroll back.
if (inputFresh() && awayBudget > 0) {
motionCarry += awayBudget;
awayBudget = 0;
}
if (motionSource === "touch" || withinCarry(moved)) {
if (motionSource !== "touch") motionCarry = Math.max(0, motionCarry - moved);
awayTaken += moved;
lastMotionAt = now();
if (following && awayTaken > FOLLOW_EDGE_THRESHOLD) following = false;
return false;
}
} else if (moved < 0 && motionDirection === -1 && (touchHeld || motionFresh())) {
if (inputFresh() && towardBudget > 0) {
motionCarry += towardBudget;
towardBudget = 0;
}
if (motionSource === "touch" || withinCarry(-moved)) {
if (motionSource !== "touch") motionCarry = Math.max(0, motionCarry + moved);
awayTaken = Math.max(0, awayTaken + moved);
lastMotionAt = now();
if (distance === 0) {
following = true;
awayBudget = 0;
towardBudget = 0;
awayTaken = 0;
motionDirection = 0;
motionSource = null;
motionCarry = 0;
}
return false;
}
}
if (moved > 0 && inputFresh() && awayBudget > 0) {
const attributed = Math.min(awayBudget, moved);
awayBudget -= attributed;
// Touch displacement need not match finger travel pixel-for-pixel.
if (touchHeld || attributed === moved) {
awayTaken += moved;
motionDirection = 1;
motionSource = touchHeld ? "touch" : "input";
// One notch can animate across several frames; the wiring drops the
// claim after the first, so carry its remainder into the motion.
motionCarry = awayBudget;
lastMotionAt = now();
if (following && awayTaken > FOLLOW_EDGE_THRESHOLD) following = false;
return false;
}
} else if (moved < 0 && inputFresh() && towardBudget > 0) {
const attributed = Math.min(towardBudget, -moved);
towardBudget -= attributed;
if (touchHeld || attributed === -moved) {
awayTaken = Math.max(0, awayTaken + moved);
motionDirection = -1;
motionSource = touchHeld ? "touch" : "input";
motionCarry = towardBudget;
lastMotionAt = now();
if (distance === 0) {
following = true;
awayBudget = 0;
towardBudget = 0;
awayTaken = 0;
motionDirection = 0;
}
return false;
}
}
return pinVerdict(distance);
},
onResize(distance: number): boolean {
if (!active) return false;
if (pinVerdict(distance)) {
// The caller pins to the live end; record the destination so the
// pin's own scroll (or its absence under test) reads as no movement.
lastDistance = 0;
return true;
}
lastDistance = distance;
return false;
},
};
}