From 7e8aefef05d18e74233487b8e548af36e5fe0010 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 30 Jul 2026 21:20:48 +0800 Subject: [PATCH 1/2] revert(flow-chat): restore previous output-follow behavior - Revert the streamed line-growth smoothing introduced in fa6a727. - Fix the transition from new-turn pinning to tail-follow mode. - Prevent severe viewport flickering during streamed output. --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 11 +- ...rtualMessageList.session-boundary.test.tsx | 13 +- .../components/modern/VirtualMessageList.tsx | 18 ++- .../modern/flowChatScrollStability.ts | 4 +- .../modern/useFlowChatFollowOutput.test.tsx | 80 +--------- .../modern/useFlowChatFollowOutput.ts | 150 +++++++----------- 6 files changed, 78 insertions(+), 198 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index 5b0d1708f..f3e41be8a 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -335,15 +335,8 @@ cumulative provisional whitespace. Any deferred follow is then replayed. ## C. Follow-Output Mode (continuous tail) When the viewport is in follow-output mode and the latest turn is still -streaming, the user's intent is "keep the tail visible". Text layout grows in -discrete line-height steps even when characters are revealed smoothly, so the -continuous RAF loop eases `scrollTop` toward the bottom with a retargetable -exponential step. It does not restart native smooth scrolling or snap by a -whole line on observer notifications. - -Content `scrollHeight` growth is not a viewport resize. Physical-bottom -synchronization is reserved for an actual `clientHeight` change; live content -growth is owned by the continuous follow loop. +streaming, the user's intent is "keep the tail visible". The continuous +RAF loop re-pins `scrollTop` toward the bottom every frame. Collapses interact with follow mode in three mutually exclusive ways: diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 92ebc30c1..86cbc99aa 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -818,28 +818,19 @@ describe('VirtualMessageList session boundary', () => { it('does not let physical-bottom follow compete with a semantic element anchor', () => { expect(shouldSyncPhysicalBottom({ - viewportSizeChanged: true, + viewportGeometryChanged: true, collapseProtectionActive: false, wasAtPhysicalBottom: true, ownsElementAnchor: true, })).toBe(false); expect(shouldSyncPhysicalBottom({ - viewportSizeChanged: true, + viewportGeometryChanged: true, collapseProtectionActive: false, wasAtPhysicalBottom: true, ownsElementAnchor: false, })).toBe(true); }); - it('does not treat streamed content growth as a viewport resize', () => { - expect(shouldSyncPhysicalBottom({ - viewportSizeChanged: false, - collapseProtectionActive: false, - wasAtPhysicalBottom: true, - ownsElementAnchor: false, - })).toBe(false); - }); - it('suppresses only negative virtualizer compensation while following the streaming tail', () => { expect(shouldSuppressFollowingTailNegativeScrollBy({ requestedTop: -242, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 000250187..9d86bd46f 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -687,10 +687,11 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX || Math.abs(scroller.clientHeight - previousGeometry.clientHeight) > COMPENSATION_EPSILON_PX; if ( - !viewportSizeChanged || + !viewportGeometryChanged || pendingCollapseIntentRef.current.active || retainedCollapseAnchorRef.current !== null ) { @@ -707,7 +708,7 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX + ( + Math.abs(scroller.scrollHeight - previousScrollerGeometry.scrollHeight) > COMPENSATION_EPSILON_PX || + Math.abs(scroller.clientHeight - previousScrollerGeometry.clientHeight) > COMPENSATION_EPSILON_PX + ) ); const wasAtPhysicalBottom = Boolean( previousScrollerGeometry && @@ -1512,7 +1516,7 @@ const VirtualMessageListSession = forwardRef; -describe('computeContinuousFollowStep', () => { - it('spreads a line-height-sized tail growth across multiple frames', () => { - const firstStep = computeContinuousFollowStep(24, 1000 / 60); - - expect(firstStep).toBeGreaterThan(0); - expect(firstStep).toBeLessThan(24); - }); - - it('retargets proportionally while capping large catch-up jumps', () => { - const smallStep = computeContinuousFollowStep(24, 1000 / 60); - const largeStep = computeContinuousFollowStep(240, 1000 / 60); - - expect(largeStep).toBeGreaterThan(smallStep); - expect(largeStep).toBeLessThanOrEqual(32); - }); - - it('snaps only the final subpixel remainder', () => { - expect(computeContinuousFollowStep(0.4, 1000 / 60)).toBe(0.4); - expect(computeContinuousFollowStep(Number.NaN, 1000 / 60)).toBe(0); - }); -}); - function setScrollerMetrics( scroller: HTMLElement, metrics: { scrollHeight: number; clientHeight: number; scrollTop: number }, @@ -254,57 +229,4 @@ describe('useFlowChatFollowOutput', () => { expect(performAutoFollowScroll).toHaveBeenCalledTimes(1); expect(performLatestTurnStickyPin).not.toHaveBeenCalled(); }); - - it('eases line-height growth without issuing another bottom snap', () => { - const queuedFrames: FrameRequestCallback[] = []; - let nextFrameId = 0; - vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { - queuedFrames.push(callback); - nextFrameId += 1; - return nextFrameId; - })); - - const scroller = document.createElement('div'); - setScrollerMetrics(scroller, { - scrollHeight: 1500, - clientHeight: 500, - scrollTop: 1000, - }); - const performAutoFollowScroll = vi.fn(() => { - scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; - }); - - act(() => { - root.render( - { - controller = nextController; - }} - performAutoFollowScroll={performAutoFollowScroll} - />, - ); - }); - - act(() => { - controller?.enterFollowOutput('auto-follow'); - }); - expect(performAutoFollowScroll).toHaveBeenCalledTimes(1); - - setScrollerMetrics(scroller, { - scrollHeight: 1524, - clientHeight: 500, - scrollTop: 1000, - }); - const firstFollowFrame = queuedFrames.shift(); - expect(firstFollowFrame).toBeDefined(); - - act(() => { - firstFollowFrame?.(1000 / 60); - }); - - expect(performAutoFollowScroll).toHaveBeenCalledTimes(1); - expect(scroller.scrollTop).toBeGreaterThan(1000); - expect(scroller.scrollTop).toBeLessThan(1024); - }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index 79f7b4f4c..66e0942be 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -12,46 +12,6 @@ const AUTO_FOLLOW_BOTTOM_THRESHOLD_PX = 24; const USER_SCROLL_DIRECTION_EPSILON_PX = 0.5; const USER_SCROLL_INTENT_WINDOW_MS = 450; const USER_SCROLL_INTENT_PROGRAMMATIC_GRACE_MS = 80; -const CONTINUOUS_FOLLOW_TIME_CONSTANT_MS = 55; -const CONTINUOUS_FOLLOW_MIN_STEP_PX = 0.75; -const CONTINUOUS_FOLLOW_MAX_STEP_PX = 32; -const CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX = 0.5; -const CONTINUOUS_FOLLOW_MAX_FRAME_DELTA_MS = 34; - -/** - * Move toward a newly-grown tail without snapping by a whole text line. - * - * Streaming prose only changes layout when a line wraps, so the physical - * bottom advances in line-height-sized steps even though characters arrive - * smoothly. This exponential step is retargetable on every animation frame: - * a later wrap extends the same motion instead of restarting a browser-native - * smooth scroll. - */ -export function computeContinuousFollowStep( - distancePx: number, - frameDeltaMs: number, -): number { - const distance = Number.isFinite(distancePx) ? Math.max(0, distancePx) : 0; - if (distance <= CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX) { - return distance; - } - - const deltaMs = Number.isFinite(frameDeltaMs) - ? Math.min(CONTINUOUS_FOLLOW_MAX_FRAME_DELTA_MS, Math.max(0, frameDeltaMs)) - : 0; - const easedStep = distance * ( - 1 - Math.exp(-deltaMs / CONTINUOUS_FOLLOW_TIME_CONSTANT_MS) - ); - const step = Math.min( - distance, - CONTINUOUS_FOLLOW_MAX_STEP_PX, - Math.max(CONTINUOUS_FOLLOW_MIN_STEP_PX, easedStep), - ); - - return distance - step <= CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX - ? distance - : step; -} export type FollowOutputEnterReason = 'jump-to-latest' | 'auto-follow'; export type FollowOutputExitReason = @@ -123,14 +83,13 @@ export function useFlowChatFollowOutput({ const [isFollowingOutput, setIsFollowingOutput] = useState(false); const isFollowingOutputRef = useRef(isFollowingOutput); + const followFrameRef = useRef(null); const programmaticScrollUntilMsRef = useRef(0); const explicitUserScrollIntentUntilMsRef = useRef(0); const lastObservedScrollTopRef = useRef(0); const previousSessionIdRef = useRef(activeSessionId); const armedAutoFollowTurnIdRef = useRef(null); const continuousFollowFrameRef = useRef(null); - const lastContinuousFollowFrameMsRef = useRef(null); - const prefersReducedMotionRef = useRef(false); const isStreamingRef = useRef(isStreaming); const performAutoFollowScrollRef = useRef(performAutoFollowScroll); const onContinuousFollowFrameRef = useRef(onContinuousFollowFrame); @@ -150,8 +109,12 @@ export function useFlowChatFollowOutput({ cancelAnimationFrame(continuousFollowFrameRef.current); continuousFollowFrameRef.current = null; } - if (!nextValue) { - lastContinuousFollowFrameMsRef.current = null; + }, []); + + const cancelScheduledFollow = useCallback(() => { + if (followFrameRef.current !== null) { + cancelAnimationFrame(followFrameRef.current); + followFrameRef.current = null; } }, []); @@ -160,7 +123,6 @@ export function useFlowChatFollowOutput({ cancelAnimationFrame(continuousFollowFrameRef.current); continuousFollowFrameRef.current = null; } - lastContinuousFollowFrameMsRef.current = null; }, []); /** @@ -168,12 +130,12 @@ export function useFlowChatFollowOutput({ * * Why this exists: * - Streaming text + auto-collapsing tool cards generate dense bursts of - * DOM mutations and CSS transitions. - * - Text layout still grows one whole line at a time. Snapping to the new - * bottom on each wrap makes otherwise-smooth typewriter output shake. + * DOM mutations and CSS transitions. Event-driven follow (via observers) + * is gated by `shouldSuspendAutoFollow` during transitions, which makes + * the viewport visibly stall and then jump after the transition ends. * - This loop runs every animation frame while follow + streaming is - * active and eases scrollTop toward the latest tail. New growth retargets - * the in-flight motion without restarting it. + * active, pushing scrollTop toward the latest token regardless of any + * intermediate layout shrink. The result is a smooth, continuous tail. * * Safety: * - Programmatic scrolls inside this loop bump @@ -182,17 +144,15 @@ export function useFlowChatFollowOutput({ * - The loop bails out as soon as follow is exited, streaming ends, the * scroller disappears, or the viewport is already pinned to the bottom. */ - const runContinuousFollowFrame = useCallback((nowMs: number) => { + const runContinuousFollowFrame = useCallback(() => { continuousFollowFrameRef.current = null; if (!isFollowingOutputRef.current || !isStreamingRef.current) { - lastContinuousFollowFrameMsRef.current = null; return; } const scroller = scrollerRef.current; if (!scroller) { - lastContinuousFollowFrameMsRef.current = null; return; } @@ -207,22 +167,10 @@ export function useFlowChatFollowOutput({ const isSuspended = shouldSuspendAutoFollowRef.current?.() === true; const measuredDistance = getAutoFollowDistanceFromBottomRef.current?.(scroller) ?? getDistanceFromBottom(scroller); - const previousFrameMs = lastContinuousFollowFrameMsRef.current; - const frameDeltaMs = previousFrameMs === null - ? 1000 / 60 - : nowMs - previousFrameMs; - lastContinuousFollowFrameMsRef.current = nowMs; - - if (!isSuspended && measuredDistance > CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX) { - programmaticScrollUntilMsRef.current = nowMs + PROGRAMMATIC_SCROLL_GUARD_MS; + if (!isSuspended && measuredDistance > AUTO_FOLLOW_BOTTOM_THRESHOLD_PX) { + programmaticScrollUntilMsRef.current = performance.now() + PROGRAMMATIC_SCROLL_GUARD_MS; explicitUserScrollIntentUntilMsRef.current = 0; - - if (prefersReducedMotionRef.current) { - performAutoFollowScrollRef.current(); - } else { - const step = computeContinuousFollowStep(measuredDistance, frameDeltaMs); - scroller.scrollTop += step; - } + performAutoFollowScrollRef.current(); lastObservedScrollTopRef.current = scroller.scrollTop; } @@ -264,6 +212,7 @@ export function useFlowChatFollowOutput({ const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => { cancelPendingAutoFollowArm(); + cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; setFollowingOutput(true); const followAction = reason === 'jump-to-latest' @@ -272,6 +221,7 @@ export function useFlowChatFollowOutput({ runProgrammaticScroll(followAction); }, [ cancelPendingAutoFollowArm, + cancelScheduledFollow, performAutoFollowScroll, performUserFollowScroll, runProgrammaticScroll, @@ -280,13 +230,14 @@ export function useFlowChatFollowOutput({ const exitFollowOutput = useCallback((_reason: FollowOutputExitReason) => { cancelPendingAutoFollowArm(); + cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; setFollowingOutput(false); const scroller = scrollerRef.current; if (scroller) { lastObservedScrollTopRef.current = scroller.scrollTop; } - }, [cancelPendingAutoFollowArm, scrollerRef, setFollowingOutput]); + }, [cancelPendingAutoFollowArm, cancelScheduledFollow, scrollerRef, setFollowingOutput]); const armFollowOutputForNewTurn = useCallback(() => { if (!latestTurnId) { @@ -295,10 +246,12 @@ export function useFlowChatFollowOutput({ } armedAutoFollowTurnIdRef.current = latestTurnId; + cancelScheduledFollow(); setFollowingOutput(false); runProgrammaticScroll(performLatestTurnStickyPin); }, [ cancelPendingAutoFollowArm, + cancelScheduledFollow, latestTurnId, performLatestTurnStickyPin, runProgrammaticScroll, @@ -329,11 +282,13 @@ export function useFlowChatFollowOutput({ } cancelPendingAutoFollowArm(); + cancelScheduledFollow(); setFollowingOutput(true); runProgrammaticScroll(performAutoFollowScroll); return true; }, [ cancelPendingAutoFollowArm, + cancelScheduledFollow, latestTurnId, performAutoFollowScroll, runProgrammaticScroll, @@ -380,17 +335,42 @@ export function useFlowChatFollowOutput({ const scheduleFollowToLatest = useCallback((_reason: string) => { if ( !isFollowingOutputRef.current || - !isStreamingRef.current || - virtualItemCount === 0 + !isStreaming || + virtualItemCount === 0 || + shouldSuspendAutoFollow?.() === true ) { return; } - // Observer notifications only ensure that the continuous, retargetable - // loop is awake. They must not start a separate native smooth scroll or - // snap directly to the bottom; either would recreate the per-line jump. - startContinuousFollowLoop(); - }, [startContinuousFollowLoop, virtualItemCount]); + if (followFrameRef.current !== null) { + return; + } + + followFrameRef.current = requestAnimationFrame(() => { + followFrameRef.current = null; + + if (!isFollowingOutputRef.current || !isStreaming || virtualItemCount === 0) { + return; + } + + if (shouldSuspendAutoFollow?.() === true) { + return; + } + + const scroller = scrollerRef.current; + if (!scroller) { + return; + } + + const rawDistanceFromBottom = getDistanceFromBottom(scroller); + const distanceFromBottom = getAutoFollowDistanceFromBottom?.(scroller) ?? rawDistanceFromBottom; + if (distanceFromBottom <= AUTO_FOLLOW_BOTTOM_THRESHOLD_PX) { + return; + } + + runProgrammaticScroll(performAutoFollowScroll); + }); + }, [getAutoFollowDistanceFromBottom, isStreaming, performAutoFollowScroll, runProgrammaticScroll, scrollerRef, shouldSuspendAutoFollow, virtualItemCount]); const handleScroll = useCallback(() => { const scroller = scrollerRef.current; @@ -459,6 +439,7 @@ export function useFlowChatFollowOutput({ previousSessionIdRef.current = activeSessionId; cancelPendingAutoFollowArm(); + cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; const nextFollowState = Boolean(activeSessionId && virtualItemCount === 0); @@ -471,6 +452,7 @@ export function useFlowChatFollowOutput({ }, [ activeSessionId, cancelPendingAutoFollowArm, + cancelScheduledFollow, latestTurnId, setFollowingOutput, virtualItemCount, @@ -497,24 +479,12 @@ export function useFlowChatFollowOutput({ return () => document.removeEventListener('visibilitychange', handleVisibility); }, [startContinuousFollowLoop]); - useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return; - } - const media = window.matchMedia('(prefers-reduced-motion: reduce)'); - const syncPreference = () => { - prefersReducedMotionRef.current = media.matches; - }; - syncPreference(); - media.addEventListener?.('change', syncPreference); - return () => media.removeEventListener?.('change', syncPreference); - }, []); - useEffect(() => { return () => { + cancelScheduledFollow(); stopContinuousFollowLoop(); }; - }, [stopContinuousFollowLoop]); + }, [cancelScheduledFollow, stopContinuousFollowLoop]); return { isFollowingOutput, From 7d23373ee8789bcf56d3e9aae47f35da6e3de4ba Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 31 Jul 2026 00:25:55 +0800 Subject: [PATCH 2/2] fix(flow-chat): stabilize tail follow and delayed shrink recovery - Coordinate pinned-turn handoff with natural viewport-tail geometry. - Replace competing follow scroll writes with a single smooth RAF loop. - Recover delayed virtualizer shrink clamps through viewport transactions. - Preserve collapse anchors across late measurements with generation-based quiet settlement. - Add regression coverage and document FlowChat scroll stability rules. --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 63 ++++- .../FlowChatViewportCoordinator.test.ts | 48 +++- .../modern/FlowChatViewportCoordinator.ts | 34 ++- ...rtualMessageList.session-boundary.test.tsx | 116 ++++++++ .../components/modern/VirtualMessageList.tsx | 265 +++++++++++++----- .../modern/flowChatScrollStability.ts | 85 +++++- .../modern/useFlowChatFollowOutput.test.tsx | 204 +++++++++++++- .../modern/useFlowChatFollowOutput.ts | 205 +++++++++----- 8 files changed, 856 insertions(+), 164 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index f3e41be8a..06b836b03 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -161,6 +161,19 @@ restores it after the list remeasures. While an element anchor is active, the coordinator also owns virtualizer compensation corrections, so independent scroll writers cannot fight the pinned header. +The logical `isFollowingOutput` flag follows the same ownership rule: it is +only true while the coordinator owns `following-tail`. A `sticky-latest` pin +clears the flag and arms its turn for handoff. Once collapse protection and +unsettled pin growth have drained, the handoff re-enters tail follow when +either the pin reservation is empty or the natural content tail (excluding +Footer reservations) reaches the viewport bottom. The latter condition avoids +making real content grow through stale synthetic pin space before follow can +start. This prevents a stale React render from allowing follow effects to +overwrite a pinned header. +The armed turn identity is owned only by `useFlowChatFollowOutput`; the list +must not mirror it in a second ref because session resume and pin preparation +can otherwise update the two identities in different commits. + Collapse anchors have three phases: active while CSS layout is changing, retained-provisional while delayed virtualizer measurements may still arrive, and settled-grace after the provisional estimate has been reconciled to current @@ -193,10 +206,13 @@ source of truth because integer `scrollHeight` can overstate the browser's subpixel scroll limit. Physical-bottom synchronization must yield whenever the coordinator owns an -element anchor. A sticky pin intentionally sits at the physical bottom created -by its reservation; treating that geometry as tail-follow causes every content -growth measurement to push the pinned header upward before the coordinator can -restore it. +element anchor. It also yields while streaming `following-tail` owns the +viewport, because the single tail loop is the writer for content-growth motion. +A sticky pin intentionally sits at the physical bottom created by its +reservation; treating that geometry as tail-follow causes every content growth +measurement to push the pinned header upward before the coordinator can restore +it. Pinned, anchored, and non-streaming paths keep the normal physical-bottom +synchronization behavior. Sticky pin floors are not reduced from a transient target rect. Positive effective content growth first enters a short settlement ledger (currently @@ -335,15 +351,27 @@ cumulative provisional whitespace. Any deferred follow is then replayed. ## C. Follow-Output Mode (continuous tail) When the viewport is in follow-output mode and the latest turn is still -streaming, the user's intent is "keep the tail visible". The continuous -RAF loop re-pins `scrollTop` toward the bottom every frame. +streaming, the user's intent is "keep the tail visible". After the viewport +coordinator has entered `following-tail`, one RAF loop eases `scrollTop` toward +the effective bottom. Follow events only wake this loop; they do not launch +additional scroll writers. The target subtracts the current Footer +reservation, and large gaps snap directly to the target instead of leaving the +user visibly behind the output. + +The loop is dormant while `pinned-item`, `preserving-element`, or a collapse +transaction owns the viewport. It never clears reservations or calls +`followTail()` from inside the animation frame. This keeps the semantic +handoff and Virtuoso compensation paths authoritative while allowing small +line-height growth to move over several frames. Explicit "jump to latest" +navigation keeps its native smooth scroll; the RAF loop waits for that motion +to settle before writing. Collapses interact with follow mode in three mutually exclusive ways: 1. **Known collapse while follow + streaming is active:** the intent applies synchronous Footer pre-compensation before the card shrinks. The active intent allows shrink reconciliation even though tail follow is running. - When the CSS window ends, the transaction becomes a retained-provisional + When the CSS window ends, the transaction becomes a retained-provisional collapse anchor instead of shrinking the Footer from a signed net-height estimate. Virtuoso can publish the matching item measurement after the CSS transition and after @@ -351,16 +379,25 @@ Collapses interact with follow mode in three mutually exclusive ways: viewport by exactly the removed pixels. The retained transaction records the latest safe follow position. After a negative-layout quiet window, it replaces both the provisional `px` and stale - `floorPx` with the minimum geometrically required Footer range. If a later + `floorPx` with the minimum geometrically required Footer range. The final + release uses one timer plus a geometry generation: any effective height + change invalidates that timer's snapshot, and the timer performs one more + quiet check instead of every token allocating new timer work. If a later measurement clamps below that position, the scroll handler synchronously extends the range and restores it before paint. Real content growth and downward follow movement consume the range one-for-one. User intent, a new pin, session reset, or a final quiet grace releases the retained anchor. Stream end restarts the same settlement path; it does not preserve the provisional full-card estimate indefinitely. -2. **Unsignaled shrink while follow + streaming is active:** there is no - semantic collapse transaction to preserve, so the RAF loop re-pins to the - new bottom on the next frame. +2. **Unsignaled shrink while follow + streaming is active:** a strict physical + clamp signature (the previous and current geometries are both at their + physical bottoms, the range shrink matches the negative `scrollTop` delta, + viewport height is stable, and there is no user intent) starts a + `late-shrink` viewport transaction. The scroll handler extends the Footer and + restores the pre-clamp position synchronously, covering virtualizer size + commits that arrive after the originating collapse transaction was released. + Other unsignaled shrinks remain owned by the tail loop; it follows only + downward toward the new effective bottom on the next frame. A negative `scrollBy` issued by Virtuoso after a virtualized height reduction is also suppressed when the previous geometry was already at the physical bottom. That compensation would move the viewport away from the @@ -371,7 +408,9 @@ Collapses interact with follow mode in three mutually exclusive ways: deferred until the intent's TTL lapses. The loop is cancelled as soon as follow exits (user upward scroll, -session change, streaming ends, or an explicit navigation). +session change, or streaming end). Explicit "jump to latest" navigation pauses +the writer while its native smooth scroll completes, then resumes the same +single tail loop. ## Why `overflow-anchor: none` Must Stay diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts index 9e35fbb6d..513cfc0e1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts @@ -37,26 +37,68 @@ afterEach(() => { }); describe('FlowChatViewportCoordinator', () => { - it('hands off to tail follow only after every reservation is drained', () => { + it('hands off after the pin drains or natural content reaches the viewport tail', () => { expect(canHandoffPinnedItemToTail({ pinReservationPx: 1029, collapseReservationPx: 0, + pendingStickyPinGrowthPx: 0, hasPendingCollapseIntent: false, + viewport: { + scrollTop: 900, + clientHeight: 1000, + naturalContentHeight: 1899, + }, })).toBe(false); expect(canHandoffPinnedItemToTail({ - pinReservationPx: 0, + pinReservationPx: 1029, + collapseReservationPx: 0, + pendingStickyPinGrowthPx: 0, + hasPendingCollapseIntent: false, + viewport: { + scrollTop: 900, + clientHeight: 1000, + naturalContentHeight: 1900, + }, + })).toBe(true); + expect(canHandoffPinnedItemToTail({ + pinReservationPx: 1029, collapseReservationPx: 200, + pendingStickyPinGrowthPx: 0, hasPendingCollapseIntent: false, + viewport: { + scrollTop: 900, + clientHeight: 1000, + naturalContentHeight: 2000, + }, })).toBe(false); expect(canHandoffPinnedItemToTail({ - pinReservationPx: 0, + pinReservationPx: 1029, collapseReservationPx: 0, + pendingStickyPinGrowthPx: 20, + hasPendingCollapseIntent: false, + viewport: { + scrollTop: 900, + clientHeight: 1000, + naturalContentHeight: 2000, + }, + })).toBe(false); + expect(canHandoffPinnedItemToTail({ + pinReservationPx: 1029, + collapseReservationPx: 0, + pendingStickyPinGrowthPx: 0, hasPendingCollapseIntent: true, + viewport: { + scrollTop: 900, + clientHeight: 1000, + naturalContentHeight: 2000, + }, })).toBe(false); expect(canHandoffPinnedItemToTail({ pinReservationPx: 0, collapseReservationPx: 0, + pendingStickyPinGrowthPx: 0, hasPendingCollapseIntent: false, + viewport: null, })).toBe(true); }); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts index 224b2f9a3..04acb89a5 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts @@ -32,12 +32,40 @@ const ELEMENT_ANCHOR_RANGE_GUARD_PX = 1; export function canHandoffPinnedItemToTail(options: { pinReservationPx: number; collapseReservationPx: number; + pendingStickyPinGrowthPx: number; hasPendingCollapseIntent: boolean; + viewport: { + scrollTop: number; + clientHeight: number; + naturalContentHeight: number; + } | null; }): boolean { + if ( + options.collapseReservationPx > ELEMENT_ANCHOR_EPSILON_PX || + options.pendingStickyPinGrowthPx > ELEMENT_ANCHOR_EPSILON_PX || + options.hasPendingCollapseIntent + ) { + return false; + } + + if (options.pinReservationPx <= ELEMENT_ANCHOR_EPSILON_PX) { + return true; + } + + const viewport = options.viewport; + if ( + !viewport || + !Number.isFinite(viewport.scrollTop) || + !Number.isFinite(viewport.clientHeight) || + !Number.isFinite(viewport.naturalContentHeight) || + viewport.clientHeight <= ELEMENT_ANCHOR_EPSILON_PX + ) { + return false; + } + return ( - options.pinReservationPx <= ELEMENT_ANCHOR_EPSILON_PX && - options.collapseReservationPx <= ELEMENT_ANCHOR_EPSILON_PX && - !options.hasPendingCollapseIntent + viewport.naturalContentHeight + ELEMENT_ANCHOR_EPSILON_PX >= + viewport.scrollTop + viewport.clientHeight ); } diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 86cbc99aa..7c547ab90 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -20,6 +20,7 @@ import { releasePinReservationForUserNavigation, resolveAutoCollapseAnchorScrollTop, resolveCollapseIntentSettlementStrategy, + resolveFollowingTailShrinkClampRecovery, resolveProvisionalStickyPinReservationPx, resolveStickyPinGrowthSettlementStrategy, settleRetainedCollapseReservationForAnchor, @@ -822,13 +823,22 @@ describe('VirtualMessageList session boundary', () => { collapseProtectionActive: false, wasAtPhysicalBottom: true, ownsElementAnchor: true, + isFollowingTail: false, })).toBe(false); expect(shouldSyncPhysicalBottom({ viewportGeometryChanged: true, collapseProtectionActive: false, wasAtPhysicalBottom: true, ownsElementAnchor: false, + isFollowingTail: false, })).toBe(true); + expect(shouldSyncPhysicalBottom({ + viewportGeometryChanged: true, + collapseProtectionActive: false, + wasAtPhysicalBottom: true, + ownsElementAnchor: false, + isFollowingTail: true, + })).toBe(false); }); it('suppresses only negative virtualizer compensation while following the streaming tail', () => { @@ -858,6 +868,53 @@ describe('VirtualMessageList session boundary', () => { })).toBe(false); }); + it('recognizes only a non-user physical-bottom clamp from a shrinking scroll range', () => { + const clampGeometry = { + previousGeometry: { + scrollTop: 645.3333129882812, + scrollHeight: 1_673, + clientHeight: 1_027, + }, + currentGeometry: { + scrollTop: 402.6666564941406, + scrollHeight: 1_430, + clientHeight: 1_027, + }, + isFollowingOutput: true, + isStreamingOutput: true, + hasRecentUserUpwardIntent: false, + scrollbarPointerInteractionActive: false, + collapseProtectionActive: false, + }; + + const recovery = resolveFollowingTailShrinkClampRecovery(clampGeometry); + expect(recovery?.targetScrollTop).toBe(645.3333129882812); + expect(recovery?.rangeShrinkPx).toBe(243); + expect(recovery?.scrollClampPx).toBeCloseTo(242.6666564941406, 10); + expect(resolveFollowingTailShrinkClampRecovery({ + ...clampGeometry, + hasRecentUserUpwardIntent: true, + })).toBeNull(); + expect(resolveFollowingTailShrinkClampRecovery({ + ...clampGeometry, + isFollowingOutput: false, + })).toBeNull(); + expect(resolveFollowingTailShrinkClampRecovery({ + ...clampGeometry, + currentGeometry: { + ...clampGeometry.currentGeometry, + scrollTop: 500, + }, + })).toBeNull(); + expect(resolveFollowingTailShrinkClampRecovery({ + ...clampGeometry, + currentGeometry: { + ...clampGeometry.currentGeometry, + clientHeight: 900, + }, + })).toBeNull(); + }); + it('cancels unsettled sticky pin growth only for unsignaled height corrections', () => { expect(getCanceledUnsettledStickyPinGrowthPx({ pendingGrowthPx: 207, @@ -1101,6 +1158,65 @@ describe('VirtualMessageList session boundary', () => { ); }); + it('recovers a late following-tail shrink clamp after collapse protection was released', () => { + flowDiagnosticsMocks.enabled = true; + const session = createSession('session-a', 'turn-a'); + session.dialogTurns[0].status = 'processing'; + session.dialogTurns[0].modelRounds = [{ + id: 'round-turn-a', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 1, + } as typeof session.dialogTurns[number]['modelRounds'][number]]; + stateMocks.activeSession = session; + stateMocks.virtualItems = [createItem('turn-a'), createModelItem('turn-a')]; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + + let contentHeight = 2_076; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 1_000 }, + scrollHeight: { + configurable: true, + get: () => contentHeight + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { configurable: true, writable: true, value: 0 }, + }); + const footerHeightBeforeClamp = Number.parseFloat(footer.style.height); + const stableScrollTop = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = stableScrollTop; + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + contentHeight -= 250; + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; + act(() => { + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + expect(scroller.scrollTop).toBe(stableScrollTop); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo( + footerHeightBeforeClamp + 251, + 2, + ); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.handleScroll', + message: 'Following-tail shrink clamp recovered as a viewport transaction', + })); + }); + it('retains following-tail collapse protection after the animation finalizer', () => { flowDiagnosticsMocks.enabled = true; const session = createSession('session-a', 'turn-a'); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 9d86bd46f..d665a542b 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -76,6 +76,7 @@ import { releasePinReservationForUserNavigation, resolveAutoCollapseAnchorScrollTop, resolveCollapseIntentSettlementStrategy, + resolveFollowingTailShrinkClampRecovery, resolveProvisionalStickyPinReservationPx, resolveStickyPinGrowthSettlementStrategy, sanitizeBottomReservationState, @@ -485,6 +486,7 @@ const VirtualMessageListSession = forwardRef(null); const retainedCollapseReleaseTimerRef = useRef(null); const retainedCollapseSettleGenerationRef = useRef(0); + const retainedCollapseGeometryGenerationRef = useRef(0); const pendingStickyPinGrowthRef = useRef<{ targetTurnId: string | null; amountPx: number; @@ -495,6 +497,7 @@ const VirtualMessageListSession = forwardRef(null); const settlePendingStickyPinGrowthRef = useRef<(reason: string) => void>(() => {}); const maybeHandoffPinnedTurnToTailRef = useRef<(reason: string) => boolean>(() => false); + const preparePinnedTurnFollowHandoffRef = useRef<() => void>(() => {}); const finalizeCollapseIntentRef = useRef<( reason: string, options?: { expectedExpiresAtMs?: number; suppressHandoff?: boolean }, @@ -555,6 +558,7 @@ const VirtualMessageListSession = forwardRef { retainedCollapseSettleGenerationRef.current += 1; + retainedCollapseGeometryGenerationRef.current = 0; if (retainedCollapseSettleTimerRef.current !== null) { window.clearTimeout(retainedCollapseSettleTimerRef.current); retainedCollapseSettleTimerRef.current = null; @@ -707,11 +711,17 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX; if (ownsElementAnchor) { viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'viewport-resize'); @@ -1015,29 +1025,39 @@ const VirtualMessageListSession = forwardRef { - retainedCollapseReleaseTimerRef.current = null; - if (releaseGeneration !== retainedCollapseSettleGenerationRef.current) { - return; - } - retainedCollapseAnchorRef.current = null; - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'F', - location: 'VirtualMessageList.releaseSettledCollapseAnchor', - message: 'Settled collapse anchor released after layout remained quiet', - data: () => ({ - reason, - retainedAnchor, - reservation: bottomReservationStateRef.current, - scrollTop: scroller.scrollTop, - scrollHeight: scroller.scrollHeight, - clientHeight: scroller.clientHeight, - coordinatorMode: viewportCoordinatorRef.current.getMode(), - }), - }); - } - }, RETAINED_COLLAPSE_RELEASE_QUIET_MS); + retainedCollapseGeometryGenerationRef.current += 1; + const scheduleReleaseWhenQuiet = (geometryGeneration: number) => { + retainedCollapseReleaseTimerRef.current = window.setTimeout(() => { + retainedCollapseReleaseTimerRef.current = null; + if (releaseGeneration !== retainedCollapseSettleGenerationRef.current) { + return; + } + const currentGeometryGeneration = retainedCollapseGeometryGenerationRef.current; + if (currentGeometryGeneration !== geometryGeneration) { + scheduleReleaseWhenQuiet(currentGeometryGeneration); + return; + } + retainedCollapseAnchorRef.current = null; + retainedCollapseGeometryGenerationRef.current = 0; + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'F', + location: 'VirtualMessageList.releaseSettledCollapseAnchor', + message: 'Settled collapse anchor released after layout remained quiet', + data: () => ({ + reason, + retainedAnchor, + reservation: bottomReservationStateRef.current, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } + }, RETAINED_COLLAPSE_RELEASE_QUIET_MS); + }; + scheduleReleaseWhenQuiet(retainedCollapseGeometryGenerationRef.current); if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ @@ -1077,6 +1097,7 @@ const VirtualMessageListSession = forwardRef { retainedCollapseSettleTimerRef.current = null; @@ -1488,6 +1509,10 @@ const VirtualMessageListSession = forwardRef { setStaticAnchorWindowTurnId(null); }, [activeSession?.sessionId]); - const latestTurnAutoFollowStateRef = useRef<{ - turnId: string | null; - sawPositiveFloor: boolean; - }>({ - turnId: latestTurnId, - sawPositiveFloor: false, - }); const hasPrimedMountedStreamingTurnFollowRef = useRef(false); const previousLatestTurnIdForFollowRef = useRef(latestTurnId); const previousSessionIdForFollowRef = useRef(activeSession?.sessionId); @@ -3416,6 +3440,82 @@ const VirtualMessageListSession = forwardRef ({ + recovery: followingTailShrinkClampRecovery, + previousGeometry: previousScrollerGeometry, + currentGeometry: currentScrollerGeometry, + reservation: bottomReservationStateRef.current, + scrollTopAfter: scrollerElement.scrollTop, + }), + }); + } + return; + } + if ( + retainedCollapseAnchorRef.current && + intentCheckScrollDelta > COMPENSATION_EPSILON_PX && + !hasRecentUserUpwardIntent && + !scrollbarPointerInteractionActiveRef.current + ) { + retainedCollapseAnchorRef.current.anchorScrollTop = Math.max( + retainedCollapseAnchorRef.current.anchorScrollTop, + intentCheckScrollTop, + ); + } if ( intentCheckScrollDelta < -COMPENSATION_EPSILON_PX && isFollowingOutputRef.current && @@ -4668,6 +4768,9 @@ const VirtualMessageListSession = forwardRef ( + viewportCoordinatorRef.current.getMode() === 'following-tail' && + !viewportCoordinatorRef.current.ownsElementAnchor() + ), // Subtract the bottom-reservation footer so the follow controller treats // synthetic footer space as "already at the bottom". Without this, the // post-collapse footer (kept around to preserve the upper anchor) would be @@ -4806,15 +4917,16 @@ const VirtualMessageListSession = forwardRef { + getAutoFollowTargetScrollTop: (scroller) => { const compensationPx = getTotalBottomCompensationPx(); return Math.max( 0, - scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop - compensationPx, + scroller.scrollHeight - scroller.clientHeight - compensationPx, ); }, onContinuousFollowFrame: undefined, }); + preparePinnedTurnFollowHandoffRef.current = preparePinnedTurnFollowHandoff; useEffect(() => { if (hasPrimedMountedStreamingTurnFollowRef.current) { @@ -4830,10 +4942,6 @@ const VirtualMessageListSession = forwardRef 0) { @@ -4878,10 +4981,6 @@ const VirtualMessageListSession = forwardRef { - const trackingState = latestTurnAutoFollowStateRef.current; - if ( - !latestTurnId || - trackingState.turnId !== latestTurnId || - isFollowingOutput - ) { + if (!latestTurnId || isFollowingOutput) { return false; } @@ -4943,18 +5033,25 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { - trackingState.sawPositiveFloor = true; - } - const collapseIntent = pendingCollapseIntentRef.current; const hasPendingCollapseIntent = ( collapseIntent.active ); + const pendingStickyPinGrowthPx = pendingStickyPinGrowthRef.current.amountPx; + const scroller = scrollerElementRef.current; + const viewport = scroller + ? { + scrollTop: scroller.scrollTop, + clientHeight: scroller.clientHeight, + naturalContentHeight: snapshotMeasuredContentHeight(scroller, reservationState), + } + : null; if (!canHandoffPinnedItemToTail({ pinReservationPx: reservationState.pin.px, collapseReservationPx: reservationState.collapse.px, + pendingStickyPinGrowthPx, hasPendingCollapseIntent, + viewport, })) { if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ @@ -4964,7 +5061,9 @@ const VirtualMessageListSession = forwardRef ({ reason, reservation: reservationState, + pendingStickyPinGrowthPx, hasPendingCollapseIntent, + viewport, coordinatorMode: viewportCoordinatorRef.current.getMode(), }), }); @@ -4978,13 +5077,14 @@ const VirtualMessageListSession = forwardRef ({ reason, reservation: reservationState }), + data: () => ({ + reason, + reservation: reservationState, + pendingStickyPinGrowthPx, + viewport, + }), }); } - latestTurnAutoFollowStateRef.current = { - turnId: null, - sawPositiveFloor: false, - }; return true; } return false; @@ -4994,6 +5094,7 @@ const VirtualMessageListSession = forwardRef !Number.isFinite(value))) { + return null; + } + + if ( + Math.abs(previous.clientHeight - current.clientHeight) > + FOLLOWING_TAIL_SHRINK_CLAMP_TOLERANCE_PX + ) { + return null; + } + + const previousMaxScrollTop = Math.max(0, previous.scrollHeight - previous.clientHeight); + const currentMaxScrollTop = Math.max(0, current.scrollHeight - current.clientHeight); + const rangeShrinkPx = previousMaxScrollTop - currentMaxScrollTop; + const scrollClampPx = previous.scrollTop - current.scrollTop; + if ( + rangeShrinkPx <= COMPENSATION_EPSILON_PX || + scrollClampPx <= COMPENSATION_EPSILON_PX || + Math.abs(previousMaxScrollTop - previous.scrollTop) > + FOLLOWING_TAIL_SHRINK_CLAMP_TOLERANCE_PX || + Math.abs(currentMaxScrollTop - current.scrollTop) > + FOLLOWING_TAIL_SHRINK_CLAMP_TOLERANCE_PX || + Math.abs(rangeShrinkPx - scrollClampPx) > + FOLLOWING_TAIL_SHRINK_CLAMP_TOLERANCE_PX + ) { + return null; + } + + return { + targetScrollTop: previous.scrollTop, + rangeShrinkPx, + scrollClampPx, + }; +} + export function getCanceledUnsettledStickyPinGrowthPx(options: { pendingGrowthPx: number; shrinkPx: number; diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx index e280a46d8..5daf1ac81 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx @@ -3,7 +3,10 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; +import { + computeContinuousFollowStep, + useFlowChatFollowOutput, +} from './useFlowChatFollowOutput'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -25,11 +28,15 @@ function Harness({ onController, performAutoFollowScroll, performLatestTurnStickyPin = vi.fn(), + canAnimateTailFollow, + getAutoFollowTargetScrollTop, }: { scroller: HTMLElement; onController: (controller: FollowOutputController) => void; performAutoFollowScroll: () => void; performLatestTurnStickyPin?: () => void; + canAnimateTailFollow?: () => boolean; + getAutoFollowTargetScrollTop?: (scroller: HTMLElement) => number; }) { const scrollerRef = React.useRef(scroller); scrollerRef.current = scroller; @@ -43,6 +50,8 @@ function Harness({ performUserFollowScroll: vi.fn(), performAutoFollowScroll, performLatestTurnStickyPin, + canAnimateTailFollow, + getAutoFollowTargetScrollTop, }); onController(controller); @@ -74,6 +83,162 @@ describe('useFlowChatFollowOutput', () => { vi.unstubAllGlobals(); }); + it('eases a small tail gap without using a second full-scroll action', () => { + expect(computeContinuousFollowStep(24, 1000 / 60)).toBeGreaterThan(0); + expect(computeContinuousFollowStep(24, 1000 / 60)).toBeLessThan(24); + expect(computeContinuousFollowStep(240, 1000 / 60)).toBeLessThanOrEqual(32); + expect(computeContinuousFollowStep(Number.NaN, 1000 / 60)).toBe(0); + + const queuedFrames: FrameRequestCallback[] = []; + let nextFrameId = 0; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + queuedFrames.push(callback); + nextFrameId += 1; + return nextFrameId; + })); + + const scroller = document.createElement('div'); + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const performAutoFollowScroll = vi.fn(() => { + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; + }); + + act(() => { + root.render( + { + controller = nextController; + }} + performAutoFollowScroll={performAutoFollowScroll} + />, + ); + }); + + act(() => { + controller?.enterFollowOutput('auto-follow'); + }); + expect(performAutoFollowScroll).toHaveBeenCalledTimes(1); + + setScrollerMetrics(scroller, { + scrollHeight: 1524, + clientHeight: 500, + scrollTop: 1000, + }); + const firstFollowFrame = queuedFrames.shift(); + expect(firstFollowFrame).toBeDefined(); + + act(() => { + firstFollowFrame?.(1000 / 60); + }); + + act(() => { + controller?.scheduleFollowToLatest('observer-growth'); + }); + + expect(performAutoFollowScroll).toHaveBeenCalledTimes(1); + expect(scroller.scrollTop).toBeGreaterThan(1000); + expect(scroller.scrollTop).toBeLessThan(1024); + }); + + it('does not animate until the tail coordinator owns the viewport', () => { + const queuedFrames: FrameRequestCallback[] = []; + let nextFrameId = 0; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + queuedFrames.push(callback); + nextFrameId += 1; + return nextFrameId; + })); + + const scroller = document.createElement('div'); + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const performAutoFollowScroll = vi.fn(() => { + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; + }); + + act(() => { + root.render( + { + controller = nextController; + }} + performAutoFollowScroll={performAutoFollowScroll} + canAnimateTailFollow={() => false} + />, + ); + }); + + act(() => { + controller?.enterFollowOutput('auto-follow'); + }); + setScrollerMetrics(scroller, { + scrollHeight: 1524, + clientHeight: 500, + scrollTop: 1000, + }); + const firstFollowFrame = queuedFrames.shift(); + expect(firstFollowFrame).toBeDefined(); + + act(() => { + firstFollowFrame?.(1000 / 60); + }); + + expect(performAutoFollowScroll).toHaveBeenCalledTimes(1); + expect(scroller.scrollTop).toBe(1000); + }); + + it('does not animate upward when the effective target moves backward', () => { + const queuedFrames: FrameRequestCallback[] = []; + let nextFrameId = 0; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + queuedFrames.push(callback); + nextFrameId += 1; + return nextFrameId; + })); + + const scroller = document.createElement('div'); + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const performAutoFollowScroll = vi.fn(); + + act(() => { + root.render( + { + controller = nextController; + }} + performAutoFollowScroll={performAutoFollowScroll} + getAutoFollowTargetScrollTop={() => 980} + />, + ); + }); + + act(() => { + controller?.enterFollowOutput('auto-follow'); + }); + const firstFollowFrame = queuedFrames.shift(); + expect(firstFollowFrame).toBeDefined(); + + act(() => { + firstFollowFrame?.(1000 / 60); + }); + + expect(scroller.scrollTop).toBe(1000); + }); + it('exits output follow immediately when explicit user scroll intent is already away from bottom', () => { const scroller = document.createElement('div'); setScrollerMetrics(scroller, { @@ -194,6 +359,43 @@ describe('useFlowChatFollowOutput', () => { expect(controller?.isFollowingOutput).toBe(false); }); + it('keeps a sticky pin armed while clearing logical follow ownership', () => { + const scroller = document.createElement('div'); + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const performAutoFollowScroll = vi.fn(); + + act(() => { + root.render( + { + controller = nextController; + }} + performAutoFollowScroll={performAutoFollowScroll} + />, + ); + }); + + act(() => { + controller?.enterFollowOutput('auto-follow'); + controller?.preparePinnedTurnFollowHandoff(); + }); + + expect(controller?.isFollowingOutput).toBe(false); + + let activated = false; + act(() => { + activated = controller?.activateArmedFollowOutput() ?? false; + }); + + expect(activated).toBe(true); + expect(controller?.isFollowingOutput).toBe(true); + }); + it('resumes a mounted streaming session at the tail without replaying sticky pin', () => { const scroller = document.createElement('div'); setScrollerMetrics(scroller, { diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index 66e0942be..48c155793 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -12,6 +12,39 @@ const AUTO_FOLLOW_BOTTOM_THRESHOLD_PX = 24; const USER_SCROLL_DIRECTION_EPSILON_PX = 0.5; const USER_SCROLL_INTENT_WINDOW_MS = 450; const USER_SCROLL_INTENT_PROGRAMMATIC_GRACE_MS = 80; +const CONTINUOUS_FOLLOW_TIME_CONSTANT_MS = 70; +const CONTINUOUS_FOLLOW_MIN_STEP_PX = 0.75; +const CONTINUOUS_FOLLOW_MAX_STEP_PX = 32; +const CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX = 0.5; +const CONTINUOUS_FOLLOW_MAX_ANIMATED_DISTANCE_PX = 96; +const CONTINUOUS_FOLLOW_MAX_FRAME_DELTA_MS = 34; +const NATIVE_SMOOTH_FOLLOW_GRACE_MS = 320; + +export function computeContinuousFollowStep( + distancePx: number, + frameDeltaMs: number, +): number { + const distance = Number.isFinite(distancePx) ? Math.max(0, distancePx) : 0; + if (distance <= CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX) { + return distance; + } + + const deltaMs = Number.isFinite(frameDeltaMs) + ? Math.min(CONTINUOUS_FOLLOW_MAX_FRAME_DELTA_MS, Math.max(0, frameDeltaMs)) + : 0; + const easedStep = distance * ( + 1 - Math.exp(-deltaMs / CONTINUOUS_FOLLOW_TIME_CONSTANT_MS) + ); + const step = Math.min( + distance, + CONTINUOUS_FOLLOW_MAX_STEP_PX, + Math.max(CONTINUOUS_FOLLOW_MIN_STEP_PX, easedStep), + ); + + return distance - step <= CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX + ? distance + : step; +} export type FollowOutputEnterReason = 'jump-to-latest' | 'auto-follow'; export type FollowOutputExitReason = @@ -41,7 +74,8 @@ interface UseFlowChatFollowOutputOptions { * bottom-tracking on the next frame after the suspension clears. */ shouldSuspendAutoFollow?: () => boolean; - getAutoFollowDistanceFromBottom?: (scroller: HTMLElement) => number; + canAnimateTailFollow?: () => boolean; + getAutoFollowTargetScrollTop?: (scroller: HTMLElement) => number; /** * Optional per-frame hook invoked from inside the continuous follow loop. * Used to reconcile sticky-latest pin floor in lockstep with the scroll @@ -54,6 +88,7 @@ interface UseFlowChatFollowOutputResult { isFollowingOutput: boolean; enterFollowOutput: (reason: FollowOutputEnterReason) => void; exitFollowOutput: (reason: FollowOutputExitReason) => void; + preparePinnedTurnFollowHandoff: () => void; armFollowOutputForNewTurn: () => void; resumeFollowOutputForMountedStream: () => boolean; activateArmedFollowOutput: () => boolean; @@ -77,29 +112,31 @@ export function useFlowChatFollowOutput({ performAutoFollowScroll, performLatestTurnStickyPin, shouldSuspendAutoFollow, - getAutoFollowDistanceFromBottom, + canAnimateTailFollow, + getAutoFollowTargetScrollTop, onContinuousFollowFrame, }: UseFlowChatFollowOutputOptions): UseFlowChatFollowOutputResult { const [isFollowingOutput, setIsFollowingOutput] = useState(false); const isFollowingOutputRef = useRef(isFollowingOutput); - const followFrameRef = useRef(null); const programmaticScrollUntilMsRef = useRef(0); const explicitUserScrollIntentUntilMsRef = useRef(0); const lastObservedScrollTopRef = useRef(0); const previousSessionIdRef = useRef(activeSessionId); const armedAutoFollowTurnIdRef = useRef(null); const continuousFollowFrameRef = useRef(null); + const lastContinuousFollowFrameMsRef = useRef(null); + const nativeSmoothFollowUntilMsRef = useRef(0); const isStreamingRef = useRef(isStreaming); - const performAutoFollowScrollRef = useRef(performAutoFollowScroll); const onContinuousFollowFrameRef = useRef(onContinuousFollowFrame); - const getAutoFollowDistanceFromBottomRef = useRef(getAutoFollowDistanceFromBottom); + const canAnimateTailFollowRef = useRef(canAnimateTailFollow); + const getAutoFollowTargetScrollTopRef = useRef(getAutoFollowTargetScrollTop); const shouldSuspendAutoFollowRef = useRef(shouldSuspendAutoFollow); isStreamingRef.current = isStreaming; - performAutoFollowScrollRef.current = performAutoFollowScroll; onContinuousFollowFrameRef.current = onContinuousFollowFrame; - getAutoFollowDistanceFromBottomRef.current = getAutoFollowDistanceFromBottom; + canAnimateTailFollowRef.current = canAnimateTailFollow; + getAutoFollowTargetScrollTopRef.current = getAutoFollowTargetScrollTop; shouldSuspendAutoFollowRef.current = shouldSuspendAutoFollow; const setFollowingOutput = useCallback((nextValue: boolean) => { @@ -109,12 +146,9 @@ export function useFlowChatFollowOutput({ cancelAnimationFrame(continuousFollowFrameRef.current); continuousFollowFrameRef.current = null; } - }, []); - - const cancelScheduledFollow = useCallback(() => { - if (followFrameRef.current !== null) { - cancelAnimationFrame(followFrameRef.current); - followFrameRef.current = null; + if (!nextValue) { + lastContinuousFollowFrameMsRef.current = null; + nativeSmoothFollowUntilMsRef.current = 0; } }, []); @@ -123,6 +157,7 @@ export function useFlowChatFollowOutput({ cancelAnimationFrame(continuousFollowFrameRef.current); continuousFollowFrameRef.current = null; } + lastContinuousFollowFrameMsRef.current = null; }, []); /** @@ -144,46 +179,75 @@ export function useFlowChatFollowOutput({ * - The loop bails out as soon as follow is exited, streaming ends, the * scroller disappears, or the viewport is already pinned to the bottom. */ - const runContinuousFollowFrame = useCallback(() => { + const runContinuousFollowFrame = useCallback((nowMs: number) => { continuousFollowFrameRef.current = null; if (!isFollowingOutputRef.current || !isStreamingRef.current) { + lastContinuousFollowFrameMsRef.current = null; return; } const scroller = scrollerRef.current; if (!scroller) { + lastContinuousFollowFrameMsRef.current = null; + return; + } + + if (document.hidden) { + lastContinuousFollowFrameMsRef.current = null; + return; + } + + const scheduleNextFrame = () => { + if (!isFollowingOutputRef.current || !isStreamingRef.current || document.hidden) { + return; + } + continuousFollowFrameRef.current = requestAnimationFrame(runContinuousFollowFrame); + }; + + if (canAnimateTailFollowRef.current?.() === false) { + lastContinuousFollowFrameMsRef.current = null; return; } - onContinuousFollowFrameRef.current?.(); + const previousFrameMs = lastContinuousFollowFrameMsRef.current; + const frameDeltaMs = previousFrameMs === null + ? 1000 / 60 + : nowMs - previousFrameMs; + lastContinuousFollowFrameMsRef.current = nowMs; + + if (nowMs < nativeSmoothFollowUntilMsRef.current) { + scheduleNextFrame(); + return; + } // While a known collapse animation / layout transition is in flight, the // VirtualMessageList anchor-lock + bottom-reservation footer is preserving - // the upper visual anchor. Issuing a programmatic scroll-to-bottom from - // this loop would fight that machinery and re-introduce the "sink-down" - // jitter the user reported. We simply re-arm the next frame and resume on - // the first frame after the suspension clears. + // the upper visual anchor. The loop remains alive but must not write until + // that transaction releases ownership. const isSuspended = shouldSuspendAutoFollowRef.current?.() === true; - const measuredDistance = getAutoFollowDistanceFromBottomRef.current?.(scroller) - ?? getDistanceFromBottom(scroller); - if (!isSuspended && measuredDistance > AUTO_FOLLOW_BOTTOM_THRESHOLD_PX) { - programmaticScrollUntilMsRef.current = performance.now() + PROGRAMMATIC_SCROLL_GUARD_MS; - explicitUserScrollIntentUntilMsRef.current = 0; - performAutoFollowScrollRef.current(); - lastObservedScrollTopRef.current = scroller.scrollTop; + if (!isSuspended) { + onContinuousFollowFrameRef.current?.(); + const targetScrollTop = getAutoFollowTargetScrollTopRef.current?.(scroller) + ?? Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const distanceToTarget = Math.max(0, targetScrollTop - scroller.scrollTop); + + if (distanceToTarget > CONTINUOUS_FOLLOW_SNAP_THRESHOLD_PX) { + const nextScrollTop = distanceToTarget > CONTINUOUS_FOLLOW_MAX_ANIMATED_DISTANCE_PX + ? targetScrollTop + : scroller.scrollTop + computeContinuousFollowStep(distanceToTarget, frameDeltaMs); + programmaticScrollUntilMsRef.current = nowMs + PROGRAMMATIC_SCROLL_GUARD_MS; + explicitUserScrollIntentUntilMsRef.current = 0; + scroller.scrollTop = Math.min(targetScrollTop, nextScrollTop); + lastObservedScrollTopRef.current = scroller.scrollTop; + } } if (!isFollowingOutputRef.current || !isStreamingRef.current) { return; } - // Stop the loop when the page is hidden to avoid unnecessary work - if (document.hidden) { - return; - } - - continuousFollowFrameRef.current = requestAnimationFrame(runContinuousFollowFrame); + scheduleNextFrame(); }, [scrollerRef]); const startContinuousFollowLoop = useCallback(() => { @@ -212,8 +276,10 @@ export function useFlowChatFollowOutput({ const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => { cancelPendingAutoFollowArm(); - cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; + nativeSmoothFollowUntilMsRef.current = reason === 'jump-to-latest' + ? performance.now() + NATIVE_SMOOTH_FOLLOW_GRACE_MS + : 0; setFollowingOutput(true); const followAction = reason === 'jump-to-latest' ? performUserFollowScroll @@ -221,7 +287,6 @@ export function useFlowChatFollowOutput({ runProgrammaticScroll(followAction); }, [ cancelPendingAutoFollowArm, - cancelScheduledFollow, performAutoFollowScroll, performUserFollowScroll, runProgrammaticScroll, @@ -230,32 +295,44 @@ export function useFlowChatFollowOutput({ const exitFollowOutput = useCallback((_reason: FollowOutputExitReason) => { cancelPendingAutoFollowArm(); - cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; + nativeSmoothFollowUntilMsRef.current = 0; setFollowingOutput(false); const scroller = scrollerRef.current; if (scroller) { lastObservedScrollTopRef.current = scroller.scrollTop; } - }, [cancelPendingAutoFollowArm, cancelScheduledFollow, scrollerRef, setFollowingOutput]); + }, [cancelPendingAutoFollowArm, scrollerRef, setFollowingOutput]); - const armFollowOutputForNewTurn = useCallback(() => { + // Pinned latest turns are a handoff transaction: the pin owns the viewport + // until its reservation is consumed, then the armed turn may resume tail + // follow. Keep the arm while clearing logical follow ownership so the two + // semantic owners can never be active at the same time. + const preparePinnedTurnFollowHandoff = useCallback(() => { if (!latestTurnId) { - cancelPendingAutoFollowArm(); return; } armedAutoFollowTurnIdRef.current = latestTurnId; - cancelScheduledFollow(); + explicitUserScrollIntentUntilMsRef.current = 0; + nativeSmoothFollowUntilMsRef.current = 0; setFollowingOutput(false); + }, [latestTurnId, setFollowingOutput]); + + const armFollowOutputForNewTurn = useCallback(() => { + if (!latestTurnId) { + cancelPendingAutoFollowArm(); + return; + } + + preparePinnedTurnFollowHandoff(); runProgrammaticScroll(performLatestTurnStickyPin); }, [ cancelPendingAutoFollowArm, - cancelScheduledFollow, latestTurnId, performLatestTurnStickyPin, + preparePinnedTurnFollowHandoff, runProgrammaticScroll, - setFollowingOutput, ]); const resumeFollowOutputForMountedStream = useCallback(() => { @@ -282,13 +359,12 @@ export function useFlowChatFollowOutput({ } cancelPendingAutoFollowArm(); - cancelScheduledFollow(); + nativeSmoothFollowUntilMsRef.current = 0; setFollowingOutput(true); runProgrammaticScroll(performAutoFollowScroll); return true; }, [ cancelPendingAutoFollowArm, - cancelScheduledFollow, latestTurnId, performAutoFollowScroll, runProgrammaticScroll, @@ -322,6 +398,7 @@ export function useFlowChatFollowOutput({ ); } explicitUserScrollIntentUntilMsRef.current = now + USER_SCROLL_INTENT_WINDOW_MS; + nativeSmoothFollowUntilMsRef.current = 0; if (isFollowingOutputRef.current) { // Input handlers see the upward intent before scrollTop necessarily moves. @@ -335,42 +412,17 @@ export function useFlowChatFollowOutput({ const scheduleFollowToLatest = useCallback((_reason: string) => { if ( !isFollowingOutputRef.current || - !isStreaming || + !isStreamingRef.current || virtualItemCount === 0 || shouldSuspendAutoFollow?.() === true ) { return; } - if (followFrameRef.current !== null) { - return; - } - - followFrameRef.current = requestAnimationFrame(() => { - followFrameRef.current = null; - - if (!isFollowingOutputRef.current || !isStreaming || virtualItemCount === 0) { - return; - } - - if (shouldSuspendAutoFollow?.() === true) { - return; - } - - const scroller = scrollerRef.current; - if (!scroller) { - return; - } - - const rawDistanceFromBottom = getDistanceFromBottom(scroller); - const distanceFromBottom = getAutoFollowDistanceFromBottom?.(scroller) ?? rawDistanceFromBottom; - if (distanceFromBottom <= AUTO_FOLLOW_BOTTOM_THRESHOLD_PX) { - return; - } - - runProgrammaticScroll(performAutoFollowScroll); - }); - }, [getAutoFollowDistanceFromBottom, isStreaming, performAutoFollowScroll, runProgrammaticScroll, scrollerRef, shouldSuspendAutoFollow, virtualItemCount]); + // Follow events only wake the single tail writer. They must not launch a + // second scroll action that competes with the RAF loop or the virtualizer. + startContinuousFollowLoop(); + }, [shouldSuspendAutoFollow, startContinuousFollowLoop, virtualItemCount]); const handleScroll = useCallback(() => { const scroller = scrollerRef.current; @@ -439,8 +491,8 @@ export function useFlowChatFollowOutput({ previousSessionIdRef.current = activeSessionId; cancelPendingAutoFollowArm(); - cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; + nativeSmoothFollowUntilMsRef.current = 0; const nextFollowState = Boolean(activeSessionId && virtualItemCount === 0); if (nextFollowState) { @@ -452,7 +504,6 @@ export function useFlowChatFollowOutput({ }, [ activeSessionId, cancelPendingAutoFollowArm, - cancelScheduledFollow, latestTurnId, setFollowingOutput, virtualItemCount, @@ -481,15 +532,15 @@ export function useFlowChatFollowOutput({ useEffect(() => { return () => { - cancelScheduledFollow(); stopContinuousFollowLoop(); }; - }, [cancelScheduledFollow, stopContinuousFollowLoop]); + }, [stopContinuousFollowLoop]); return { isFollowingOutput, enterFollowOutput, exitFollowOutput, + preparePinnedTurnFollowHandoff, armFollowOutputForNewTurn, resumeFollowOutputForMountedStream, activateArmedFollowOutput,