From 6f2ec2b32df546ac0b8d77ecc44533de080c7089 Mon Sep 17 00:00:00 2001 From: czs914893385 Date: Tue, 13 Oct 2026 16:08:49 +0800 Subject: [PATCH 1/2] perf(web-ui): stop re-measuring transcripts while a pane divider is dragged --- .../ui/src/components/SplitView/SplitView.tsx | 29 ++++++- .../components/panels/base/FlexiblePanel.tsx | 4 + .../panels/content-canvas/ContentCanvas.tsx | 4 + .../content-canvas/editor-area/EditorArea.tsx | 4 + .../editor-area/EditorGroup.tsx | 4 + src/web-ui/src/app/scenes/session/AuxPane.tsx | 5 +- .../session/ChatPane.composer-layout.test.tsx | 12 ++- .../src/app/scenes/session/ChatPane.tsx | 4 + .../src/app/scenes/session/SessionScene.tsx | 7 +- .../components/btw/BtwSessionPanel.tsx | 4 + .../btw/BtwVirtualSessionList.test.tsx | 81 ++++++++++++++++++- .../components/btw/BtwVirtualSessionList.tsx | 30 ++++++- .../modern/ModernFlowChatContainer.tsx | 4 + .../components/modern/VirtualMessageList.tsx | 35 +++++++- ...seFlowChatVirtualizer.measurement.test.tsx | 56 ++++++++++++- .../modern/useFlowChatVirtualizer.ts | 33 +++++++- src/web-ui/src/shared/ui/SplitView.test.tsx | 72 ++++++++++++++++- 17 files changed, 370 insertions(+), 18 deletions(-) diff --git a/design-system/packages/ui/src/components/SplitView/SplitView.tsx b/design-system/packages/ui/src/components/SplitView/SplitView.tsx index d1c4536b6f..2074fbe12e 100644 --- a/design-system/packages/ui/src/components/SplitView/SplitView.tsx +++ b/design-system/packages/ui/src/components/SplitView/SplitView.tsx @@ -60,6 +60,10 @@ export function SplitView({ const frameRef = useRef(null); const dragRef = useRef<{ pointerId: number; target: HTMLDivElement; x: number; size: number; latest: number; + /** Container width sampled once at pointerdown, so moves never force a layout read. */ + containerWidth: number; + /** Last size written to the CSS variable, so a sub-pixel move can skip the write. */ + painted: number; cursor: string; userSelect: string; } | null>(null); const callbacks = useRef({ onRightSizeChange, onResizeStateChange }); @@ -88,6 +92,16 @@ export function SplitView({ return () => { observer?.disconnect(); window.removeEventListener('resize', measure); }; }, [mode, layout]); + // `--_split-view-right-size` is what the pane geometry actually reads, and React + // rewrites it whenever `resolvedSize` changes (a container/window resize during the + // drag re-renders with a new resolved size while the drag keeps writing the variable + // directly). Keep the sub-pixel guard anchored to the displayed value, otherwise a + // later move that rounds to the size the drag last wrote would be skipped and the + // pane would stay at the re-rendered width for the rest of the gesture. + useLayoutEffect(() => { + if (dragRef.current) dragRef.current.painted = resolvedSize; + }, [resolvedSize]); + const finishResize = useCallback((commit: boolean) => { const drag = dragRef.current; if (!drag) return; @@ -116,15 +130,20 @@ export function SplitView({ const updateDrag = (event: PointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; - const measuredWidth = rootRef.current?.getBoundingClientRect().width ?? 0; - const currentBounds = measuredWidth > 0 - ? sizeBounds(measuredWidth, dividerWidth, minLeftSize, minRightSize, maxRightSize) : bounds; + const currentBounds = drag.containerWidth > 0 + ? sizeBounds(drag.containerWidth, dividerWidth, minLeftSize, minRightSize, maxRightSize) : bounds; const delta = (drag.x - event.clientX) * resizeDirection; drag.latest = Math.min(currentBounds.max, Math.max(currentBounds.min, drag.size + delta)); if (frameRef.current !== null) return; frameRef.current = requestAnimationFrame(() => { frameRef.current = null; - if (dragRef.current) rootRef.current?.style.setProperty('--_split-view-right-size', `${dragRef.current.latest}px`); + const current = dragRef.current; + if (!current) return; + // A sub-pixel move paints the same rounded width; keep the written value + // instead of dirtying the DOM style on every animation frame. + if (Math.round(current.latest) === Math.round(current.painted)) return; + current.painted = current.latest; + rootRef.current?.style.setProperty('--_split-view-right-size', `${current.latest}px`); }); }; @@ -166,6 +185,8 @@ export function SplitView({ dragRef.current = { pointerId: event.pointerId, target: event.currentTarget, x: event.clientX, size: resolvedSize, latest: resolvedSize, + containerWidth: rootRef.current?.getBoundingClientRect().width ?? 0, + painted: resolvedSize, cursor: document.body.style.cursor, userSelect: document.body.style.userSelect, }; event.currentTarget.setPointerCapture?.(event.pointerId); diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index c7752b32d4..a38dbc115e 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -176,6 +176,8 @@ interface ExtendedFlexiblePanelProps extends FlexiblePanelProps { isActive?: boolean; /** File no longer exists on disk (from editor); drives tab "deleted" label */ onFileMissingFromDiskChange?: (missing: boolean) => void; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; } const FlexiblePanel: React.FC = memo(({ @@ -189,6 +191,7 @@ const FlexiblePanel: React.FC = memo(({ isActive = true, onFileMissingFromDiskChange, terminalResizeSuspended = false, + resizeSuspended = false, }) => { const { t, formatDate } = useI18n('components'); const documentSession = useEditorDocument(); @@ -856,6 +859,7 @@ const FlexiblePanel: React.FC = memo(({ workspacePath={content.data?.workspacePath || workspacePath} viewKind={content.data?.viewKind} displayTitle={content.data?.displayTitle} + resizeSuspended={resizeSuspended} /> ); diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index 0586310947..737db89095 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -41,6 +41,8 @@ export interface ContentCanvasProps { toolbarActions?: React.ReactNode; /** Suspend terminal fit/PTY resize while the hosting panel is animating. */ terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; /** Whether this host exposes Mission Control. */ missionControlEnabled?: boolean; /** Host-provided content for the no-tabs state. */ @@ -58,6 +60,7 @@ export const ContentCanvas: React.FC = ({ onCollapsePanel, toolbarActions, terminalResizeSuspended = false, + resizeSuspended = false, missionControlEnabled = true, emptyState, }) => { @@ -212,6 +215,7 @@ export const ContentCanvas: React.FC = ({ disablePopOut={disablePopOut} toolbarActions={toolbarActions} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} /> diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx index b683b33451..36dcdb0bcb 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx @@ -20,6 +20,8 @@ export interface EditorAreaProps { disablePopOut?: boolean; toolbarActions?: React.ReactNode; terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; } export const EditorArea: React.FC = ({ @@ -33,6 +35,7 @@ export const EditorArea: React.FC = ({ disablePopOut = false, toolbarActions, terminalResizeSuspended = false, + resizeSuspended = false, }) => { const containerRef = useRef(null); const topRowRef = useRef(null); @@ -161,6 +164,7 @@ export const EditorArea: React.FC = ({ disablePopOut={disablePopOut} toolbarActions={toolbarActions === undefined || groupId === toolbarGroupId ? toolbarActions : null} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} /> ); diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx index bf63321944..c135d77d00 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx @@ -87,6 +87,8 @@ export interface EditorGroupProps { disablePopOut?: boolean; toolbarActions?: React.ReactNode; terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; } export const EditorGroup: React.FC = ({ @@ -117,6 +119,7 @@ export const EditorGroup: React.FC = ({ disablePopOut = false, toolbarActions, terminalResizeSuspended = false, + resizeSuspended = false, }) => { const { t } = useTranslation('components'); const mode = useContext(CanvasStoreModeContext); @@ -300,6 +303,7 @@ export const EditorGroup: React.FC = ({ onInteraction={onInteraction} workspacePath={workspacePath} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} /> )) diff --git a/src/web-ui/src/app/scenes/session/AuxPane.tsx b/src/web-ui/src/app/scenes/session/AuxPane.tsx index 930d0ab324..03f761e468 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPane.tsx @@ -36,12 +36,14 @@ interface AuxPaneProps { workspacePath?: string; isSceneActive?: boolean; terminalResizeSuspended?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; isFullscreen?: boolean; onToggleFullscreen?: () => void; } const AuxPane = forwardRef( - ({ workspacePath, isSceneActive = true, terminalResizeSuspended = false, isFullscreen = false, onToggleFullscreen }, ref) => { + ({ workspacePath, isSceneActive = true, terminalResizeSuspended = false, resizeSuspended = false, isFullscreen = false, onToggleFullscreen }, ref) => { const { t } = useI18n('components'); const activeSessionId = useSyncExternalStore( flowChatStore.subscribe.bind(flowChatStore), @@ -191,6 +193,7 @@ const AuxPane = forwardRef( onInteraction={handleInteraction} onBeforeClose={handleBeforeClose} terminalResizeSuspended={terminalResizeSuspended} + resizeSuspended={resizeSuspended} missionControlEnabled={false} emptyState={
({ - ModernFlowChatContainer: () =>
, + ModernFlowChatContainer: ({ resizeSuspended = false }: { resizeSuspended?: boolean }) => ( +
+ ), })); vi.mock('@/flow_chat/components/ChatInput', () => ({ ChatInput: ({ layoutResizeSuspended }: { layoutResizeSuspended: boolean }) => ( @@ -35,12 +37,16 @@ it('docks only the composer and forwards resize suspension without remounting th expect(dock.contains(composer)).toBe(true); expect(dock.contains(transcript)).toBe(false); composer.value = 'unsent'; - for (const isDragging of [true, false]) { - act(() => root.render()); + // Both the drag itself and the settle window two frames after it: the + // transcript stops measuring for either, the composer only for the drag. + for (const [isDragging, resizeSuspended] of [[true, true], [false, false], [false, true]] as const) { + act(() => root.render()); expect(container.querySelector('[data-testid="transcript"]')).toBe(transcript); expect(container.querySelector('[data-testid="composer"]')).toBe(composer); expect(composer.value).toBe('unsent'); expect(composer.dataset.resizeSuspended).toBe(String(isDragging)); + expect(transcript.dataset.resizeSuspended).toBe(String(resizeSuspended)); } } finally { act(() => root.unmount()); diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 8260ed892d..80491980b7 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -37,6 +37,8 @@ interface ChatPaneProps { workspacePath?: string; isDragging?: boolean; showChatInput?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; /** Whether the host-owned session right panel is open. */ isRightPanelOpen?: boolean; /** Toggle the host-owned session right panel. */ @@ -58,6 +60,7 @@ const ChatPaneInner: React.FC = ({ workspacePath, isDragging = false, showChatInput = false, + resizeSuspended = false, isRightPanelOpen = false, onToggleRightPanel, emptyState, @@ -134,6 +137,7 @@ const ChatPaneInner: React.FC = ({ = ({ workspacePath, isEntering = const isContentOnly = pane.mode === 'content-only'; const isBottomHidden = isContentOnly || state.layout.bottomTerminalPanelCollapsed; const isDragging = isDraggingContent || isDraggingBottom; + // A pane-resize drag moves both panes' widths every frame; the transcripts + // measure only once the settled layout is in place. + const isPaneResizeSuspended = isDraggingContent || isContentSettling; const bottomTerminalPanelMode = isBottomHidden ? 'collapsed' : getPanelDisplayMode(currentBottomHeight, BOTTOM_TERMINAL_PANEL_CONFIG); @@ -230,6 +233,7 @@ const SessionScene: React.FC = ({ workspacePath, isEntering = data-openbitfun-scene="session" data-openbitfun-part="chat">
} @@ -238,7 +242,8 @@ const SessionScene: React.FC = ({ workspacePath, isEntering = data-openbitfun-scene="session" data-openbitfun-part="auxiliary"> + terminalResizeSuspended={isContentSettling || isDraggingContent} + resizeSuspended={isPaneResizeSuspended} />
} /> diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index fa38dee455..98a1e5119e 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -103,6 +103,8 @@ export interface BtwSessionPanelProps { workspacePath?: string; viewKind?: BtwSessionViewKind; displayTitle?: string; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; } const resolveSessionTitle = (session?: Pick | null, fallback = 'Side thread') => @@ -154,6 +156,7 @@ const BtwSessionPanelContent: React.FC { const { t } = useTranslation('flow-chat'); @@ -1205,6 +1208,7 @@ const BtwSessionPanelContent: React.FC )} {childSession.sessionKind === 'btw' && ({ + /** Every published settled width, in render order. */ + widths: [] as Array, + /** Whether the render called itself a frame of a still-moving pane. */ + suspensions: [] as boolean[], + renders: 0, +})); + +vi.mock('../modern/useFlowChatVirtualizer', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useFlowChatVirtualizer: (options: Parameters[0]) => { + virtualizerProbe.renders += 1; + virtualizerProbe.widths.push(options.estimateContext?.availableWidthPx); + virtualizerProbe.suspensions.push(options.measurementSuspended === true); + return actual.useFlowChatVirtualizer(options); + }, + }; +}); + vi.mock('../modern/VirtualItemRenderer', () => ({ VirtualItemRenderer: ({ index, measureRef }: { index: number; measureRef: React.Ref }) => (
{index}
@@ -28,7 +55,7 @@ describe('BtwVirtualSessionList', () => { type: 'image-analyzing', turnId: `turn-${index}`, } as VirtualItem)); - function render(renderedItems = items) { + function render(renderedItems = items, measurementSuspended = false) { act(() => root.render( { viewportOwner={owner} exploreGroupStates={new Map()} isHistorical={false} + measurementSuspended={measurementSuspended} />, )); } + /** The width the observer saw, as the list published it to the virtualizer. */ + function publishedWidths() { + return virtualizerProbe.widths.filter((width, index) => ( + index === 0 || virtualizerProbe.widths[index - 1] !== width + )); + } + + /** Deliver the scroller's own resize, which is the one a divider drag drives. */ + function resizeScroller(widthPx: number) { + Object.defineProperty(scroller, 'clientWidth', { configurable: true, value: widthPx }); + for (const observer of [...resizeObservers]) { + if (observer.targets.has(scroller)) observer.callback([], {} as ResizeObserver); + } + } + beforeEach(() => { frames = new Map(); resizeObservers = []; nextFrame = 0; + virtualizerProbe.widths.length = 0; + virtualizerProbe.suspensions.length = 0; + virtualizerProbe.renders = 0; vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++nextFrame, callback); return nextFrame; @@ -227,4 +273,37 @@ describe('BtwVirtualSessionList', () => { expect(scroller.scrollHeight - 15).toBeGreaterThan(measuredTotal); } }); + + /** + * The pane-resize case. + * + * A divider drag narrows this scroller on every frame, and the row estimates + * — with the heights measured against them — are derived from its width. The + * list has to hold the settled width for the whole gesture and publish the + * width the drag ended on exactly once, so the virtualizer re-measures once + * too instead of once per frame. + */ + it('holds the settled width while a pane resize is suspended and applies it once on settle', () => { + // The mount publishes the width the panel settled on. + render(); + expect(publishedWidths()).toEqual([undefined, 500]); + + // The drag starts; every frame below is a width change with no measurement. + render(undefined, true); + expect(virtualizerProbe.suspensions.at(-1)).toBe(true); + const rendersWhenTheDragStarted = virtualizerProbe.renders; + for (const widthPx of [480, 460, 440, 420]) act(() => resizeScroller(widthPx)); + + expect(virtualizerProbe.renders).toBe(rendersWhenTheDragStarted); + expect(publishedWidths()).toEqual([undefined, 500]); + + // The layout settles: the width the drag ended on lands once. + render(undefined, false); + expect(publishedWidths()).toEqual([undefined, 500, 420]); + expect(virtualizerProbe.suspensions.at(-1)).toBe(false); + + // And once only: later renders carry the same settled width. + render(undefined, false); + expect(publishedWidths()).toEqual([undefined, 500, 420]); + }); }); diff --git a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx index 9ddeea8c77..0720768fd1 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwVirtualSessionList.tsx @@ -1,5 +1,5 @@ import { useEmbeddedTimelineFollow } from '../../timeline/useEmbeddedTimelineFollow'; -import { Fragment, useEffect, useRef, useState, type RefObject, type MutableRefObject } from 'react'; +import { Fragment, useEffect, useLayoutEffect, useRef, useState, type RefObject, type MutableRefObject } from 'react'; import type { VirtualItem } from '../../store/modernFlowChatStore'; import { VirtualItemRenderer } from '../modern/VirtualItemRenderer'; import { useFlowChatVirtualizer } from '../modern/useFlowChatVirtualizer'; @@ -32,26 +32,51 @@ interface BtwVirtualSessionListProps { isHistorical: boolean; viewState?: BtwPanelViewState; onExpandGroup?: (id: string) => void; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + measurementSuspended?: boolean; } /** The embedded transcript shares row placement, not the primary session shell. */ export function BtwVirtualSessionList({ items: sourceItems, scrollerRef, headerRef, followRef, viewportOwner, exploreGroupStates, isHistorical, viewState, onExpandGroup, + measurementSuspended = false, }: BtwVirtualSessionListProps) { const { sessionId } = useFlowChatContext(); const { items, reader } = useConversationTimeline(sourceItems, sessionId, 'side'); const windowRef = useRef(null); const [width, setWidth] = useState(0); + const measurementSuspendedRef = useRef(measurementSuspended); + measurementSuspendedRef.current = measurementSuspended; + /** The last width the observer saw while the pane was still moving. */ + const pendingWidthRef = useRef(null); useEffect(() => { const scroller = scrollerRef.current; if (!scroller) return; - const updateWidth = () => setWidth(scroller.clientWidth); + const updateWidth = () => { + /* + * A divider drag changes this scroller's width every frame, and the row + * estimates — with every height measured against them — are derived from + * it. Hold the last settled width for the duration; the settle below + * publishes the width the drag ended on exactly once. + */ + if (measurementSuspendedRef.current) { + pendingWidthRef.current = scroller.clientWidth; + return; + } + setWidth(scroller.clientWidth); + }; const observer = new ResizeObserver(updateWidth); observer.observe(scroller); updateWidth(); return () => observer.disconnect(); }, [scrollerRef]); + useLayoutEffect(() => { + if (measurementSuspended) return; + const pending = pendingWidthRef.current; + pendingWidthRef.current = null; + if (pending !== null) setWidth(current => (current === pending ? current : pending)); + }, [measurementSuspended]); const cancelAimRef = useRef<() => void>(() => {}); const pinnedKeys = useTimelineInteraction(scrollerRef, reader, () => followRef.current, undefined, () => { followRef.current = false; @@ -72,6 +97,7 @@ export function BtwVirtualSessionList({ }, estimateContextRevision: `${width}|${isHistorical}|${[...exploreGroupStates] .map(([id, expanded]) => `${id}:${expanded}`).join(',')}`, + measurementSuspended, scrollPaddingStartPx: 0, writeViewport: viewportOwner.write, shiftViewport: viewportOwner.shift, diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index f54ec11f69..72814d510c 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -132,6 +132,8 @@ const log = createLogger('ModernFlowChatContainer'); interface ModernFlowChatContainerProps { className?: string; isViewportActive?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + resizeSuspended?: boolean; /** Whether the host-owned session right panel is open. */ isRightPanelOpen?: boolean; /** Toggle the host-owned session right panel. */ @@ -303,6 +305,7 @@ function backgroundCommandSummaryFromActivity(activity: BackgroundCommandActivit export const ModernFlowChatContainer: React.FC = ({ className = '', isViewportActive = true, + resizeSuspended = false, isRightPanelOpen = false, onToggleRightPanel, emptyState, @@ -2836,6 +2839,7 @@ export const ModernFlowChatContainer: React.FC = ( items={virtualItems} latestTurnId={latestTurnId} isViewportActive={isViewportActive} + measurementSuspended={resizeSuspended} presentationMode={isRenderingHistoryProjection ? 'history-window' : 'tail'} viewportMode={isViewportDetachedFromLiveTail ? 'history-reading' : 'live-tail'} historyWindow={isShowingHistoryPresentation ? activeHistoryPresentation?.range ?? null : null} 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 a4a8161120..6360e40918 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -227,6 +227,8 @@ export interface VirtualMessageListProps { /** Session catalog identity, independent from the current history window. */ latestTurnId?: string; isViewportActive?: boolean; + /** The hosting pane is being resized; width-derived measurement waits for it to settle. */ + measurementSuspended?: boolean; presentationMode?: 'tail' | 'history-window'; viewportMode?: 'live-tail' | 'history-reading'; historyWindow?: ActiveTurnRenderRange | null; @@ -380,6 +382,7 @@ const VirtualMessageListSession = forwardRef(null); /** Native minimization withdraws the scroller without unmounting the session. */ const isViewportSuspendedRef = useRef(false); + const measurementSuspendedRef = useRef(measurementSuspended); + measurementSuspendedRef.current = measurementSuspended; + /** The last width the observer saw while the pane was still moving. */ + const pendingViewportWidthRef = useRef(null); const suspendedViewportScrollTopRef = useRef(null); const viewportResumeFrameRef = useRef(null); /** Remaining resize callbacks over which to keep a resting viewport at the end. */ @@ -554,6 +561,7 @@ const VirtualMessageListSession = forwardRef isViewportSuspendedRef.current, + measurementSuspended, scrollPaddingStartPx: FLOWCHAT_TURN_TOP_GAP_PX, writeViewport: viewportOwner.write, shiftViewport: viewportOwner.shift, @@ -1815,7 +1823,18 @@ const VirtualMessageListSession = forwardRef { + if (measurementSuspended) return; + const pending = pendingViewportWidthRef.current; + pendingViewportWidthRef.current = null; + if (pending !== null) setViewportWidthPx(current => (current === pending ? current : pending)); + }, [measurementSuspended]); + /** * Top-align a Turn, without scrolling into the reserved blank to do it. * diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx index 1adecb9890..8f532a376e 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.measurement.test.tsx @@ -35,9 +35,13 @@ interface HarnessProps { onApi: (api: FlowChatVirtualizer) => void; isViewportSuspended?: () => boolean; widthPx?: number; + /** A pane resize is changing the width on every frame. */ + measurementSuspended?: boolean; } -function Harness({ scroller, header, items, onApi, isViewportSuspended, widthPx }: HarnessProps) { +function Harness({ + scroller, header, items, onApi, isViewportSuspended, widthPx, measurementSuspended, +}: HarnessProps) { const scrollerRef = React.useRef(scroller); const headerRef = React.useRef(header); onApi(useFlowChatVirtualizer({ @@ -49,6 +53,7 @@ function Harness({ scroller, header, items, onApi, isViewportSuspended, widthPx getKnownItemHeightPx: (item: Item) => item.knownHeightPx, estimateContext: { availableWidthPx: widthPx }, isViewportSuspended, + measurementSuspended, scrollPaddingStartPx: 0, writeViewport: () => true, })); @@ -222,6 +227,55 @@ describe('useFlowChatVirtualizer measurement', () => { ]); }); + /** + * The pane-resize case: the width changes on every frame of a divider drag, + * and every measurement this pass takes is derived from it. + * + * The row is read through a counting `offsetHeight`, which is the work the + * pass is for: one read per pass, and the count says how many passes ran. + */ + it('skips the layout-signature pass while a pane resize is suspended and runs it once on release', () => { + const items = [{ key: 'a' }, { key: 'b' }, { key: 'c' }]; + let rowHeightPx = 300; + let rowReads = 0; + const row = document.createElement('div'); + row.setAttribute('data-virtual-index', '2'); + Object.defineProperty(row, 'offsetHeight', { + configurable: true, + get: () => { rowReads += 1; return rowHeightPx; }, + }); + scroller.appendChild(row); + const renderAt = (widthPx: number, measurementSuspended?: boolean) => act(() => root.render( + { api = next; }} + />, + )); + + renderAt(500); + expect(api.getItemBounds(2)).toEqual({ startPx: ESTIMATE_PX * 2, endPx: ESTIMATE_PX * 2 + 300 }); + const readsAfterSettle = rowReads; + + // The drag starts: the content re-wraps and the width keeps changing. + rowHeightPx = 380; + for (const widthPx of [480, 440, 400, 360]) renderAt(widthPx, true); + expect(rowReads).toBe(readsAfterSettle); + expect(api.getItemBounds(2)).toEqual({ startPx: ESTIMATE_PX * 2, endPx: ESTIMATE_PX * 2 + 300 }); + + // The layout settles: one pass, against the width that stopped moving. + renderAt(360, false); + expect(rowReads).toBe(readsAfterSettle + 1); + expect(api.getItemBounds(2)).toEqual({ startPx: ESTIMATE_PX * 2, endPx: ESTIMATE_PX * 2 + 380 }); + + // And nothing after it: the settled signature is the measured one. + renderAt(360, false); + expect(rowReads).toBe(readsAfterSettle + 1); + }); + it('ignores an element carrying no usable index', () => { const stray = document.createElement('div'); stray.setAttribute('data-virtual-index', 'not-an-index'); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts index 6520d9e5cf..bc2f428a92 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -181,6 +181,15 @@ export interface UseFlowChatVirtualizerOptions { reconcileOpeningMeasurement?: () => boolean; /** The host has temporarily withdrawn the scroller, such as window minimization. */ isViewportSuspended?: () => boolean; + /** + * A pane resize is changing the available width on every frame. + * + * Both the estimates and every measured row are derived from that width, so + * there is no settled layout to measure against until the gesture ends. The + * layout-signature pass below holds for the duration and runs exactly once + * when this goes back to false. + */ + measurementSuspended?: boolean; /** * Gap kept above a Turn that has been scrolled to the top of the viewport. * Applied by the virtualizer itself so that its re-aim, which runs while @@ -350,6 +359,7 @@ export function useFlowChatVirtualizer({ startAtTailOnMount = false, reconcileOpeningMeasurement, isViewportSuspended = () => false, + measurementSuspended = false, scrollPaddingStartPx, writeViewport, shiftViewport = () => false, @@ -413,6 +423,8 @@ export function useFlowChatVirtualizer({ estimateContextRef.current = estimateContext; const isViewportSuspendedRef = useRef(isViewportSuspended); isViewportSuspendedRef.current = isViewportSuspended; + const measurementSuspendedRef = useRef(measurementSuspended); + measurementSuspendedRef.current = measurementSuspended; const [contentStartPx, setContentStartPx] = useState(0); useEffect(() => { @@ -743,11 +755,26 @@ export function useFlowChatVirtualizer({ const measuredLayoutRef = useRef(layoutSignature); const previousKeysRef = useRef(new Set()); const retiredMeasurementsRef = useRef(0); + /** A pass owed to a layout that a pane drag had not finished moving. */ + const pendingLayoutMeasureRef = useRef(false); useLayoutEffect(() => { const keys = new Set(items.map(getItemKeyRef.current)); for (const key of previousKeysRef.current) if (!keys.has(key)) retiredMeasurementsRef.current++; previousKeysRef.current = keys; - if (measuredLayoutRef.current === layoutSignature && retiredMeasurementsRef.current < 2048) return; + if (measuredLayoutRef.current === layoutSignature && retiredMeasurementsRef.current < 2048 + && !pendingLayoutMeasureRef.current) return; + /* + * A divider drag changes the available width every frame, and everything + * this pass reads — the estimates and the rendered rows' heights — is + * derived from that width. Measuring each intermediate width is what the + * drag would spend its frame on, so the invalidation is remembered rather + * than acted on: `measuredLayoutRef` keeps the settled signature, and the + * release runs exactly one pass against the layout that stopped moving. + */ + if (measurementSuspendedRef.current) { + pendingLayoutMeasureRef.current = true; + return; + } if (isViewportSuspendedRef.current()) return; const scroller = scrollerRef.current; const before = (virtualizer as unknown as MeasuringVirtualizer).getMeasurements(); @@ -763,7 +790,9 @@ export function useFlowChatVirtualizer({ } measuredLayoutRef.current = layoutSignature; retiredMeasurementsRef.current = 0; - }, [itemKeyRevision, layoutSignature, items, measureRenderedItems, scrollerRef, shiftViewport, virtualizer]); + pendingLayoutMeasureRef.current = false; + }, [itemKeyRevision, layoutSignature, items, measureRenderedItems, scrollerRef, shiftViewport, virtualizer, + measurementSuspended]); const getItemBounds = useCallback((index: number): FlowChatItemBounds | null => { const measurement = (virtualizer as unknown as MeasuringVirtualizer) diff --git a/src/web-ui/src/shared/ui/SplitView.test.tsx b/src/web-ui/src/shared/ui/SplitView.test.tsx index b0dd1c8579..3bfe73af35 100644 --- a/src/web-ui/src/shared/ui/SplitView.test.tsx +++ b/src/web-ui/src/shared/ui/SplitView.test.tsx @@ -13,6 +13,8 @@ describe('shared SplitView interaction contract', () => { let resized: () => void; let changed: ReturnType; let resizing: ReturnType; + /** Every element whose box was measured, in order, so the tests can attribute reads. */ + let measured: HTMLElement[]; const part = (name: string) => container.querySelector(`[data-openbitfun-component="split-view"][data-openbitfun-part="${name}"]`)!; const render = (props: Partial = {}) => act(() => root.render(