mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* 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]>
329 lines
12 KiB
TypeScript
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
|
|
});
|
|
});
|