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 09b266bb16..f3e41be8a5 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 @@ -202,7 +202,11 @@ Sticky pin floors are not reduced from a transient target rect. Positive effective content growth first enters a short settlement ledger (currently 300 ms) instead of immediately removing physical bottom range. An unsignaled negative height correction cancels matching unsettled growth; a known collapse -does not. Stable growth then consumes the pin floor in one synchronous Footer +does not. Growth that reaches the complete remaining pin floor settles +immediately because the sticky viewport has reached its tail-follow handoff +boundary; if a collapse transaction is still active, that settlement resumes +as soon as the transaction finishes. Sub-threshold growth still waits for the +quiet window. Stable growth consumes the pin floor in one synchronous Footer update. Live pin reconciliation may increase a floor immediately, but cannot shrink it while Virtuoso item measurements are still moving. Stream end performs one final pin measurement when the target is available, transfers all remaining @@ -314,16 +318,19 @@ manual or otherwise unsignaled intents use the TTL timer. The scroll handler kee timer fallback for browsers that delay timers. While the intent is alive, the grow branch of `measureHeightChange` protects the collapse reservation, but it may still consume measured content growth from the sticky pin reservation. -Once the intent settles, residual collapse space enters retained-provisional -settlement even when the sticky pinned item still owns the viewport. This is -required for first-turn `scrollTop === 0`: provisional full-card estimates must -not accumulate merely to fill an otherwise short viewport. -If a collapsing header owns the viewport, the footer is instead reduced -atomically to the minimum range that can retain the current `scrollTop`, then -that settled range is promoted to a protected collapse floor before the -semantic anchor is restored. This prevents a clear-and-reacquire frame without -retaining the full provisional estimate; later content growth can still drain -the protected range. Any deferred follow is then replayed. +Intent settlement follows the current semantic viewport owner. A sticky pinned +turn always reconciles provisional collapse space back into a freshly measured +pin reservation, even when the active transaction established a non-zero +collapse floor. That floor protects the pin only while layout is moving; it +must not cause the full-card estimate to survive into the next collapse. If the +pinned target is temporarily unavailable, settlement retries without dropping +the current range. A following tail instead enters retained-provisional quiet +settlement, while a collapsing header that owns `preserving-element` reduces +the footer atomically to the minimum range needed by its captured `scrollTop`. +A detached protected viewport uses the same geometric settlement against its +current `scrollTop`, without retaining provisional pixels above that range. +These owner-specific transactions prevent both clear-and-reacquire frames and +cumulative provisional whitespace. Any deferred follow is then replayed. ## C. Follow-Output Mode (continuous tail) 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 70c5ae2830..86cbc99aa1 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 @@ -19,11 +19,12 @@ import { reconcileUnsignaledShrinkReservation, releasePinReservationForUserNavigation, resolveAutoCollapseAnchorScrollTop, + resolveCollapseIntentSettlementStrategy, resolveProvisionalStickyPinReservationPx, + resolveStickyPinGrowthSettlementStrategy, settleRetainedCollapseReservationForAnchor, - settleCollapseReservationForPreservedViewport, + settleCollapseReservationForViewport, shouldBypassShrinkCompensationInTailFollow, - shouldPreserveCollapseReservationAfterIntent, shouldClearExpiredProvisionalStickyPin, shouldSyncPhysicalBottom, shouldSuppressFollowingTailNegativeScrollBy, @@ -439,7 +440,7 @@ describe('VirtualMessageList session boundary', () => { it('transfers collapse space to a sticky pin in one reservation state', () => { const currentState = { - collapse: { kind: 'collapse' as const, px: 1_583, floorPx: 0 }, + collapse: { kind: 'collapse' as const, px: 1_583, floorPx: 181 }, pin: { kind: 'pin' as const, px: 0, @@ -450,8 +451,8 @@ describe('VirtualMessageList session boundary', () => { }; const nextPin = { ...currentState.pin, - px: 378, - floorPx: 378, + px: 559, + floorPx: 559, }; expect(transferCollapseReservationToPin(currentState, nextPin)).toEqual({ @@ -603,7 +604,7 @@ describe('VirtualMessageList session boundary', () => { }); it('protects a settled element range from later unsignaled shrink reconciliation', () => { - const settledState = settleCollapseReservationForPreservedViewport({ + const settledState = settleCollapseReservationForViewport({ collapse: { kind: 'collapse', px: 1_022, floorPx: 670 }, pin: { kind: 'pin', @@ -623,6 +624,23 @@ describe('VirtualMessageList session boundary', () => { floorPx: 702, }); + const settledPinnedRange = settleCollapseReservationForViewport({ + collapse: { kind: 'collapse', px: 152.7, floorPx: 67.7 }, + pin: { + kind: 'pin', + px: 674.6, + floorPx: 674.6, + mode: 'sticky-latest', + targetTurnId: 'turn-a', + }, + }, { + scrollTop: 5_122, + scrollHeight: 6_235, + clientHeight: 1_027, + }); + expect(settledPinnedRange.collapse.px).toBeCloseTo(67.7, 1); + expect(settledPinnedRange.collapse.floorPx).toBeCloseTo(67.7, 1); + const protectedState = protectCurrentCollapseReservation({ collapse: { kind: 'collapse', px: 784, floorPx: 670 }, pin: { @@ -858,41 +876,87 @@ describe('VirtualMessageList session boundary', () => { })).toBe(0); }); - it('lets known streaming collapses reconcile while preserving their reservation', () => { - expect(shouldBypassShrinkCompensationInTailFollow({ - isFollowingOutput: true, - isStreamingOutput: true, + it('settles sticky pin growth as soon as it exhausts the remaining pin floor', () => { + expect(resolveStickyPinGrowthSettlementStrategy({ + pendingGrowthPx: 0, + pinFloorPx: 11.77, hasActiveCollapseIntent: false, - })).toBe(true); - expect(shouldBypassShrinkCompensationInTailFollow({ - isFollowingOutput: true, - isStreamingOutput: true, + })).toBe('none'); + expect(resolveStickyPinGrowthSettlementStrategy({ + pendingGrowthPx: 10, + pinFloorPx: 11.77, + hasActiveCollapseIntent: false, + })).toBe('wait-for-quiet'); + expect(resolveStickyPinGrowthSettlementStrategy({ + pendingGrowthPx: 126, + pinFloorPx: 11.77, hasActiveCollapseIntent: true, - })).toBe(false); - expect(shouldPreserveCollapseReservationAfterIntent({ + })).toBe('wait-for-collapse'); + expect(resolveStickyPinGrowthSettlementStrategy({ + pendingGrowthPx: 126, + pinFloorPx: 11.77, + hasActiveCollapseIntent: false, + })).toBe('settle-now'); + }); + + it('selects an explicit settlement strategy for each collapse viewport owner', () => { + const stickyReservation = { + collapse: { kind: 'collapse' as const, px: 152.7, floorPx: 67.7 }, + pin: { + kind: 'pin' as const, + px: 674.6, + floorPx: 674.6, + mode: 'sticky-latest' as const, + targetTurnId: 'turn-a', + }, + }; + + expect(resolveCollapseIntentSettlementStrategy({ + coordinatorMode: 'pinned-item', + isFollowingOutput: false, + isStreamingOutput: true, + reservation: stickyReservation, + })).toBe('reconcile-sticky-pin'); + expect(resolveCollapseIntentSettlementStrategy({ + coordinatorMode: 'following-tail', isFollowingOutput: true, isStreamingOutput: true, - isPreservingElement: false, - hasProtectedCollapseRange: false, - })).toBe(true); - expect(shouldPreserveCollapseReservationAfterIntent({ + reservation: stickyReservation, + })).toBe('retain-following-tail'); + expect(resolveCollapseIntentSettlementStrategy({ + coordinatorMode: 'preserving-element', isFollowingOutput: false, isStreamingOutput: true, - isPreservingElement: false, - hasProtectedCollapseRange: false, - })).toBe(false); - expect(shouldPreserveCollapseReservationAfterIntent({ + reservation: stickyReservation, + })).toBe('settle-preserved-element'); + expect(resolveCollapseIntentSettlementStrategy({ + coordinatorMode: 'idle', isFollowingOutput: false, isStreamingOutput: true, - isPreservingElement: true, - hasProtectedCollapseRange: false, - })).toBe(true); - expect(shouldPreserveCollapseReservationAfterIntent({ + reservation: stickyReservation, + })).toBe('settle-protected-viewport'); + expect(resolveCollapseIntentSettlementStrategy({ + coordinatorMode: 'idle', isFollowingOutput: false, isStreamingOutput: true, - isPreservingElement: false, - hasProtectedCollapseRange: true, + reservation: { + ...stickyReservation, + collapse: { ...stickyReservation.collapse, floorPx: 0 }, + }, + })).toBe('drain'); + }); + + it('lets known streaming collapses reconcile without bypassing active intents', () => { + expect(shouldBypassShrinkCompensationInTailFollow({ + isFollowingOutput: true, + isStreamingOutput: true, + hasActiveCollapseIntent: false, })).toBe(true); + expect(shouldBypassShrinkCompensationInTailFollow({ + isFollowingOutput: true, + isStreamingOutput: true, + hasActiveCollapseIntent: true, + })).toBe(false); }); it('recovers the last stable scroll position when an auto collapse arrives after clamp', () => { 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 9248143bcd..9d86bd46f1 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -75,12 +75,13 @@ import { reconcileUnsignaledShrinkReservation, releasePinReservationForUserNavigation, resolveAutoCollapseAnchorScrollTop, + resolveCollapseIntentSettlementStrategy, resolveProvisionalStickyPinReservationPx, + resolveStickyPinGrowthSettlementStrategy, sanitizeBottomReservationState, - settleCollapseReservationForPreservedViewport, + settleCollapseReservationForViewport, settleRetainedCollapseReservationForAnchor, shouldBypassShrinkCompensationInTailFollow, - shouldPreserveCollapseReservationAfterIntent, shouldClearExpiredProvisionalStickyPin, shouldSuppressFollowingTailNegativeScrollBy, shouldSyncPhysicalBottom, @@ -1237,6 +1238,51 @@ const VirtualMessageListSession = forwardRef { + const pending = pendingStickyPinGrowthRef.current; + const strategy = resolveStickyPinGrowthSettlementStrategy({ + pendingGrowthPx: pending.amountPx, + pinFloorPx: bottomReservationStateRef.current.pin.floorPx, + hasActiveCollapseIntent: pendingCollapseIntentRef.current.active, + }); + + if (strategy === 'none') { + clearPendingStickyPinGrowth(`${reason}:nothing-pending`); + return; + } + if (strategy === 'settle-now') { + if (stickyPinGrowthSettleTimerRef.current !== null) { + window.clearTimeout(stickyPinGrowthSettleTimerRef.current); + stickyPinGrowthSettleTimerRef.current = null; + } + settlePendingStickyPinGrowthRef.current(`${reason}:threshold-reached`); + return; + } + if (strategy === 'wait-for-collapse') { + if (stickyPinGrowthSettleTimerRef.current !== null) { + window.clearTimeout(stickyPinGrowthSettleTimerRef.current); + stickyPinGrowthSettleTimerRef.current = null; + } + return; + } + if ( + options?.preserveExistingQuietTimer && + stickyPinGrowthSettleTimerRef.current !== null + ) { + return; + } + + if (stickyPinGrowthSettleTimerRef.current !== null) { + window.clearTimeout(stickyPinGrowthSettleTimerRef.current); + } + stickyPinGrowthSettleTimerRef.current = window.setTimeout(() => { + settlePendingStickyPinGrowthRef.current('settle-timeout'); + }, STICKY_PIN_GROWTH_SETTLE_MS); + }, [clearPendingStickyPinGrowth]); + const queuePendingStickyPinGrowth = useCallback((targetTurnId: string, amountPx: number) => { const sanitizedAmountPx = sanitizeReservationPx(amountPx); if (sanitizedAmountPx <= COMPENSATION_EPSILON_PX) { @@ -1254,13 +1300,8 @@ const VirtualMessageListSession = forwardRef { - settlePendingStickyPinGrowthRef.current('settle-timeout'); - }, STICKY_PIN_GROWTH_SETTLE_MS); - }, []); + reconcilePendingStickyPinGrowthSettlement('growth-queued'); + }, [reconcilePendingStickyPinGrowthSettlement]); useEffect(() => () => { if (stickyPinGrowthSettleTimerRef.current !== null) { @@ -1541,16 +1582,18 @@ const VirtualMessageListSession = forwardRef ({ reason, intent, - coordinatorMode: viewportCoordinatorRef.current.getMode(), - reservation: bottomReservationStateRef.current, + coordinatorMode, + settlementStrategy, + reservation: reservationBeforeSettlement, isFollowingOutput: isFollowingOutputRef.current, isStreamingOutput: isStreamingOutputRef.current, }), @@ -2712,15 +2765,8 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX, - }); const scroller = scrollerElementRef.current; - const nextState = preserveReservation - ? coordinatorMode === 'preserving-element' - ? scroller - ? settleCollapseReservationForPreservedViewport( - bottomReservationStateRef.current, - { - scrollTop: scroller.scrollTop, - scrollHeight: scroller.scrollHeight, - clientHeight: scroller.clientHeight, - }, - ) - : protectCurrentCollapseReservation(bottomReservationStateRef.current) - : bottomReservationStateRef.current - : drainCollapseReservationPreservingPinnedItem(reason); + const nextState = (() => { + switch (settlementStrategy) { + case 'reconcile-sticky-pin': + case 'drain': + return drainCollapseReservationPreservingPinnedItem(reason); + case 'settle-preserved-element': + case 'settle-protected-viewport': + return scroller + ? settleCollapseReservationForViewport( + bottomReservationStateRef.current, + { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }, + ) + : protectCurrentCollapseReservation(bottomReservationStateRef.current); + } + })(); if (nextState === null) { pendingCollapseIntentRef.current = intent; collapseIntentFinalizeTimerRef.current = window.setTimeout(() => { @@ -2769,6 +2813,20 @@ const VirtualMessageListSession = forwardRef ({ + reason, + settlementStrategy, + coordinatorMode, + reservationBefore: reservationBeforeSettlement, + reservationAfter: bottomReservationStateRef.current, + }), + }); + } if (coordinatorMode === 'preserving-element' && scroller) { viewportCoordinatorRef.current.restoreElementAnchor( scroller, @@ -2791,6 +2849,11 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { + return 'settle-protected-viewport'; + } + return 'drain'; } export function resolveAutoCollapseAnchorScrollTop(options: {