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..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,22 +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". 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". 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
@@ -358,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
@@ -378,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 92ebc30c1..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,
@@ -818,25 +819,25 @@ 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,
+ isFollowingTail: false,
})).toBe(false);
expect(shouldSyncPhysicalBottom({
- viewportSizeChanged: true,
+ viewportGeometryChanged: true,
collapseProtectionActive: false,
wasAtPhysicalBottom: true,
ownsElementAnchor: false,
+ isFollowingTail: false,
})).toBe(true);
- });
-
- it('does not treat streamed content growth as a viewport resize', () => {
expect(shouldSyncPhysicalBottom({
- viewportSizeChanged: false,
+ viewportGeometryChanged: true,
collapseProtectionActive: false,
wasAtPhysicalBottom: true,
ownsElementAnchor: false,
+ isFollowingTail: true,
})).toBe(false);
});
@@ -867,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,
@@ -1110,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 000250187..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;
@@ -687,10 +691,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
) {
@@ -706,11 +711,17 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX;
if (ownsElementAnchor) {
viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'viewport-resize');
@@ -1014,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({
@@ -1076,6 +1097,7 @@ const VirtualMessageListSession = forwardRef {
retainedCollapseSettleTimerRef.current = null;
@@ -1452,9 +1474,12 @@ 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 &&
@@ -1484,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);
@@ -3412,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 &&
@@ -4664,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
@@ -4802,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) {
@@ -4826,10 +4942,6 @@ const VirtualMessageListSession = forwardRef 0) {
@@ -4874,10 +4981,6 @@ const VirtualMessageListSession = forwardRef {
- const trackingState = latestTurnAutoFollowStateRef.current;
- if (
- !latestTurnId ||
- trackingState.turnId !== latestTurnId ||
- isFollowingOutput
- ) {
+ if (!latestTurnId || isFollowingOutput) {
return false;
}
@@ -4939,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({
@@ -4960,7 +5061,9 @@ const VirtualMessageListSession = forwardRef ({
reason,
reservation: reservationState,
+ pendingStickyPinGrowthPx,
hasPendingCollapseIntent,
+ viewport,
coordinatorMode: viewportCoordinatorRef.current.getMode(),
}),
});
@@ -4974,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;
@@ -4990,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 946bc6279..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
@@ -12,28 +12,6 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
type FollowOutputController = ReturnType;
-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 },
@@ -50,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;
@@ -68,6 +50,8 @@ function Harness({
performUserFollowScroll: vi.fn(),
performAutoFollowScroll,
performLatestTurnStickyPin,
+ canAnimateTailFollow,
+ getAutoFollowTargetScrollTop,
});
onController(controller);
@@ -99,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, {
@@ -219,17 +359,14 @@ describe('useFlowChatFollowOutput', () => {
expect(controller?.isFollowingOutput).toBe(false);
});
- it('resumes a mounted streaming session at the tail without replaying sticky pin', () => {
+ it('keeps a sticky pin armed while clearing logical follow ownership', () => {
const scroller = document.createElement('div');
setScrollerMetrics(scroller, {
scrollHeight: 1500,
clientHeight: 500,
- scrollTop: 0,
- });
- const performAutoFollowScroll = vi.fn(() => {
- scroller.scrollTop = 1000;
+ scrollTop: 1000,
});
- const performLatestTurnStickyPin = vi.fn();
+ const performAutoFollowScroll = vi.fn();
act(() => {
root.render(
@@ -239,40 +376,37 @@ describe('useFlowChatFollowOutput', () => {
controller = nextController;
}}
performAutoFollowScroll={performAutoFollowScroll}
- performLatestTurnStickyPin={performLatestTurnStickyPin}
/>,
);
});
- let resumed = false;
act(() => {
- resumed = controller?.resumeFollowOutputForMountedStream() ?? false;
+ controller?.enterFollowOutput('auto-follow');
+ controller?.preparePinnedTurnFollowHandoff();
});
- expect(resumed).toBe(true);
+ expect(controller?.isFollowingOutput).toBe(false);
+
+ let activated = false;
+ act(() => {
+ activated = controller?.activateArmedFollowOutput() ?? false;
+ });
+
+ expect(activated).toBe(true);
expect(controller?.isFollowingOutput).toBe(true);
- 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;
- }));
-
+ it('resumes a mounted streaming session at the tail without replaying sticky pin', () => {
const scroller = document.createElement('div');
setScrollerMetrics(scroller, {
scrollHeight: 1500,
clientHeight: 500,
- scrollTop: 1000,
+ scrollTop: 0,
});
const performAutoFollowScroll = vi.fn(() => {
- scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 1000;
});
+ const performLatestTurnStickyPin = vi.fn();
act(() => {
root.render(
@@ -282,29 +416,19 @@ describe('useFlowChatFollowOutput', () => {
controller = nextController;
}}
performAutoFollowScroll={performAutoFollowScroll}
+ performLatestTurnStickyPin={performLatestTurnStickyPin}
/>,
);
});
+ let resumed = false;
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);
+ resumed = controller?.resumeFollowOutputForMountedStream() ?? false;
});
+ expect(resumed).toBe(true);
+ expect(controller?.isFollowingOutput).toBe(true);
expect(performAutoFollowScroll).toHaveBeenCalledTimes(1);
- expect(scroller.scrollTop).toBeGreaterThan(1000);
- expect(scroller.scrollTop).toBeLessThan(1024);
+ expect(performLatestTurnStickyPin).not.toHaveBeenCalled();
});
});
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..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,21 +12,14 @@ 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_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;
-/**
- * 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,
@@ -81,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
@@ -94,6 +88,7 @@ interface UseFlowChatFollowOutputResult {
isFollowingOutput: boolean;
enterFollowOutput: (reason: FollowOutputEnterReason) => void;
exitFollowOutput: (reason: FollowOutputExitReason) => void;
+ preparePinnedTurnFollowHandoff: () => void;
armFollowOutputForNewTurn: () => void;
resumeFollowOutputForMountedStream: () => boolean;
activateArmedFollowOutput: () => boolean;
@@ -117,7 +112,8 @@ export function useFlowChatFollowOutput({
performAutoFollowScroll,
performLatestTurnStickyPin,
shouldSuspendAutoFollow,
- getAutoFollowDistanceFromBottom,
+ canAnimateTailFollow,
+ getAutoFollowTargetScrollTop,
onContinuousFollowFrame,
}: UseFlowChatFollowOutputOptions): UseFlowChatFollowOutputResult {
const [isFollowingOutput, setIsFollowingOutput] = useState(false);
@@ -130,17 +126,17 @@ export function useFlowChatFollowOutput({
const armedAutoFollowTurnIdRef = useRef(null);
const continuousFollowFrameRef = useRef(null);
const lastContinuousFollowFrameMsRef = useRef(null);
- const prefersReducedMotionRef = useRef(false);
+ 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) => {
@@ -152,6 +148,7 @@ export function useFlowChatFollowOutput({
}
if (!nextValue) {
lastContinuousFollowFrameMsRef.current = null;
+ nativeSmoothFollowUntilMsRef.current = 0;
}
}, []);
@@ -168,12 +165,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
@@ -196,46 +193,61 @@ export function useFlowChatFollowOutput({
return;
}
- onContinuousFollowFrameRef.current?.();
+ 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;
+ }
- // 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.
- 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;
- explicitUserScrollIntentUntilMsRef.current = 0;
+ if (nowMs < nativeSmoothFollowUntilMsRef.current) {
+ scheduleNextFrame();
+ return;
+ }
- if (prefersReducedMotionRef.current) {
- performAutoFollowScrollRef.current();
- } else {
- const step = computeContinuousFollowStep(measuredDistance, frameDeltaMs);
- scroller.scrollTop += step;
+ // While a known collapse animation / layout transition is in flight, the
+ // VirtualMessageList anchor-lock + bottom-reservation footer is preserving
+ // the upper visual anchor. The loop remains alive but must not write until
+ // that transaction releases ownership.
+ const isSuspended = shouldSuspendAutoFollowRef.current?.() === true;
+ 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;
}
- 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(() => {
@@ -265,6 +277,9 @@ export function useFlowChatFollowOutput({
const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => {
cancelPendingAutoFollowArm();
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
@@ -281,6 +296,7 @@ export function useFlowChatFollowOutput({
const exitFollowOutput = useCallback((_reason: FollowOutputExitReason) => {
cancelPendingAutoFollowArm();
explicitUserScrollIntentUntilMsRef.current = 0;
+ nativeSmoothFollowUntilMsRef.current = 0;
setFollowingOutput(false);
const scroller = scrollerRef.current;
if (scroller) {
@@ -288,21 +304,35 @@ export function useFlowChatFollowOutput({
}
}, [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;
+ explicitUserScrollIntentUntilMsRef.current = 0;
+ nativeSmoothFollowUntilMsRef.current = 0;
setFollowingOutput(false);
+ }, [latestTurnId, setFollowingOutput]);
+
+ const armFollowOutputForNewTurn = useCallback(() => {
+ if (!latestTurnId) {
+ cancelPendingAutoFollowArm();
+ return;
+ }
+
+ preparePinnedTurnFollowHandoff();
runProgrammaticScroll(performLatestTurnStickyPin);
}, [
cancelPendingAutoFollowArm,
latestTurnId,
performLatestTurnStickyPin,
+ preparePinnedTurnFollowHandoff,
runProgrammaticScroll,
- setFollowingOutput,
]);
const resumeFollowOutputForMountedStream = useCallback(() => {
@@ -329,6 +359,7 @@ export function useFlowChatFollowOutput({
}
cancelPendingAutoFollowArm();
+ nativeSmoothFollowUntilMsRef.current = 0;
setFollowingOutput(true);
runProgrammaticScroll(performAutoFollowScroll);
return true;
@@ -367,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.
@@ -381,16 +413,16 @@ export function useFlowChatFollowOutput({
if (
!isFollowingOutputRef.current ||
!isStreamingRef.current ||
- virtualItemCount === 0
+ 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.
+ // 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();
- }, [startContinuousFollowLoop, virtualItemCount]);
+ }, [shouldSuspendAutoFollow, startContinuousFollowLoop, virtualItemCount]);
const handleScroll = useCallback(() => {
const scroller = scrollerRef.current;
@@ -460,6 +492,7 @@ export function useFlowChatFollowOutput({
previousSessionIdRef.current = activeSessionId;
cancelPendingAutoFollowArm();
explicitUserScrollIntentUntilMsRef.current = 0;
+ nativeSmoothFollowUntilMsRef.current = 0;
const nextFollowState = Boolean(activeSessionId && virtualItemCount === 0);
if (nextFollowState) {
@@ -497,19 +530,6 @@ 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 () => {
stopContinuousFollowLoop();
@@ -520,6 +540,7 @@ export function useFlowChatFollowOutput({
isFollowingOutput,
enterFollowOutput,
exitFollowOutput,
+ preparePinnedTurnFollowHandoff,
armFollowOutputForNewTurn,
resumeFollowOutputForMountedStream,
activateArmedFollowOutput,