From 42df393a49a050413c1eed7f7119be76b60dabf7 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 7 Aug 2026 00:41:51 +0800 Subject: [PATCH 1/3] fix(flow-chat): add viewport reservation diagnostics for stuck tail whitespace Add gated flow_chat_diagnostics probes that make a growing bottom blank attributable to its reservation owner: - Periodic bottom-reservation ledger sampling (collapse/pin px+floor, intent state, owner kind, coordinator mode, follow/streaming flags, scroller geometry) while the logging setting is enabled - Collapse-intent finalize retry counting plus a trace when the drain target is not renderable - Auto-follow suspension duration probe (fires once after 2s) - Subagent/task card ResizeObserver height-change correlation probes - Anchor-restore bottom-range additions tracked per source in the viewport coordinator - footer dataset.reservationPx so DevTools shows the synthetic tail space at a glance - flowChatDiagnostics.subscribe() so sampling can start/stop when the setting toggles at runtime --- .../modern/FlowChatViewportCoordinator.ts | 22 +- ...rtualMessageList.session-boundary.test.tsx | 1 + .../components/modern/VirtualMessageList.tsx | 219 +++++++++++++++++- .../diagnostics/flowChatDiagnostics.test.ts | 26 +++ .../diagnostics/flowChatDiagnostics.ts | 24 +- 5 files changed, 283 insertions(+), 9 deletions(-) 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 32e4589cd8..2834614838 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts @@ -80,6 +80,10 @@ export class FlowChatViewportCoordinator { private pendingElementAnchorRestore: PendingElementAnchorRestore | null = null; private rangeHost: FlowChatViewportRangeHost | null = null; private nextElementAnchorLease = 0; + // Diagnostics: accumulated bottom-range additions per source since the last + // semantic ownership release, so flowchat.log can attribute reservation + // growth to the anchor-restore path that requested it. + private rangeAdditionBySource = new Map(); setRangeHost(host: FlowChatViewportRangeHost | null): void { this.rangeHost = host; @@ -322,8 +326,13 @@ export class FlowChatViewportCoordinator { this.rangeHost && (this.mode === 'pinned-item' || this.mode === 'preserving-element') ) { + const additionalPx = remainingCorrection + ELEMENT_ANCHOR_RANGE_GUARD_PX; + this.rangeAdditionBySource.set( + source, + (this.rangeAdditionBySource.get(source) ?? 0) + additionalPx, + ); const rangeExtended = this.rangeHost.ensureBottomRange({ - additionalPx: remainingCorrection + ELEMENT_ANCHOR_RANGE_GUARD_PX, + additionalPx, mode: this.mode, source, }); @@ -342,11 +351,13 @@ export class FlowChatViewportCoordinator { data: () => ({ mode: this.mode, source, + additionalPx, rangeExtended, remainingCorrection, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, + rangeAdditionBySource: Object.fromEntries(this.rangeAdditionBySource), }), }); } @@ -406,9 +417,16 @@ export class FlowChatViewportCoordinator { hypothesis: 'B', location: 'FlowChatViewportCoordinator.release', message: 'Viewport coordinator released semantic ownership', - data: () => ({ previousMode, previousPreservationPhase, hadElementAnchor, reason }), + data: () => ({ + previousMode, + previousPreservationPhase, + hadElementAnchor, + reason, + rangeAdditionBySource: Object.fromEntries(this.rangeAdditionBySource), + }), }); } + this.rangeAdditionBySource.clear(); } private validateElementAnchor(source: string): void { 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 8c292f2968..2907e15007 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 @@ -80,6 +80,7 @@ vi.mock('@/infrastructure/diagnostics/flowChatDiagnostics', () => ({ flowChatDiagnostics: { isEnabled: () => flowDiagnosticsMocks.enabled, trace: flowDiagnosticsMocks.trace, + subscribe: vi.fn(() => () => {}), }, })); 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 39a53c8838..cad0f8b554 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -291,6 +291,8 @@ interface PendingCollapseIntentState { distanceFromBottomBeforeCollapse: number; baseTotalCompensationPx: number; cumulativeShrinkPx: number; + /** Number of times finalization was deferred because the drain target was not renderable. */ + finalizeRetries: number; } interface RetainedCollapseAnchorState { @@ -335,6 +337,7 @@ function createInactiveCollapseIntentState(): PendingCollapseIntentState { distanceFromBottomBeforeCollapse: 0, baseTotalCompensationPx: 0, cumulativeShrinkPx: 0, + finalizeRetries: 0, }; } @@ -644,6 +647,13 @@ const VirtualMessageListSession = forwardRef(null); const retainedCollapseSettleGenerationRef = useRef(0); const retainedCollapseGeometryGenerationRef = useRef(0); + // Diagnostics: continuous auto-follow suspension tracking (hypothesis D). + const autoFollowSuspendStartMsRef = useRef(null); + const autoFollowSuspendLoggedRef = useRef(false); + // Diagnostics: subagent/task card height observation (hypothesis C). + const subagentHeightObserverRef = useRef(null); + const observedSubagentElementsRef = useRef>(new WeakSet()); + const subagentHeightCacheRef = useRef>(new Map()); const pendingStickyPinGrowthRef = useRef<{ targetTurnId: string | null; amountPx: number; @@ -808,6 +818,9 @@ const VirtualMessageListSession = forwardRef { @@ -3307,7 +3320,31 @@ const VirtualMessageListSession = forwardRef ({ + reason, + finalizeRetries: retriedIntent.finalizeRetries, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + pinMode: pinReservation.mode, + pinTargetTurnId: pinReservation.targetTurnId, + stickyPinTargetRendered, + reservation: bottomReservationStateRef.current, + }), + }); + } collapseIntentFinalizeTimerRef.current = window.setTimeout(() => { collapseIntentFinalizeTimerRef.current = null; finalizeCollapseIntentRef.current('collapse-intent-target-retry', { @@ -3380,6 +3417,7 @@ const VirtualMessageListSession = forwardRef clearCollapseIntentScheduling, [clearCollapseIntentScheduling]); + // Diagnostics (hypothesis C): periodic bottom-reservation ledger. Sampling + // only runs while flow_chat_diagnostics is enabled and stops as soon as the + // setting is turned off. It records which reservation owner holds the + // synthetic tail space, so a stuck/growing bottom blank can be attributed to + // the exact field (collapse vs pin) and owner without a debugger. + useEffect(() => { + let ledgerTimer: number | null = null; + const stopLedger = () => { + if (ledgerTimer !== null) { + window.clearInterval(ledgerTimer); + ledgerTimer = null; + } + }; + const startLedger = () => { + if (ledgerTimer !== null) { + return; + } + ledgerTimer = window.setInterval(() => { + if (!flowChatDiagnostics.isEnabled()) { + return; + } + const scroller = scrollerElementRef.current; + const footer = footerElementRef.current; + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.reservationLedger', + message: 'Bottom reservation ledger sample', + data: () => { + const state = bottomReservationStateRef.current; + const intent = pendingCollapseIntentRef.current; + return { + reservation: state, + ownerKind: collapseReservationOwnerRef.current?.kind ?? null, + ownerGeneration: collapseReservationOwnerRef.current?.generation ?? null, + intentActive: intent.active, + intentToolId: intent.toolId, + intentToolName: intent.toolName, + intentExpiresInMs: intent.active + ? Math.max(0, Math.round(intent.expiresAtMs - performance.now())) + : null, + intentFinalizeRetries: intent.finalizeRetries, + retainedCollapseAnchor: retainedCollapseAnchorRef.current, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + distanceFromBottom: scroller + ? Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop) + : null, + footerRenderedHeight: footer ? Math.round(footer.getBoundingClientRect().height) : null, + inputStackFooterPx: inputStackFooterPxRef.current, + }; + }, + }); + }, 500); + }; + if (flowChatDiagnostics.isEnabled()) { + startLedger(); + } + const unsubscribe = flowChatDiagnostics.subscribe(enabled => { + if (enabled) { + startLedger(); + } else { + stopLedger(); + } + }); + return () => { + unsubscribe(); + stopLedger(); + }; + }, []); + const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { restoreScrollerMethodsRef.current?.(); restoreScrollerMethodsRef.current = null; @@ -3956,11 +4068,46 @@ const VirtualMessageListSession = forwardRef { const collapseIntent = pendingCollapseIntentRef.current; - return ( + const suspended = ( !isViewportActiveRef.current || viewportGeometrySuspendedRef.current || collapseIntent.active ); + const now = performance.now(); + if (suspended) { + if (autoFollowSuspendStartMsRef.current === null) { + autoFollowSuspendStartMsRef.current = now; + autoFollowSuspendLoggedRef.current = false; + } else if ( + !autoFollowSuspendLoggedRef.current && + now - autoFollowSuspendStartMsRef.current > 2000 && + flowChatDiagnostics.isEnabled() + ) { + autoFollowSuspendLoggedRef.current = true; + const startedAtMs = autoFollowSuspendStartMsRef.current; + flowChatDiagnostics.trace({ + hypothesis: 'D', + location: 'VirtualMessageList.shouldSuspendAutoFollow', + message: 'Auto-follow suspension persisted beyond 2s', + data: () => ({ + suspendedForMs: Math.round(now - startedAtMs), + intentActive: pendingCollapseIntentRef.current.active, + intentToolId: pendingCollapseIntentRef.current.toolId, + intentToolName: pendingCollapseIntentRef.current.toolName, + intentFinalizeRetries: pendingCollapseIntentRef.current.finalizeRetries, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + reservation: bottomReservationStateRef.current, + retainedCollapseAnchor: retainedCollapseAnchorRef.current, + isViewportActive: isViewportActiveRef.current, + viewportGeometrySuspended: viewportGeometrySuspendedRef.current, + }), + }); + } + } else { + autoFollowSuspendStartMsRef.current = null; + autoFollowSuspendLoggedRef.current = false; + } + return suspended; }, []); const scheduleFollowToLatestWithViewportState = useCallback((reason: string) => { @@ -4071,6 +4218,68 @@ const VirtualMessageListSession = forwardRef { + if (element instanceof HTMLElement && element.dataset.toolCardId) { + return element.dataset.toolCardId; + } + const closestCard = element.closest('[data-tool-card-id]'); + return closestCard?.dataset.toolCardId ?? 'subagent-projection'; + }; + const syncSubagentHeightObservers = () => { + if (!flowChatDiagnostics.isEnabled()) { + return; + } + const nodes = Array.from(scrollerElement.querySelectorAll( + '[data-tool-card-id], .subagent-projection-wrapper', + )); + for (const node of nodes) { + if (observedSubagentElementsRef.current.has(node)) { + continue; + } + observedSubagentElementsRef.current.add(node); + const toolId = readSubagentElementToolId(node); + subagentHeightCache.set(toolId, Math.round(node.getBoundingClientRect().height)); + subagentHeightObserverRef.current?.observe(node); + } + }; + subagentHeightObserverRef.current?.disconnect(); + subagentHeightObserverRef.current = new ResizeObserver((entries) => { + if (!flowChatDiagnostics.isEnabled()) { + return; + } + for (const entry of entries) { + const target = entry.target; + const toolId = readSubagentElementToolId(target); + const heightBefore = subagentHeightCache.get(toolId) ?? null; + const heightAfter = Math.round(target.getBoundingClientRect().height); + subagentHeightCache.set(toolId, heightAfter); + if (heightBefore === null || Math.abs(heightAfter - heightBefore) <= 1) { + continue; + } + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.subagentHeightObserver', + message: 'Task/subagent card height changed', + data: () => ({ + toolId, + heightBefore, + heightAfter, + heightDelta: heightAfter - heightBefore, + reservation: bottomReservationStateRef.current, + intentActive: pendingCollapseIntentRef.current.active, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + scrollTop: scrollerElement.scrollTop, + scrollHeight: scrollerElement.scrollHeight, + clientHeight: scrollerElement.clientHeight, + }), + }); + } + }); + // Batch observer-triggered work into a single rAF per frame let observerBatchPending = false; const scheduleObserverBatch = () => { @@ -4078,6 +4287,7 @@ const VirtualMessageListSession = forwardRef { observerBatchPending = false; + syncSubagentHeightObservers(); scheduleHeightMeasure(2); scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); @@ -4664,6 +4874,7 @@ const VirtualMessageListSession = forwardRef { }); }); + it('notifies subscribers when the enabled state changes', () => { + const listener = vi.fn(); + const unsubscribe = flowChatDiagnostics.subscribe(listener); + + flowChatDiagnostics.setEnabled(true); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith(true); + + flowChatDiagnostics.setEnabled(false); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith(false); + + unsubscribe(); + flowChatDiagnostics.setEnabled(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('does not notify subscribers when the enabled state is unchanged', () => { + const listener = vi.fn(); + flowChatDiagnostics.subscribe(listener); + + flowChatDiagnostics.setEnabled(false); + flowChatDiagnostics.setEnabled(false); + expect(listener).not.toHaveBeenCalled(); + }); + it('flushes queued diagnostics when disabled', async () => { flowChatDiagnostics.setEnabled(true); flowChatDiagnostics.trace({ diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts index ff790c3b3b..f337b55b91 100644 --- a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts @@ -31,11 +31,24 @@ class FlowChatDiagnosticsRecorder { private flushInFlight: Promise | null = null; private flushRequested = false; private droppedEntrySummary: DroppedEntrySummary | null = null; + private listeners = new Set<(enabled: boolean) => void>(); isEnabled(): boolean { return this.enabled; } + /** + * Registers a listener invoked whenever the enabled state changes. Returns + * an unsubscribe function. Used by viewport diagnostics that must start or + * stop periodic sampling when the logging setting is toggled at runtime. + */ + subscribe(listener: (enabled: boolean) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + setEnabled(enabled: boolean): void { const supportedEnabled = enabled && isTauriRuntime(); if (this.enabled === supportedEnabled) { @@ -50,12 +63,12 @@ class FlowChatDiagnosticsRecorder { location: 'FlowChatDiagnosticsRecorder.setEnabled', message: 'Flow Chat diagnostics enabled', }); - return; + } else { + window.removeEventListener('pagehide', this.handlePageHide); + this.clearFlushTimer(); + void this.flush(); } - - window.removeEventListener('pagehide', this.handlePageHide); - this.clearFlushTimer(); - void this.flush(); + this.listeners.forEach(listener => listener(this.enabled)); } trace(probe: FlowChatDiagnosticProbe): void { @@ -113,6 +126,7 @@ class FlowChatDiagnosticsRecorder { this.droppedEntrySummary = null; this.clearFlushTimer(); this.flushInFlight = null; + this.listeners.clear(); if (typeof window !== 'undefined') { window.removeEventListener('pagehide', this.handlePageHide); } From b55977cafffd41116bcf19aff61a52a25466355a Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 7 Aug 2026 12:47:38 +0800 Subject: [PATCH 2/3] fix(flow-chat): wrap long tokens inside subagent cards Long unbroken tokens (file paths, model names, URLs) inside the subagent projection text and task card headers could stretch the min-content width of the card and change the layout height. Add overflow-wrap:anywhere and min-width:0 so wrapping happens inside the card instead of widening the layout. --- .../flow_chat/components/modern/SubagentItems.scss | 2 ++ .../components/subagent/SubagentProjectionView.scss | 11 +++++++++++ .../src/flow_chat/tool-cards/TaskToolDisplay.scss | 1 + 3 files changed, 14 insertions(+) diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index 7e2283514d..b63780bdc4 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -42,6 +42,8 @@ overflow-y: auto; overscroll-behavior: contain; contain: layout paint style; + // Long tokens wrap inside the card instead of widening the FlowChat item. + overflow-wrap: anywhere; .flow-text-block--subagent-compact { margin-bottom: 0.15rem; diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss index 7b52cb31ac..058ca92eca 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss @@ -9,6 +9,10 @@ overflow-y: auto; overscroll-behavior: contain; box-sizing: border-box; + /* Long unbreakable tokens (file paths, model ids, URLs) must wrap inside + the card instead of inflating the min-content width of the virtual item, + which shifts the surrounding FlowChat layout. */ + overflow-wrap: anywhere; } .subagent-projection-container--expanded { @@ -28,6 +32,13 @@ .subagent-projection-content { min-width: 0; + overflow-wrap: anywhere; + + .markdown-renderer, + .text-content, + .flow-text-block { + overflow-wrap: anywhere; + } .flow-thinking-item .thinking-content { padding-left: 0; diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index ade4241b94..aeb593b4eb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -192,6 +192,7 @@ align-items: center; gap: var(--bf-appearance-token-flowchat-inline-gap); width: 100%; + min-width: 0; min-height: 20px; } From 0a18ac18b6630f5cb9f0b07de3602d68a522658d Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 7 Aug 2026 12:47:38 +0800 Subject: [PATCH 3/3] fix(flow-chat): drain stuck bottom reservations at the physical bottom Subagent card collapses pre-compensate the footer with provisional tail space. At the physical bottom every settle variant recomputes exactly the current reservation as its geometric minimum (the synthetic footer holds scrollTop high), so collapse compensation could only drain through content growth or repeated up/down scrub cycles - the reported growing bottom whitespace that occasionally cleared on scroll. Introduce convergeBottomReservationsToContentBottom: when the scroller sits at the physical bottom with no preserving/pinned viewport owner and no active collapse intent, clear collapse + pin reservations in one synchronous footer update and fall scrollTop to the new content bottom. Wire it into retained quiet settlement, collapse-intent finalization (non-pinned strategies), the idle reservation audit, the scroll handler (any scroll event at the physical bottom is bottom intent), the collapse-intent accumulation guard (a new provisional estimate no longer stacks on a settled bottom reservation), and the input-stack-shrink drain. The idle audit only releases a preserved element anchor for bottom users; mid-list readers keep their reading anchor. Also bound collapse-intent finalize retries (20 max) so a virtualized sticky pin target cannot keep the intent (and auto-follow suspension) alive forever, and cap intent-window growth consumption at the measured protection line. Tests cover growth protection, convergence positive/negative cases, force settle at mid-list, and the stale-pin audit path. --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 97 ++ ...rtualMessageList.session-boundary.test.tsx | 917 +++++++++++++++++- .../components/modern/VirtualMessageList.tsx | 414 +++++++- 3 files changed, 1389 insertions(+), 39 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 4aefe19c57..9b3afd06d1 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 @@ -114,6 +114,15 @@ measurement convergence has no such owner. An idle measurement with no owner must only rebase the measured height and must never create Footer space from a negative `scrollHeight` delta alone. +Owners are bounded by an idle reservation audit: while no streaming, collapse +intent, retained anchor, or viewport owner is active and the user has been idle +past `RETAINED_COLLAPSE_RELEASE_QUIET_MS`, `VirtualMessageList` settles the +collapse reservation to its geometric minimum for the current scroll position +and drops sticky pins whose target is no longer the latest turn. This is a +safety net, not a release mechanism: a legitimate owner that still needs its +range keeps it, but a stuck owner can no longer accumulate synthetic tail +whitespace across audit intervals. + Reservation state is ref-owned first and mirrored into React state. A Virtuoso Footer remount must synchronously read the ref-owned value; otherwise one stale React commit can remove exactly the reserved scroll range for a frame. @@ -328,6 +337,29 @@ shrinks. `VirtualMessageList` uses that event to: This pre-compensation is what avoids the flash. +Finalization is bounded. When the settlement strategy is `reconcile-sticky-pin` +or `drain` and the drain target (the sticky pin user message) is not +renderable, `drainCollapseReservationPreservingPinnedItem` returns `null` and +finalization retries every 50 ms. Retries are capped at +`COLLAPSE_INTENT_FINALIZE_MAX_RETRIES`; past the cap the intent force-settles +to the geometric minimum instead of staying active. An intent that stays +active indefinitely would suspend auto-follow via `shouldSuspendAutoFollow` +and let every later collapse coalesce onto the same stuck provisional +reservation, which shows up as bottom whitespace that grows with each +subagent card event. + +While an intent is alive, `measureHeightChange` growth is consumed only down +to the measured protection level (`baseTotalCompensationPx` plus the measured +shrink so far minus the pin reservation), never below the collapse floor. The +part of the provisional estimate above the measured shrink is inflation and +must drain on growth; keeping it intact sediments subagent wrapping/growth +into footer whitespace whenever finalization is delayed. + +After a `preserving-element` settlement, the coordinator anchor is released +immediately when the collapse reservation reaches zero; a retained anchor +with no synthetic range to preserve must not linger until the next user +scroll. + Runtime status is transient session UI state, not a `FlowItem`. The always-mounted `RuntimeStatusSlot` occupies the first 24px of the existing Footer spacer and switches only `visibility`; showing, hiding, and clearing it never change list @@ -359,6 +391,41 @@ This is deliberately separate from the VirtualMessageList collapse-intent TTL and settlement timers: the former controls when a card may compact, while the latter protects the viewport while its height changes. +### Bottom convergence (dead-lock escape for stuck tail whitespace) + +Every settle variant above computes the geometric minimum for the *current* +`scrollTop` via `getRequiredTotalPxForScrollTop`. At the physical bottom this +recomputes exactly the current reservation: the synthetic footer holds +`scrollTop` high, and the required range equals that same synthetic space. The +result is a dead-lock where collapse compensation can only drain through +measured content growth or deliberate downward scrolling. This is why the old +behavior needed repeated up/down scrub cycles to shrink the whitespace, and +why the idle audit could not help either (it settled to the same value). + +`VirtualMessageList.convergeBottomReservationsToContentBottom` breaks the +dead-lock with a bottom-convergence pass: + +- Detection: `|(scrollHeight - clientHeight) - scrollTop| <= + BOTTOM_CONVERGE_EPSILON_PX` (2px) while the coordinator does not own + `preserving-element` / `pinned-item` and no collapse intent is active. + A mid-list reader never matches, so anchor-preserving settles stay intact. +- Action: collapse and pin reservations are cleared in one synchronous Footer + update, then `scrollTop` falls to the new content bottom. The browser would + clamp here anyway; writing it explicitly keeps the scroll/height baselines + consistent. Pending turn-pin / sticky-pin-growth request state is left + alone; those requests expire or settle against the empty reservation on + their own paths. +- Callers: retained quiet settlement (`settleRetainedCollapseRange`), + collapse-intent finalization for the non-pinned strategies, + the idle reservation audit, `handleScroll` (any scroll event at the + physical bottom is bottom intent — one pass replaces per-scrub + consumption), the collapse-intent accumulation guard (a new provisional + estimate must not stack on a settled bottom reservation), and the + input-stack-shrink drain. + +A bottom user converges once and sees the content bottom; a mid-list reader +keeps the geometric minimum for the captured `scrollTop`. + ## Runtime Flow ## A. Known Tool Card Collapse @@ -707,6 +774,11 @@ If a future collapsible component shows the same "header drops" or "flash on col - Pre-collapse intent must capture the anchor before the component shrinks. - Compensation must not be consumed too early during active layout transitions. - Session changes and empty-list resets must clear compensation and anchor state. +- A user at the physical bottom must never retain synthetic tail space: every + settlement path (retained quiet settle, intent finalize, idle audit, scroll + handler, accumulation guards) must converge reservations to the content + bottom instead of settling to the geometric minimum for a `scrollTop` that + the reservation itself holds high. ## Common Ways To Break This @@ -758,6 +830,31 @@ The diagnostic schema groups events by hypothesis: - `D`: Virtuoso scroll compensation and tail-follow ownership - `E`: streaming tool-card collapse intent and anchor preservation +Additional probes help attribute a stuck or growing bottom blank to its owner: + +- `VirtualMessageList.reservationLedger` (hypothesis `C`, sampled every 500 ms + while enabled): full `collapse`/`pin` reservation state, owner kind, intent + activity and remaining TTL, finalize retry count, retained anchor, + coordinator mode, follow/streaming flags, scroller geometry, distance from + bottom, and the rendered footer height. +- `VirtualMessageList.finalizeCollapseIntent` (hypothesis `E`): every deferred + finalization with the retry count and whether the sticky pin target is + rendered; the settlement-completed event closes the intent lifecycle. +- `VirtualMessageList.shouldSuspendAutoFollow` (hypothesis `D`): fires once + when auto-follow stays suspended longer than 2 s. +- `VirtualMessageList.subagentHeightObserver` (hypothesis `C`): task/subagent + card height changes correlated with the reservation and intent state. +- `FlowChatViewportCoordinator.restoreElementAnchor` (hypothesis `C`): anchor + bottom-range additions per source since the last semantic release. +- `VirtualMessageList.convergeBottomReservationsToContentBottom` + (hypothesis `C`): every bottom-convergence pass with the reason, coordinator + mode, reservation before, and the post-convergence scroll geometry. A + healthy session should converge after each card collapse settles; repeated + convergences without an intervening reservation source indicate a producer + that keeps re-adding synthetic space. +- The Footer DOM node exposes `data-reservation-px` (the current synthetic + tail space) for at-a-glance DevTools inspection without log parsing. + Do not add message content, tool arguments, file contents, or other sensitive payloads to this channel. Keep all data producers lazy and guard hot-path probes with `flowChatDiagnostics.isEnabled()` before allocating probe objects. 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 2907e15007..c4377a6cd6 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 @@ -2029,10 +2029,16 @@ describe('VirtualMessageList session boundary', () => { for (let frame = 0; frame < 4; frame += 1) { flushAnimationFrame(); } - const wasSettledAnchorReleased = () => flowDiagnosticsMocks.trace.mock.calls.some(([event]) => ( - event.location === 'VirtualMessageList.releaseSettledCollapseAnchor' + // The scrollTop now sits exactly at the (new) physical bottom, so the + // retained settlement converges the synthetic range to the content + // bottom instead of keeping it until the quiet-release timer. This is + // the dead-lock escape: at the physical bottom the geometric minimum + // equals the current reservation, so keeping the range would leave + // permanent tail whitespace. + const wasBottomConverged = () => flowDiagnosticsMocks.trace.mock.calls.some(([event]) => ( + event.location === 'VirtualMessageList.convergeBottomReservationsToContentBottom' )); - for (let attempt = 0; attempt < 3 && !wasSettledAnchorReleased(); attempt += 1) { + for (let attempt = 0; attempt < 3 && !wasBottomConverged(); attempt += 1) { act(() => { vi.runOnlyPendingTimers(); }); @@ -2040,7 +2046,11 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); } } - expect(wasSettledAnchorReleased()).toBe(true); + expect(wasBottomConverged()).toBe(true); + // The synthetic range is gone: the footer returns to the same geometric + // minimum as the earlier settle (no extra whitespace for the bottom user). + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(settledFooterHeight, 1); + expect(scroller.scrollTop).toBeLessThan(1_100); } finally { vi.useRealTimers(); } @@ -3881,4 +3891,903 @@ describe('VirtualMessageList session boundary', () => { expect(flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint).not.toHaveBeenCalled(); expect(container.querySelector('[data-history-boundary-status="not-ready"]')?.textContent).toBe('Older history is not ready yet.'); }); + + it('bounds collapse-intent finalize retries when the sticky pin target is not renderable', () => { + flowDiagnosticsMocks.enabled = true; + const session = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + dialogTurns: [ + { + id: 'turn-a', + sessionId: 'session-a', + userMessage: { id: 'user-turn-a', content: 'turn-a', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }, + { + id: 'turn-b', + sessionId: 'session-a', + userMessage: { id: 'user-turn-b', content: 'turn-b', timestamp: 2 }, + modelRounds: [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 2, + } as typeof session.dialogTurns[number]['modelRounds'][number]], + status: 'processing', + startTime: 2, + }, + ], + }); + stateMocks.activeSession = session; + stateMocks.virtualItems = [ + createItem('turn-a'), + createModelItem('turn-a'), + createItem('turn-b'), + createModelItem('turn-b'), + ]; + const listRef = React.createRef(); + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(scroller).not.toBeNull(); + if (!scroller) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + + const pinnedUserMessage = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + ); + expect(pinnedUserMessage).not.toBeNull(); + if (!pinnedUserMessage) { + return; + } + vi.spyOn(pinnedUserMessage, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 57, + bottom: 87, + height: 30, + })); + + // Establish a sticky-latest pin on the latest turn while its target renders, + // and dispatch the subagent card collapse in the same act batch: the active + // intent blocks the pinned-item -> tail handoff until finalization, which + // is the state in which a real subagent card collapse lands mid-stream. + // Fake timers must be active before the dispatch so the collapse TTL and + // the 50ms finalize retry loop run under test control. + vi.useFakeTimers(); + try { + let pinStatus: ReturnType = 'rejected'; + act(() => { + pinStatus = listRef.current?.pinTurnToTopWithStatus('turn-b', { + pinMode: 'sticky-latest', + behavior: 'auto', + }) ?? 'rejected'; + const modelRoundAnchor = container.querySelector( + '[data-item-type="model-round"][data-turn-id="turn-b"]', + ); + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: modelRoundAnchor, + reason: 'auto', + }, + })); + }); + expect(pinStatus).toBe('settled'); + + // The reservation re-render inside the act recreated the wrappers. Evict + // the pinned target now; no further React re-render will restore it while + // the finalize retry loop runs (it only writes refs). + act(() => { + container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + )?.remove(); + }); + + // Advance past the collapse TTL and let the 50ms finalize retry loop run. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + act(() => { + vi.advanceTimersByTime(2_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + const deferredTraces = flowDiagnosticsMocks.trace.mock.calls.filter(([event]) => ( + event.location === 'VirtualMessageList.finalizeCollapseIntent' && + event.message === 'Collapse intent finalization deferred (drain target not renderable)' + )); + // The dangerous path was exercised: the drain target was not renderable. + expect(deferredTraces.length).toBeGreaterThan(0); + + // Bounded retries: the intent must finalize instead of staying active + // forever (which would suspend auto-follow and strand the reservation). + const completedTraces = flowDiagnosticsMocks.trace.mock.calls.filter(([event]) => ( + event.location === 'VirtualMessageList.finalizeCollapseIntent' && + event.message === 'Collapse intent reservation settlement completed' + )); + expect(completedTraces.length).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('drains an idle input-stack-shrink reservation that no owner protects', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + 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; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 0, + }); + + // Collapse the input stack: the shrink is preserved as a bottom + // reservation (owner: input-stack-shrink) because the user is not at + // the physical bottom - exactly the leftover that would otherwise + // persist as permanent tail whitespace. + const footerHeightBeforeShrink = Number.parseFloat(footer.style.height); + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(footerHeightBeforeShrink, 1); + + // With the user idle at the top, no streaming, no collapse intent and + // no retained anchor, the audit settles the reservation to its + // geometric minimum (zero here) instead of leaving it behind. + act(() => { + vi.advanceTimersByTime(1_500); + }); + flushAnimationFrame(); + + expect(Number.parseFloat(footer.style.height)).toBeLessThan(footerHeightBeforeShrink - 1); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.idleReservationAudit', + message: 'Idle reservation audit settled synthetic tail space', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('converges a collapse reservation to the content bottom when finalizing at the physical bottom', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + 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; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, // physical bottom + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // A subagent card collapses while the user sits at the physical bottom: + // provisional compensation equals the card height. + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: null, + reason: 'auto', + }, + })); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + + // Finalize after the collapse TTL. At the physical bottom the geometric + // minimum for the current scrollTop equals the current reservation, so + // the settle would keep the whitespace; bottom convergence clears it. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + expect(scroller.scrollTop).toBe(4_000); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('converges bottom reservations when a scroll event arrives at the physical bottom', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + activeSessionStateMocks.isProcessing = true; // streaming: no rAF drain + + 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; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, // physical bottom + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // Input stack shrinks while streaming: the shrink is preserved as a + // reservation (no rAF drain while streaming) and scrollTop is capped by + // the synthetic footer - the exact dead-lock state the user reported. + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // Any scroll event while at the physical bottom is bottom intent: + // converge in one pass instead of per-scrub consumption. + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + + expect(Number.parseFloat(footer.style.height)).toBeLessThan(baseFooterHeight - 1); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('does not stack a second collapse provisional estimate on a settled bottom reservation', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + 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; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + const dispatchCollapseIntent = (cardHeight: number) => { + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight, + anchorElement: null, + reason: 'auto', + }, + })); + }); + }; + + // First collapse: provisional 500, then finalize converges it away. + dispatchCollapseIntent(500); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // Second collapse: the guard converges the stale reservation before + // estimating, so the new provisional is just 300, not 300 + 500. + dispatchCollapseIntent(300); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 300, 1); + + // Let the second intent finalize so no timers outlive the test. + act(() => { + vi.advanceTimersByTime(1_000); + }); + } finally { + vi.useRealTimers(); + } + }); + + it('converges a bottom-stuck reservation during the idle audit', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + 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; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 0, // reading the top: the shrink reservation stays + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // Input stack shrinks with the user away from the bottom: the rAF drain + // does not fire (not at the physical bottom), so the reservation stays. + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // User moves to the physical bottom; the idle audit must converge the + // reservation instead of settling to the (equal) geometric minimum. + scroller.scrollTop = 4_000; + act(() => { + vi.advanceTimersByTime(1_500); + }); + flushAnimationFrame(); + + expect(Number.parseFloat(footer.style.height)).toBeLessThan(baseFooterHeight - 1); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('consumes only the provisional inflation while a collapse intent is alive', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + 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 naturalContentHeight = 5_000; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 1_000 }, + scrollHeight: { + configurable: true, + get: () => naturalContentHeight + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { configurable: true, writable: true, value: 0 }, + }); + // Sit at the physical bottom before the collapse. + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; + const baseFooterHeight = Number.parseFloat(footer.style.height); + + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: null, + reason: 'auto', + }, + })); + }); + // Provisional compensation equals the full card height at the bottom. + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + expect(footer.dataset.reservationPx).toBe('500'); + + // Establish the measurement baseline. The intent render re-mounts the + // scroller ref in this test harness, which resets the measured-height + // baseline; a real Virtuoso does not do that. Note: under fake timers + // requestAnimationFrame is also faked, so measurements advance with + // advanceTimersByTime instead of the rAF stub queue. + const triggerMeasure = () => { + act(() => { + window.dispatchEvent(new Event('tool-card-toggle')); + }); + act(() => { + vi.advanceTimersByTime(50); + }); + }; + triggerMeasure(); + + // Measured shrink enters cumulativeShrinkPx while the intent is alive: + // the protection line rises to 200 and the reservation stays at 500. + naturalContentHeight -= 200; + triggerMeasure(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + + // Content growth of 100 may only consume the inflation above the 200px + // measured protection (collapse 500 -> 400), never the protection line. + naturalContentHeight += 100; + triggerMeasure(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 400, 1); + expect(footer.dataset.reservationPx).toBe('400'); + + // Finalize the intent so no timers outlive the test. + act(() => { + vi.advanceTimersByTime(1_000); + }); + } finally { + vi.useRealTimers(); + } + }); + + it('does not converge reservations for a mid-list reader', () => { + flowDiagnosticsMocks.enabled = true; + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + 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; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 3_000, // mid-list: far from the physical bottom + }); + // Sync the scroll baseline (a real browser would have produced scroll + // events while reaching this position); without this the next scroll + // event sees a large synthetic delta and consumes the reservation. + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // Input stack shrinks while the user reads mid-list: the rAF drain does + // not fire and the reservation stays as protected tail space. + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // A scroll event at a mid-list position must not converge the reservation + // (the reader's reading position still needs the anchor range). + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + expect(flowDiagnosticsMocks.trace).not.toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + }); + + it('does not converge reservations while a pinned turn owns the viewport', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + const session = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + dialogTurns: [ + { + id: 'turn-a', + sessionId: 'session-a', + userMessage: { id: 'user-turn-a', content: 'turn-a', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }, + { + id: 'turn-b', + sessionId: 'session-a', + userMessage: { id: 'user-turn-b', content: 'turn-b', timestamp: 2 }, + modelRounds: [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 2, + } as typeof session.dialogTurns[number]['modelRounds'][number]], + status: 'processing', + startTime: 2, + }, + ], + }); + stateMocks.activeSession = session; + stateMocks.virtualItems = [ + createItem('turn-a'), + createModelItem('turn-a'), + createItem('turn-b'), + createModelItem('turn-b'), + ]; + const listRef = React.createRef(); + + 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; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + const pinnedUserMessage = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + ); + expect(pinnedUserMessage).not.toBeNull(); + if (!pinnedUserMessage) { + return; + } + vi.spyOn(pinnedUserMessage, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 57, + bottom: 87, + height: 30, + })); + + act(() => { + listRef.current?.pinTurnToTopWithStatus('turn-b', { + pinMode: 'sticky-latest', + behavior: 'auto', + }); + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // A collapse lands while the pinned turn owns the viewport; the + // provisional range is real reservation, not convergible space. + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: null, + reason: 'auto', + }, + })); + }); + const footerAfterIntent = Number.parseFloat(footer.style.height); + expect(footerAfterIntent).toBeGreaterThan(baseFooterHeight); + + // Scroll at the physical bottom: convergence must be rejected while the + // coordinator owns the pinned item, leaving the reservation intact. + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(footerAfterIntent, 1); + expect(flowDiagnosticsMocks.trace).not.toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + + // Finalize the intent (reconcile into the pin reservation) so no timers + // outlive the test. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + } finally { + vi.useRealTimers(); + } + }); + + it('does not converge reservations while an element anchor preserves the viewport', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const anchor = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-a"]', + ); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(anchor).not.toBeNull(); + if (!scroller || !footer || !anchor) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_200, // near the bottom but not at it: a reading position + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: anchor, + reason: 'auto', + }, + })); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + + // Finalize: the coordinator stays in preserving-element (retained) with + // a non-zero reservation because the reader is not at the physical + // bottom, so the settle keeps the geometric minimum. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + const footerAfterSettle = Number.parseFloat(footer.style.height); + expect(footerAfterSettle).toBeGreaterThan(baseFooterHeight); + + // The idle audit must not converge the reservation while the element + // anchor still preserves the reading position. + act(() => { + vi.advanceTimersByTime(1_500); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(footerAfterSettle, 1); + expect(flowDiagnosticsMocks.trace).not.toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('force-settles a non-bottom collapse to the geometric minimum after bounded retries', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + const session = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + dialogTurns: [ + { + id: 'turn-a', + sessionId: 'session-a', + userMessage: { id: 'user-turn-a', content: 'turn-a', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }, + { + id: 'turn-b', + sessionId: 'session-a', + userMessage: { id: 'user-turn-b', content: 'turn-b', timestamp: 2 }, + modelRounds: [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 2, + } as typeof session.dialogTurns[number]['modelRounds'][number]], + status: 'processing', + startTime: 2, + }, + ], + }); + stateMocks.activeSession = session; + stateMocks.virtualItems = [ + createItem('turn-a'), + createModelItem('turn-a'), + createItem('turn-b'), + createModelItem('turn-b'), + ]; + const listRef = React.createRef(); + + 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; + } + // Mid-list: 200px above the physical bottom, so the geometric minimum + // after settle is 100px, not zero. + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 3_800, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + const pinnedUserMessage = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + ); + expect(pinnedUserMessage).not.toBeNull(); + if (!pinnedUserMessage) { + return; + } + vi.spyOn(pinnedUserMessage, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 57, + bottom: 87, + height: 30, + })); + + act(() => { + listRef.current?.pinTurnToTopWithStatus('turn-b', { + pinMode: 'sticky-latest', + behavior: 'auto', + }); + const modelRoundAnchor = container.querySelector( + '[data-item-type="model-round"][data-turn-id="turn-b"]', + ); + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: modelRoundAnchor, + reason: 'auto', + }, + })); + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + // Provisional: 500 - 200 (distance from bottom) = 300. + expect(baseFooterHeight).toBeGreaterThan(0); + + // Evict the pinned target so every finalize retry defers. + act(() => { + container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + )?.remove(); + }); + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + act(() => { + vi.advanceTimersByTime(2_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + // Bounded retries exhausted: the intent force-settles to the geometric + // minimum for the current mid-list scrollTop (300 -> 100), instead of + // staying active or keeping the full provisional. + const completedTraces = flowDiagnosticsMocks.trace.mock.calls.filter(([event]) => ( + event.location === 'VirtualMessageList.finalizeCollapseIntent' && + event.message === 'Collapse intent reservation settlement completed' + )); + expect(completedTraces.length).toBe(1); + const settledFooterHeight = Number.parseFloat(footer.style.height); + expect(settledFooterHeight).toBeLessThan(baseFooterHeight); + expect(settledFooterHeight).toBeGreaterThan(0); + // data-reservation-px mirrors the settled geometric minimum (100px + // scroll range plus the 1px anchor guard). + expect(footer.dataset.reservationPx).toBe('101'); + } finally { + vi.useRealTimers(); + } + }); }); 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 cad0f8b554..171a80fb3b 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -129,6 +129,19 @@ const STICKY_PIN_GROWTH_SETTLE_MS = 300; const RETAINED_COLLAPSE_QUIET_SETTLE_MS = 120; const RETAINED_COLLAPSE_QUIET_SETTLE_FRAMES = 2; const RETAINED_COLLAPSE_RELEASE_QUIET_MS = COLLAPSE_INTENT_TTL_MS; +// Upper bound for finalize retries when the collapse drain target (a sticky +// pin user message) is not renderable. Without this cap the intent stays +// active forever, suspending auto-follow and stranding its provisional +// reservation, so every later collapse coalesces onto the same stuck intent. +const COLLAPSE_INTENT_FINALIZE_MAX_RETRIES = 20; +// Idle audit cadence for the bottom-reservation safety net. Kept above the +// collapse TTL so a legitimately protected transition finishes first. +const IDLE_RESERVATION_AUDIT_INTERVAL_MS = 1500; +// Tolerance for "user is at the physical bottom" detection used by the bottom +// convergence paths. Slightly looser than the scroll baseline epsilon so a +// footer-sized scrollHeight rounding or a pending browser clamp still counts +// as "at the bottom" and gets converged instead of staying stuck. +const BOTTOM_CONVERGE_EPSILON_PX = 2; const HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS = 320; const IDLE_HISTORY_WINDOW_BOUNDARY_STATE: Record< SessionHistoryWindowDirection, @@ -1274,6 +1287,112 @@ const VirtualMessageListSession = forwardRef { + if (!scroller || !canProcessViewportGeometry(scroller)) { + return false; + } + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + return Math.abs(maxScrollTop - scroller.scrollTop) <= BOTTOM_CONVERGE_EPSILON_PX; + }, [canProcessViewportGeometry]); + + const convergeBottomReservationsToContentBottom = useCallback((reason: string): boolean => { + const scroller = scrollerElementRef.current; + if (!scroller || !isAtPhysicalBottom(scroller)) { + return false; + } + const coordinatorMode = viewportCoordinatorRef.current.getMode(); + if ( + coordinatorMode === 'preserving-element' || + coordinatorMode === 'pinned-item' + ) { + // A semantic anchor or pinned turn owns the viewport; clearing synthetic + // tail space would move content under the user's reading position. + return false; + } + if (pendingCollapseIntentRef.current.active) { + return false; + } + + const currentState = bottomReservationStateRef.current; + const hasCollapse = ( + currentState.collapse.px > COMPENSATION_EPSILON_PX || + currentState.collapse.floorPx > COMPENSATION_EPSILON_PX + ); + const hasPin = ( + currentState.pin.px > COMPENSATION_EPSILON_PX || + currentState.pin.floorPx > COMPENSATION_EPSILON_PX + ); + if (!hasCollapse && !hasPin) { + // Nothing to converge, but a stuck retained anchor / owner may still be + // blocking the idle audit — release them so later paths can run. + if (retainedCollapseAnchorRef.current !== null) { + retainedCollapseAnchorRef.current = null; + clearRetainedCollapseSettlement(); + } + clearCollapseReservationOwner(); + return false; + } + + // Clear both reservations in one synchronous footer update. Pending turn + // pin / sticky-pin-growth request state is left alone: those requests have + // their own expiry paths and settle cleanly against an empty reservation. + const nextState = createInitialBottomReservationState(); + pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); + retainedCollapseAnchorRef.current = null; + clearRetainedCollapseSettlement(); + clearCollapseReservationOwner(); + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + + // Fall to the new content bottom. The browser would clamp here anyway; + // writing it explicitly keeps the scroll/height baselines consistent. + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + scroller.scrollTop = maxScrollTop; + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextState); + recordScrollerGeometry(scroller); + + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + data: () => ({ + reason, + coordinatorMode, + reservationBefore: currentState, + scrollTopAfter: scroller.scrollTop, + scrollHeightAfter: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }), + }); + } + return true; + }, [ + applyFooterCompensationNow, + clearCollapseReservationOwner, + clearRetainedCollapseSettlement, + isAtPhysicalBottom, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + updateBottomReservationState, + ]); + const preserveCollapseAnchorScrollTop = useCallback(( scroller: HTMLElement, targetScrollTop: number, @@ -1349,6 +1468,15 @@ const VirtualMessageListSession = forwardRef { + if (!collapseProtectionActive) { + return consumeBottomCompensation(heightDelta, { + consumeStickyPinFloor: false, + }); + } + // While a collapse intent is alive, growth must not remove the range + // the ongoing animation still needs, but the provisional estimate can + // exceed the measured shrink so far. Only that inflation is safe to + // consume; otherwise growth during the intent window sediments into + // the footer and shows up as bottom whitespace (especially when the + // intent finalization is delayed by an unrenderable drain target). + const measuredProtectionPx = Math.max( + previousReservationState.collapse.floorPx, + Math.max( + 0, + collapseIntent0.baseTotalCompensationPx + + Math.max( + 0, + collapseIntent0.cumulativeShrinkPx - + collapseIntent0.distanceFromBottomBeforeCollapse, + ) - + getReservationTotalPx(previousReservationState.pin), + ), + ); + const consumablePx = Math.max( + 0, + previousReservationState.collapse.px - measuredProtectionPx, + ); + if (consumablePx <= COMPENSATION_EPSILON_PX) { + return previousReservationState; + } + return consumeBottomCompensation(Math.min(heightDelta, consumablePx), { + consumeStickyPinFloor: false, + }); + })(); const immediatelyConsumedPx = Math.max( 0, getTotalBottomCompensationPx(previousReservationState) - @@ -3300,10 +3448,47 @@ const VirtualMessageListSession = forwardRef= COLLAPSE_INTENT_FINALIZE_MAX_RETRIES; const nextState = (() => { + // Bottom convergence: at the physical bottom, settling to the geometric + // minimum for the current scrollTop recomputes the current reservation + // (dead-lock). The correct settled view for a bottom user is the content + // bottom, so converge instead. `reconcile-sticky-pin` is excluded: it + // owns a pinned turn. (`retain-following-tail` already returned above; + // it waits for the quiet settle pass while Virtuoso measurements may + // still be moving.) + if ( + settlementStrategy !== 'reconcile-sticky-pin' && + scroller && + isAtPhysicalBottom(scroller) + ) { + if (coordinatorMode === 'preserving-element') { + // The user is looking at the bottom; no anchor range needs to keep + // this viewport position after convergence. + viewportCoordinatorRef.current.release(`collapse-finalize:${reason}:bottom-converged`); + } + if (convergeBottomReservationsToContentBottom(`collapse-finalize:${reason}`)) { + return bottomReservationStateRef.current; + } + } switch (settlementStrategy) { case 'reconcile-sticky-pin': case 'drain': + if (forceSettle) { + return scroller + ? settleCollapseReservationForViewport( + bottomReservationStateRef.current, + { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }, + ) + : protectCurrentCollapseReservation(bottomReservationStateRef.current); + } return drainCollapseReservationPreservingPinnedItem(reason); case 'settle-preserved-element': case 'settle-protected-viewport': @@ -3337,6 +3522,7 @@ const VirtualMessageListSession = forwardRef ({ reason, finalizeRetries: retriedIntent.finalizeRetries, + forceSettleNext: retriedIntent.finalizeRetries >= COLLAPSE_INTENT_FINALIZE_MAX_RETRIES, coordinatorMode: viewportCoordinatorRef.current.getMode(), pinMode: pinReservation.mode, pinTargetTurnId: pinReservation.targetTurnId, @@ -3396,6 +3582,15 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX && !intent.active ) { - const nextScrollTop = scrollerElement.scrollTop; - const scrollDelta = nextScrollTop - previousScrollTopRef.current; - if (scrollDelta > COMPENSATION_EPSILON_PX) { - const nextCompensationState = consumeBottomCompensation(scrollDelta); - applyFooterCompensationNow(nextCompensationState); - if (retainedCollapseAnchorRef.current) { - retainedCollapseAnchorRef.current.anchorScrollTop = Math.max( - retainedCollapseAnchorRef.current.anchorScrollTop, - scrollerElement.scrollTop, + // Bottom convergence: when the user is already at the physical bottom, + // per-scroll consumption never triggers (scrollDelta is 0 because the + // synthetic footer caps scrollTop), so the only way to shrink the + // whitespace was repeated up/down scrubbing. Any scroll event while at + // the physical bottom is bottom intent — converge in one pass. + if (convergeBottomReservationsToContentBottom('scroll-at-bottom')) { + // Converged; reservations are zero and baselines were re-synced. + } else { + const nextScrollTop = scrollerElement.scrollTop; + const scrollDelta = nextScrollTop - previousScrollTopRef.current; + if (scrollDelta > COMPENSATION_EPSILON_PX) { + const nextCompensationState = consumeBottomCompensation(scrollDelta); + applyFooterCompensationNow(nextCompensationState); + if (retainedCollapseAnchorRef.current) { + retainedCollapseAnchorRef.current.anchorScrollTop = Math.max( + retainedCollapseAnchorRef.current.anchorScrollTop, + scrollerElement.scrollTop, + ); + } + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( + scrollerElement, + nextCompensationState, ); } - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( - scrollerElement, - nextCompensationState, - ); } } @@ -4798,8 +5004,15 @@ const VirtualMessageListSession = forwardRef).detail; - acquireCollapseReservationOwner('tool-collapse'); const previousIntent = pendingCollapseIntentRef.current; + // Accumulation guard: if the previous intent already settled but left a + // stale reservation behind (the bottom dead-lock), converging first stops + // the new provisional estimate from stacking on top of it. Only safe when + // no active intent/anchor is mid-flight. + if (!previousIntent.active) { + convergeBottomReservationsToContentBottom('collapse-intent-arrived-at-bottom'); + } + acquireCollapseReservationOwner('tool-collapse'); clearRetainedCollapseSettlement(); if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ @@ -5007,6 +5220,7 @@ const VirtualMessageListSession = forwardRef { + const auditIdleReservations = () => { + const scroller = scrollerElementRef.current; + if (!canProcessViewportGeometry(scroller)) { + return; + } + if (isStreamingOutputRef.current) { + return; + } + if (pendingCollapseIntentRef.current.active || retainedCollapseAnchorRef.current !== null) { + return; + } + const coordinatorMode = viewportCoordinatorRef.current.getMode(); + if (coordinatorMode === 'pinned-item' || coordinatorMode === 'following-tail') { + return; + } + if ( + performance.now() - lastUserScrollIntentAtMsRef.current < + RETAINED_COLLAPSE_RELEASE_QUIET_MS + ) { + return; + } + if ( + scrollbarPointerInteractionActiveRef.current || + touchScrollIntentStartYRef.current !== null + ) { + return; + } + + const currentState = bottomReservationStateRef.current; + const hasCollapse = ( + currentState.collapse.px > COMPENSATION_EPSILON_PX || + currentState.collapse.floorPx > COMPENSATION_EPSILON_PX + ); + const hasPin = ( + currentState.pin.px > COMPENSATION_EPSILON_PX || + currentState.pin.floorPx > COMPENSATION_EPSILON_PX + ); + if (!hasCollapse && !hasPin) { + // A bottom user needs no element-anchor correction (tail follow and + // physical-bottom sync own the viewport), so releasing the preserved + // anchor is safe. A mid-list reader may still rely on the retained + // anchor for reading-position corrections, so keep it while the user + // is away from the physical bottom. + if (coordinatorMode === 'preserving-element' && isAtPhysicalBottom(scroller)) { + viewportCoordinatorRef.current.release('idle-audit-empty'); + } + return; + } + + // Bottom convergence: at the physical bottom the geometric minimum for + // the current scrollTop equals the current reservation, so the plain + // settle below would keep the whitespace forever. Converge instead. + if (convergeBottomReservationsToContentBottom('idle-audit-bottom')) { + return; + } + + let nextState = hasCollapse + ? settleCollapseReservationForViewport(currentState, { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }) + : currentState; + const pin = nextState.pin; + if (pin.mode === 'sticky-latest' && pin.targetTurnId && pin.targetTurnId !== latestTurnId) { + clearPinReservationForUserNavigation('idle-audit-stale-pin', { + preserveCurrentRange: false, + }); + nextState = bottomReservationStateRef.current; + } + if (areBottomReservationStatesEqual(currentState, nextState)) { + return; + } + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + if (nextState.collapse.px <= COMPENSATION_EPSILON_PX) { + clearCollapseReservationOwner(); + // Same rule as the empty-reservation branch: only a bottom user's + // preserved anchor may be released by the idle audit. + if ( + viewportCoordinatorRef.current.getMode() === 'preserving-element' && + isAtPhysicalBottom(scroller) + ) { + viewportCoordinatorRef.current.release('idle-audit-empty'); + } + } + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextState); + recordScrollerGeometry(scroller); + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.idleReservationAudit', + message: 'Idle reservation audit settled synthetic tail space', + data: () => ({ + reservationBefore: currentState, + reservationAfter: bottomReservationStateRef.current, + coordinatorMode, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }), + }); + } + }; + + const auditTimer = window.setInterval( + auditIdleReservations, + IDLE_RESERVATION_AUDIT_INTERVAL_MS, + ); + return () => window.clearInterval(auditTimer); + }, [ + applyFooterCompensationNow, + canProcessViewportGeometry, + clearCollapseReservationOwner, + clearPinReservationForUserNavigation, + convergeBottomReservationsToContentBottom, + isAtPhysicalBottom, + latestTurnId, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + updateBottomReservationState, + ]); + const isStreamingOutput = React.useMemo(() => { if (viewportMode === 'history-reading') { return false;