fix(webui): keep streaming tail visible (#5140)

This commit is contained in:
chengyongru 2026-07-28 18:18:44 +08:00 committed by GitHub
parent 1faf0826f6
commit 76ab04ac48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 215 additions and 102 deletions

View File

@ -275,7 +275,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (el) { if (el) {
const top = Math.max(0, el.scrollHeight - el.clientHeight); const top = Math.max(0, el.scrollHeight - el.clientHeight);
if (smooth) { if (smooth) {
threadMotionRef.current?.animateTo(top); threadMotionRef.current?.navigateLatestTo(top);
} else { } else {
threadMotionRef.current?.jumpTo(top); threadMotionRef.current?.jumpTo(top);
} }
@ -289,7 +289,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
(smooth = false, options?: { force?: boolean }) => { (smooth = false, options?: { force?: boolean }) => {
const force = options?.force ?? false; const force = options?.force ?? false;
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return; if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
threadMotionRef.current?.resumeAutoFollow(); if (!smooth) threadMotionRef.current?.resumeAutoFollow();
scrollToBottomNow(smooth); scrollToBottomNow(smooth);
}, },
[scrollToBottomNow], [scrollToBottomNow],

View File

@ -4,7 +4,7 @@ interface ThreadCameraMotionProfile {
* camera closes roughly 95% of an uncapped distance in three time constants. * camera closes roughly 95% of an uncapped distance in three time constants.
*/ */
responseTimeMs: number; responseTimeMs: number;
/** Prevents a large completion batch from turning into a one-frame jump. */ /** Prevents a long explicit navigation from turning into a one-frame jump. */
maxSpeedPxPerSecond: number; maxSpeedPxPerSecond: number;
/** Avoids spending frames chasing sub-pixel layout noise. */ /** Avoids spending frames chasing sub-pixel layout noise. */
settleDistancePx: number; settleDistancePx: number;
@ -12,13 +12,6 @@ interface ThreadCameraMotionProfile {
maxFrameDeltaMs: number; maxFrameDeltaMs: number;
} }
const THREAD_CAMERA_FOLLOW_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 90,
maxSpeedPxPerSecond: 1_200,
settleDistancePx: 0.5,
maxFrameDeltaMs: 50,
};
const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = { const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 110, responseTimeMs: 110,
maxSpeedPxPerSecond: 12_000, maxSpeedPxPerSecond: 12_000,
@ -26,18 +19,6 @@ const THREAD_CAMERA_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
maxFrameDeltaMs: 50, maxFrameDeltaMs: 50,
}; };
/**
* Reduced motion still preserves spatial continuity. Snapping a long thread
* to its destination removes the very context that helps users understand
* where the viewport moved; this profile shortens that motion instead.
*/
const THREAD_CAMERA_REDUCED_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 55,
maxSpeedPxPerSecond: 2_400,
settleDistancePx: 0.5,
maxFrameDeltaMs: 50,
};
const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = { const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly<ThreadCameraMotionProfile> = {
responseTimeMs: 45, responseTimeMs: 45,
maxSpeedPxPerSecond: 24_000, maxSpeedPxPerSecond: 24_000,
@ -63,12 +44,10 @@ interface ThreadCameraOptions {
prefersReducedMotion?: () => boolean; prefersReducedMotion?: () => boolean;
} }
type ThreadCameraMotionKind = "follow" | "navigation";
/** /**
* A time-based ease-out chase rather than a start/end tween. The target can * A time-based ease-out chase rather than a start/end tween. The target can
* move on every streamed line without restarting a duration or adding another * move during explicit history navigation without restarting a duration or
* frame loop. * adding another frame loop.
*/ */
function easeOutChase( function easeOutChase(
current: number, current: number,
@ -108,7 +87,6 @@ export class ThreadCameraController {
private phase: "idle" | "following" = "idle"; private phase: "idle" | "following" = "idle";
private target = 0; private target = 0;
private lastTimestamp: number | null = null; private lastTimestamp: number | null = null;
private motionKind: ThreadCameraMotionKind = "follow";
constructor( constructor(
getViewport: () => ThreadCameraViewport | null, getViewport: () => ThreadCameraViewport | null,
@ -131,25 +109,31 @@ export class ThreadCameraController {
this.write(viewport, this.target); this.write(viewport, this.target);
} }
/**
* Automatic follow is a layout constraint, not navigation. Resolve it in
* the geometry frame so streamed content and viewport resizing cannot build
* up hidden travel below the visible tail.
*/
followTo(top: number): ThreadCameraFollowResult | null { followTo(top: number): ThreadCameraFollowResult | null {
return this.moveTo(top, "follow"); const viewport = this.getViewport();
if (!viewport) return null;
this.cancel();
this.target = Math.max(0, top);
this.write(viewport, this.target);
return "settled";
} }
navigateTo(top: number): ThreadCameraFollowResult | null { navigateTo(top: number): ThreadCameraFollowResult | null {
return this.moveTo(top, "navigation"); return this.moveTo(top);
} }
private moveTo( private moveTo(top: number): ThreadCameraFollowResult | null {
top: number,
motionKind: ThreadCameraMotionKind,
): ThreadCameraFollowResult | null {
const viewport = this.getViewport(); const viewport = this.getViewport();
if (!viewport) return null; if (!viewport) return null;
const current = viewport.scrollTop; const current = viewport.scrollTop;
this.target = Math.max(0, top); this.target = Math.max(0, top);
this.motionKind = motionKind;
const motion = this.currentMotion(motionKind); const motion = this.currentMotion();
if (this.phase === "following") { if (this.phase === "following") {
return "retargeted"; return "retargeted";
} }
@ -171,7 +155,6 @@ export class ThreadCameraController {
} }
this.phase = "idle"; this.phase = "idle";
this.lastTimestamp = null; this.lastTimestamp = null;
this.motionKind = "follow";
} }
dispose(): void { dispose(): void {
@ -186,7 +169,7 @@ export class ThreadCameraController {
return; return;
} }
const motion = this.currentMotion(this.motionKind); const motion = this.currentMotion();
const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60); const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60);
const deltaMs = Math.min( const deltaMs = Math.min(
motion.maxFrameDeltaMs, motion.maxFrameDeltaMs,
@ -220,16 +203,11 @@ export class ThreadCameraController {
this.frameId = this.scheduler.request(this.advance); this.frameId = this.scheduler.request(this.advance);
}; };
private currentMotion(kind: ThreadCameraMotionKind): ThreadCameraMotionProfile { private currentMotion(): ThreadCameraMotionProfile {
if (kind === "navigation") {
return this.prefersReducedMotion() return this.prefersReducedMotion()
? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION ? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION
: THREAD_CAMERA_NAVIGATION_MOTION; : THREAD_CAMERA_NAVIGATION_MOTION;
} }
return this.prefersReducedMotion()
? THREAD_CAMERA_REDUCED_MOTION
: THREAD_CAMERA_FOLLOW_MOTION;
}
private write(viewport: ThreadCameraViewport, top: number): void { private write(viewport: ThreadCameraViewport, top: number): void {
try { try {

View File

@ -8,6 +8,7 @@ type ThreadMotionMode =
| "anchor-prompt" | "anchor-prompt"
| "follow-output" | "follow-output"
| "follow-completion" | "follow-completion"
| "navigating-latest"
| "navigating-history" | "navigating-history"
| "browsing-history"; | "browsing-history";
@ -17,6 +18,7 @@ type AutomaticThreadMotionMode =
| "follow-output"; | "follow-output";
type ThreadMotionEvent = type ThreadMotionEvent =
| "navigate-latest"
| "navigate-history" | "navigate-history"
| "navigation-settled" | "navigation-settled"
| "user-scroll" | "user-scroll"
@ -34,25 +36,38 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
> >
> = { > = {
idle: { idle: {
"navigate-latest": "navigating-latest",
"navigate-history": "navigating-history", "navigate-history": "navigating-history",
"user-scroll": "browsing-history", "user-scroll": "browsing-history",
}, },
"anchor-prompt": { "anchor-prompt": {
"navigate-latest": "navigating-latest",
"navigate-history": "navigating-history", "navigate-history": "navigating-history",
"user-scroll": "browsing-history", "user-scroll": "browsing-history",
"turn-completed": "follow-completion", "turn-completed": "follow-completion",
}, },
"follow-output": { "follow-output": {
"navigate-latest": "navigating-latest",
"navigate-history": "navigating-history", "navigate-history": "navigating-history",
"user-scroll": "browsing-history", "user-scroll": "browsing-history",
"turn-completed": "follow-completion", "turn-completed": "follow-completion",
}, },
"follow-completion": { "follow-completion": {
"navigate-latest": "navigating-latest",
"navigate-history": "navigating-history", "navigate-history": "navigating-history",
"user-scroll": "browsing-history", "user-scroll": "browsing-history",
"composer-input": "idle", "composer-input": "idle",
}, },
"navigating-latest": {
"navigate-latest": "navigating-latest",
"navigate-history": "navigating-history",
"navigation-settled": "current-automatic-mode",
"user-scroll": "browsing-history",
"boundary-scroll": "browsing-history",
"resume-follow": "current-automatic-mode",
},
"navigating-history": { "navigating-history": {
"navigate-latest": "navigating-latest",
"navigate-history": "navigating-history", "navigate-history": "navigating-history",
"navigation-settled": "browsing-history", "navigation-settled": "browsing-history",
"user-scroll": "browsing-history", "user-scroll": "browsing-history",
@ -60,6 +75,7 @@ const THREAD_MOTION_TRANSITIONS: Readonly<
"resume-follow": "current-automatic-mode", "resume-follow": "current-automatic-mode",
}, },
"browsing-history": { "browsing-history": {
"navigate-latest": "navigating-latest",
"navigate-history": "navigating-history", "navigate-history": "navigating-history",
"resume-follow": "current-automatic-mode", "resume-follow": "current-automatic-mode",
}, },
@ -124,9 +140,10 @@ function defaultScheduler(): ThreadMotionScheduler {
} }
/** /**
* Owns the policy that turns discrete layout events into continuous camera * Owns the policy that turns discrete layout events into automatic tail
* motion. Callers only invalidate geometry; one display frame coalesces those * pinning or explicit camera navigation. Callers only invalidate geometry;
* notifications, reads the authoritative layout, and retargets the camera. * one display frame coalesces those notifications and reads the authoritative
* layout before applying either policy.
*/ */
export class ThreadMotionCoordinator { export class ThreadMotionCoordinator {
private readonly camera: ThreadMotionCamera; private readonly camera: ThreadMotionCamera;
@ -241,8 +258,18 @@ export class ThreadMotionCoordinator {
this.camera.jumpTo(top); this.camera.jumpTo(top);
} }
animateTo(top: number): ThreadCameraFollowResult | null { /**
return this.camera.navigateTo(top); * Explicitly navigate to the live tail while allowing authoritative layout
* frames to retarget that destination as streamed output continues to grow.
*/
navigateLatestTo(top: number): ThreadCameraFollowResult | null {
this.camera.cancel();
this.transition("navigate-latest");
const result = this.camera.navigateTo(top);
if (!result || result === "settled") {
this.settleLatestNavigation();
}
return result;
} }
navigateHistoryTo(top: number): ThreadCameraFollowResult | null { navigateHistoryTo(top: number): ThreadCameraFollowResult | null {
@ -271,6 +298,15 @@ export class ThreadMotionCoordinator {
*/ */
observeScroll(nearBottom: boolean): ThreadScrollOwner { observeScroll(nearBottom: boolean): ThreadScrollOwner {
switch (this.mode) { switch (this.mode) {
case "navigating-latest":
if (!this.camera.isFollowing()) {
if (nearBottom) {
this.settleLatestNavigation();
} else {
this.invalidateGeometry();
}
}
return "navigation";
case "navigating-history": case "navigating-history":
if (!this.camera.isFollowing()) { if (!this.camera.isFollowing()) {
this.transition("navigation-settled"); this.transition("navigation-settled");
@ -307,7 +343,8 @@ export class ThreadMotionCoordinator {
private isHistoryMode(): boolean { private isHistoryMode(): boolean {
return ( return (
this.mode === "navigating-history" this.mode === "navigating-latest"
|| this.mode === "navigating-history"
|| this.mode === "browsing-history" || this.mode === "browsing-history"
); );
} }
@ -336,15 +373,17 @@ export class ThreadMotionCoordinator {
const result = this.camera.followTo(target); const result = this.camera.followTo(target);
if ( if (
result result
&& ( && Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX
|| result === "retargeted"
)
) { ) {
this.onAutoFollow?.(); this.onAutoFollow?.();
} }
} }
private settleLatestNavigation(): void {
if (!this.transition("navigation-settled")) return;
this.invalidateGeometry();
}
private readonly flushGeometry = (): void => { private readonly flushGeometry = (): void => {
this.measurementFrameId = null; this.measurementFrameId = null;
if (!this.geometryDirty) return; if (!this.geometryDirty) return;
@ -358,6 +397,13 @@ export class ThreadMotionCoordinator {
if (!geometry) return; if (!geometry) return;
this.onGeometry?.(geometry); this.onGeometry?.(geometry);
if (this.mode === "navigating-latest") {
const result = this.camera.navigateTo(geometry.maxScrollTop);
if (!result || result === "settled") {
this.settleLatestNavigation();
}
return;
}
if (this.mode === "follow-completion") { if (this.mode === "follow-completion") {
this.followGeometry(geometry); this.followGeometry(geometry);
return; return;
@ -374,8 +420,9 @@ export class ThreadMotionCoordinator {
return; return;
} }
// Before output exists, the real lower scroll boundary is the only // Before output exists, the real lower scroll boundary is the only
// position with zero hidden downward travel. Once output exists, start // position with zero hidden downward travel. Once output exists, first
// from the prompt origin and let the follow camera reveal its growth. // establish the prompt origin; automatic follow below then resolves the
// current tail in this same authoritative geometry frame.
this.camera.jumpTo( this.camera.jumpTo(
this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop, this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop,
); );

View File

@ -39,74 +39,57 @@ function cameraHarness(prefersReducedMotion = false) {
} }
describe("ThreadCameraController", () => { describe("ThreadCameraController", () => {
it("responds immediately, then eases out as a static target gets closer", () => { it("pins automatic follow in the geometry frame without camera debt", () => {
const { camera, viewport, advance } = cameraHarness(); const { camera, viewport, frames } = cameraHarness();
camera.followTo(60); expect(camera.followTo(60)).toBe("settled");
advance(16);
const firstStep = viewport.scrollTop;
advance(16);
const secondStep = viewport.scrollTop - firstStep;
advance(16);
const thirdStep = viewport.scrollTop - firstStep - secondStep;
expect(firstStep).toBeGreaterThan(0); expect(viewport.scrollTop).toBe(60);
expect(secondStep).toBeGreaterThan(0); expect(frames).toHaveLength(0);
expect(thirdStep).toBeGreaterThan(0); expect(camera.isFollowing()).toBe(false);
expect(secondStep).toBeLessThan(firstStep);
expect(thirdStep).toBeLessThan(secondStep);
}); });
it("retargets an active follow without adding another loop", () => { it("retargets active history navigation without adding another loop", () => {
const { camera, viewport, frames, advance } = cameraHarness(); const { camera, viewport, frames, advance } = cameraHarness();
expect(camera.followTo(100)).toBe("started"); expect(camera.navigateTo(100)).toBe("started");
expect(frames).toHaveLength(1); expect(frames).toHaveLength(1);
advance(16); advance(16);
expect(frames).toHaveLength(1); expect(frames).toHaveLength(1);
expect(camera.followTo(180)).toBe("retargeted"); expect(camera.navigateTo(180)).toBe("retargeted");
expect(frames).toHaveLength(1); expect(frames).toHaveLength(1);
for (let frame = 0; frame < 120; frame += 1) advance(16); for (let frame = 0; frame < 120; frame += 1) advance(16);
expect(viewport.scrollTop).toBe(180); expect(viewport.scrollTop).toBe(180);
}); });
it("tracks repeated target growth as one monotonic camera movement", () => { it("pins repeated automatic targets without accumulating lag", () => {
const { camera, viewport, advance } = cameraHarness(); const { camera, viewport, frames } = cameraHarness();
camera.followTo(80); camera.followTo(80);
advance(16); expect(viewport.scrollTop).toBe(80);
const first = viewport.scrollTop;
camera.followTo(140); camera.followTo(140);
advance(16); expect(viewport.scrollTop).toBe(140);
const second = viewport.scrollTop;
camera.followTo(220); camera.followTo(220);
advance(16); expect(viewport.scrollTop).toBe(220);
const third = viewport.scrollTop; expect(frames).toHaveLength(0);
expect(camera.isFollowing()).toBe(false);
expect(first).toBeGreaterThan(0);
expect(second).toBeGreaterThan(first);
expect(third).toBeGreaterThan(second);
expect(camera.isFollowing()).toBe(true);
}); });
it("uses a faster motion profile for explicit long-distance navigation", () => { it("eases explicit long-distance navigation across frames", () => {
const follow = cameraHarness(); const { camera, viewport, advance } = cameraHarness();
const navigation = cameraHarness();
follow.camera.followTo(1_000); camera.navigateTo(1_000);
navigation.camera.navigateTo(1_000); advance(16);
follow.advance(16);
navigation.advance(16);
expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop); expect(viewport.scrollTop).toBeGreaterThan(0);
expect(navigation.viewport.scrollTop).toBeLessThan(1_000); expect(viewport.scrollTop).toBeLessThan(1_000);
}); });
it("gives an immediate jump command priority over an active follow", () => { it("gives an immediate jump command priority over an active follow", () => {
const { camera, viewport, scheduler, frames } = cameraHarness(); const { camera, viewport, scheduler, frames } = cameraHarness();
camera.followTo(240); camera.navigateTo(240);
expect(frames).toHaveLength(1); expect(frames).toHaveLength(1);
camera.jumpTo(40); camera.jumpTo(40);
@ -116,12 +99,12 @@ describe("ThreadCameraController", () => {
expect(frames).toHaveLength(0); expect(frames).toHaveLength(0);
}); });
it("preserves spatial continuity with a shorter reduced-motion chase", () => { it("preserves spatial continuity with shorter reduced-motion navigation", () => {
const regular = cameraHarness(); const regular = cameraHarness();
const reduced = cameraHarness(true); const reduced = cameraHarness(true);
expect(regular.camera.followTo(240)).toBe("started"); expect(regular.camera.navigateTo(240)).toBe("started");
expect(reduced.camera.followTo(240)).toBe("started"); expect(reduced.camera.navigateTo(240)).toBe("started");
regular.advance(16); regular.advance(16);
reduced.advance(16); reduced.advance(16);

View File

@ -36,7 +36,7 @@ function motionHarness(initial?: Partial<ThreadMotionGeometry>) {
}), }),
dispose: vi.fn(), dispose: vi.fn(),
jumpTo: vi.fn(), jumpTo: vi.fn(),
followTo: vi.fn(() => "started" as const), followTo: vi.fn(() => "settled" as const),
isFollowing: vi.fn(() => cameraFollowing), isFollowing: vi.fn(() => cameraFollowing),
navigateTo: vi.fn(() => { navigateTo: vi.fn(() => {
cameraFollowing = true; cameraFollowing = true;
@ -120,7 +120,7 @@ describe("ThreadMotionCoordinator", () => {
}); });
}); });
it("retargets repeated output growth without restarting camera ownership", () => { it("pins repeated output growth on each authoritative geometry frame", () => {
const { const {
camera, camera,
coordinator, coordinator,
@ -465,6 +465,83 @@ describe("ThreadMotionCoordinator", () => {
expect(coordinator.snapshot().mode).toBe("browsing-history"); expect(coordinator.snapshot().mode).toBe("browsing-history");
}); });
it("retargets smooth latest navigation before resuming automatic follow", () => {
const {
camera,
coordinator,
advanceFrame,
setCameraFollowing,
setGeometry,
} = motionHarness();
coordinator.updateTurn({
id: "turn-1",
promptId: "prompt-1",
hasOutput: true,
});
advanceFrame();
coordinator.takeUserControl();
camera.followTo.mockClear();
camera.navigateTo.mockClear();
expect(coordinator.navigateLatestTo(1_400)).toBe("started");
expect(coordinator.snapshot().mode).toBe("navigating-latest");
setGeometry({
scrollTop: 900,
scrollHeight: 2_100,
});
coordinator.invalidateGeometry();
advanceFrame();
expect(camera.navigateTo.mock.calls).toEqual([[1_400], [1_600]]);
expect(camera.followTo).not.toHaveBeenCalled();
setGeometry({ scrollTop: 1_600 });
setCameraFollowing(false);
expect(coordinator.observeScroll(true)).toBe("navigation");
expect(coordinator.snapshot().mode).toBe("follow-output");
advanceFrame();
expect(camera.followTo).toHaveBeenCalledWith(1_600);
});
it("keeps latest navigation through completion and final layout growth", () => {
const {
camera,
coordinator,
advanceFrame,
setCameraFollowing,
setGeometry,
} = motionHarness();
coordinator.updateTurn({
id: "turn-1",
promptId: "prompt-1",
hasOutput: true,
});
advanceFrame();
coordinator.takeUserControl();
coordinator.navigateLatestTo(1_400);
camera.followTo.mockClear();
camera.navigateTo.mockClear();
coordinator.completeTurn();
setGeometry({
scrollTop: 1_000,
scrollHeight: 2_300,
});
coordinator.invalidateGeometry();
advanceFrame();
expect(camera.navigateTo).toHaveBeenCalledWith(1_800);
expect(camera.followTo).not.toHaveBeenCalled();
expect(coordinator.snapshot().mode).toBe("navigating-latest");
setGeometry({ scrollTop: 1_800 });
setCameraFollowing(false);
expect(coordinator.observeScroll(true)).toBe("navigation");
expect(coordinator.snapshot().mode).toBe("idle");
});
it("pins a waiting prompt to the exact lower boundary across all layout changes", () => { it("pins a waiting prompt to the exact lower boundary across all layout changes", () => {
const { const {
camera, camera,

View File

@ -11,6 +11,7 @@ import {
windowMessages, windowMessages,
} from "@/components/thread/ThreadViewport"; } from "@/components/thread/ThreadViewport";
import { ThreadCameraController } from "@/components/thread/thread-camera"; import { ThreadCameraController } from "@/components/thread/thread-camera";
import { ThreadMotionCoordinator } from "@/components/thread/thread-motion";
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
const messages: UIMessage[] = [ const messages: UIMessage[] = [
@ -819,6 +820,33 @@ describe("ThreadViewport", () => {
} }
}); });
it("gives smooth scroll-to-bottom navigation ownership of the latest target", () => {
const navigateLatestTo = vi.spyOn(
ThreadMotionCoordinator.prototype,
"navigateLatestTo",
).mockReturnValue("started");
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming
composer={<div>composer</div>}
/>,
);
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2_400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
});
act(() => {
dispatchUserScroll(scroller);
});
fireEvent.click(screen.getByRole("button", { name: "Scroll to bottom" }));
expect(navigateLatestTo).toHaveBeenCalledWith(1_800);
});
it("pins the waiting boundary across composer and grid-track growth", async () => { it("pins the waiting boundary across composer and grid-track growth", async () => {
const resizeObserver = stubResizeObserver(); const resizeObserver = stubResizeObserver();
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");