mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 07:34:25 +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>
123 lines
4.5 KiB
TypeScript
123 lines
4.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { createNewestFirstFollow, FOLLOW_EDGE_THRESHOLD } from "./transcript-follow";
|
|
|
|
function makeFollow(startAt = 0) {
|
|
let t = startAt;
|
|
const follow = createNewestFirstFollow(() => t);
|
|
const tick = (ms: number) => {
|
|
t += ms;
|
|
};
|
|
follow.setActive(true);
|
|
return { follow, tick };
|
|
}
|
|
|
|
describe("createNewestFirstFollow", () => {
|
|
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("does not disengage when a prepend shift lands inside the input window after an in-zone nudge", () => {
|
|
// 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.
|
|
const { follow, tick } = makeFollow();
|
|
tick(1000);
|
|
follow.input(30);
|
|
tick(200); // inside the intent window
|
|
expect(follow.onScroll(500)).toBe(false); // user mid-gesture: no pin either
|
|
expect(follow.isFollowing()).toBe(true);
|
|
tick(400); // gesture over
|
|
expect(follow.onScroll(500)).toBe(true); // now pin back
|
|
expect(follow.isFollowing()).toBe(true);
|
|
});
|
|
|
|
it("disengages on accumulated user input beyond the threshold", () => {
|
|
const { follow } = makeFollow();
|
|
follow.input(80);
|
|
expect(follow.isFollowing()).toBe(true);
|
|
follow.input(80); // cumulative 160 > threshold
|
|
expect(follow.isFollowing()).toBe(false);
|
|
expect(follow.onScroll(400)).toBe(false); // no pinning once disengaged
|
|
});
|
|
|
|
it("upward input rolls the accumulator back instead of counting as intent", () => {
|
|
const { follow } = makeFollow();
|
|
follow.input(100);
|
|
follow.input(-90);
|
|
follow.input(100); // net 110 <= threshold
|
|
expect(follow.isFollowing()).toBe(true);
|
|
});
|
|
|
|
it("a pin clears sub-threshold residue so old nudges cannot accumulate", () => {
|
|
const { follow, tick } = makeFollow();
|
|
follow.input(100);
|
|
tick(1000);
|
|
expect(follow.onScroll(300)).toBe(true); // pinned; residue cleared
|
|
follow.input(100); // fresh gesture: 100 <= threshold on its own
|
|
expect(follow.isFollowing()).toBe(true);
|
|
});
|
|
|
|
it("re-engages when the viewport returns to the top zone", () => {
|
|
const { follow } = makeFollow();
|
|
follow.input(FOLLOW_EDGE_THRESHOLD + 1);
|
|
expect(follow.isFollowing()).toBe(false);
|
|
follow.onAtTopChange(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
|
|
});
|
|
});
|