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 f3e41be8a5..5b0d1708fe 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,8 +335,15 @@ 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". 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. 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 86cbc99aa1..92ebc30c1c 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,19 +818,28 @@ describe('VirtualMessageList session boundary', () => { it('does not let physical-bottom follow compete with a semantic element anchor', () => { expect(shouldSyncPhysicalBottom({ - viewportGeometryChanged: true, + viewportSizeChanged: true, collapseProtectionActive: false, wasAtPhysicalBottom: true, ownsElementAnchor: true, })).toBe(false); expect(shouldSyncPhysicalBottom({ - viewportGeometryChanged: true, + viewportSizeChanged: 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 9d86bd46f1..0002501874 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -687,11 +687,10 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX || + const viewportSizeChanged = Math.abs(scroller.clientHeight - previousGeometry.clientHeight) > COMPENSATION_EPSILON_PX; if ( - !viewportGeometryChanged || + !viewportSizeChanged || pendingCollapseIntentRef.current.active || retainedCollapseAnchorRef.current !== null ) { @@ -708,7 +707,7 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX || - Math.abs(scroller.clientHeight - previousScrollerGeometry.clientHeight) > COMPENSATION_EPSILON_PX - ) + Math.abs(scroller.clientHeight - previousScrollerGeometry.clientHeight) > COMPENSATION_EPSILON_PX ); const wasAtPhysicalBottom = Boolean( previousScrollerGeometry && @@ -1516,7 +1512,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 }, @@ -229,4 +254,57 @@ 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 66e0942be3..79f7b4f4c1 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,46 @@ 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 = @@ -83,13 +123,14 @@ 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); @@ -109,12 +150,8 @@ 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; } }, []); @@ -123,6 +160,7 @@ export function useFlowChatFollowOutput({ cancelAnimationFrame(continuousFollowFrameRef.current); continuousFollowFrameRef.current = null; } + lastContinuousFollowFrameMsRef.current = null; }, []); /** @@ -130,12 +168,12 @@ export function useFlowChatFollowOutput({ * * Why this exists: * - Streaming text + auto-collapsing tool cards generate dense bursts of - * 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. + * 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. * - This loop runs every animation frame while follow + streaming is - * active, pushing scrollTop toward the latest token regardless of any - * intermediate layout shrink. The result is a smooth, continuous tail. + * active and eases scrollTop toward the latest tail. New growth retargets + * the in-flight motion without restarting it. * * Safety: * - Programmatic scrolls inside this loop bump @@ -144,15 +182,17 @@ 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; } @@ -167,10 +207,22 @@ export function useFlowChatFollowOutput({ 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; + 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; explicitUserScrollIntentUntilMsRef.current = 0; - performAutoFollowScrollRef.current(); + + if (prefersReducedMotionRef.current) { + performAutoFollowScrollRef.current(); + } else { + const step = computeContinuousFollowStep(measuredDistance, frameDeltaMs); + scroller.scrollTop += step; + } lastObservedScrollTopRef.current = scroller.scrollTop; } @@ -212,7 +264,6 @@ export function useFlowChatFollowOutput({ const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => { cancelPendingAutoFollowArm(); - cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; setFollowingOutput(true); const followAction = reason === 'jump-to-latest' @@ -221,7 +272,6 @@ export function useFlowChatFollowOutput({ runProgrammaticScroll(followAction); }, [ cancelPendingAutoFollowArm, - cancelScheduledFollow, performAutoFollowScroll, performUserFollowScroll, runProgrammaticScroll, @@ -230,14 +280,13 @@ 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, cancelScheduledFollow, scrollerRef, setFollowingOutput]); + }, [cancelPendingAutoFollowArm, scrollerRef, setFollowingOutput]); const armFollowOutputForNewTurn = useCallback(() => { if (!latestTurnId) { @@ -246,12 +295,10 @@ export function useFlowChatFollowOutput({ } armedAutoFollowTurnIdRef.current = latestTurnId; - cancelScheduledFollow(); setFollowingOutput(false); runProgrammaticScroll(performLatestTurnStickyPin); }, [ cancelPendingAutoFollowArm, - cancelScheduledFollow, latestTurnId, performLatestTurnStickyPin, runProgrammaticScroll, @@ -282,13 +329,11 @@ export function useFlowChatFollowOutput({ } cancelPendingAutoFollowArm(); - cancelScheduledFollow(); setFollowingOutput(true); runProgrammaticScroll(performAutoFollowScroll); return true; }, [ cancelPendingAutoFollowArm, - cancelScheduledFollow, latestTurnId, performAutoFollowScroll, runProgrammaticScroll, @@ -335,42 +380,17 @@ export function useFlowChatFollowOutput({ const scheduleFollowToLatest = useCallback((_reason: string) => { if ( !isFollowingOutputRef.current || - !isStreaming || - virtualItemCount === 0 || - shouldSuspendAutoFollow?.() === true + !isStreamingRef.current || + virtualItemCount === 0 ) { 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]); + // 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]); const handleScroll = useCallback(() => { const scroller = scrollerRef.current; @@ -439,7 +459,6 @@ export function useFlowChatFollowOutput({ previousSessionIdRef.current = activeSessionId; cancelPendingAutoFollowArm(); - cancelScheduledFollow(); explicitUserScrollIntentUntilMsRef.current = 0; const nextFollowState = Boolean(activeSessionId && virtualItemCount === 0); @@ -452,7 +471,6 @@ export function useFlowChatFollowOutput({ }, [ activeSessionId, cancelPendingAutoFollowArm, - cancelScheduledFollow, latestTurnId, setFollowingOutput, virtualItemCount, @@ -479,12 +497,24 @@ 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(); }; - }, [cancelScheduledFollow, stopContinuousFollowLoop]); + }, [stopContinuousFollowLoop]); return { isFollowingOutput,