From e8ddcbab1c727924b68a5d95b6c92ef904213e65 Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 28 Jul 2026 16:24:43 +0800 Subject: [PATCH] fix(web-ui): stabilize flow chat viewport anchoring - centralize pinned-item, tail-follow, and preserved-element behavior in a semantic viewport coordinator - synchronize footer reservations with streaming growth and tool-card collapse transactions - preserve tool-card header positions through the shared height contract - handle virtualizer compensation, late scroll clamping, and pinned-to-tail handoff - add focused regression tests and update scroll stability documentation --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 155 +- .../FlowChatViewportCoordinator.test.ts | 292 ++++ .../modern/FlowChatViewportCoordinator.ts | 251 +++ ...rtualMessageList.session-boundary.test.tsx | 203 ++- .../components/modern/VirtualMessageList.tsx | 1449 ++++++++++++----- .../tool-cards/FileOperationToolCard.tsx | 36 +- .../tool-cards/ModelThinkingDisplay.tsx | 1 + .../tool-cards/TodoWriteDisplay.test.tsx | 112 +- .../flow_chat/tool-cards/TodoWriteDisplay.tsx | 30 +- .../useToolCardHeightContract.test.tsx | 118 ++ .../tool-cards/useToolCardHeightContract.ts | 31 +- 11 files changed, 2174 insertions(+), 504 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.test.tsx 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 d2febb55f9..52cd9f5df3 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 @@ -45,7 +45,7 @@ Read this before changing any of the following: - footer height / footer rendering in `VirtualMessageList.tsx` - scroll compensation state or refs -- anchor-lock timing +- semantic anchor lifetime and one-shot fallback restoration - `ResizeObserver` / `MutationObserver` / transition listeners - `flowchat:tool-card-collapse-intent` - `tool-card-toggle` @@ -92,6 +92,15 @@ temporary tail space, but keeps its own semantics: The rendered footer height is the sum of all active reservations. +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. + +The Virtuoso Footer does not receive reservation pixels through React context. +Its stable DOM node is updated imperatively, and its ref callback restores the +current ref-owned height on mount. This keeps reservation updates from causing +an additional measurement-sensitive Virtuoso render. + Important details: - the real footer height is `MESSAGE_LIST_FOOTER_HEIGHT + totalBottomReservationPx` @@ -113,7 +122,10 @@ If you forget to subtract reservation space, future shrink/growth calculations b - `floorPx`: the minimum tail space needed to keep the pinned target stable `sticky-latest` is used for the "latest turn should stay pinned to top" behavior. -Its floor can be reconciled from live DOM measurements as content grows or shrinks. +Its floor grows when live DOM measurements require more range and drains only +from measured positive content growth. +The pinned item may hand off to tail-follow only after both the complete pin +reservation (`px`, not only `floorPx`) and collapse reservation reach zero. ## 2. Synchronous Footer DOM Apply @@ -130,16 +142,45 @@ This is intentional. It ensures the browser uses the new footer height in the sa If you move compensation back to "React render only", the flash can return because the DOM may still be one frame behind when `scrollTop` is restored. -## 3. Anchor Lock - -`anchorLockRef` temporarily remembers the desired `scrollTop`. - -It exists for two reasons: - -- immediate restore right after compensation is applied -- follow-up enforcement during scroll events while the layout is still settling - -The immediate restore handles the critical path. The scroll listener is the safety net. +## 3. Semantic Anchor Coordinator + +`FlowChatViewportCoordinator` owns the semantic viewport anchor. It tracks one +primary mode at a time: pinned item, following tail, or preserving an element. +Tool cards supply an anchor element but never calculate scroll offsets or +heights. The coordinator records the element's viewport-relative position and +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. + +There is no persistent scroll-position lock or scroll-listener lock. For an unsignaled +shrink with no semantic element anchor, `restoreScrollPositionOnce()` performs +one clamped `scrollTop` fallback using the pre-change position. It is a bounded +last resort, not a second controller: subsequent layout changes are handled by +the semantic anchor (when present), the reservation model, or follow mode. + +An element anchor also owns the minimum physical scroll range needed to restore +its offset. After writing `scrollTop`, the coordinator remeasures the actual DOM +offset. If a positive correction remains because the browser clamped at the +bottom, the range host synchronously extends the matching reservation, flushes +layout, and retries in the same frame. The post-write DOM measurement is the +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. + +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 +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 atomic collapse-to-pin transfer using the settled required +range. ## 4. Collapse Intent @@ -148,6 +189,7 @@ Some collapses are predictable before layout actually shrinks. `flowchat:tool-card-collapse-intent` is emitted before a known collapsible UI shrinks. `VirtualMessageList` uses that event to: +- capture the card root as the semantic header anchor - capture the pre-collapse anchor `scrollTop` - capture the bottom distance before collapse - estimate required compensation from current card height @@ -163,14 +205,15 @@ If the list waits until `ResizeObserver` sees the shrink, the browser may alread When a helper-backed card or region is about to collapse: -1. it dispatches `flowchat:tool-card-collapse-intent` before the collapse state is applied +1. it dispatches `flowchat:tool-card-collapse-intent` with its anchor element before the collapse state is applied 2. `VirtualMessageList` estimates the upcoming shrink using `cardHeight` 3. `VirtualMessageList` adds provisional footer compensation immediately -4. `VirtualMessageList` activates anchor lock using the current `scrollTop` +4. `VirtualMessageList` applies the provisional footer synchronously and records + the semantic anchor's viewport offset 5. actual layout shrink happens 6. `ResizeObserver` / `MutationObserver` / transition listeners trigger `measureHeightChange()` 7. measured shrink reconciles the compensation to the real final value -8. anchor lock restores / enforces the final `scrollTop` +8. the coordinator restores the anchor element's exact viewport-relative position Common examples: @@ -185,7 +228,8 @@ If a shrink happens without a collapse intent: 1. `measureHeightChange()` detects the negative height delta 2. compensation falls back to `shrinkAmount - distanceFromBottom` -3. anchor lock uses the previously known scroll position +3. `restoreScrollPositionOnce()` makes one clamped fallback restore using the + previously known scroll position This path is safer than doing nothing, but it is more likely to show visible movement than the pre-compensation path. @@ -202,12 +246,15 @@ covers deliberate user toggles.) During those transitions, the DOM may report intermediate sizes for multiple frames. -The collapse intent carries a hard TTL (`expiresAtMs`, currently 1000 ms). -While the intent is alive, the grow branch of `measureHeightChange` does not -consume compensation, so a mid-animation intermediate size cannot drain it too -early. When the TTL lapses, `replayDeferredFollowIfSettled` drains residual -compensation and replays any deferred follow. There is no transition-event -listener: expiry is purely time-based. +The collapse intent carries a hard TTL (`expiresAtMs`, currently 1000 ms), but +its settlement is autonomous rather than scroll-driven. Automatic collapses are +finalized after a short settle-frame window; manual or otherwise unsignaled +intents use the TTL timer. The scroll handler keeps only a throttled-background +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 is transferred to the settled +sticky pin in one state/DOM update and any deferred follow is replayed. ## C. Follow-Output Mode (continuous tail) @@ -215,20 +262,28 @@ When the viewport is in follow-output mode and the latest turn is still streaming, the user's intent is "keep the tail visible". The continuous RAF loop re-pins `scrollTop` toward the bottom every frame. -Collapses interact with follow mode in two mutually exclusive ways: - -1. **Follow + streaming active:** `handleToolCardCollapseIntent` returns - early and writes no intent, and the shrink branch of - `measureHeightChange` is skipped. The RAF loop simply re-pins to the - new bottom on the next frame, absorbing the shrink in ~16 ms. Because - automatic collapses are now instant (Rule Zero), the shrink is a - single-frame step — there is no multi-frame animation for the loop to - chase, which is what previously produced the "conversation sinks - down" drift. Not writing an intent here also means nothing - accumulates, so issue #1176 (permanent footer whitespace) cannot - occur in this path. -2. **Not following (user reading older content):** the intent + - pre-compensation + anchor-lock path applies as described above, and +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 short protection window ends, the collapse reservation remains + consumable by real streaming growth instead of being removed immediately; + stream end performs the final exact reconciliation. This keeps the card + header stable without giving up tail-follow ownership. + If React/Virtuoso has already clamped `scrollTop` before a data-driven auto + intent reaches the list's layout handler, the handler extends the collapse + reservation as needed and restores the last stable follow position before + paint. Manual collapses and non-follow viewports do not use this fallback. +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. + 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 + tail; the next follow frame owns the single tail correction instead. +3. **Not following (user reading older content):** the intent + + pre-compensation + semantic-anchor path applies as described above, and `shouldSuspendAutoFollow` keeps event-driven follow scheduling deferred until the intent's TTL lapses. @@ -256,6 +311,7 @@ If you remove `overflow-anchor: none`, the browser may apply its own anchor corr `flowchat:tool-card-collapse-intent` - dispatch before a collapse that can reduce list height near the bottom +- include the card root as `anchorElement`; its top edge represents the stable header position - include `cardHeight` when possible - purpose: pre-compensate before the browser clamps scroll position @@ -277,6 +333,15 @@ If a future collapsible component shows the same "header drops" or "flash on col - Effective height comparisons must subtract current compensation. - Footer DOM compensation must be applied synchronously before anchor restore. - Anchor restore must clamp against current `maxScrollTop`. +- A stalled positive anchor correction must extend physical bottom range and + retry before paint. +- Resize and height observers must not synchronize to the physical bottom while + a semantic element anchor owns the viewport. +- Sticky pin floors must shrink from measured content growth, not a transient + target-element position. +- A user gesture that exits pinned mode must release the semantic anchor and + clear or atomically transfer the pin reservation in the same operation; an + idle coordinator must never retain a live pin reservation. - 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. @@ -294,13 +359,16 @@ If a future collapsible component shows the same "header drops" or "flash on col - Removing `flowchat:tool-card-collapse-intent` from a helper-backed collapsible component. - Dispatching collapse intent after `setState` instead of before it. - Removing `overflow-anchor: none`. -- Removing the intent TTL / expiry drain (`replayDeferredFollowIfSettled`). -- Simplifying anchor restore to a one-shot restore without the scroll listener fallback. -- Removing the follow-mode early return in `handleToolCardCollapseIntent` / - `measureHeightChange`. During follow + streaming the RAF loop absorbs the - (now single-frame) shrink by re-pinning next frame; injecting compensation + - anchor lock there instead freezes the viewport on older content and causes - the "occasionally not at the bottom" bug. +- Removing the intent TTL, settle-frame finalizer, or the throttled scroll + fallback that covers delayed background timers. +- Reintroducing a persistent scroll-listener lock or allowing multiple competing + scroll writers. Semantic anchors and the bounded fallback must remain separate. +- Passing reservation pixels through Virtuoso context or React-owned Footer + styles. The stable Footer DOM and ref-owned reservation are the hot path. +- Restoring the blanket follow-mode early return in + `handleToolCardCollapseIntent` or applying it to an active known intent in + `measureHeightChange`. Known streaming collapses require synchronous range + reservation; only unsignaled shrinks are delegated entirely to the RAF loop. - Removing the `shouldSuspendAutoFollow` gate from event-driven follow scheduling. Outside follow mode it keeps deferred follows from firing while a collapse intent is still protecting the anchor. @@ -323,6 +391,7 @@ Use this checklist: ## Related Files - `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx` +- `src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts` - `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss` - `src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts` - `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` 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 new file mode 100644 index 0000000000..be928a4727 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts @@ -0,0 +1,292 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + canHandoffPinnedItemToTail, + FlowChatViewportCoordinator, +} from './FlowChatViewportCoordinator'; + +function setRect(element: HTMLElement, top: number): void { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: top + 40, + height: 40, + left: 0, + right: 300, + top, + width: 300, + x: 0, + y: top, + toJSON: () => ({}), + }), + }); +} + +function setScrollerGeometry(scroller: HTMLElement, scrollTop: number): void { + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 500 }, + scrollHeight: { configurable: true, value: 2000 }, + scrollTop: { configurable: true, writable: true, value: scrollTop }, + }); +} + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe('FlowChatViewportCoordinator', () => { + it('hands off to tail follow only after every reservation is drained', () => { + expect(canHandoffPinnedItemToTail({ + pinReservationPx: 1029, + collapseReservationPx: 0, + hasPendingCollapseIntent: false, + })).toBe(false); + expect(canHandoffPinnedItemToTail({ + pinReservationPx: 0, + collapseReservationPx: 200, + hasPendingCollapseIntent: false, + })).toBe(false); + expect(canHandoffPinnedItemToTail({ + pinReservationPx: 0, + collapseReservationPx: 0, + hasPendingCollapseIntent: true, + })).toBe(false); + expect(canHandoffPinnedItemToTail({ + pinReservationPx: 0, + collapseReservationPx: 0, + hasPendingCollapseIntent: false, + })).toBe(true); + }); + + it('restores a collapsing card header to its captured viewport offset', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const card = document.createElement('div'); + scroller.append(card); + document.body.append(scroller); + setScrollerGeometry(scroller, 900); + setRect(scroller, 0); + setRect(card, 120); + + const coordinator = new FlowChatViewportCoordinator(); + expect(coordinator.preserveElement(card)).toBe(true); + + setRect(card, 80); + expect(coordinator.restoreElementAnchor(scroller)).toBe(true); + expect(scroller.scrollTop).toBe(860); + }); + + it('does not let automatic tail follow replace a preserved card anchor', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const card = document.createElement('div'); + scroller.append(card); + document.body.append(scroller); + setScrollerGeometry(scroller, 900); + setRect(scroller, 0); + setRect(card, 120); + + const coordinator = new FlowChatViewportCoordinator(); + coordinator.preserveElement(card); + + expect(coordinator.followTail()).toBe(false); + expect(coordinator.getMode()).toBe('preserving-element'); + expect(coordinator.followTail({ force: true })).toBe(true); + expect(coordinator.getMode()).toBe('following-tail'); + }); + + it('keeps a pinned item anchored until follow mode takes ownership', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const item = document.createElement('div'); + scroller.append(item); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(item, 57); + + const coordinator = new FlowChatViewportCoordinator(); + expect(coordinator.pinElement(item)).toBe(true); + + setRect(item, 87); + expect(coordinator.restoreElementAnchor(scroller)).toBe(true); + expect(scroller.scrollTop).toBe(730); + expect(coordinator.getMode()).toBe('pinned-item'); + + coordinator.followTail({ force: true }); + setRect(item, 117); + expect(coordinator.restoreElementAnchor(scroller)).toBe(false); + }); + + it('does not let a tool-card collapse replace an active pinned-item anchor', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const pinnedItem = document.createElement('div'); + const toolCard = document.createElement('div'); + scroller.append(pinnedItem, toolCard); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(pinnedItem, 57); + setRect(toolCard, 300); + + const coordinator = new FlowChatViewportCoordinator(); + coordinator.pinElement(pinnedItem); + + expect(coordinator.preserveElement(toolCard)).toBe(false); + expect(coordinator.getMode()).toBe('pinned-item'); + }); + + it('owns virtualizer scroll compensation while an element anchor is active', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const pinnedItem = document.createElement('div'); + scroller.append(pinnedItem); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(pinnedItem, 57); + + const coordinator = new FlowChatViewportCoordinator(); + expect(coordinator.ownsElementAnchor()).toBe(false); + coordinator.pinElement(pinnedItem); + expect(coordinator.ownsElementAnchor()).toBe(true); + coordinator.followTail({ force: true }); + expect(coordinator.ownsElementAnchor()).toBe(false); + }); + + it('restores an idle viewport position once without creating a persistent lock', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + document.body.append(scroller); + setScrollerGeometry(scroller, 900); + + const coordinator = new FlowChatViewportCoordinator(); + expect(coordinator.restoreScrollPositionOnce(scroller, 1200, 'test-idle')).toBe(true); + expect(scroller.scrollTop).toBe(1200); + + scroller.scrollTop = 900; + expect(coordinator.getMode()).toBe('idle'); + expect(scroller.scrollTop).toBe(900); + }); + + it('clamps the idle fallback restore to the current physical scroll range', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + document.body.append(scroller); + setScrollerGeometry(scroller, 900); + + const coordinator = new FlowChatViewportCoordinator(); + expect(coordinator.restoreScrollPositionOnce(scroller, 5000, 'test-clamp')).toBe(true); + expect(scroller.scrollTop).toBe(1500); + }); + + it('delegates one-shot restoration to the semantic anchor when it owns the viewport', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const pinnedItem = document.createElement('div'); + scroller.append(pinnedItem); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(pinnedItem, 57); + + const coordinator = new FlowChatViewportCoordinator(); + coordinator.pinElement(pinnedItem); + setRect(pinnedItem, 87); + + expect(coordinator.restoreScrollPositionOnce(scroller, 0, 'test-semantic')).toBe(true); + expect(scroller.scrollTop).toBe(730); + }); + + it('extends the physical bottom range before retrying a stalled semantic restore', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const pinnedItem = document.createElement('div'); + scroller.append(pinnedItem); + document.body.append(scroller); + + let scrollHeight = 1200; + const clientHeight = 500; + let scrollTop = 700; + let itemTop = 57; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, get: () => clientHeight }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (requested: number) => { + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const applied = Math.min(maxScrollTop, Math.max(0, requested)); + itemTop -= applied - scrollTop; + scrollTop = applied; + }, + }, + }); + setRect(scroller, 0); + vi.spyOn(pinnedItem, 'getBoundingClientRect').mockImplementation(() => ({ + bottom: itemTop + 40, + height: 40, + left: 0, + right: 300, + top: itemTop, + width: 300, + x: 0, + y: itemTop, + toJSON: () => ({}), + })); + + const ensureBottomRange = vi.fn(({ additionalPx }: { additionalPx: number }) => { + scrollHeight += additionalPx; + return true; + }); + const coordinator = new FlowChatViewportCoordinator(); + coordinator.setRangeHost({ ensureBottomRange }); + coordinator.pinElement(pinnedItem); + + scrollHeight = 1150; + scroller.scrollTop = 700; + expect(scrollTop).toBe(650); + expect(itemTop).toBe(107); + + expect(coordinator.restoreElementAnchor(scroller, 'test-range-recovery')).toBe(true); + expect(ensureBottomRange).toHaveBeenCalledWith(expect.objectContaining({ + additionalPx: 51, + mode: 'pinned-item', + source: 'test-range-recovery', + })); + expect(scrollTop).toBe(700); + expect(itemTop).toBe(57); + coordinator.release('test-cleanup'); + }); + + it('reconciles a pinned anchor from the internal animation-frame guard', () => { + let scheduledFrame: FrameRequestCallback | null = null; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + scheduledFrame = callback; + return 1; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const pinnedItem = document.createElement('div'); + scroller.append(pinnedItem); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(pinnedItem, 57); + + const coordinator = new FlowChatViewportCoordinator(); + coordinator.pinElement(pinnedItem); + setRect(pinnedItem, 157); + + expect(scheduledFrame).not.toBeNull(); + (scheduledFrame as FrameRequestCallback)(0); + expect(scroller.scrollTop).toBe(800); + coordinator.release('test-cleanup'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts new file mode 100644 index 0000000000..8b8f22245f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts @@ -0,0 +1,251 @@ +export type FlowChatViewportAnchorMode = + | 'idle' + | 'pinned-item' + | 'following-tail' + | 'preserving-element'; + +export interface FlowChatViewportRangeHost { + ensureBottomRange(options: { + additionalPx: number; + mode: Extract; + source: string; + }): boolean; +} + +type ElementAnchor = { + element: HTMLElement; + scroller: HTMLElement; + offsetFromScrollerTop: number; + expiresAtMs: number | null; +}; + +const ELEMENT_ANCHOR_TTL_MS = 1000; +const ELEMENT_ANCHOR_EPSILON_PX = 0.5; +const ELEMENT_ANCHOR_RANGE_GUARD_PX = 1; + +export function canHandoffPinnedItemToTail(options: { + pinReservationPx: number; + collapseReservationPx: number; + hasPendingCollapseIntent: boolean; +}): boolean { + return ( + options.pinReservationPx <= ELEMENT_ANCHOR_EPSILON_PX && + options.collapseReservationPx <= ELEMENT_ANCHOR_EPSILON_PX && + !options.hasPendingCollapseIntent + ); +} + +function nowMs(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +/** Owns anchor priority independently from the virtualizer implementation. */ +export class FlowChatViewportCoordinator { + private mode: FlowChatViewportAnchorMode = 'idle'; + private elementAnchor: ElementAnchor | null = null; + private anchorGuardFrame: number | null = null; + private rangeHost: FlowChatViewportRangeHost | null = null; + + setRangeHost(host: FlowChatViewportRangeHost | null): void { + this.rangeHost = host; + } + + getMode(): FlowChatViewportAnchorMode { + this.expireElementAnchor(); + return this.mode; + } + + ownsElementAnchor(): boolean { + this.expireElementAnchor(); + return Boolean( + this.elementAnchor && + (this.mode === 'pinned-item' || this.mode === 'preserving-element'), + ); + } + + pinItem(_reason = 'unspecified'): void { + this.stopAnchorGuard(); + this.elementAnchor = null; + this.mode = 'pinned-item'; + } + + pinElement(element: HTMLElement | null | undefined): boolean { + return this.captureElement(element, 'pinned-item', null); + } + + followTail(options?: { force?: boolean }): boolean { + this.expireElementAnchor(); + if (this.mode === 'preserving-element' && !options?.force) { + return false; + } + + this.stopAnchorGuard(); + this.elementAnchor = null; + this.mode = 'following-tail'; + return true; + } + + preserveElement(element: HTMLElement | null | undefined): boolean { + this.expireElementAnchor(); + if (!element || this.mode === 'following-tail' || this.mode === 'pinned-item') { + return false; + } + + return this.captureElement( + element, + 'preserving-element', + nowMs() + ELEMENT_ANCHOR_TTL_MS, + ); + } + + private captureElement( + element: HTMLElement | null | undefined, + mode: 'pinned-item' | 'preserving-element', + expiresAtMs: number | null, + ): boolean { + if (!element) { + return false; + } + + const scroller = element.closest('[data-virtuoso-scroller="true"]'); + if (!scroller) { + return false; + } + + const elementRect = element.getBoundingClientRect(); + const scrollerRect = scroller.getBoundingClientRect(); + this.elementAnchor = { + element, + scroller, + offsetFromScrollerTop: elementRect.top - scrollerRect.top, + expiresAtMs, + }; + this.mode = mode; + this.startAnchorGuard(); + return true; + } + + restoreElementAnchor(scroller: HTMLElement, source = 'external'): boolean { + this.expireElementAnchor(); + const anchor = this.elementAnchor; + if (!anchor || (this.mode !== 'preserving-element' && this.mode !== 'pinned-item')) { + return false; + } + if (!anchor.element.isConnected) { + return false; + } + + const readCorrection = () => { + const elementRect = anchor.element.getBoundingClientRect(); + const scrollerRect = scroller.getBoundingClientRect(); + return elementRect.top - scrollerRect.top - anchor.offsetFromScrollerTop; + }; + const applyCorrection = (correction: number) => { + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const desiredScrollTop = scroller.scrollTop + correction; + const requestedScrollTop = Math.min(maxScrollTop, Math.max(0, desiredScrollTop)); + scroller.scrollTop = requestedScrollTop; + }; + + const initialCorrection = readCorrection(); + if (Math.abs(initialCorrection) <= ELEMENT_ANCHOR_EPSILON_PX) { + return false; + } + + applyCorrection(initialCorrection); + let remainingCorrection = readCorrection(); + + if ( + remainingCorrection > ELEMENT_ANCHOR_EPSILON_PX && + this.rangeHost && + (this.mode === 'pinned-item' || this.mode === 'preserving-element') + ) { + const rangeExtended = this.rangeHost.ensureBottomRange({ + additionalPx: remainingCorrection + ELEMENT_ANCHOR_RANGE_GUARD_PX, + mode: this.mode, + source, + }); + if (rangeExtended) { + void scroller.scrollHeight; + remainingCorrection = readCorrection(); + if (Math.abs(remainingCorrection) > ELEMENT_ANCHOR_EPSILON_PX) { + applyCorrection(remainingCorrection); + } + } + } + return true; + } + + restoreScrollPositionOnce( + scroller: HTMLElement, + targetScrollTop: number, + source = 'unspecified', + ): boolean { + if (this.ownsElementAnchor()) { + this.restoreElementAnchor(scroller, source); + return true; + } + + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const previousScrollTop = scroller.scrollTop; + const nextScrollTop = Math.min(maxScrollTop, Math.max(0, targetScrollTop)); + if (Math.abs(nextScrollTop - previousScrollTop) <= ELEMENT_ANCHOR_EPSILON_PX) { + return false; + } + + scroller.scrollTop = nextScrollTop; + return true; + } + + release(_reason = 'unspecified'): void { + this.stopAnchorGuard(); + this.elementAnchor = null; + this.mode = 'idle'; + } + + private expireElementAnchor(): void { + if ( + this.elementAnchor?.expiresAtMs !== null && + this.elementAnchor?.expiresAtMs !== undefined && + this.elementAnchor.expiresAtMs < nowMs() + ) { + this.elementAnchor = null; + this.stopAnchorGuard(); + if (this.mode === 'preserving-element') { + this.mode = 'idle'; + } + } + } + + private startAnchorGuard(): void { + if (this.anchorGuardFrame !== null || typeof requestAnimationFrame === 'undefined') { + return; + } + this.anchorGuardFrame = requestAnimationFrame(this.runAnchorGuardFrame); + } + + private stopAnchorGuard(): void { + if (this.anchorGuardFrame === null || typeof cancelAnimationFrame === 'undefined') { + this.anchorGuardFrame = null; + return; + } + cancelAnimationFrame(this.anchorGuardFrame); + this.anchorGuardFrame = null; + } + + private runAnchorGuardFrame = (): void => { + this.anchorGuardFrame = null; + this.expireElementAnchor(); + const anchor = this.elementAnchor; + if ( + !anchor || + (this.mode !== 'pinned-item' && this.mode !== 'preserving-element') || + !anchor.scroller.isConnected + ) { + return; + } + + this.restoreElementAnchor(anchor.scroller, 'anchor-guard'); + this.startAnchorGuard(); + }; +} 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 0377a16363..468a932451 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 @@ -4,7 +4,18 @@ import React from 'react'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { VirtualMessageList, type VirtualMessageListRef } from './VirtualMessageList'; +import { + consumeBottomReservationForContentGrowth, + getCanceledUnsettledStickyPinGrowthPx, + resolveAutoCollapseAnchorScrollTop, + shouldBypassShrinkCompensationInTailFollow, + shouldPreserveCollapseReservationAfterIntent, + shouldSyncPhysicalBottom, + shouldSuppressFollowingTailNegativeScrollBy, + transferCollapseReservationToPin, + VirtualMessageList, + type VirtualMessageListRef, +} from './VirtualMessageList'; import { activeSessionHistoryProjectionHandoff } from './historyProjectionHandoff'; import type { Session } from '../../types/flow-chat'; import type { VirtualItem } from '../../store/modernFlowChatStore'; @@ -88,7 +99,7 @@ vi.mock('react-virtuoso', () => ({ data-session-id={stateMocks.activeSession?.sessionId ?? ''} tabIndex={0} > - {props.components?.Header ? : null} + {props.components?.Header ? : null} {props.data ?.map((item: VirtualItem, index: number) => ({ item, index })) .filter(({ index }: { index: number }) => { @@ -106,7 +117,7 @@ vi.mock('react-virtuoso', () => ({ {item.type === 'user-message' ? item.data.content : item.turnId} ))} - {props.components?.Footer ? : null} + {props.components?.Footer ? : null} ); }), @@ -365,6 +376,192 @@ describe('VirtualMessageList session boundary', () => { vi.unstubAllGlobals(); }); + it('keeps the Virtuoso footer mounted across parent rerenders', () => { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + + act(() => { + root.render(); + }); + const firstFooter = container.querySelector('.message-list-footer'); + expect(firstFooter).not.toBeNull(); + if (!(firstFooter instanceof HTMLElement)) { + return; + } + firstFooter.style.height = '900px'; + firstFooter.style.minHeight = '900px'; + + act(() => { + root.render(); + }); + + expect(container.querySelector('.message-list-footer')).toBe(firstFooter); + expect(firstFooter.style.height).toBe('900px'); + expect(firstFooter.style.minHeight).toBe('900px'); + }); + + it('transfers collapse space to a sticky pin in one reservation state', () => { + const currentState = { + collapse: { kind: 'collapse' as const, px: 1_583, floorPx: 0 }, + pin: { + kind: 'pin' as const, + px: 0, + floorPx: 0, + mode: 'sticky-latest' as const, + targetTurnId: 'turn-a', + }, + }; + const nextPin = { + ...currentState.pin, + px: 378, + floorPx: 378, + }; + + expect(transferCollapseReservationToPin(currentState, nextPin)).toEqual({ + collapse: { kind: 'collapse', px: 0, floorPx: 0 }, + pin: nextPin, + }); + }); + + it('drains a sticky pin floor only from measured content growth', () => { + const currentState = { + collapse: { kind: 'collapse' as const, px: 20, floorPx: 0 }, + pin: { + kind: 'pin' as const, + px: 100, + floorPx: 100, + mode: 'sticky-latest' as const, + targetTurnId: 'turn-a', + }, + }; + + expect(consumeBottomReservationForContentGrowth(currentState, 35, true)).toEqual({ + collapse: { kind: 'collapse', px: 0, floorPx: 0 }, + pin: { + ...currentState.pin, + px: 85, + floorPx: 85, + }, + }); + expect(consumeBottomReservationForContentGrowth(currentState, 35, false)).toEqual({ + collapse: { kind: 'collapse', px: 0, floorPx: 0 }, + pin: currentState.pin, + }); + expect(consumeBottomReservationForContentGrowth(currentState, 35, true, true)).toEqual({ + collapse: currentState.collapse, + pin: { + ...currentState.pin, + px: 65, + floorPx: 65, + }, + }); + }); + + it('does not let physical-bottom follow compete with a semantic element anchor', () => { + expect(shouldSyncPhysicalBottom({ + viewportGeometryChanged: true, + collapseProtectionActive: false, + wasAtPhysicalBottom: true, + ownsElementAnchor: true, + })).toBe(false); + expect(shouldSyncPhysicalBottom({ + viewportGeometryChanged: true, + collapseProtectionActive: false, + wasAtPhysicalBottom: true, + ownsElementAnchor: false, + })).toBe(true); + }); + + it('suppresses only negative virtualizer compensation while following the streaming tail', () => { + expect(shouldSuppressFollowingTailNegativeScrollBy({ + requestedTop: -242, + isFollowingOutput: true, + isStreamingOutput: true, + wasAtPhysicalBottom: true, + })).toBe(true); + expect(shouldSuppressFollowingTailNegativeScrollBy({ + requestedTop: 36, + isFollowingOutput: true, + isStreamingOutput: true, + wasAtPhysicalBottom: true, + })).toBe(false); + expect(shouldSuppressFollowingTailNegativeScrollBy({ + requestedTop: -242, + isFollowingOutput: true, + isStreamingOutput: true, + wasAtPhysicalBottom: false, + })).toBe(false); + expect(shouldSuppressFollowingTailNegativeScrollBy({ + requestedTop: -242, + isFollowingOutput: false, + isStreamingOutput: true, + wasAtPhysicalBottom: true, + })).toBe(false); + }); + + it('cancels unsettled sticky pin growth only for unsignaled height corrections', () => { + expect(getCanceledUnsettledStickyPinGrowthPx({ + pendingGrowthPx: 207, + shrinkPx: 207, + hasActiveCollapseIntent: false, + })).toBe(207); + expect(getCanceledUnsettledStickyPinGrowthPx({ + pendingGrowthPx: 207, + shrinkPx: 55, + hasActiveCollapseIntent: false, + })).toBe(55); + expect(getCanceledUnsettledStickyPinGrowthPx({ + pendingGrowthPx: 207, + shrinkPx: 207, + hasActiveCollapseIntent: true, + })).toBe(0); + }); + + it('lets known streaming collapses reconcile while preserving their reservation', () => { + expect(shouldBypassShrinkCompensationInTailFollow({ + isFollowingOutput: true, + isStreamingOutput: true, + hasActiveCollapseIntent: false, + })).toBe(true); + expect(shouldBypassShrinkCompensationInTailFollow({ + isFollowingOutput: true, + isStreamingOutput: true, + hasActiveCollapseIntent: true, + })).toBe(false); + expect(shouldPreserveCollapseReservationAfterIntent({ + isFollowingOutput: true, + isStreamingOutput: true, + })).toBe(true); + expect(shouldPreserveCollapseReservationAfterIntent({ + isFollowingOutput: false, + isStreamingOutput: true, + })).toBe(false); + }); + + it('recovers the last stable scroll position when an auto collapse arrives after clamp', () => { + expect(resolveAutoCollapseAnchorScrollTop({ + currentScrollTop: 1127.33, + previousStableScrollTop: 1302.67, + reason: 'auto', + isFollowingOutput: true, + isStreamingOutput: true, + })).toBe(1302.67); + expect(resolveAutoCollapseAnchorScrollTop({ + currentScrollTop: 1127.33, + previousStableScrollTop: 1302.67, + reason: 'manual', + isFollowingOutput: true, + isStreamingOutput: true, + })).toBe(1127.33); + expect(resolveAutoCollapseAnchorScrollTop({ + currentScrollTop: 1127.33, + previousStableScrollTop: 1302.67, + reason: 'auto', + isFollowingOutput: false, + isStreamingOutput: true, + })).toBe(1127.33); + }); + it('resets viewport-local at-bottom state when the active session changes', () => { stateMocks.activeSession = createSession('session-a', 'turn-a'); stateMocks.virtualItems = [createItem('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 ba1389bd98..313c567dd0 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -12,7 +12,12 @@ */ import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, forwardRef, useImperativeHandle } from 'react'; -import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; +import { + Virtuoso, + VirtuosoHandle, + type Components, + type ContextProp, +} from 'react-virtuoso'; import { useTranslation } from 'react-i18next'; import { useActiveSessionState } from '../../hooks/useActiveSessionState'; import { VirtualItemRenderer } from './VirtualItemRenderer'; @@ -59,11 +64,14 @@ import { getFlowChatSearchTextRoot, setFlowChatSearchHighlight, } from './flowChatSearchDom'; +import { + canHandoffPinnedItemToTail, + FlowChatViewportCoordinator, + type FlowChatViewportRangeHost, +} from './FlowChatViewportCoordinator'; import './VirtualMessageList.scss'; const COMPENSATION_EPSILON_PX = 0.5; -const ANCHOR_LOCK_MIN_DEVIATION_PX = 0.5; -const ANCHOR_LOCK_DURATION_MS = 450; const PINNED_TURN_VIEWPORT_OFFSET_PX = 57; // Keep in sync with `.message-list-header`. const TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX = 6; const USER_UPWARD_SCROLL_INTENT_WINDOW_MS = 800; @@ -80,6 +88,43 @@ const HISTORY_PROJECTION_HANDOFF_MAX_DURATION_MS = 5000; const SESSION_OPEN_HANDOFF_ITEM_BUDGET = 24; const PREVIOUS_HISTORY_BOUNDARY_STATUS_DURATION_MS = 2500; const SEARCH_NAVIGATION_MAX_ATTEMPTS = 24; +const COLLAPSE_INTENT_TTL_MS = 1000; +const AUTO_COLLAPSE_SETTLE_FRAMES = 4; +const STICKY_PIN_GROWTH_SETTLE_MS = 300; + +type FlowChatVirtuosoContext = { + footerRef: React.RefCallback; + previousHistoryBoundaryStatusNode: React.ReactNode; + reserveSpaceForIndicator: boolean; + showBreathingIndicator: boolean; +}; + +const FlowChatVirtuosoHeader = ({ context }: ContextProp) => ( + <> +
+ {context.previousHistoryBoundaryStatusNode} + +); + +const FlowChatVirtuosoFooter = ({ context }: ContextProp) => ( + <> + +
+ +); + +const FLOW_CHAT_VIRTUOSO_COMPONENTS: Components = { + Header: FlowChatVirtuosoHeader, + Footer: FlowChatVirtuosoFooter, +}; +const FLOW_CHAT_VIRTUOSO_OVERSCAN = { main: 600, reverse: 600 } as const; +const FLOW_CHAT_VIRTUOSO_VIEWPORT_INCREASE = { top: 600, bottom: 600 } as const; type LatestEndAnchorResolveReason = | 'raf' @@ -134,13 +179,6 @@ export interface VirtualMessageListProps { onUserScrollIntent?: () => void; } -interface ScrollAnchorLockState { - active: boolean; - targetScrollTop: number; - reason: 'transition-shrink' | 'instant-shrink' | null; - lockUntilMs: number; -} - interface PendingCollapseIntentState { active: boolean; anchorScrollTop: number; @@ -152,6 +190,19 @@ interface PendingCollapseIntentState { cumulativeShrinkPx: number; } +function createInactiveCollapseIntentState(): PendingCollapseIntentState { + return { + active: false, + anchorScrollTop: 0, + toolId: null, + toolName: null, + expiresAtMs: 0, + distanceFromBottomBeforeCollapse: 0, + baseTotalCompensationPx: 0, + cumulativeShrinkPx: 0, + }; +} + interface LatestEndAnchorRequestState { turnId: string; targetIndex: number; @@ -187,6 +238,21 @@ interface BottomReservationState { pin: PinBottomReservation; } +export function transferCollapseReservationToPin( + currentState: BottomReservationState, + nextPinReservation: PinBottomReservation, +): BottomReservationState { + return { + ...currentState, + collapse: { + ...currentState.collapse, + px: 0, + floorPx: 0, + }, + pin: nextPinReservation, + }; +} + interface ScrollerGeometrySnapshot { scrollTop: number; scrollHeight: number; @@ -369,6 +435,120 @@ function getReservationConsumablePx(reservation: BottomReservationBase): number return Math.max(0, reservation.px - reservation.floorPx); } +export function consumeBottomReservationForContentGrowth( + state: BottomReservationState, + amountPx: number, + consumeStickyPinFloor: boolean, + preserveCollapseReservation = false, +): BottomReservationState { + let remaining = Math.max(0, amountPx); + + const collapseConsumablePx = preserveCollapseReservation + ? 0 + : getReservationConsumablePx(state.collapse); + const collapseConsumed = Math.min(collapseConsumablePx, remaining); + remaining -= collapseConsumed; + + const pinConsumablePx = getReservationConsumablePx(state.pin); + const pinConsumed = Math.min(pinConsumablePx, remaining); + remaining -= pinConsumed; + + const stickyPinFloorConsumed = consumeStickyPinFloor && state.pin.mode === 'sticky-latest' + ? Math.min(state.pin.floorPx, remaining) + : 0; + + return sanitizeBottomReservationState({ + collapse: { + ...state.collapse, + px: state.collapse.px - collapseConsumed, + }, + pin: { + ...state.pin, + px: state.pin.px - pinConsumed - stickyPinFloorConsumed, + floorPx: state.pin.floorPx - stickyPinFloorConsumed, + }, + }); +} + +export function shouldSyncPhysicalBottom(options: { + viewportGeometryChanged: boolean; + collapseProtectionActive: boolean; + wasAtPhysicalBottom: boolean; + ownsElementAnchor: boolean; +}): boolean { + return ( + options.viewportGeometryChanged && + !options.collapseProtectionActive && + options.wasAtPhysicalBottom && + !options.ownsElementAnchor + ); +} + +export function shouldSuppressFollowingTailNegativeScrollBy(options: { + requestedTop: number | null; + isFollowingOutput: boolean; + isStreamingOutput: boolean; + wasAtPhysicalBottom: boolean; +}): boolean { + return ( + options.requestedTop !== null && + options.requestedTop < -COMPENSATION_EPSILON_PX && + options.isFollowingOutput && + options.isStreamingOutput && + options.wasAtPhysicalBottom + ); +} + +export function getCanceledUnsettledStickyPinGrowthPx(options: { + pendingGrowthPx: number; + shrinkPx: number; + hasActiveCollapseIntent: boolean; +}): number { + if (options.hasActiveCollapseIntent) { + return 0; + } + return Math.min( + sanitizeReservationPx(options.pendingGrowthPx), + sanitizeReservationPx(options.shrinkPx), + ); +} + +export function shouldBypassShrinkCompensationInTailFollow(options: { + isFollowingOutput: boolean; + isStreamingOutput: boolean; + hasActiveCollapseIntent: boolean; +}): boolean { + return ( + options.isFollowingOutput && + options.isStreamingOutput && + !options.hasActiveCollapseIntent + ); +} + +export function shouldPreserveCollapseReservationAfterIntent(options: { + isFollowingOutput: boolean; + isStreamingOutput: boolean; +}): boolean { + return options.isFollowingOutput && options.isStreamingOutput; +} + +export function resolveAutoCollapseAnchorScrollTop(options: { + currentScrollTop: number; + previousStableScrollTop: number; + reason: string | null | undefined; + isFollowingOutput: boolean; + isStreamingOutput: boolean; +}): number { + if ( + options.reason !== 'auto' || + !options.isFollowingOutput || + !options.isStreamingOutput + ) { + return options.currentScrollTop; + } + return Math.max(options.currentScrollTop, options.previousStableScrollTop); +} + const VirtualMessageListSession = forwardRef(({ onUserScrollIntent, }, ref) => { @@ -428,8 +608,10 @@ const VirtualMessageListSession = forwardRef(null); const sessionOpenHandoffSessionIdRef = useRef(null); const previousActiveSessionIdForOpenHandoffRef = useRef(undefined); + const viewportCoordinatorRef = useRef(new FlowChatViewportCoordinator()); const pendingStaticAnchorTurnIdRef = useRef(null); const bottomReservationStateRef = useRef(createInitialBottomReservationState()); + const restoreScrollerMethodsRef = useRef<(() => void) | null>(null); const previousMeasuredHeightRef = useRef(null); const previousScrollTopRef = useRef(0); const previousScrollerGeometryRef = useRef(null); @@ -466,21 +648,27 @@ const VirtualMessageListSession = forwardRef({ - active: false, - targetScrollTop: 0, - reason: null, - lockUntilMs: 0, + const pendingCollapseIntentRef = useRef( + createInactiveCollapseIntentState(), + ); + const collapseIntentFinalizeTimerRef = useRef(null); + const collapseIntentSettleFrameRef = useRef(null); + const pendingStickyPinGrowthRef = useRef<{ + targetTurnId: string | null; + amountPx: number; + }>({ + targetTurnId: null, + amountPx: 0, }); - const pendingCollapseIntentRef = useRef({ - active: false, - anchorScrollTop: 0, - toolId: null, - toolName: null, - expiresAtMs: 0, - distanceFromBottomBeforeCollapse: 0, - baseTotalCompensationPx: 0, - cumulativeShrinkPx: 0, + const stickyPinGrowthSettleTimerRef = useRef(null); + const settlePendingStickyPinGrowthRef = useRef<(reason: string) => void>(() => {}); + const maybeHandoffPinnedTurnToTailRef = useRef<(reason: string) => boolean>(() => false); + const finalizeCollapseIntentRef = useRef<( + reason: string, + options?: { expectedExpiresAtMs?: number; suppressHandoff?: boolean }, + ) => boolean>(() => false); + const exitPinnedViewportForUserIntentRef = useRef<(reason: string) => void>((reason) => { + viewportCoordinatorRef.current.release(reason); }); const followOutputControllerRef = useRef<{ handleUserScrollIntent: () => void; @@ -494,8 +682,8 @@ const VirtualMessageListSession = forwardRef(null); // Mirror of `isFollowingOutput` for use inside listeners that are registered // once per mount. When follow mode is active we deliberately bypass collapse - // pre-compensation and anchor lock so the continuous follow loop can keep - // tracking the bottom without fighting the layout-stability machinery. + // pre-compensation and semantic-anchor restoration so the continuous follow + // loop can keep tracking the bottom without fighting layout stabilization. const isFollowingOutputRef = useRef(false); const isStreamingOutputRef = useRef(false); const previousIsStreamingOutputRef = useRef(false); @@ -534,6 +722,26 @@ const VirtualMessageListSession = forwardRef { + const heightPx = getFooterHeightPx(compensationPx); + footer.style.height = `${heightPx}px`; + footer.style.minHeight = `${heightPx}px`; + }, [getFooterHeightPx]); + + const handleFooterElementRef = useCallback((element: HTMLDivElement | null) => { + footerElementRef.current = element; + if (!element) { + return; + } + + // Virtuoso may replace its Footer during a measurement commit. Reapply + // the ref-owned reservation synchronously so the physical scroll range + // never falls back to a stale React render for one frame. + applyFooterHeightToElement(element, getTotalBottomCompensationPx()); + void element.offsetHeight; + void scrollerElementRef.current?.scrollHeight; + }, [applyFooterHeightToElement, getTotalBottomCompensationPx]); + const snapshotMeasuredContentHeight = useCallback(( scroller: HTMLElement, reservationState: BottomReservationState = bottomReservationStateRef.current, @@ -597,7 +805,9 @@ const VirtualMessageListSession = forwardRef { + const notifyUserScrollIntent = useCallback((reason = 'user-scroll-intent') => { + exitPinnedViewportForUserIntentRef.current(reason); + followOutputControllerRef.current.handleUserScrollIntent(); onUserScrollIntent?.(); }, [onUserScrollIntent]); @@ -622,7 +832,21 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { + const ownsElementAnchor = viewportCoordinatorRef.current.ownsElementAnchor(); + const willWrite = shouldSyncPhysicalBottom({ + viewportGeometryChanged, + collapseProtectionActive: pendingCollapseIntentRef.current.active, + wasAtPhysicalBottom, + ownsElementAnchor, + }) && Math.abs(maxScrollTop - scroller.scrollTop) > COMPENSATION_EPSILON_PX; + if (ownsElementAnchor) { + viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'viewport-resize'); + previousScrollTopRef.current = scroller.scrollTop; + recordScrollerGeometry(scroller); + return true; + } + + if (willWrite) { scroller.scrollTop = maxScrollTop; staticInitialHistoryUserLeftBottomRef.current = false; } @@ -718,10 +942,11 @@ const VirtualMessageListSession = forwardRef BottomReservationState), ) => { + const previous = bottomReservationStateRef.current; + const rawNext = typeof updater === 'function' ? updater(previous) : updater; + const next = sanitizeBottomReservationState(rawNext); + bottomReservationStateRef.current = next; setBottomReservationState(prev => { - const rawNext = typeof updater === 'function' ? updater(prev) : updater; - const next = sanitizeBottomReservationState(rawNext); - bottomReservationStateRef.current = next; return areBottomReservationStatesEqual(next, prev) ? prev : next; }); }, []); @@ -761,32 +986,25 @@ const VirtualMessageListSession = forwardRef { + const consumeBottomCompensation = useCallback(( + amountPx: number, + options?: { + consumeStickyPinFloor?: boolean; + preserveCollapseReservation?: boolean; + }, + ) => { if (amountPx <= COMPENSATION_EPSILON_PX) { return bottomReservationStateRef.current; } let resolvedNextState = bottomReservationStateRef.current; updateBottomReservationState(prev => { - let remaining = Math.max(0, amountPx); - - const collapseConsumablePx = getReservationConsumablePx(prev.collapse); - const collapseConsumed = Math.min(collapseConsumablePx, remaining); - remaining -= collapseConsumed; - - const pinConsumablePx = getReservationConsumablePx(prev.pin); - const pinConsumed = Math.min(pinConsumablePx, remaining); - - const nextState: BottomReservationState = { - collapse: { - ...prev.collapse, - px: Math.max(prev.collapse.floorPx, prev.collapse.px - collapseConsumed), - }, - pin: { - ...prev.pin, - px: Math.max(prev.pin.floorPx, prev.pin.px - pinConsumed), - }, - }; + const nextState = consumeBottomReservationForContentGrowth( + prev, + amountPx, + options?.consumeStickyPinFloor === true, + options?.preserveCollapseReservation === true, + ); resolvedNextState = nextState; return nextState; }); @@ -801,19 +1019,171 @@ const VirtualMessageListSession = forwardRef { + if (stickyPinGrowthSettleTimerRef.current !== null) { + window.clearTimeout(stickyPinGrowthSettleTimerRef.current); + stickyPinGrowthSettleTimerRef.current = null; + } + pendingStickyPinGrowthRef.current = { + targetTurnId: null, + amountPx: 0, + }; + }, []); + + const settlePendingStickyPinGrowth = useCallback((reason: string) => { + stickyPinGrowthSettleTimerRef.current = null; + const pending = pendingStickyPinGrowthRef.current; + if (pending.amountPx <= COMPENSATION_EPSILON_PX || !pending.targetTurnId) { + return; + } + + const currentState = bottomReservationStateRef.current; + const pinReservation = currentState.pin; + if ( + pinReservation.mode !== 'sticky-latest' || + pinReservation.targetTurnId !== pending.targetTurnId || + !viewportCoordinatorRef.current.ownsElementAnchor() + ) { + clearPendingStickyPinGrowth(`${reason}:pin-owner-changed`); + return; + } + + if (pendingCollapseIntentRef.current.active) { + stickyPinGrowthSettleTimerRef.current = window.setTimeout(() => { + settlePendingStickyPinGrowthRef.current('collapse-settled-retry'); + }, 50); + return; + } + + const consumedPx = Math.min(pending.amountPx, pinReservation.floorPx); + pendingStickyPinGrowthRef.current = { + targetTurnId: null, + amountPx: 0, + }; + if (consumedPx <= COMPENSATION_EPSILON_PX) { + clearPendingStickyPinGrowth(`${reason}:nothing-consumable`); + return; + } + + const nextState = sanitizeBottomReservationState({ + ...currentState, + pin: { + ...pinReservation, + px: pinReservation.px - consumedPx, + floorPx: pinReservation.floorPx - consumedPx, + }, + }); + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + const scroller = scrollerElementRef.current; + if (scroller) { + viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'sticky-pin-growth-settled'); + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextState); + recordScrollerGeometry(scroller); + } + maybeHandoffPinnedTurnToTailRef.current(`sticky-pin-growth-settled:${reason}`); + }, [ + applyFooterCompensationNow, + clearPendingStickyPinGrowth, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + updateBottomReservationState, + ]); + settlePendingStickyPinGrowthRef.current = settlePendingStickyPinGrowth; + + const queuePendingStickyPinGrowth = useCallback((targetTurnId: string, amountPx: number) => { + const sanitizedAmountPx = sanitizeReservationPx(amountPx); + if (sanitizedAmountPx <= COMPENSATION_EPSILON_PX) { + return; + } + + const previousPending = pendingStickyPinGrowthRef.current; + const nextPending = previousPending.targetTurnId === targetTurnId + ? { + targetTurnId, + amountPx: previousPending.amountPx + sanitizedAmountPx, + } + : { + targetTurnId, + amountPx: sanitizedAmountPx, + }; + pendingStickyPinGrowthRef.current = nextPending; + if (stickyPinGrowthSettleTimerRef.current !== null) { + window.clearTimeout(stickyPinGrowthSettleTimerRef.current); + } + stickyPinGrowthSettleTimerRef.current = window.setTimeout(() => { + settlePendingStickyPinGrowthRef.current('settle-timeout'); + }, STICKY_PIN_GROWTH_SETTLE_MS); + }, []); + + useEffect(() => () => { + if (stickyPinGrowthSettleTimerRef.current !== null) { + window.clearTimeout(stickyPinGrowthSettleTimerRef.current); + stickyPinGrowthSettleTimerRef.current = null; + } + }, []); + + const ensureSemanticAnchorRange = useCallback(( + options: Parameters[0], + ) => { + const additionalPx = sanitizeReservationPx(options.additionalPx); + const footer = footerElementRef.current; + const scroller = scrollerElementRef.current; + if (additionalPx <= COMPENSATION_EPSILON_PX || !footer || !scroller) { + return false; + } + + const previousState = bottomReservationStateRef.current; + const nextState: BottomReservationState = options.mode === 'pinned-item' + ? { + ...previousState, + pin: { + ...previousState.pin, + px: previousState.pin.px + additionalPx, + floorPx: previousState.pin.mode === 'sticky-latest' + ? previousState.pin.floorPx + additionalPx + : previousState.pin.floorPx, + }, + } + : { + ...previousState, + collapse: { + ...previousState.collapse, + px: previousState.collapse.px + additionalPx, + }, + }; + + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextState); + return true; + }, [ + applyFooterCompensationNow, + snapshotMeasuredContentHeight, + updateBottomReservationState, + ]); + + useLayoutEffect(() => { + const coordinator = viewportCoordinatorRef.current; + coordinator.setRangeHost({ + ensureBottomRange: ensureSemanticAnchorRange, + }); + return () => { + coordinator.setRangeHost(null); + }; + }, [ensureSemanticAnchorRange]); + + // When the input collapses (e.g. user sends a message), preserve its previous + // space as a reservation before synchronizing the ref-owned Footer height. + // The Virtuoso Footer has no React-owned height, so this layout effect is the + // single writer for input-stack height changes and cannot expose a smaller + // intermediate Footer to the browser. // // Detect the footer shrink in a useLayoutEffect (fires synchronously after // commit, before paint) and extend the bottom collapse reservation by the @@ -827,15 +1197,13 @@ const VirtualMessageListSession = forwardRef= performance.now()) return; + if (intent.active) return; const collapsePx = getReservationTotalPx(bottomReservationStateRef.current.collapse); if (collapsePx <= COMPENSATION_EPSILON_PX) return; const distanceFromBottom = Math.max( @@ -898,56 +1266,6 @@ const VirtualMessageListSession = forwardRef { - if (!anchorLockRef.current.active) return; - anchorLockRef.current = { - active: false, - targetScrollTop: 0, - reason: null, - lockUntilMs: 0, - }; - }, []); - - const activateAnchorLock = useCallback((targetScrollTop: number, reason: 'transition-shrink' | 'instant-shrink') => { - const nextTarget = Math.max(anchorLockRef.current.targetScrollTop, targetScrollTop); - anchorLockRef.current = { - active: true, - targetScrollTop: nextTarget, - reason, - lockUntilMs: performance.now() + ANCHOR_LOCK_DURATION_MS, - }; - }, []); - - const restoreAnchorLockNow = useCallback((reason: string) => { - const scroller = scrollerElementRef.current; - const lockState = anchorLockRef.current; - if (!scroller || !lockState.active) return false; - - const now = performance.now(); - if (now > lockState.lockUntilMs) { - const intent = pendingCollapseIntentRef.current; - const intentActive = intent.active && intent.expiresAtMs >= now; - if (!intentActive) { - releaseAnchorLock(`expired-before-${reason}`); - return false; - } - } - - const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - const targetScrollTop = Math.min(lockState.targetScrollTop, maxScrollTop); - const currentScrollTop = scroller.scrollTop; - const restoreDelta = targetScrollTop - currentScrollTop; - - if (Math.abs(restoreDelta) <= ANCHOR_LOCK_MIN_DEVIATION_PX) { - return false; - } - - scroller.scrollTop = targetScrollTop; - previousScrollTopRef.current = targetScrollTop; - recordScrollerGeometry(scroller); - return true; - }, [recordScrollerGeometry, releaseAnchorLock]); - const measureHeightChange = useCallback(() => { const scroller = scrollerElementRef.current; if (!scroller) return; @@ -990,7 +1308,13 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { @@ -1011,16 +1335,33 @@ const VirtualMessageListSession = forwardRef 0) { const collapseIntent0 = pendingCollapseIntentRef.current; - const collapseProtectionActive = collapseIntent0.active && collapseIntent0.expiresAtMs >= performance.now(); - if (currentTotalCompensation > COMPENSATION_EPSILON_PX && collapseProtectionActive) { - previousScrollTopRef.current = currentScrollTop; - recordScrollerGeometry(scroller); - return; + const collapseProtectionActive = collapseIntent0.active; + const previousReservationState = bottomReservationStateRef.current; + const pinReservation = previousReservationState.pin; + const canSettleStickyPinGrowth = ( + pinReservation.mode === 'sticky-latest' && + Boolean(pinReservation.targetTurnId) && + viewportCoordinatorRef.current.ownsElementAnchor() + ); + const nextReservationState = consumeBottomCompensation(heightDelta, { + consumeStickyPinFloor: false, + preserveCollapseReservation: collapseProtectionActive, + }); + const immediatelyConsumedPx = Math.max( + 0, + getTotalBottomCompensationPx(previousReservationState) - + getTotalBottomCompensationPx(nextReservationState), + ); + const unsettledGrowthPx = Math.max(0, heightDelta - immediatelyConsumedPx); + if (canSettleStickyPinGrowth && pinReservation.targetTurnId) { + queuePendingStickyPinGrowth( + pinReservation.targetTurnId, + Math.min(unsettledGrowthPx, nextReservationState.pin.floorPx), + ); } - - const nextReservationState = consumeBottomCompensation(heightDelta); applyFooterCompensationNow(nextReservationState); - previousScrollTopRef.current = currentScrollTop; + viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'measure-grow'); + previousScrollTopRef.current = scroller.scrollTop; recordScrollerGeometry(scroller); return; } @@ -1028,25 +1369,50 @@ const VirtualMessageListSession = forwardRef= now; + const pendingStickyPinGrowth = pendingStickyPinGrowthRef.current; + const canceledPendingGrowthPx = getCanceledUnsettledStickyPinGrowthPx({ + pendingGrowthPx: pendingStickyPinGrowth.amountPx, + shrinkPx: -heightDelta, + hasActiveCollapseIntent: collapseIntent.active, + }); + if (canceledPendingGrowthPx > COMPENSATION_EPSILON_PX) { + pendingStickyPinGrowthRef.current = { + targetTurnId: pendingStickyPinGrowth.amountPx - canceledPendingGrowthPx > COMPENSATION_EPSILON_PX + ? pendingStickyPinGrowth.targetTurnId + : null, + amountPx: Math.max(0, pendingStickyPinGrowth.amountPx - canceledPendingGrowthPx), + }; + if ( + pendingStickyPinGrowthRef.current.amountPx <= COMPENSATION_EPSILON_PX && + stickyPinGrowthSettleTimerRef.current !== null + ) { + window.clearTimeout(stickyPinGrowthSettleTimerRef.current); + stickyPinGrowthSettleTimerRef.current = null; + } + } + const shrinkAmount = Math.max(0, -heightDelta - canceledPendingGrowthPx); + if (shrinkAmount <= COMPENSATION_EPSILON_PX) { + viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'measure-unsettled-growth-reverted'); + previousScrollTopRef.current = scroller.scrollTop; + recordScrollerGeometry(scroller); + return; + } + const hasValidCollapseIntent = collapseIntent.active; // For unsignaled shrinks, the visible gap to the bottom determines the // required compensation. We no longer ratchet up via Math.max with the // previous collapse.px: stale compensation from an earlier protected @@ -1094,23 +1460,24 @@ const VirtualMessageListSession = forwardRef= performance.now() + collapseIntent.active ); // During a collapse animation, let collapse compensation own the footer space. // Recomputing sticky pin floor from intermediate DOM heights causes the two @@ -1537,13 +1903,18 @@ const VirtualMessageListSession = forwardRef { + const currentState = bottomReservationStateRef.current; + if (getReservationTotalPx(currentState.collapse) <= COMPENSATION_EPSILON_PX) { + return currentState; + } + + const scroller = scrollerElementRef.current; + const ownsPinnedItem = viewportCoordinatorRef.current.getMode() === 'pinned-item'; + const stickyPinTarget = ( + ownsPinnedItem && + currentState.pin.mode === 'sticky-latest' && + currentState.pin.targetTurnId + ) + ? currentState.pin.targetTurnId + : null; + const resolvedPinMetrics = scroller && stickyPinTarget + ? resolveTurnPinMetrics( + stickyPinTarget, + getTotalBottomCompensationPx(currentState), + ) + : null; + if (stickyPinTarget && !resolvedPinMetrics) { + return null; + } + + const nextPinReservation = stickyPinTarget && resolvedPinMetrics + ? buildPinReservation( + stickyPinTarget, + 'sticky-latest', + resolvedPinMetrics.missingTailSpace, + currentState.pin, + ) + : currentState.pin; + const nextState = transferCollapseReservationToPin(currentState, nextPinReservation); + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + if (scroller && stickyPinTarget) { + viewportCoordinatorRef.current.restoreElementAnchor( + scroller, + `collapse-drain:${reason}`, + ); + } + return nextState; + }, [ + applyFooterCompensationNow, + buildPinReservation, + getTotalBottomCompensationPx, + resolveTurnPinMetrics, + updateBottomReservationState, + ]); + + const clearCollapseIntentScheduling = useCallback(() => { + if (collapseIntentFinalizeTimerRef.current !== null) { + window.clearTimeout(collapseIntentFinalizeTimerRef.current); + collapseIntentFinalizeTimerRef.current = null; + } + if (collapseIntentSettleFrameRef.current !== null) { + cancelAnimationFrame(collapseIntentSettleFrameRef.current); + collapseIntentSettleFrameRef.current = null; + } + }, []); + + const finalizeCollapseIntent = useCallback(( + reason: string, + options?: { expectedExpiresAtMs?: number; suppressHandoff?: boolean }, + ) => { + const intent = pendingCollapseIntentRef.current; + if ( + !intent.active || + ( + options?.expectedExpiresAtMs !== undefined && + intent.expiresAtMs !== options.expectedExpiresAtMs + ) + ) { + return false; + } + + clearCollapseIntentScheduling(); + pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); + const preserveReservation = shouldPreserveCollapseReservationAfterIntent({ + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + }); + const nextState = preserveReservation + ? bottomReservationStateRef.current + : drainCollapseReservationPreservingPinnedItem(reason); + if (nextState === null) { + pendingCollapseIntentRef.current = intent; + collapseIntentFinalizeTimerRef.current = window.setTimeout(() => { + collapseIntentFinalizeTimerRef.current = null; + finalizeCollapseIntentRef.current('collapse-intent-target-retry', { + expectedExpiresAtMs: intent.expiresAtMs, + suppressHandoff: options?.suppressHandoff, + }); + }, 50); + return false; + } + + if (deferredFollowReasonRef.current) { + const deferredReason = deferredFollowReasonRef.current; + deferredFollowReasonRef.current = null; + followOutputControllerRef.current.scheduleFollowToLatest(`${deferredReason}-after-collapse`); + } + if (!options?.suppressHandoff) { + maybeHandoffPinnedTurnToTailRef.current(`collapse-finalized:${reason}`); + } + return true; + }, [clearCollapseIntentScheduling, drainCollapseReservationPreservingPinnedItem]); + finalizeCollapseIntentRef.current = finalizeCollapseIntent; + + const scheduleCollapseIntentFinalization = useCallback(( + intent: PendingCollapseIntentState, + reason: string | null | undefined, + ) => { + clearCollapseIntentScheduling(); + const expectedExpiresAtMs = intent.expiresAtMs; + collapseIntentFinalizeTimerRef.current = window.setTimeout(() => { + collapseIntentFinalizeTimerRef.current = null; + finalizeCollapseIntentRef.current('collapse-intent-timeout', { + expectedExpiresAtMs, + }); + }, Math.max(0, expectedExpiresAtMs - performance.now())); + + if (reason !== 'auto') { + return; + } + + const settle = (remainingFrames: number) => { + collapseIntentSettleFrameRef.current = requestAnimationFrame(() => { + if (remainingFrames > 1) { + settle(remainingFrames - 1); + return; + } + collapseIntentSettleFrameRef.current = null; + finalizeCollapseIntentRef.current('auto-collapse-layout-settled', { + expectedExpiresAtMs, + }); + }); + }; + settle(AUTO_COLLAPSE_SETTLE_FRAMES); + }, [clearCollapseIntentScheduling]); + + useEffect(() => clearCollapseIntentScheduling, [clearCollapseIntentScheduling]); + const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { + restoreScrollerMethodsRef.current?.(); + restoreScrollerMethodsRef.current = null; + if (el && el instanceof HTMLElement) { + const originalScrollTo = el.scrollTo; + const originalScrollBy = el.scrollBy; + const wrapScrollMethod = ( + method: 'scrollTo' | 'scrollBy', + original: typeof el.scrollTo, + ): typeof el.scrollTo => { + return ((...args: unknown[]) => { + let requestedScrollByTop: number | null = null; + if (method === 'scrollBy') { + const firstArg = args[0]; + if (typeof firstArg === 'number' && typeof args[1] === 'number') { + requestedScrollByTop = args[1]; + } else if ( + firstArg !== null && + typeof firstArg === 'object' && + 'top' in firstArg && + typeof firstArg.top === 'number' + ) { + requestedScrollByTop = firstArg.top; + } + } + const previousGeometry = previousScrollerGeometryRef.current; + const previousMaxScrollTop = previousGeometry + ? Math.max(0, previousGeometry.scrollHeight - previousGeometry.clientHeight) + : null; + const wasAtPhysicalBottomBeforeScrollBy = Boolean( + previousGeometry && + previousMaxScrollTop !== null && + Math.abs(previousMaxScrollTop - previousGeometry.scrollTop) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX + ); + const hasSemanticAnchor = ( + method === 'scrollBy' && + viewportCoordinatorRef.current.ownsElementAnchor() + ); + const suppressVirtualizerCompensation = ( + hasSemanticAnchor || + ( + method === 'scrollBy' && + shouldSuppressFollowingTailNegativeScrollBy({ + requestedTop: requestedScrollByTop, + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + wasAtPhysicalBottom: wasAtPhysicalBottomBeforeScrollBy, + }) + ) + ); + if (!suppressVirtualizerCompensation) { + Reflect.apply(original, el, args); + } + }) as typeof el.scrollTo; + }; + const wrappedScrollTo = typeof originalScrollTo === 'function' + ? wrapScrollMethod('scrollTo', originalScrollTo) + : null; + const wrappedScrollBy = typeof originalScrollBy === 'function' + ? wrapScrollMethod('scrollBy', originalScrollBy) + : null; + if (wrappedScrollTo) { + el.scrollTo = wrappedScrollTo; + } + if (wrappedScrollBy) { + el.scrollBy = wrappedScrollBy; + } + restoreScrollerMethodsRef.current = () => { + if (wrappedScrollTo && el.scrollTo === wrappedScrollTo) { + el.scrollTo = originalScrollTo; + } + if (wrappedScrollBy && el.scrollBy === wrappedScrollBy) { + el.scrollBy = originalScrollBy; + } + }; scrollerElementRef.current = el; setScrollerElement(el); return; @@ -2049,7 +2642,7 @@ const VirtualMessageListSession = forwardRef { const collapseIntent = pendingCollapseIntentRef.current; - return collapseIntent.active && collapseIntent.expiresAtMs >= performance.now(); + return collapseIntent.active; }, []); const scheduleFollowToLatestWithViewportState = useCallback((reason: string) => { @@ -2065,25 +2658,13 @@ const VirtualMessageListSession = forwardRef { previousMeasuredHeightRef.current = null; previousScrollTopRef.current = 0; + viewportCoordinatorRef.current.release('session-reset'); clearTurnPinRequest(); cancelLatestEndAnchorStabilization(); cancelStaticInitialHistoryBottomGuard(); - anchorLockRef.current = { - active: false, - targetScrollTop: 0, - reason: null, - lockUntilMs: 0, - }; - pendingCollapseIntentRef.current = { - active: false, - anchorScrollTop: 0, - toolId: null, - toolName: null, - expiresAtMs: 0, - distanceFromBottomBeforeCollapse: 0, - baseTotalCompensationPx: 0, - cumulativeShrinkPx: 0, - }; + clearCollapseIntentScheduling(); + clearPendingStickyPinGrowth('session-reset'); + pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); previousScrollerGeometryRef.current = null; pendingStaticAnchorTurnIdRef.current = null; pendingStaticTurnPinRef.current = null; @@ -2097,6 +2678,8 @@ const VirtualMessageListSession = forwardRef { return () => { @@ -2206,60 +2796,22 @@ const VirtualMessageListSession = forwardRef { const now = performance.now(); const intent = pendingCollapseIntentRef.current; - const stillActive = intent.active && intent.expiresAtMs >= now; - if (!stillActive && intent.active) { - // Collapse intent just expired — drain any residual collapse - // compensation. When the intent was active, consumption was blocked - // in measureHeightChange (grow branch early return). Now that the - // protection is over, collapse.px would only be consumed by future - // content growth or user scroll, which may never happen if the - // content has already finished arriving. Drain it immediately. - const collapsePx = getReservationTotalPx(bottomReservationStateRef.current.collapse); - if (collapsePx > COMPENSATION_EPSILON_PX) { - const next: BottomReservationState = { - ...bottomReservationStateRef.current, - collapse: { - ...bottomReservationStateRef.current.collapse, - px: 0, - floorPx: 0, - }, - }; - updateBottomReservationState(next); - applyFooterCompensationNow(next); - } - pendingCollapseIntentRef.current = { - active: false, - anchorScrollTop: 0, - toolId: null, - toolName: null, - expiresAtMs: 0, - distanceFromBottomBeforeCollapse: 0, - baseTotalCompensationPx: 0, - cumulativeShrinkPx: 0, - }; - } - if (deferredFollowReasonRef.current && !shouldSuspendAutoFollow()) { - const deferredReason = deferredFollowReasonRef.current; - deferredFollowReasonRef.current = null; - followOutputControllerRef.current.scheduleFollowToLatest(`${deferredReason}-after-collapse`); + if (intent.active && intent.expiresAtMs < now) { + finalizeCollapseIntent('collapse-intent-scroll-backup', { + expectedExpiresAtMs: intent.expiresAtMs, + }); } }; const handleScroll = () => { const now = performance.now(); const intent = pendingCollapseIntentRef.current; - const collapseProtectionActive = intent.active && intent.expiresAtMs >= now; - if (anchorLockRef.current.active && now > anchorLockRef.current.lockUntilMs && !collapseProtectionActive) { - releaseAnchorLock('expired-before-scroll'); - } + const collapseProtectionActive = intent.active; // Reactive shrink-clamp restore: in follow + streaming mode, an upward // jump in scrollTop that we did NOT request from JS and that is NOT @@ -2284,7 +2836,6 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX && - !anchorLockRef.current.active && !collapseProtectionActive ) { const nextScrollTop = scrollerElement.scrollTop; @@ -2316,19 +2866,10 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { - const nextScrollTop = scrollerElement.scrollTop; - const maxScrollTop = Math.max(0, scrollerElement.scrollHeight - scrollerElement.clientHeight); - if (anchorLockRef.current.active && performance.now() <= anchorLockRef.current.lockUntilMs) { - const targetScrollTop = Math.min(anchorLockRef.current.targetScrollTop, maxScrollTop); - const restoreDelta = targetScrollTop - nextScrollTop; - if (Math.abs(restoreDelta) > ANCHOR_LOCK_MIN_DEVIATION_PX) { - scrollerElement.scrollTop = targetScrollTop; - previousScrollTopRef.current = targetScrollTop; - recordScrollerGeometry(scrollerElement); - return; - } - } + if (viewportCoordinatorRef.current.restoreElementAnchor(scrollerElement, 'scroll-handler')) { + previousScrollTopRef.current = scrollerElement.scrollTop; + recordScrollerGeometry(scrollerElement); + return; } previousScrollTopRef.current = scrollerElement.scrollTop; recordScrollerGeometryIfLayoutStable(scrollerElement); @@ -2342,10 +2883,6 @@ const VirtualMessageListSession = forwardRef anchorLockRef.current.lockUntilMs && !collapseProtectionActive) { - releaseAnchorLock('expired-after-scroll'); - } - replayDeferredFollowIfSettled(); }; scrollerElement.addEventListener('scroll', handleScroll, { passive: true }); @@ -2363,7 +2900,6 @@ const VirtualMessageListSession = forwardRef { @@ -2438,7 +2972,6 @@ const VirtualMessageListSession = forwardRef { @@ -2459,7 +2992,6 @@ const VirtualMessageListSession = forwardRef { @@ -2489,26 +3021,32 @@ const VirtualMessageListSession = forwardRef).detail; - // In follow-output + streaming mode, skip the collapse compensation path - // entirely. The user wants the viewport tracking the latest streaming - // token; footer compensation + anchor lock would freeze the viewport on - // older content and require a deferred follow path to resume, which is - // the source of the "occasionally not at the bottom" bug. Instead, let - // the continuous follow loop (60fps RAF) re-pin to the bottom on the - // next frame — the shrink is absorbed in ~16ms and invisible to the user. - // Not injecting compensation here also means nothing accumulates, so - // issue #1176 (permanent whitespace) cannot occur in this code path. - if (isFollowingOutputRef.current && isStreamingOutputRef.current) { - return; + if (pendingCollapseIntentRef.current.active) { + finalizeCollapseIntent('collapse-intent-superseded', { + expectedExpiresAtMs: pendingCollapseIntentRef.current.expiresAtMs, + suppressHandoff: true, + }); } + viewportCoordinatorRef.current.preserveElement(detail?.anchorElement); + const baseTotalCompensationPx = getTotalBottomCompensationPx(); + const currentScrollTop = scrollerElement.scrollTop; + const previousStableScrollTop = previousScrollTopRef.current; + const anchorScrollTop = resolveAutoCollapseAnchorScrollTop({ + currentScrollTop, + previousStableScrollTop, + reason: detail?.reason, + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + }); const distanceFromBottom = Math.max( 0, - scrollerElement.scrollHeight - scrollerElement.clientHeight - scrollerElement.scrollTop + scrollerElement.scrollHeight - scrollerElement.clientHeight - currentScrollTop ); const effectiveDistanceFromBottom = Math.max(0, distanceFromBottom - baseTotalCompensationPx); const estimatedShrink = Math.max(0, detail?.cardHeight ?? 0); @@ -2516,16 +3054,17 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { const nextReservationState: BottomReservationState = { ...bottomReservationStateRef.current, @@ -2537,9 +3076,38 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { + let maxScrollTop = Math.max( + 0, + scrollerElement.scrollHeight - scrollerElement.clientHeight, + ); + let rangeExtensionPx = 0; + if (anchorScrollTop - maxScrollTop > COMPENSATION_EPSILON_PX) { + rangeExtensionPx = anchorScrollTop - maxScrollTop + 1; + const currentState = bottomReservationStateRef.current; + const extendedState: BottomReservationState = { + ...currentState, + collapse: { + ...currentState.collapse, + px: currentState.collapse.px + rangeExtensionPx, + }, + }; + updateBottomReservationState(extendedState); + applyFooterCompensationNow(extendedState); + maxScrollTop = Math.max( + 0, + scrollerElement.scrollHeight - scrollerElement.clientHeight, + ); + } + scrollerElement.scrollTop = Math.min(anchorScrollTop, maxScrollTop); + previousScrollTopRef.current = scrollerElement.scrollTop; + recordScrollerGeometry(scrollerElement); + } + + scheduleCollapseIntentFinalization(nextIntent, detail?.reason); + scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); }; @@ -2599,12 +3167,12 @@ const VirtualMessageListSession = forwardRef { + const clearPinReservationForUserNavigation = useCallback(( + reason = 'user-navigation', + options?: { preserveCurrentRange?: boolean }, + ) => { const currentState = bottomReservationStateRef.current; const scroller = scrollerElementRef.current; const hasActivePin = ( @@ -3007,8 +3571,8 @@ const VirtualMessageListSession = forwardRef { + viewportCoordinatorRef.current.release(reason); + clearPinReservationForUserNavigation(reason, { preserveCurrentRange: true }); + }; + const isStreamingOutput = React.useMemo(() => { if (isProcessing) { return true; @@ -3094,65 +3669,79 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { - const next = { - ...bottomReservationStateRef.current, - collapse: { - ...bottomReservationStateRef.current.collapse, - px: 0, - floorPx: 0, - }, - }; + // Collapse compensation can be much larger than the tail space needed to + // keep a sticky user message pinned. Transfer it to the pin reservation in + // one DOM update so reducing the footer cannot temporarily remove the scroll + // range beneath the semantic anchor. + const currentReservationState = bottomReservationStateRef.current; + const collapsePx = getReservationTotalPx(currentReservationState.collapse); + const pendingStickyPinGrowthPx = pendingStickyPinGrowthRef.current.amountPx; + if ( + collapsePx > COMPENSATION_EPSILON_PX || + pendingStickyPinGrowthPx > COMPENSATION_EPSILON_PX + ) { + const stickyPinTarget = ( + currentReservationState.pin.mode === 'sticky-latest' && + currentReservationState.pin.targetTurnId + ) + ? currentReservationState.pin.targetTurnId + : null; + const resolvedPinMetrics = scroller && stickyPinTarget + ? resolveTurnPinMetrics( + stickyPinTarget, + getTotalBottomCompensationPx(currentReservationState), + ) + : null; + if (stickyPinTarget && !resolvedPinMetrics) { + // Keep the existing reservation until the virtualized target is + // measurable. Clearing it here would necessarily move the viewport. + return; + } + + const requiredPinPx = resolvedPinMetrics + ? sanitizeReservationPx(resolvedPinMetrics.missingTailSpace) + : 0; + const nextPinReservation = stickyPinTarget && resolvedPinMetrics + ? { + ...currentReservationState.pin, + px: requiredPinPx, + floorPx: requiredPinPx, + mode: 'sticky-latest' as const, + targetTurnId: stickyPinTarget, + } + : currentReservationState.pin; + const next = transferCollapseReservationToPin( + currentReservationState, + nextPinReservation, + ); updateBottomReservationState(next); applyFooterCompensationNow(next); + if (scroller && stickyPinTarget) { + viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'stream-end-reservation-transfer'); + } // Footer height shrank: if we were following the bottom, re-pin in the // same turn to avoid a one-frame whole-pane jump that looks like a flash. - if (scroller && wasNearBottom) { + if (scroller && wasNearBottom && !stickyPinTarget) { scroller.scrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); } } // Clear any lingering collapse intent so auto-follow and compensation // consumption resume immediately after the turn ends. - pendingCollapseIntentRef.current = { - active: false, - anchorScrollTop: 0, - toolId: null, - toolName: null, - expiresAtMs: 0, - distanceFromBottomBeforeCollapse: 0, - baseTotalCompensationPx: 0, - cumulativeShrinkPx: 0, - }; + clearCollapseIntentScheduling(); + clearPendingStickyPinGrowth('stream-end-reconciled'); + pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); + maybeHandoffPinnedTurnToTailRef.current('stream-end'); - const pinReservation = bottomReservationStateRef.current.pin; - if ( - pinReservation.mode !== 'sticky-latest' || - !pinReservation.targetTurnId || - getReservationTotalPx(pinReservation) <= COMPENSATION_EPSILON_PX - ) { - return; - } - - clearPinReservationForUserNavigation(); - requestAnimationFrame(() => { - const liveScroller = scrollerElementRef.current; - if (!liveScroller) { - return; - } - const maxScrollTop = Math.max(0, liveScroller.scrollHeight - liveScroller.clientHeight); - // Avoid a no-op scrollTo that still forces a layout pass / visual hitch. - if (Math.abs(liveScroller.scrollTop - maxScrollTop) > 1) { - liveScroller.scrollTop = maxScrollTop; - } - staticInitialHistoryUserLeftBottomRef.current = false; - }); - }, [applyFooterCompensationNow, clearPinReservationForUserNavigation, isStreamingOutput, updateBottomReservationState]); + }, [ + applyFooterCompensationNow, + clearCollapseIntentScheduling, + clearPendingStickyPinGrowth, + getTotalBottomCompensationPx, + isStreamingOutput, + resolveTurnPinMetrics, + updateBottomReservationState, + ]); const scrollToLatestEndPositionInternal = useCallback((behavior: 'auto' | 'smooth') => { const scroller = scrollerElementRef.current; @@ -3199,7 +3788,9 @@ const VirtualMessageListSession = forwardRef { + if (!viewportCoordinatorRef.current.followTail()) { + return; + } scrollToLatestEndPositionInternal('auto'); }, [scrollToLatestEndPositionInternal]); @@ -3335,6 +3933,7 @@ const VirtualMessageListSession = forwardRef { + viewportCoordinatorRef.current.followTail({ force: true }); scrollToLatestEndPositionInternal('smooth'); }, performAutoFollowScroll: performAutoFollowSync, @@ -3346,6 +3945,7 @@ const VirtualMessageListSession = forwardRef { + const maybeHandoffPinnedTurnToTail = useCallback((_reason: string) => { const trackingState = latestTurnAutoFollowStateRef.current; if ( !latestTurnId || trackingState.turnId !== latestTurnId || - isFollowingOutput || - !isStreamingOutput + isFollowingOutput ) { - return; + return false; } const hasPendingLatestStickyPin = ( @@ -3483,19 +4082,31 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { + if (reservationState.pin.floorPx > COMPENSATION_EPSILON_PX) { trackingState.sawPositiveFloor = true; - return; + } + + const collapseIntent = pendingCollapseIntentRef.current; + const hasPendingCollapseIntent = ( + collapseIntent.active + ); + if (!canHandoffPinnedItemToTail({ + pinReservationPx: reservationState.pin.px, + collapseReservationPx: reservationState.collapse.px, + hasPendingCollapseIntent, + })) { + return false; } if (activateArmedFollowOutput()) { @@ -3503,18 +4114,29 @@ const VirtualMessageListSession = forwardRef { + maybeHandoffPinnedTurnToTail('reservation-state-change'); + }, [ + bottomReservationState.collapse.px, + bottomReservationState.pin.floorPx, + bottomReservationState.pin.mode, + bottomReservationState.pin.px, + bottomReservationState.pin.targetTurnId, + isStreamingOutput, + maybeHandoffPinnedTurnToTail, + ]); followOutputControllerRef.current = { handleUserScrollIntent, @@ -3524,44 +4146,36 @@ const VirtualMessageListSession = forwardRef { + if (isFollowingOutput) { + viewportCoordinatorRef.current.followTail({ force: true }); + return; + } + if (viewportCoordinatorRef.current.getMode() === 'following-tail') { + viewportCoordinatorRef.current.release('follow-ended'); + } + }, [isFollowingOutput, isStreamingOutput]); + + // When entering follow-output during streaming, end any active protection + // window but keep its reservation. Once the intent is inactive, real content + // growth can consume that range without exposing a one-frame shrink. const previousIsFollowingOutputRef = useRef(false); useEffect(() => { if (!previousIsFollowingOutputRef.current && isFollowingOutput && isStreamingOutput) { const intent = pendingCollapseIntentRef.current; if (intent.active) { - pendingCollapseIntentRef.current = { - active: false, - anchorScrollTop: 0, - toolId: null, - toolName: null, - expiresAtMs: 0, - distanceFromBottomBeforeCollapse: 0, - baseTotalCompensationPx: 0, - cumulativeShrinkPx: 0, - }; - } - const collapsePx = getReservationTotalPx(bottomReservationStateRef.current.collapse); - if (collapsePx > COMPENSATION_EPSILON_PX) { - const next = { - ...bottomReservationStateRef.current, - collapse: { - ...bottomReservationStateRef.current.collapse, - px: 0, - floorPx: 0, - }, - }; - updateBottomReservationState(next); - applyFooterCompensationNow(next); + finalizeCollapseIntent('follow-output-entered', { + expectedExpiresAtMs: intent.expiresAtMs, + suppressHandoff: true, + }); } } previousIsFollowingOutputRef.current = isFollowingOutput; - }, [applyFooterCompensationNow, isFollowingOutput, isStreamingOutput, updateBottomReservationState]); + }, [ + finalizeCollapseIntent, + isFollowingOutput, + isStreamingOutput, + ]); const scrollToTurn = useCallback((turnIndex: number) => { if (!virtuosoRef.current) return; @@ -4158,7 +4772,7 @@ const VirtualMessageListSession = forwardRef(() => { if (!activeSessionId || !latestTurnId) { return null; @@ -4714,8 +5328,6 @@ const VirtualMessageListSession = forwardRef { if (!useStaticInitialHistoryList) { autoScrolledInitialHistoryRenderKeyRef.current = null; @@ -4821,6 +5433,44 @@ const VirtualMessageListSession = forwardRef previousHistoryBoundaryStatus?.sessionId === activeSessionId ? ( +
+ {previousHistoryBoundaryStatus.state === 'preparing' + ? t('historyState.preparingOlderHistory') + : t('historyState.olderHistoryNotReady')} +
+ ) : null, + [activeSessionId, previousHistoryBoundaryStatus, t], + ); + const virtuosoContext = React.useMemo(() => ({ + // Reservation pixels are applied imperatively to the stable Footer node. + // Keeping them out of context prevents a measurement-sensitive Virtuoso + // render for every compensation update. + footerRef: handleFooterElementRef, + previousHistoryBoundaryStatusNode, + reserveSpaceForIndicator, + showBreathingIndicator, + }), [ + handleFooterElementRef, + previousHistoryBoundaryStatusNode, + reserveSpaceForIndicator, + showBreathingIndicator, + ]); + const computeVirtuosoItemKey = useCallback((_: number, item: VirtualItem) => ( + `${activeSessionId ?? 'no-active-session'}:${getVirtualItemStableKey(item)}` + ), [activeSessionId]); + const renderVirtuosoItem = useCallback((index: number, item: VirtualItem) => ( + + ), [virtuosoFirstItemIndex]); // ── Render ──────────────────────────────────────────────────────────── if (virtualItems.length === 0) { return ( @@ -4835,20 +5485,6 @@ const VirtualMessageListSession = forwardRef - {previousHistoryBoundaryStatus.state === 'preparing' - ? t('historyState.preparingOlderHistory') - : t('historyState.olderHistoryNotReady')} -
- ) : null; - return (
`${activeSessionId ?? 'no-active-session'}:${getVirtualItemStableKey(item)}`} - itemContent={(index, item) => ( - - )} + computeItemKey={computeVirtuosoItemKey} + itemContent={renderVirtuosoItem} followOutput={false} alignToBottom={false} @@ -4924,7 +5555,7 @@ const VirtualMessageListSession = forwardRef ( - <> -
- {previousHistoryBoundaryStatusNode} - - ), - Footer: () => ( - <> - -
- - ), - }} + context={virtuosoContext} + components={FLOW_CHAT_VIRTUOSO_COMPONENTS} /> )} diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index ff8c97159c..3e75be4d47 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -143,10 +143,11 @@ export const FileOperationToolCard: React.FC = ({ const previousExpansionStatusRef = useRef(status); const previousFailureStatusRef = useRef(status); const userToggledContentRef = useRef(false); - const lastStableExpandedHeightRef = useRef(0); const { cardRootRef, applyExpandedState: applyHeightContractExpandedState, + dispatchCollapseIntent, + dispatchToolCardToggle, } = useToolCardHeightContract({ toolId, toolName: toolItem.toolName, @@ -573,13 +574,6 @@ export const FileOperationToolCard: React.FC = ({ writeTypewriter.isRevealing, ]); - useLayoutEffect(() => { - const measuredHeight = cardRootRef.current?.getBoundingClientRect().height ?? 0; - if (!isFailed && isContentExpanded && measuredHeight > 0) { - lastStableExpandedHeightRef.current = measuredHeight; - } - }, [cardRootRef, isContentExpanded, isFailed, previewVariant, status]); - useLayoutEffect(() => { const previousStatus = previousFailureStatusRef.current; previousFailureStatusRef.current = status; @@ -588,29 +582,15 @@ export const FileOperationToolCard: React.FC = ({ return; } - const currentMeasuredHeight = cardRootRef.current?.getBoundingClientRect().height ?? 0; - const lastStableExpandedHeight = lastStableExpandedHeightRef.current; - const estimatedShrinkHeight = Math.max(lastStableExpandedHeight, currentMeasuredHeight); - - if (estimatedShrinkHeight <= currentMeasuredHeight + 0.5) { - return; - } - - window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { - detail: { - toolId: toolId ?? null, - toolName: toolItem.toolName, - cardHeight: estimatedShrinkHeight, - filePath: currentFilePath || null, - reason: 'auto', - }, - })); - window.dispatchEvent(new CustomEvent('tool-card-toggle')); + dispatchCollapseIntent('auto', { + filePath: currentFilePath || null, + }); + dispatchToolCardToggle(); }, [ - cardRootRef, currentFilePath, + dispatchCollapseIntent, + dispatchToolCardToggle, isContentExpanded, - previewVariant, status, toolId, toolItem.toolName, diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index adba6a6b35..8a99e0945b 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -56,6 +56,7 @@ export const ModelThinkingDisplay: React.FC = ({ const { applyExpandedState } = useToolCardHeightContract({ toolId: thinkingItem.id, toolName: 'thinking', + getAnchorElement: () => wrapperRef.current, getCardHeight: () => { const contentScrollHeight = contentRef.current?.scrollHeight ?? null; const wrapperHeight = wrapperRef.current?.getBoundingClientRect().height ?? null; diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx index 3413954f88..1d17199494 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx @@ -1,6 +1,53 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; import { createTodoRenderItems } from './todoRenderItems'; +import { TodoWriteDisplay } from './TodoWriteDisplay'; + +vi.mock('react-i18next', async (importOriginal) => ({ + ...await importOriginal(), + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../hooks/useDialogTurnTodos', () => ({ + useDialogTurnTodos: () => [], +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const config: ToolCardConfig = { + toolName: 'TodoWrite', + displayName: 'TodoWrite', + icon: 'list-todo', + requiresConfirmation: false, + resultDisplayType: 'detailed', + displayMode: 'standard', +}; + +function createTodoWriteItem(status: 'pending' | 'in_progress'): FlowToolItem { + return { + id: 'todo-tool-a', + type: 'tool', + toolName: 'TodoWrite', + timestamp: 1, + status: 'streaming', + isParamsStreaming: true, + partialParams: { + todos: [{ id: 'todo-a', content: 'Implement change', status }], + }, + toolCall: { + id: 'todo-tool-a', + input: {}, + }, + }; +} describe('createTodoRenderItems', () => { it('keeps React render keys unique when restored todos reuse ids', () => { @@ -18,3 +65,66 @@ describe('createTodoRenderItems', () => { ]); }); }); + +describe('TodoWriteDisplay automatic collapse', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + const height = (this as HTMLElement).querySelector?.('.todo-expanded-body') ? 320 : 64; + return { + bottom: height, + height, + left: 0, + right: 300, + top: 0, + width: 300, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('publishes an auto collapse intent before removing expanded todos', () => { + let receivedDetail: Record | null = null; + let hadExpandedBodyWhenIntentFired = false; + const handleIntent = (event: Event) => { + receivedDetail = (event as CustomEvent>).detail; + hadExpandedBodyWhenIntentFired = Boolean(container.querySelector('.todo-expanded-body')); + }; + window.addEventListener('flowchat:tool-card-collapse-intent', handleIntent); + + try { + act(() => { + root.render(); + }); + expect(container.querySelector('.todo-expanded-body')).not.toBeNull(); + + act(() => { + root.render(); + }); + + expect(hadExpandedBodyWhenIntentFired).toBe(true); + expect(receivedDetail).toMatchObject({ + toolId: 'todo-tool-a', + toolName: 'TodoWrite', + cardHeight: 320, + reason: 'auto', + }); + expect(container.querySelector('.todo-expanded-body')).toBeNull(); + } finally { + window.removeEventListener('flowchat:tool-card-collapse-intent', handleIntent); + } + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx index da7bfb1914..e490780347 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx @@ -2,7 +2,7 @@ * Tool card for TodoWrite. */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useLayoutEffect } from 'react'; import { ListTodo, CheckCircle2, Circle, XCircle } from 'lucide-react'; import { TaskRunningIndicator } from '../../component-library'; import { useTranslation } from 'react-i18next'; @@ -73,10 +73,32 @@ export const TodoWriteDisplay: React.FC = ({ ? { status: 'completed' as const, defaultIcon: 'status' as const } : { status, defaultIcon: 'tool' as const }; - const isExpanded = useMemo(() => { - if (expandedState !== null) return expandedState; + const desiredAutomaticExpanded = useMemo(() => { return inProgressTasks.length === 0 && todosToDisplay.length > 0 && !isAllCompleted; - }, [expandedState, inProgressTasks.length, todosToDisplay.length, isAllCompleted]); + }, [inProgressTasks.length, todosToDisplay.length, isAllCompleted]); + const [automaticExpanded, setAutomaticExpanded] = useState(desiredAutomaticExpanded); + + // Keep the currently rendered automatic state for one layout commit. This + // lets the shared height contract publish the collapse intent before the + // second synchronous commit removes the expanded body. + useLayoutEffect(() => { + if (expandedState !== null || automaticExpanded === desiredAutomaticExpanded) { + return; + } + applyExpandedState( + automaticExpanded, + desiredAutomaticExpanded, + setAutomaticExpanded, + { reason: 'auto' }, + ); + }, [ + applyExpandedState, + automaticExpanded, + desiredAutomaticExpanded, + expandedState, + ]); + + const isExpanded = expandedState ?? automaticExpanded; const isLoading = status === 'preparing' || status === 'streaming' || status === 'running'; diff --git a/src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.test.tsx b/src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.test.tsx new file mode 100644 index 0000000000..415ce3e279 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom + +import React, { useLayoutEffect } from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useToolCardHeightContract } from './useToolCardHeightContract'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function Harness({ height, collapse }: { height: number; collapse: boolean }) { + const { cardRootRef, dispatchCollapseIntent } = useToolCardHeightContract({ + toolId: 'tool-a', + toolName: 'Write', + }); + + useLayoutEffect(() => { + if (collapse) { + dispatchCollapseIntent('auto'); + } + }, [collapse, dispatchCollapseIntent]); + + return
; +} + +function CustomAnchorHarness({ collapse }: { collapse: boolean }) { + const anchorRef = React.useRef(null); + const { dispatchCollapseIntent } = useToolCardHeightContract({ + toolId: 'thinking-a', + toolName: 'thinking', + getAnchorElement: () => anchorRef.current, + getCardHeight: () => 240, + }); + + useLayoutEffect(() => { + if (collapse) { + dispatchCollapseIntent('auto'); + } + }, [collapse, dispatchCollapseIntent]); + + return
; +} + +describe('useToolCardHeightContract', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + const height = Number((this as HTMLElement).dataset.height ?? 0); + return { + bottom: height, + height, + left: 0, + right: 300, + top: 0, + width: 300, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('reports the pre-collapse height and semantic anchor after a state-driven shrink', () => { + let receivedDetail: Record | null = null; + const handleIntent = (event: Event) => { + receivedDetail = (event as CustomEvent>).detail; + }; + window.addEventListener('flowchat:tool-card-collapse-intent', handleIntent); + + try { + act(() => root.render()); + act(() => root.render()); + + expect(receivedDetail).toMatchObject({ + toolId: 'tool-a', + toolName: 'Write', + cardHeight: 320, + reason: 'auto', + }); + expect(receivedDetail?.anchorElement).toBe(container.firstElementChild); + } finally { + window.removeEventListener('flowchat:tool-card-collapse-intent', handleIntent); + } + }); + + it('supports a semantic anchor owned by a non-card wrapper', () => { + let receivedDetail: Record | null = null; + const handleIntent = (event: Event) => { + receivedDetail = (event as CustomEvent>).detail; + }; + window.addEventListener('flowchat:tool-card-collapse-intent', handleIntent); + + try { + act(() => root.render()); + expect(receivedDetail).toMatchObject({ + toolId: 'thinking-a', + toolName: 'thinking', + cardHeight: 240, + }); + expect(receivedDetail?.anchorElement).toBe( + container.querySelector('[data-testid="custom-anchor"]'), + ); + } finally { + window.removeEventListener('flowchat:tool-card-collapse-intent', handleIntent); + } + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts b/src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts index 84dc084b82..9284d06066 100644 --- a/src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts +++ b/src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts @@ -1,10 +1,11 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useLayoutEffect, useRef } from 'react'; export type ToolCardCollapseReason = 'manual' | 'auto'; interface UseToolCardHeightContractOptions { toolId: string | null | undefined; toolName: string; getCardHeight?: () => number | null; + getAnchorElement?: () => HTMLElement | null; } interface ApplyHeightContractOptions { @@ -17,8 +18,20 @@ export function useToolCardHeightContract({ toolId, toolName, getCardHeight, + getAnchorElement, }: UseToolCardHeightContractOptions) { const cardRootRef = useRef(null); + const lastMeasuredHeightRef = useRef(0); + const previousMeasuredHeightRef = useRef(0); + + useLayoutEffect(() => { + const nextHeight = cardRootRef.current?.getBoundingClientRect().height ?? 0; + if (nextHeight <= 0) { + return; + } + previousMeasuredHeightRef.current = lastMeasuredHeightRef.current; + lastMeasuredHeightRef.current = nextHeight; + }); const dispatchToolCardToggle = useCallback(() => { window.dispatchEvent(new CustomEvent('tool-card-toggle')); @@ -28,20 +41,26 @@ export function useToolCardHeightContract({ reason: ToolCardCollapseReason, detail?: Record, ) => { - const cardHeight = getCardHeight?.() - ?? cardRootRef.current?.getBoundingClientRect().height - ?? null; + const measuredHeight = cardRootRef.current?.getBoundingClientRect().height ?? 0; + const customHeight = getCardHeight?.() ?? 0; + const cardHeight = Math.max( + customHeight, + measuredHeight, + lastMeasuredHeightRef.current, + previousMeasuredHeightRef.current, + ) || null; window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { detail: { + ...detail, toolId: toolId ?? null, toolName, cardHeight, + anchorElement: getAnchorElement?.() ?? cardRootRef.current, reason, - ...detail, }, })); - }, [getCardHeight, toolId, toolName]); + }, [getAnchorElement, getCardHeight, toolId, toolName]); const applyExpandedState = useCallback(( currentExpanded: boolean,