Files
multica/packages/views/common/task-transcript/transcript-follow.ts
Rusty Raven 67d3952ee2 fix(transcript): auto-follow live task output in transcript dialog (#5932)
* fix(transcript): auto-follow live task output in transcript dialog (#5921)

Since the transcript event list moved to Virtuoso (#5733), a live task's
new events required manual scrolling to see — the dialog opened at the
top and never followed appended output.

Wire live-follow through Virtuoso's own primitives, per sort direction:

- Chronological (default): `followOutput` returns "smooth" while the
  task is live and the reader is at the bottom (Virtuoso's own atBottom
  tracking, with a forgiving 120px threshold). Scrolling up suspends the
  follow until the reader returns.
- Newest-first: growth is PREPENDS, which the firstItemIndex anchoring
  deliberately holds in place — so a reader parked at the top would
  silently stop seeing new rows. Track the top edge via
  `atTopStateChange` into a ref and snap back to index 0 when new events
  arrive while at the top. Readers who scrolled away are left in place.
- Opening a live chronological transcript now lands on the newest event
  (`initialTopMostItemIndex: LAST/end`, same pattern as the chat list);
  the per-listEpoch remount re-applies it after task/sort/filter
  changes. Completed tasks keep opening at the top.

Fixes #5921.

* fix(transcript): rework newest-first live follow as a user-intent latch (#5921)

The previous approach gated the newest-first follow on "is the viewport at
the top right now" — but firstItemIndex prepend anchoring moves the viewport
away from the top on every flush, so the signal broke itself: after the
first prepend the follow silently disengaged and never recovered.

Replace it with a pure latch controller (transcript-follow.ts):

- Disengage only on accumulated USER displacement (wheel/touch/key deltas,
  scrollbar drag) beyond the 120px edge zone; system displacement never
  counts, no matter how far it pushes the viewport.
- While following, non-user displacement is pinned back to the live end on
  the scroll event itself (Virtuoso's prepend compensation lands after React
  effects, so an effect-timed snap alone stays one flush behind).
- Enforcement is suppressed while the mouse is held (text-selection
  autoscroll) or mid-gesture; returning within the edge zone re-engages.
- Timeline segment clicks explicitly unlatch so navigation isn't pinned back.

Chronological followOutput switches "smooth" -> "auto": a still-animating
smooth scroll reads as "not at bottom" on the next flush and drops the
follow under rapid appends.

The latch decision table is unit-tested (transcript-follow.test.ts), and the
mechanism was validated end-to-end against react-virtuoso 4.18.7 in a
browser harness: follow-at-top, in-zone nudge + flush (no false disengage),
scroll-away with stable anchor, return-to-top re-engage, rapid flushes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:10:44 +08:00

118 lines
4.5 KiB
TypeScript

// Live-follow latch for the newest-first transcript (#5921).
//
// Newest-first shows live events as PREPENDS, and the list's firstItemIndex
// anchoring holds the viewport across a prepend — which means every flush
// moves the viewport away from the top on its own. "Am I at the top right
// now" therefore cannot distinguish a reader who scrolled away from one the
// anchoring pushed down. This controller keeps that distinction:
//
// - Follow disengages only on accumulated USER displacement away from the
// live end (wheel/touch/key deltas, or a scrollbar drag) beyond the edge
// threshold. System displacement never counts, no matter how far it moves
// the viewport.
// - While following, any non-user displacement is pinned straight back to
// the live end (`pin` from onScroll) — but never while the user is
// mid-gesture or holding the mouse down (text selection autoscroll must
// not be fought).
// - Arriving back within the edge zone (atTop) re-engages the follow.
//
// Pure state machine so the decision table is unit-testable; the dialog owns
// wiring it to DOM events.
// Forgiving "at the live end" zone, matching the chat list's atBottomThreshold:
// within this distance of the live edge the reader counts as following.
export const FOLLOW_EDGE_THRESHOLD = 120;
// How long after the last wheel/touch/key input the viewport is still treated
// as user-controlled (suppresses pinning mid-gesture, including momentum).
const INPUT_INTENT_WINDOW_MS = 300;
// WheelEvent.deltaMode 1 (lines) / 2 (pages) conversion.
export const LINE_SCROLL_PX = 40;
export interface NewestFirstFollow {
/** `isLive && sortDirection === "newest_first"`; everything is inert otherwise. */
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. timeline segment click). */
disengage(): void;
/** User scroll input in px; positive = away from the live end. */
input(delta: number): void;
/** Mousedown inside the scroller; `onScroller` = on the element itself (scrollbar). */
pointerDown(onScroller: boolean): void;
pointerUp(): void;
onAtTopChange(atTop: boolean): void;
/** Every scroll event. Returns whether to pin the viewport back to the top. */
onScroll(scrollTop: number): boolean;
}
export function createNewestFirstFollow(now: () => number = () => Date.now()): NewestFirstFollow {
let active = false;
let following = true;
// Accumulated user-caused displacement away from the live end. Compared
// against the edge threshold instead of scrollTop: absolute position mixes
// user and system displacement (a prepend can land inside the intent
// window and push the viewport past any threshold on its own).
let pendingAway = 0;
let lastInputAt = -INPUT_INTENT_WINDOW_MS;
let mouseHeld = false;
let scrollbarDrag = false;
const userControlsViewport = () =>
mouseHeld || scrollbarDrag || now() - lastInputAt < INPUT_INTENT_WINDOW_MS;
return {
setActive(a: boolean) {
active = a;
},
reset() {
following = true;
pendingAway = 0;
mouseHeld = false;
scrollbarDrag = false;
},
isFollowing: () => active && following,
disengage() {
if (active) following = false;
},
input(delta: number) {
if (!active) return;
lastInputAt = now();
pendingAway = Math.max(0, pendingAway + delta);
if (following && pendingAway > FOLLOW_EDGE_THRESHOLD) following = false;
},
pointerDown(onScroller: boolean) {
if (!active) return;
mouseHeld = true;
if (onScroller) scrollbarDrag = true;
},
pointerUp() {
mouseHeld = false;
scrollbarDrag = false;
},
onAtTopChange(atTop: boolean) {
if (!active || !atTop) return;
following = true;
pendingAway = 0;
},
onScroll(scrollTop: number): boolean {
if (!active) return false;
// A scrollbar drag is fully user-controlled: absolute position is the
// user's displacement, so the plain threshold applies.
if (scrollbarDrag && scrollTop > FOLLOW_EDGE_THRESHOLD) {
following = false;
return false;
}
if (following && !userControlsViewport() && scrollTop > 0) {
// System displacement got corrected; drop any sub-threshold residue
// so old nudges don't accumulate into a spurious disengage later.
pendingAway = 0;
return true;
}
return false;
},
};
}