mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* 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>
118 lines
4.5 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|