From 76ab04ac48badbf731f5f4e4f88c0b7d74946495 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:18:44 +0800 Subject: [PATCH] fix(webui): keep streaming tail visible (#5140) --- .../src/components/thread/ThreadViewport.tsx | 4 +- webui/src/components/thread/thread-camera.ts | 64 +++++---------- webui/src/components/thread/thread-motion.ts | 71 +++++++++++++--- webui/src/tests/thread-camera.test.ts | 69 ++++++---------- webui/src/tests/thread-motion.test.ts | 81 ++++++++++++++++++- webui/src/tests/thread-viewport.test.tsx | 28 +++++++ 6 files changed, 215 insertions(+), 102 deletions(-) diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 601c5dc67..710e948a7 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -275,7 +275,7 @@ export const ThreadViewport = forwardRef { const force = options?.force ?? false; if (!force && threadMotionRef.current?.isAutoFollowPaused()) return; - threadMotionRef.current?.resumeAutoFollow(); + if (!smooth) threadMotionRef.current?.resumeAutoFollow(); scrollToBottomNow(smooth); }, [scrollToBottomNow], diff --git a/webui/src/components/thread/thread-camera.ts b/webui/src/components/thread/thread-camera.ts index 6ed90c603..7837cec71 100644 --- a/webui/src/components/thread/thread-camera.ts +++ b/webui/src/components/thread/thread-camera.ts @@ -4,7 +4,7 @@ interface ThreadCameraMotionProfile { * camera closes roughly 95% of an uncapped distance in three time constants. */ 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; /** Avoids spending frames chasing sub-pixel layout noise. */ settleDistancePx: number; @@ -12,13 +12,6 @@ interface ThreadCameraMotionProfile { maxFrameDeltaMs: number; } -const THREAD_CAMERA_FOLLOW_MOTION: Readonly = { - responseTimeMs: 90, - maxSpeedPxPerSecond: 1_200, - settleDistancePx: 0.5, - maxFrameDeltaMs: 50, -}; - const THREAD_CAMERA_NAVIGATION_MOTION: Readonly = { responseTimeMs: 110, maxSpeedPxPerSecond: 12_000, @@ -26,18 +19,6 @@ const THREAD_CAMERA_NAVIGATION_MOTION: Readonly = { 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 = { - responseTimeMs: 55, - maxSpeedPxPerSecond: 2_400, - settleDistancePx: 0.5, - maxFrameDeltaMs: 50, -}; - const THREAD_CAMERA_REDUCED_NAVIGATION_MOTION: Readonly = { responseTimeMs: 45, maxSpeedPxPerSecond: 24_000, @@ -63,12 +44,10 @@ interface ThreadCameraOptions { prefersReducedMotion?: () => boolean; } -type ThreadCameraMotionKind = "follow" | "navigation"; - /** * 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 - * frame loop. + * move during explicit history navigation without restarting a duration or + * adding another frame loop. */ function easeOutChase( current: number, @@ -108,7 +87,6 @@ export class ThreadCameraController { private phase: "idle" | "following" = "idle"; private target = 0; private lastTimestamp: number | null = null; - private motionKind: ThreadCameraMotionKind = "follow"; constructor( getViewport: () => ThreadCameraViewport | null, @@ -131,25 +109,31 @@ export class ThreadCameraController { 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 { - 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 { - return this.moveTo(top, "navigation"); + return this.moveTo(top); } - private moveTo( - top: number, - motionKind: ThreadCameraMotionKind, - ): ThreadCameraFollowResult | null { + private moveTo(top: number): ThreadCameraFollowResult | null { const viewport = this.getViewport(); if (!viewport) return null; const current = viewport.scrollTop; this.target = Math.max(0, top); - this.motionKind = motionKind; - const motion = this.currentMotion(motionKind); + const motion = this.currentMotion(); if (this.phase === "following") { return "retargeted"; } @@ -171,7 +155,6 @@ export class ThreadCameraController { } this.phase = "idle"; this.lastTimestamp = null; - this.motionKind = "follow"; } dispose(): void { @@ -186,7 +169,7 @@ export class ThreadCameraController { return; } - const motion = this.currentMotion(this.motionKind); + const motion = this.currentMotion(); const previousTimestamp = this.lastTimestamp ?? timestamp - (1000 / 60); const deltaMs = Math.min( motion.maxFrameDeltaMs, @@ -220,15 +203,10 @@ export class ThreadCameraController { this.frameId = this.scheduler.request(this.advance); }; - private currentMotion(kind: ThreadCameraMotionKind): ThreadCameraMotionProfile { - if (kind === "navigation") { - return this.prefersReducedMotion() - ? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION - : THREAD_CAMERA_NAVIGATION_MOTION; - } + private currentMotion(): ThreadCameraMotionProfile { return this.prefersReducedMotion() - ? THREAD_CAMERA_REDUCED_MOTION - : THREAD_CAMERA_FOLLOW_MOTION; + ? THREAD_CAMERA_REDUCED_NAVIGATION_MOTION + : THREAD_CAMERA_NAVIGATION_MOTION; } private write(viewport: ThreadCameraViewport, top: number): void { diff --git a/webui/src/components/thread/thread-motion.ts b/webui/src/components/thread/thread-motion.ts index 62b077412..305d3327a 100644 --- a/webui/src/components/thread/thread-motion.ts +++ b/webui/src/components/thread/thread-motion.ts @@ -8,6 +8,7 @@ type ThreadMotionMode = | "anchor-prompt" | "follow-output" | "follow-completion" + | "navigating-latest" | "navigating-history" | "browsing-history"; @@ -17,6 +18,7 @@ type AutomaticThreadMotionMode = | "follow-output"; type ThreadMotionEvent = + | "navigate-latest" | "navigate-history" | "navigation-settled" | "user-scroll" @@ -34,25 +36,38 @@ const THREAD_MOTION_TRANSITIONS: Readonly< > > = { idle: { + "navigate-latest": "navigating-latest", "navigate-history": "navigating-history", "user-scroll": "browsing-history", }, "anchor-prompt": { + "navigate-latest": "navigating-latest", "navigate-history": "navigating-history", "user-scroll": "browsing-history", "turn-completed": "follow-completion", }, "follow-output": { + "navigate-latest": "navigating-latest", "navigate-history": "navigating-history", "user-scroll": "browsing-history", "turn-completed": "follow-completion", }, "follow-completion": { + "navigate-latest": "navigating-latest", "navigate-history": "navigating-history", "user-scroll": "browsing-history", "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": { + "navigate-latest": "navigating-latest", "navigate-history": "navigating-history", "navigation-settled": "browsing-history", "user-scroll": "browsing-history", @@ -60,6 +75,7 @@ const THREAD_MOTION_TRANSITIONS: Readonly< "resume-follow": "current-automatic-mode", }, "browsing-history": { + "navigate-latest": "navigating-latest", "navigate-history": "navigating-history", "resume-follow": "current-automatic-mode", }, @@ -124,9 +140,10 @@ function defaultScheduler(): ThreadMotionScheduler { } /** - * Owns the policy that turns discrete layout events into continuous camera - * motion. Callers only invalidate geometry; one display frame coalesces those - * notifications, reads the authoritative layout, and retargets the camera. + * Owns the policy that turns discrete layout events into automatic tail + * pinning or explicit camera navigation. Callers only invalidate geometry; + * one display frame coalesces those notifications and reads the authoritative + * layout before applying either policy. */ export class ThreadMotionCoordinator { private readonly camera: ThreadMotionCamera; @@ -241,8 +258,18 @@ export class ThreadMotionCoordinator { 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 { @@ -271,6 +298,15 @@ export class ThreadMotionCoordinator { */ observeScroll(nearBottom: boolean): ThreadScrollOwner { switch (this.mode) { + case "navigating-latest": + if (!this.camera.isFollowing()) { + if (nearBottom) { + this.settleLatestNavigation(); + } else { + this.invalidateGeometry(); + } + } + return "navigation"; case "navigating-history": if (!this.camera.isFollowing()) { this.transition("navigation-settled"); @@ -307,7 +343,8 @@ export class ThreadMotionCoordinator { private isHistoryMode(): boolean { return ( - this.mode === "navigating-history" + this.mode === "navigating-latest" + || this.mode === "navigating-history" || this.mode === "browsing-history" ); } @@ -336,15 +373,17 @@ export class ThreadMotionCoordinator { const result = this.camera.followTo(target); if ( result - && ( - Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX - || result === "retargeted" - ) + && Math.abs(target - geometry.scrollTop) > GEOMETRY_EPSILON_PX ) { this.onAutoFollow?.(); } } + private settleLatestNavigation(): void { + if (!this.transition("navigation-settled")) return; + this.invalidateGeometry(); + } + private readonly flushGeometry = (): void => { this.measurementFrameId = null; if (!this.geometryDirty) return; @@ -358,6 +397,13 @@ export class ThreadMotionCoordinator { if (!geometry) return; 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") { this.followGeometry(geometry); return; @@ -374,8 +420,9 @@ export class ThreadMotionCoordinator { return; } // Before output exists, the real lower scroll boundary is the only - // position with zero hidden downward travel. Once output exists, start - // from the prompt origin and let the follow camera reveal its growth. + // position with zero hidden downward travel. Once output exists, first + // establish the prompt origin; automatic follow below then resolves the + // current tail in this same authoritative geometry frame. this.camera.jumpTo( this.turn.hasOutput ? geometry.promptTop : geometry.maxScrollTop, ); diff --git a/webui/src/tests/thread-camera.test.ts b/webui/src/tests/thread-camera.test.ts index a755a824a..60043a911 100644 --- a/webui/src/tests/thread-camera.test.ts +++ b/webui/src/tests/thread-camera.test.ts @@ -39,74 +39,57 @@ function cameraHarness(prefersReducedMotion = false) { } describe("ThreadCameraController", () => { - it("responds immediately, then eases out as a static target gets closer", () => { - const { camera, viewport, advance } = cameraHarness(); + it("pins automatic follow in the geometry frame without camera debt", () => { + const { camera, viewport, frames } = cameraHarness(); - camera.followTo(60); - advance(16); - const firstStep = viewport.scrollTop; - advance(16); - const secondStep = viewport.scrollTop - firstStep; - advance(16); - const thirdStep = viewport.scrollTop - firstStep - secondStep; + expect(camera.followTo(60)).toBe("settled"); - expect(firstStep).toBeGreaterThan(0); - expect(secondStep).toBeGreaterThan(0); - expect(thirdStep).toBeGreaterThan(0); - expect(secondStep).toBeLessThan(firstStep); - expect(thirdStep).toBeLessThan(secondStep); + expect(viewport.scrollTop).toBe(60); + expect(frames).toHaveLength(0); + expect(camera.isFollowing()).toBe(false); }); - it("retargets an active follow without adding another loop", () => { + it("retargets active history navigation without adding another loop", () => { const { camera, viewport, frames, advance } = cameraHarness(); - expect(camera.followTo(100)).toBe("started"); + expect(camera.navigateTo(100)).toBe("started"); expect(frames).toHaveLength(1); advance(16); expect(frames).toHaveLength(1); - expect(camera.followTo(180)).toBe("retargeted"); + expect(camera.navigateTo(180)).toBe("retargeted"); expect(frames).toHaveLength(1); for (let frame = 0; frame < 120; frame += 1) advance(16); expect(viewport.scrollTop).toBe(180); }); - it("tracks repeated target growth as one monotonic camera movement", () => { - const { camera, viewport, advance } = cameraHarness(); + it("pins repeated automatic targets without accumulating lag", () => { + const { camera, viewport, frames } = cameraHarness(); camera.followTo(80); - advance(16); - const first = viewport.scrollTop; + expect(viewport.scrollTop).toBe(80); camera.followTo(140); - advance(16); - const second = viewport.scrollTop; + expect(viewport.scrollTop).toBe(140); camera.followTo(220); - advance(16); - const third = viewport.scrollTop; - - expect(first).toBeGreaterThan(0); - expect(second).toBeGreaterThan(first); - expect(third).toBeGreaterThan(second); - expect(camera.isFollowing()).toBe(true); + expect(viewport.scrollTop).toBe(220); + expect(frames).toHaveLength(0); + expect(camera.isFollowing()).toBe(false); }); - it("uses a faster motion profile for explicit long-distance navigation", () => { - const follow = cameraHarness(); - const navigation = cameraHarness(); + it("eases explicit long-distance navigation across frames", () => { + const { camera, viewport, advance } = cameraHarness(); - follow.camera.followTo(1_000); - navigation.camera.navigateTo(1_000); - follow.advance(16); - navigation.advance(16); + camera.navigateTo(1_000); + advance(16); - expect(navigation.viewport.scrollTop).toBeGreaterThan(follow.viewport.scrollTop); - expect(navigation.viewport.scrollTop).toBeLessThan(1_000); + expect(viewport.scrollTop).toBeGreaterThan(0); + expect(viewport.scrollTop).toBeLessThan(1_000); }); it("gives an immediate jump command priority over an active follow", () => { const { camera, viewport, scheduler, frames } = cameraHarness(); - camera.followTo(240); + camera.navigateTo(240); expect(frames).toHaveLength(1); camera.jumpTo(40); @@ -116,12 +99,12 @@ describe("ThreadCameraController", () => { 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 reduced = cameraHarness(true); - expect(regular.camera.followTo(240)).toBe("started"); - expect(reduced.camera.followTo(240)).toBe("started"); + expect(regular.camera.navigateTo(240)).toBe("started"); + expect(reduced.camera.navigateTo(240)).toBe("started"); regular.advance(16); reduced.advance(16); diff --git a/webui/src/tests/thread-motion.test.ts b/webui/src/tests/thread-motion.test.ts index 50ace92c1..9cb8aa8ec 100644 --- a/webui/src/tests/thread-motion.test.ts +++ b/webui/src/tests/thread-motion.test.ts @@ -36,7 +36,7 @@ function motionHarness(initial?: Partial) { }), dispose: vi.fn(), jumpTo: vi.fn(), - followTo: vi.fn(() => "started" as const), + followTo: vi.fn(() => "settled" as const), isFollowing: vi.fn(() => cameraFollowing), navigateTo: vi.fn(() => { 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 { camera, coordinator, @@ -465,6 +465,83 @@ describe("ThreadMotionCoordinator", () => { 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", () => { const { camera, diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 90d1c1cc1..2d1394dbb 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -11,6 +11,7 @@ import { windowMessages, } from "@/components/thread/ThreadViewport"; import { ThreadCameraController } from "@/components/thread/thread-camera"; +import { ThreadMotionCoordinator } from "@/components/thread/thread-motion"; import type { UIMessage } from "@/lib/types"; 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( + composer} + />, + ); + 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 () => { const resizeObserver = stubResizeObserver(); const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");