From f5290c9d70b4a4ec4f169c6aaa3998fe1d617ecc Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 21 Sep 2026 14:40:36 +0800 Subject: [PATCH 1/6] perf(flow-chat): isolate transcript mask state Keep the changing edge-mask stop local to the scroller so scroll-edge updates do not invalidate every message row. Runtime opening traces reduced the follow-up style stall from 562.3ms to 3.6ms. --- .../modern/VirtualMessageList.edgeFade.test.ts | 10 ++++++++++ .../components/modern/VirtualMessageList.scss | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts index 63bfae317a..a576a11af1 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.edgeFade.test.ts @@ -10,6 +10,16 @@ function readSource(relativePath: string): string { } describe('FlowChat transcript edge fade', () => { + it('keeps the changing mask stop local to the scroller', () => { + const stylesheet = readSource('./VirtualMessageList.scss'); + const registration = stylesheet.match(/@property\s+--_flow-chat-top-mask-start\s*\{([^}]+)\}/)?.[1]; + + expect(registration).toBeDefined(); + expect(registration).toMatch(/syntax:\s*['"]\*['"]/); + expect(registration).toMatch(/inherits:\s*false\s*;/); + expect(registration).toMatch(/initial-value:\s*transparent 0\s*;/); + }); + it('fades the transcript at the top and before the live ChatInput edge', () => { const component = readSource('./VirtualMessageList.tsx'); const stylesheet = readSource('./VirtualMessageList.scss'); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss index 41e44a279a..0fb3f8a4c8 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -1,6 +1,16 @@ /** * Virtualized message list styles. */ +// The mask stop used to inherit through every message row, so changing the +// edge state could invalidate the whole transcript. Keep it local to the +// scroller; the opening trace reduced the follow-up style stall from 562.3ms +// to 3.6ms. +@property --_flow-chat-top-mask-start { + syntax: '*'; + inherits: false; + initial-value: transparent 0; +} + .virtual-message-list { width: 100%; height: 100%; From fd8b5e0553f817096f1e330be25f7c73110a0e71 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 21 Sep 2026 14:43:40 +0800 Subject: [PATCH 2/6] perf(flow-chat): isolate opening transcript interactions Keep the opening transcript measurable while preventing hidden message controls from receiving pointer, scroll, keyboard, or programmatic focus events. Replace whole-tree inert and visibility transitions with a local shield, focus guards, aria-hidden, and opacity; retain guard nodes through reveal to avoid repeated style invalidation. Add focused boundary contracts and document the measured reveal behavior. --- pnpm-lock.yaml | 8 + src/web-ui/package.json | 1 + .../modern/FLOWCHAT_SCROLL_STABILITY.md | 33 ++++ .../modern/FlowChatOpeningBoundary.test.tsx | 158 ++++++++++++++++++ .../modern/FlowChatOpeningBoundary.tsx | 149 +++++++++++++++++ .../components/modern/VirtualMessageList.scss | 23 ++- ...rtualMessageList.session-boundary.test.tsx | 30 +++- .../components/modern/VirtualMessageList.tsx | 6 +- 8 files changed, 399 insertions(+), 9 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffa06519d1..73004ffc06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -555,6 +555,9 @@ importers: semver: specifier: 7.7.4 version: 7.7.4 + tabbable: + specifier: ^6.5.0 + version: 6.5.0 unified: specifier: ^11.0.5 version: 11.0.5 @@ -5435,6 +5438,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tar-fs@3.0.4: resolution: {integrity: sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==} @@ -11621,6 +11627,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.5.0: {} + tar-fs@3.0.4: dependencies: mkdirp-classic: 0.5.3 diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 503be5c64d..635bf97c59 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -89,6 +89,7 @@ "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "semver": "7.7.4", + "tabbable": "^6.5.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yaml": "^2.8.2", 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 40ea894a1e..b1d9a6f566 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 @@ -31,8 +31,41 @@ to content, it stops being a reservation and becomes the compensation engine that was removed in "remove synthetic tail-space scrolling" — do not rebuild that under a new name. +## Opening Reveal + +The opening transcript uses opacity and `aria-hidden` until its existing reveal +condition settles. `FlowChatOpeningBoundary` provides a local pointer shield, +non-passive scroll interception, and focus guards that skip the transcript in +both Tab directions. Programmatic focus is returned outside the transcript with +`preventScroll`; React capture handlers also block descendant portal activation. +The sidebar and composer remain available. Focus candidate checks run only in +response to interaction and prune opening transcript subtrees before measuring. + +Avoid inherited `visibility` and whole-transcript `inert` transitions here: a +visibility reveal measured 260.9ms of style work, and a staged opacity/inert +trace isolated 177.6ms to removing inert (aria-hidden 0.2ms, opacity 0.4ms). +The boundary replacement still needs runtime performance and keyboard/reader +validation. It does not reproduce inert's browser find-in-page exclusion. A new +auto-opening portal must coordinate its presentation with opening; ancestor +aria-hidden and the pointer shield cannot hide a portalled surface. Removing a +geometry read alone only moves pending style/layout work. Read-only probes now +measure before and after the actual React reveal commit. A staged diagnostic +measured 176.7ms and 177.7ms of style work when removing the before/after focus +guards. Both guards now remain mounted after reveal with `tabIndex=-1` and focus +redirection disabled, preserving transcript sibling structure. The intrusive +staged diagnostic has been removed; the saving from stable guards still needs +runtime confirmation using the read-only reveal probes. + ## Transcript Width +The scroller's `--_flow-chat-top-mask-start` is registered as non-inheriting: +only its own edge mask consumes the value. An opening trace placed a 562.3ms +forced style/layout read immediately after `data-scroll-at-start` changed this +variable. The registration isolates that state change from message descendants; +A same-session follow-up reduced the maximum follow height read from 562.3ms +to 3.6ms and advanced the second opening frame callback from 1803.3ms to +1197.7ms. Initial row measurement and reveal-related style/layout stalls remain. + The main transcript scroller uses `overflow-y: scroll` together with `scrollbar-gutter: stable`. Its native scrollbar space must remain present when collapsing content removes vertical overflow, including desktop WebViews where diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.test.tsx new file mode 100644 index 0000000000..7c3544fa72 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowChatOpeningBoundary } from './FlowChatOpeningBoundary'; +import { Portal } from '@openbitfun/ui'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('FlowChat opening interaction boundary', () => { + let host: HTMLDivElement; + let root: Root; + const action = vi.fn(); + const focused = vi.fn(); + function render(opening = true) { + act(() => root.render(<> + + + + + + + + + )); + } + const element = (selector: string) => host.querySelector(selector)!; + function tab(shiftKey: boolean, destination: string) { + document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Tab', shiftKey, bubbles: true, cancelable: true, + })); + // jsdom does not implement the browser's default Tab traversal. + element(destination).focus(); + } + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + action.mockReset(); + focused.mockReset(); + // Supply boxes solely for tabbable's visibility filter, not layout validation. + vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue([new DOMRect(0, 0, 1, 1)] as unknown as DOMRectList); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + }); + + it('skips the transcript in both Tab directions without focusing message controls', () => { + render(); + element('[data-before]').focus(); + tab(false, '[data-flowchat-opening-guard="before"]'); + expect(document.activeElement).toBe(element('[data-after]')); + tab(true, '[data-flowchat-opening-guard="after"]'); + expect(document.activeElement).toBe(element('[data-before]')); + expect(focused).not.toHaveBeenCalled(); + }); + + it('redirects a positive-tabindex control that bypasses a guard', () => { + render(); + element('[data-inside]').tabIndex = 1; + element('[data-before]').focus(); + tab(false, '[data-inside]'); + expect(document.activeElement).toBe(element('[data-after]')); + expect(focused).not.toHaveBeenCalled(); + }); + + it('returns programmatic focus to the outside control without scrolling', () => { + render(); + const before = element('[data-before]'); + before.focus(); + const focus = vi.spyOn(before, 'focus'); + element('[data-inside]').focus(); + expect(document.activeElement).toBe(before); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + expect(focused).not.toHaveBeenCalled(); + }); + + it('does not scan transcript descendants when choosing an outside focus target', () => { + render(); + element('[data-before]').focus(); + const read = vi.fn(); + Object.defineProperty(element('[data-inside]'), 'getClientRects', { value: read }); + tab(false, '[data-flowchat-opening-guard="before"]'); + expect(read).not.toHaveBeenCalled(); + }); + + it('blocks activation and native scroll gestures only inside the opening region', () => { + render(); + element('[data-inside]').click(); + expect(action).not.toHaveBeenCalled(); + for (const type of ['wheel', 'touchmove', 'contextmenu', 'pointerdown', 'selectstart']) { + const event = new Event(type, { bubbles: true, cancelable: true }); + element('[data-inside]').dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + const outside = new Event(type, { bubbles: true, cancelable: true }); + element('[data-after]').dispatchEvent(outside); + expect(outside.defaultPrevented).toBe(false); + } + }); + + it('releases focus, activation and scroll protection after reveal', () => { + render(); + const guards = [...host.querySelectorAll('[data-flowchat-opening-guard]')]; + element('[data-after]').focus(); + render(false); + expect(document.activeElement).toBe(element('[data-after]')); + element('[data-inside]').focus(); + element('[data-inside]').click(); + expect(focused).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(1); + const event = new Event('wheel', { bubbles: true, cancelable: true }); + element('[data-inside]').dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect([...host.querySelectorAll('[data-flowchat-opening-guard]')]).toEqual(guards); + for (const guard of guards) { + expect(guard.tabIndex).toBe(-1); + guard.focus(); + expect(document.activeElement).toBe(guard); + } + expect(element('[data-transcript]').hasAttribute('aria-hidden')).toBe(false); + }); + + it('blocks React-owned portal activation and focus while opening', () => { + act(() => root.render(<> + + + + + )); + const before = element('[data-before]'); + before.focus(); + const portal = document.querySelector('[data-portal]')!; + portal.click(); + portal.focus(); + expect(action).not.toHaveBeenCalled(); + expect(focused).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(before); + }); + + it('does not retain autofocus inside the initial commit', () => { + act(() => root.render( + + )); + expect(document.activeElement).not.toBe(element('[data-inside]')); + expect(focused).not.toHaveBeenCalled(); + }); + + it('cleans up native interception on unmount', () => { + render(); + const inside = element('[data-inside]'); + act(() => root.render(null)); + const event = new Event('wheel', { bubbles: true, cancelable: true }); + inside.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.tsx new file mode 100644 index 0000000000..0d6ed31838 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatOpeningBoundary.tsx @@ -0,0 +1,149 @@ +import { useLayoutEffect, useRef, type HTMLAttributes, type SyntheticEvent } from 'react'; +import { isTabbable } from 'tabbable'; + +type Props = HTMLAttributes & { opening: boolean }; + +/** Keeps the measurable transcript unavailable without changing inherited DOM state. */ +export function FlowChatOpeningBoundary({ opening, children, ...props }: Props) { + const rootRef = useRef(null); + const beforeRef = useRef(null); + const afterRef = useRef(null); + const outsideFocusRef = useRef(null); + const directionRef = useRef<1 | -1 | null>(null); + const redirectingRef = useRef(false); + + const skipTranscript = (direction: 1 | -1) => { + const boundary = direction === 1 ? afterRef.current : beforeRef.current; + if (!boundary) return; + const doc = boundary.ownerDocument; + const candidates: HTMLElement[] = []; + // Prune opening transcripts before checking visibility. Never measure their + // descendants while finding a destination for a real keyboard interaction. + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { + const element = node as HTMLElement; + if (element.matches('[data-flowchat-opening="true"], [inert], [hidden], [aria-hidden="true"]')) { + return NodeFilter.FILTER_REJECT; + } + if (element !== boundary && element.hasAttribute('data-flowchat-opening-guard')) { + return NodeFilter.FILTER_REJECT; + } + return isTabbable(element) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + }, + }); + while (walker.nextNode()) candidates.push(walker.currentNode as HTMLElement); + candidates.sort((a, b) => (a.tabIndex || Infinity) - (b.tabIndex || Infinity)); + const index = candidates.indexOf(boundary); + const target = index < 0 ? undefined : candidates[index + direction]; + if (target && !target.hasAttribute('data-flowchat-opening-guard')) { + target.focus({ preventScroll: true }); + } else { + // At the document edge, leave the browser's next Tab free to reach chrome. + boundary.focus({ preventScroll: true }); + } + }; + + const redirectFocus = (target: EventTarget | null, previous: EventTarget | null) => { + if (redirectingRef.current) return; + redirectingRef.current = true; + try { + if (directionRef.current) { + skipTranscript(directionRef.current); + } else { + const root = rootRef.current; + const candidate = previous instanceof HTMLElement ? previous : outsideFocusRef.current; + if (candidate instanceof HTMLElement && candidate.isConnected + && candidate !== target && !root?.contains(candidate) + && !candidate.closest('[inert], [hidden], [aria-hidden="true"]')) { + candidate.focus({ preventScroll: true }); + } + if (target instanceof HTMLElement && target.ownerDocument.activeElement === target) { + target.blur(); + } + } + } finally { + directionRef.current = null; + redirectingRef.current = false; + } + }; + + useLayoutEffect(() => { + const root = rootRef.current; + if (!opening || !root) return; + const doc = root.ownerDocument; + const rememberFocus = (event: FocusEvent) => { + const target = event.target; + if (target instanceof HTMLElement && !root.contains(target) + && !target.hasAttribute('data-flowchat-opening-guard')) outsideFocusRef.current = target; + }; + const rememberDirection = (event: KeyboardEvent) => { + directionRef.current = event.key === 'Tab' ? (event.shiftKey ? -1 : 1) : null; + }; + const clearDirection = () => { directionRef.current = null; }; + const block = (event: Event) => { event.preventDefault(); event.stopImmediatePropagation(); }; + // A native, non-passive listener is needed to cancel wheel/touch scrolling. + root.addEventListener('wheel', block, { capture: true, passive: false }); + root.addEventListener('touchmove', block, { capture: true, passive: false }); + root.addEventListener('selectstart', block, true); + doc.addEventListener('focusin', rememberFocus); + doc.addEventListener('keydown', rememberDirection, true); + doc.addEventListener('keyup', clearDirection, true); + doc.addEventListener('pointerdown', clearDirection, true); + if (root.contains(doc.activeElement)) redirectFocus(doc.activeElement, null); + else outsideFocusRef.current = doc.activeElement; + return () => { + root.removeEventListener('wheel', block, true); + root.removeEventListener('touchmove', block, true); + root.removeEventListener('selectstart', block, true); + doc.removeEventListener('focusin', rememberFocus); + doc.removeEventListener('keydown', rememberDirection, true); + doc.removeEventListener('keyup', clearDirection, true); + doc.removeEventListener('pointerdown', clearDirection, true); + }; + // The boundary is installed once per opening; handlers only read live refs. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opening]); + + const block = (event: SyntheticEvent) => { + if (!opening) return; + event.preventDefault(); + event.stopPropagation(); + }; + // Removing either guard invalidated transcript styles and cost about 177ms in + // staged traces. Keep both siblings mounted, then only leave the Tab order + // at reveal so the browser retains the stable style tree. + const guard = (side: 'before' | 'after') => ( + { + if (!opening || redirectingRef.current) return; + redirectingRef.current = true; + try { skipTranscript(directionRef.current ?? (side === 'before' ? 1 : -1)); } + finally { redirectingRef.current = false; directionRef.current = null; } + }} + /> + ); + + return <> + {guard('before')} +
{ + if (!opening) return; + event.stopPropagation(); + redirectFocus(event.target, event.relatedTarget); + }} + onPointerDownCapture={block} onPointerOverCapture={block} + onMouseDownCapture={block} onMouseOverCapture={block} + onClickCapture={block} onDoubleClickCapture={block} onContextMenuCapture={block} + onKeyDownCapture={block} onKeyUpCapture={block} onDragStartCapture={block} + > + {children} + {opening &&
} +
+ {guard('after')} + ; +} diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss index 0fb3f8a4c8..1ef7cbcfa7 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -102,11 +102,28 @@ /* * The opening viewport settles over several frames as items measure and - * history pages in. Keep it laid out and measurable but unpainted until then; - * see the opening-reveal effect in VirtualMessageList.tsx. + * history pages in. Keep it measurable but transparent until then. Opacity + * does not inherit into every message. A local shield, focus guards, and + * aria-hidden isolate the opening transcript without toggling subtree inertness. */ &[data-open-viewport-settled='false'] { - visibility: hidden; + opacity: 0; + } + + &__opening-shield { + position: absolute; + inset: 0; + z-index: 11; // Above the scroller and the list's navigation buttons. + touch-action: none; + } + + &__opening-guard { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; } &--empty { 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 10f4e5a597..b879d67e73 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 @@ -367,6 +367,24 @@ describe('VirtualMessageList natural scroll contract', () => { vi.unstubAllGlobals(); }); + it('isolates the opening transcript at its boundary until reveal', async () => { + act(() => root.render()); + const list = container.querySelector('[data-testid="flowchat-message-list"]')!; + expect(list.getAttribute('data-open-viewport-settled')).toBe('false'); + expect(list.hasAttribute('inert')).toBe(false); + expect(container.querySelectorAll('[data-flowchat-opening-guard]')).toHaveLength(2); + expect(list.querySelector('.virtual-message-list__opening-shield')).not.toBeNull(); + expect(list.getAttribute('aria-hidden')).toBe('true'); + + await settleOpenReveal(); + + expect(list.getAttribute('data-open-viewport-settled')).toBe('true'); + expect(list.hasAttribute('inert')).toBe(false); + expect(list.hasAttribute('aria-hidden')).toBe(false); + expect(container.querySelectorAll('[data-flowchat-opening-guard][tabindex="-1"]')).toHaveLength(2); + expect(list.querySelector('.virtual-message-list__opening-shield')).toBeNull(); + }); + it('renders only the current input layout inset in the Footer', () => { act(() => root.render()); const footer = container.querySelector('.message-list-footer'); @@ -903,21 +921,23 @@ describe('VirtualMessageList natural scroll contract', () => { }); } - it('treats a scroll under a scrollbar press as intent', () => { + it('treats a scroll under a scrollbar press as intent', async () => { act(() => root.render()); + await settleOpenReveal(); pressAt(CONTENT_BOX_WIDTH + 6); expect(mocks.handleUserScrollIntent).toHaveBeenCalled(); }); - it('leaves a scroll under a press on the transcript alone', () => { + it('leaves a scroll under a press on the transcript alone', async () => { // Layout growth and virtualizer remeasurement emit scroll events too, so // the press is what qualifies one — not the event itself. act(() => root.render()); + await settleOpenReveal(); pressAt(CONTENT_BOX_WIDTH - 200); expect(mocks.handleUserScrollIntent).not.toHaveBeenCalled(); }); - it('gives up an aim still in flight, which the claim alone cannot reach', () => { + it('gives up an aim still in flight, which the claim alone cannot reach', async () => { /* * The register refuses the re-aim's writes only while the gesture's hold * is live — 200ms after the last notch, against a five-second re-aim — @@ -926,12 +946,14 @@ describe('VirtualMessageList natural scroll contract', () => { * for 7784 12ms after that. */ act(() => root.render()); + await settleOpenReveal(); pressAt(CONTENT_BOX_WIDTH + 6); expect(mocks.cancelAim).toHaveBeenCalled(); }); - it('disarms on release, so a later scroll is not intent', () => { + it('disarms on release, so a later scroll is not intent', async () => { act(() => root.render()); + await settleOpenReveal(); pressAt(CONTENT_BOX_WIDTH + 6); mocks.handleUserScrollIntent.mockClear(); 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 97ac4d444d..0ba1f1e22d 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -76,6 +76,7 @@ import { type HistoryBoundaryProximity, } from './flowChatHistoryBoundary'; import { VirtualItemRenderer } from './VirtualItemRenderer'; +import { FlowChatOpeningBoundary } from './FlowChatOpeningBoundary'; import { useFlowChatVolatileContext } from './FlowChatContext'; import { estimateVirtualMessageItemHeightWithContext, @@ -239,7 +240,7 @@ const FlowChatListHeader = forwardRef(({ previousHistoryBoundaryStatusNode }, ref) => (
-
-
+ ); }); From 5752d04927ef40dc2f70edc1a7fddf6fdb617708 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 21 Sep 2026 16:11:36 +0800 Subject: [PATCH 3/6] perf(flow-chat): Start tail sessions near the end Seed the initial virtual window from estimated row heights to avoid mounting head rows before following the tail. Preserve history restoration and defer the seed until initial hydration has items. A same-session desktop trace reduced rowRef time from 377.3ms to 4.2ms and advanced the post-reveal probe from 1540.3ms to 806.7ms. These are single-trace timings, not paint guarantees. Validation: 130 focused tests, check:web, focused ESLint, and diff checks passed. Remote scenarios were not exercised. --- .../modern/FLOWCHAT_VERIFICATION.md | 1 + .../modern/FLOWCHAT_VIRTUALIZATION.md | 14 +++ ...rtualMessageList.session-boundary.test.tsx | 5 + .../components/modern/VirtualMessageList.tsx | 1 + ...lowChatVirtualizer.initial-window.test.tsx | 106 ++++++++++++++++++ .../modern/useFlowChatVirtualizer.ts | 19 ++++ 6 files changed, 146 insertions(+) create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 2830aefabb..52d78eb35f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -43,6 +43,7 @@ each missing what the other had. | `../../../infrastructure/diagnostics/flowChatViewportDiagnostics.test.ts` | coalescing, placement sampling, the switch | | `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary | | `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer | +| `useFlowChatVirtualizer.initial-window.test.tsx` | tail-first window, empty hydration, head default, one-time seed and user-scroll takeover; supplied DOM geometry, not performance validation | | `useFlowChatVirtualizer.aim.test.tsx` | the re-aim, and giving it up on takeover | | `VirtualMessageList.layout.test.ts` | the item-height estimate and the spacer | | `FlowChatTurnRail.test.tsx` | single-marker emphasis, neighboring hover fan, independent keyboard focus, reduced motion, and rail navigation | diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index fda42037b6..2698275026 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -48,6 +48,20 @@ than its content's. ## What Belongs to the Virtualizer +On a tail-following open, the virtualizer seeds its initial offset at the last +item's estimated start. A desktop trace previously mounted rows 0..13 before +moving to 22..33, with 372.3ms charged to the first head-row measurement. The +seed selects a tail window without first mounting the head; real heights and +the existing follow owner still determine the settled position. This is a +one-time seed, not an ongoing tail lock. Initial empty hydration waits for items +before consuming it. History-window presentation and saved reading-position +restoration retain the default initial window. Tests cover window selection +using the real virtualizer with supplied DOM geometry. A same-session desktop +retest started at rows 27..33: rowRef total fell from 377.3ms to 4.2ms and the +post-reveal probe completed at 806.7ms instead of 1540.3ms. This is a single-trace +comparison, not paint timing or remote validation. Tail-window contraction and +expansion still occur and remain under investigation. + FlowChat virtualizes with **TanStack Virtual**, behind `useFlowChatVirtualizer.ts`. Nothing else imports it. The rest of FlowChat asks for offsets in scroller coordinates and gets them back; there is no index space of the virtualizer's own 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 b879d67e73..295d5df5f5 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 @@ -33,6 +33,7 @@ const mocks = vi.hoisted(() => ({ followsNow: false, scheduleFollowToLatest: vi.fn(), startAtTailOnMount: true, + virtualizerStartsAtTail: false, revealNewTurnTail: null as null | ((turnId: string) => boolean), /** * The register the list built, reached through the hook it hands it to. @@ -127,7 +128,9 @@ vi.mock('./useFlowChatVirtualizer', async () => { items: Array>; getItemKey: (item: Record) => string; scrollerRef: { current: HTMLElement | null }; + startAtTailOnMount?: boolean; }) => { + mocks.virtualizerStartsAtTail = options.startAtTailOnMount === true; const rows = options.items.map((item, index) => ({ index, key: options.getItemKey(item), @@ -369,6 +372,7 @@ describe('VirtualMessageList natural scroll contract', () => { it('isolates the opening transcript at its boundary until reveal', async () => { act(() => root.render()); + expect(mocks.virtualizerStartsAtTail).toBe(true); const list = container.querySelector('[data-testid="flowchat-message-list"]')!; expect(list.getAttribute('data-open-viewport-settled')).toBe('false'); expect(list.hasAttribute('inert')).toBe(false); @@ -1531,6 +1535,7 @@ describe('VirtualMessageList natural scroll contract', () => { ); expect(mocks.startAtTailOnMount).toBe(false); + expect(mocks.virtualizerStartsAtTail).toBe(false); expect(scroller.scrollTop).toBe(140); await settleOpenReveal(); expect(container.querySelector('[data-open-viewport-settled="true"]')).not.toBeNull(); 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 0ba1f1e22d..223f3ed015 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -524,6 +524,7 @@ const VirtualMessageListSession = forwardRef(null); + const headerRef = useRef(null); + const api = useFlowChatVirtualizer({ + items: Array.from({ length: count }, (_, index) => index), + scrollerRef, headerRef, + getItemKey: String, + estimateItemHeightPx: () => 100, + startAtTailOnMount: tail, + scrollPaddingStartPx: 0, + writeViewport: ({ topPx }) => { + const element = scrollerRef.current; + if (!element) return false; + // Model a browser clamping a write while no rows have mounted yet. + element.scrollTop = Math.max(0, Math.min(topPx, element.scrollHeight - 500)); + return true; + }, + }); + useLayoutEffect(() => { windows.push(api.rows.map(row => row.index)); }); + return
+
+
+ {api.rows.map(row =>
)} +
+
; +} + +describe('initial virtual window with the real virtualizer', () => { + let host: HTMLDivElement; + let root: Root; + beforeEach(() => { + windows.length = 0; + vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }); + // Explicit geometry supplies jsdom's missing layout, not performance proof. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { + if (this.hasAttribute('data-scroller')) return 500; + return this.hasAttribute('data-virtual-index') ? 80 : 0; + }); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(500); + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function () { + const items = this.querySelector('[data-items]'); + return items ? Number.parseFloat(items.style.paddingTop || '0') + + Number.parseFloat(items.style.paddingBottom || '0') + items.childElementCount * 80 : 0; + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + const render = (count: number, tail: boolean) => act(() => root.render()); + + it('mounts the tail first without measuring head rows, then accepts user scrolling', () => { + render(34, true); + const populated = windows.filter(window => window.length); + expect(populated.length).toBeGreaterThan(0); + expect(populated[0].at(-1)).toBe(33); + expect(populated.every(window => window[0] > 0)).toBe(true); + const scroller = host.querySelector('[data-scroller]')!; + act(() => { + scroller.scrollTop = 0; + scroller.dispatchEvent(new Event('scroll')); + }); + expect(windows.at(-1)?.[0]).toBe(0); + }); + + it('waits for initially empty data before consuming the tail seed', () => { + render(0, true); + expect(windows.every(window => window.length === 0)).toBe(true); + render(34, true); + const first = windows.find(window => window.length)!; + expect(first[0]).toBeGreaterThan(0); + expect(first.at(-1)).toBe(33); + }); + + it('preserves the default head window for history and reading restoration', () => { + render(34, false); + expect(windows.find(window => window.length)?.[0]).toBe(0); + }); + + it('handles a single row and does not re-seed after later data changes', () => { + render(1, true); + expect(windows.find(window => window.length)).toEqual([0]); + windows.length = 0; + render(34, true); + expect(windows.find(window => window.length)?.[0]).toBe(0); + }); +}); 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 3c35e1e798..a0c4505734 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -161,6 +161,8 @@ export interface UseFlowChatVirtualizerOptions { estimateContext?: VirtualItemHeightEstimateContext; /** Stable identity for data that changes an unmeasured row's estimate. */ estimateContextRevision?: string | number; + /** Seed the first window near the tail; subsequent positioning belongs to follow. */ + startAtTailOnMount?: boolean; /** The host has temporarily withdrawn the scroller, such as window minimization. */ isViewportSuspended?: () => boolean; /** @@ -325,6 +327,7 @@ export function useFlowChatVirtualizer({ estimateItemHeightPx, estimateContext, estimateContextRevision, + startAtTailOnMount = false, isViewportSuspended = () => false, scrollPaddingStartPx, writeViewport, @@ -332,6 +335,9 @@ export function useFlowChatVirtualizer({ }: UseFlowChatVirtualizerOptions): FlowChatVirtualizer { const itemsRef = useRef(items); itemsRef.current = items; + const initialTailRef = useRef(startAtTailOnMount); + const hasInitialItemsRef = useRef(items.length > 0); + if (items.length > 0) hasInitialItemsRef.current = true; const writeViewportRef = useRef(writeViewport); writeViewportRef.current = writeViewport; /** @@ -405,6 +411,19 @@ export function useFlowChatVirtualizer({ }, [estimateContextRevision]); const virtualizer = useVirtualizer({ + // A live-tail open previously mounted rows 0..13 before moving to 22..33; + // the first head measurement flushed 372.3ms of pending layout in a trace. + // Seed the last item's estimated start without reading DOM geometry. This + // avoids that head window; real measurement and follow still own settling. + // Same-session retest: rowRef total 377.3 -> 4.2ms; reveal probe completed + // at 1540.3 -> 806.7ms. These are single-trace timings, not paint guarantees. + // Keep an empty hydration from consuming the one-time initial offset. + enabled: !initialTailRef.current || hasInitialItemsRef.current, + initialOffset: () => initialTailRef.current + ? itemsRef.current.slice(0, -1).reduce((offset, item) => ( + offset + estimateItemHeightRef.current(item, estimateContextRef.current) + ), 0) + : 0, count: items.length, getScrollElement: () => scrollerRef.current, observeElementRect: observeFlowChatViewportRect, From 1a9fcc9d425f3a471b3a1702ffc811294e83530a Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 21 Sep 2026 16:35:29 +0800 Subject: [PATCH 4/6] perf(flow-chat): Reconcile opening scroll offsets Reconcile the existing follow target after measured row sizes enter the virtualizer cache, then publish the actual offset before the next window is selected. Prevent delayed scroll-end samples from restoring an older offset and causing overscan rows to unmount and remount. Limit reconciliation to active, unsuspended opening transcripts owned by follow-output. Preserve user takeover and historical reading rules. A same-session desktop retest reduced row cleanup calls from five to zero and advanced post-reveal sampling from 786.8ms to 596.4ms. These are single-trace observations, not paint timing or remote validation. Validation: 57 focused tests rerun successfully; staged diff checked. The implementation previously passed 187 related tests, check:web, and focused ESLint. Temporary diagnostics remain outside this commit. --- .../modern/FLOWCHAT_VERIFICATION.md | 2 +- .../modern/FLOWCHAT_VIRTUALIZATION.md | 21 +++++++- ...rtualMessageList.session-boundary.test.tsx | 21 ++++++++ .../components/modern/VirtualMessageList.tsx | 10 ++++ ...lowChatVirtualizer.initial-window.test.tsx | 52 +++++++++++++++++-- .../modern/useFlowChatVirtualizer.ts | 44 ++++++++++++++++ 6 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 52d78eb35f..1155f74e1f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -43,7 +43,7 @@ each missing what the other had. | `../../../infrastructure/diagnostics/flowChatViewportDiagnostics.test.ts` | coalescing, placement sampling, the switch | | `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary | | `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer | -| `useFlowChatVirtualizer.initial-window.test.tsx` | tail-first window, empty hydration, head default, one-time seed and user-scroll takeover; supplied DOM geometry, not performance validation | +| `useFlowChatVirtualizer.initial-window.test.tsx` | tail-first window, empty hydration, head default, one-time seed, user-scroll takeover, measured-window reconciliation with delayed scroll/scroll-end delivery; supplied DOM geometry, not performance validation | | `useFlowChatVirtualizer.aim.test.tsx` | the re-aim, and giving it up on takeover | | `VirtualMessageList.layout.test.ts` | the item-height estimate and the spacer | | `FlowChatTurnRail.test.tsx` | single-marker emphasis, neighboring hover fan, independent keyboard focus, reduced motion, and rail navigation | diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 2698275026..56bb7e7495 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -59,8 +59,25 @@ restoration retain the default initial window. Tests cover window selection using the real virtualizer with supplied DOM geometry. A same-session desktop retest started at rows 27..33: rowRef total fell from 377.3ms to 4.2ms and the post-reveal probe completed at 806.7ms instead of 1540.3ms. This is a single-trace -comparison, not paint timing or remote validation. Tail-window contraction and -expansion still occur and remain under investigation. +comparison, not paint timing or remote validation. The remaining tail-window +contraction led to the measurement reconciliation described below. + +Opening measurement reconciliation now runs after a row size enters TanStack's +cache and before the queued render chooses its next window. Only an active, +unsuspended, still-opening transcript whose current owner is `follow-output` +asks the existing follow scheduler to reconcile. The offset observer then +publishes the actual scroll position without a synchronous React flush. No +displacement permission is broadened, and historical reading, user takeover, +and post-reveal streaming keep their existing rules. A pending debounced native +scroll-end sample must not overwrite this publication with its older offset. +The motivating trace measured a 729px shrink of rows 22..26 followed by window +contraction/remount and 113.8ms of removal-related style work. Tests reproduce +the contraction with reconciliation disabled and retain the same row nodes +with it enabled, including a delayed native scroll event and scroll-end timer. +A same-session desktop retest kept rows 22..33 mounted: row cleanup calls fell +from five to zero, and the post-reveal probe completed at 596.4ms instead of +786.8ms. This single-trace comparison does not establish paint timing or remote +behavior; other main-thread stalls remain. FlowChat virtualizes with **TanStack Virtual**, behind `useFlowChatVirtualizer.ts`. Nothing else imports it. The rest of FlowChat asks for offsets in scroller 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 295d5df5f5..8df92d20f7 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 @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({ scheduleFollowToLatest: vi.fn(), startAtTailOnMount: true, virtualizerStartsAtTail: false, + reconcileOpeningMeasurement: null as null | (() => boolean), revealNewTurnTail: null as null | ((turnId: string) => boolean), /** * The register the list built, reached through the hook it hands it to. @@ -129,8 +130,10 @@ vi.mock('./useFlowChatVirtualizer', async () => { getItemKey: (item: Record) => string; scrollerRef: { current: HTMLElement | null }; startAtTailOnMount?: boolean; + reconcileOpeningMeasurement?: () => boolean; }) => { mocks.virtualizerStartsAtTail = options.startAtTailOnMount === true; + mocks.reconcileOpeningMeasurement = options.reconcileOpeningMeasurement ?? null; const rows = options.items.map((item, index) => ({ index, key: options.getItemKey(item), @@ -370,6 +373,21 @@ describe('VirtualMessageList natural scroll contract', () => { vi.unstubAllGlobals(); }); + it('reconciles opening measurements only while follow owns the active viewport', () => { + act(() => root.render()); + expect(mocks.reconcileOpeningMeasurement?.()).toBe(false); + mocks.followsNow = true; + mocks.viewportOwner!.claim('follow-output'); + mocks.scheduleFollowToLatest.mockClear(); + expect(mocks.reconcileOpeningMeasurement?.()).toBe(true); + expect(mocks.scheduleFollowToLatest).toHaveBeenCalledTimes(1); + mocks.viewportOwner!.claim('user-gesture'); + expect(mocks.reconcileOpeningMeasurement?.()).toBe(false); + expect(mocks.scheduleFollowToLatest).toHaveBeenCalledTimes(1); + act(() => root.render()); + expect(mocks.reconcileOpeningMeasurement?.()).toBe(false); + }); + it('isolates the opening transcript at its boundary until reveal', async () => { act(() => root.render()); expect(mocks.virtualizerStartsAtTail).toBe(true); @@ -387,6 +405,9 @@ describe('VirtualMessageList natural scroll contract', () => { expect(list.hasAttribute('aria-hidden')).toBe(false); expect(container.querySelectorAll('[data-flowchat-opening-guard][tabindex="-1"]')).toHaveLength(2); expect(list.querySelector('.virtual-message-list__opening-shield')).toBeNull(); + mocks.followsNow = true; + mocks.viewportOwner!.claim('follow-output'); + expect(mocks.reconcileOpeningMeasurement?.()).toBe(false); }); it('renders only the current input layout inset in the Footer', () => { 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 223f3ed015..7e1436d9eb 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -522,9 +522,11 @@ const VirtualMessageListSession = forwardRef boolean>(() => false); const virtualizer = useFlowChatVirtualizer({ items: virtualItems, startAtTailOnMount: presentationMode !== 'history-window' && !shouldRestoreInitialSnapshot, + reconcileOpeningMeasurement: () => reconcileOpeningMeasurementRef.current(), scrollerRef: scrollerElementRef, headerRef: headerElementRef, getItemKey: getVirtualItemStableKey, @@ -708,6 +710,14 @@ const VirtualMessageListSession = forwardRef { + if (isOpenViewportSettledRef.current || isViewportSuspendedRef.current + || !isViewportActive || !isFollowingOutputNow() + || viewportOwner.currentOwner() !== 'follow-output') return false; + scheduleFollowToLatest(); + return true; + }; + /** * The anchor stands down for anyone aiming at a target of their own — and for * nobody else, the reader included. diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx index bc68abc86c..9e04bda52a 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx @@ -2,11 +2,14 @@ import React, { act, useLayoutEffect, useRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useFlowChatVirtualizer } from './useFlowChatVirtualizer'; +import { useFlowChatVirtualizer, type FlowChatVirtualizer } from './useFlowChatVirtualizer'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; const windows: number[][] = []; +let latestApi: FlowChatVirtualizer; +let reconcileEnabled = false; +let shortOverscan = false; function Harness({ count, tail }: { count: number; tail: boolean }) { const scrollerRef = useRef(null); const headerRef = useRef(null); @@ -16,6 +19,12 @@ function Harness({ count, tail }: { count: number; tail: boolean }) { getItemKey: String, estimateItemHeightPx: () => 100, startAtTailOnMount: tail, + reconcileOpeningMeasurement: () => { + const scroller = scrollerRef.current; + if (!reconcileEnabled || !scroller) return false; + scroller.scrollTop = Math.max(0, scroller.scrollHeight - 500); + return true; + }, scrollPaddingStartPx: 0, writeViewport: ({ topPx }) => { const element = scrollerRef.current; @@ -25,6 +34,7 @@ function Harness({ count, tail }: { count: number; tail: boolean }) { return true; }, }); + latestApi = api; useLayoutEffect(() => { windows.push(api.rows.map(row => row.index)); }); return
@@ -39,6 +49,11 @@ describe('initial virtual window with the real virtualizer', () => { let root: Root; beforeEach(() => { windows.length = 0; + reconcileEnabled = false; + shortOverscan = false; + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} @@ -47,14 +62,16 @@ describe('initial virtual window with the real virtualizer', () => { // Explicit geometry supplies jsdom's missing layout, not performance proof. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () { if (this.hasAttribute('data-scroller')) return 500; - return this.hasAttribute('data-virtual-index') ? 80 : 0; + if (!this.hasAttribute('data-virtual-index')) return 0; + return shortOverscan && Number(this.getAttribute('data-virtual-index')) < 27 ? 10 : 80; }); vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800); vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(500); vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function () { const items = this.querySelector('[data-items]'); return items ? Number.parseFloat(items.style.paddingTop || '0') - + Number.parseFloat(items.style.paddingBottom || '0') + items.childElementCount * 80 : 0; + + Number.parseFloat(items.style.paddingBottom || '0') + + [...items.children].reduce((sum, row) => sum + (row as HTMLElement).offsetHeight, 0) : 0; }); host = document.createElement('div'); document.body.append(host); @@ -65,6 +82,7 @@ describe('initial virtual window with the real virtualizer', () => { host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); }); const render = (count: number, tail: boolean) => act(() => root.render()); @@ -103,4 +121,32 @@ describe('initial virtual window with the real virtualizer', () => { render(34, true); expect(windows.find(window => window.length)?.[0]).toBe(0); }); + + it.each([false, true])('reconciles measured overscan before delayed events (enabled=%s)', enabled => { + shortOverscan = true; + render(34, true); + reconcileEnabled = enabled; + const scroller = host.querySelector('[data-scroller]')!; + act(() => { + latestApi.scrollToOffset(scroller.scrollHeight - 500, { owner: 'follow-output' }); + scroller.dispatchEvent(new Event('scroll')); + }); + const first = windows.at(-1)![0]; + if (!enabled) { + // Control: the old cached offset contracts the window back to the last row. + expect(first).toBe(27); + return; + } + expect(first).toBeLessThan(27); + const overscanRow = host.querySelector(`[data-virtual-index="${first}"]`); + expect(overscanRow).not.toBeNull(); + // Native scroll dispatch is intentionally withheld after reconciliation. + // The old scroll-end timeout must not restore its captured, outdated offset. + act(() => vi.advanceTimersByTime(200)); + expect(windows.at(-1)![0]).toBe(first); + expect(host.querySelector(`[data-virtual-index="${first}"]`)).toBe(overscanRow); + act(() => scroller.dispatchEvent(new Event('scroll'))); + expect(windows.at(-1)![0]).toBe(first); + expect(host.querySelector(`[data-virtual-index="${first}"]`)).toBe(overscanRow); + }); }); 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 a0c4505734..854bb542e8 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -27,6 +27,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import { + observeElementOffset as observeTanStackElementOffset, observeElementRect as observeTanStackElementRect, useVirtualizer, type Rect, @@ -163,6 +164,9 @@ export interface UseFlowChatVirtualizerOptions { estimateContextRevision?: string | number; /** Seed the first window near the tail; subsequent positioning belongs to follow. */ startAtTailOnMount?: boolean; + /** Reconcile an opening follow target after a measured size changes. True + * means the owner handled it and the actual offset can be published now. */ + reconcileOpeningMeasurement?: () => boolean; /** The host has temporarily withdrawn the scroller, such as window minimization. */ isViewportSuspended?: () => boolean; /** @@ -328,6 +332,7 @@ export function useFlowChatVirtualizer({ estimateContext, estimateContextRevision, startAtTailOnMount = false, + reconcileOpeningMeasurement, isViewportSuspended = () => false, scrollPaddingStartPx, writeViewport, @@ -338,6 +343,10 @@ export function useFlowChatVirtualizer({ const initialTailRef = useRef(startAtTailOnMount); const hasInitialItemsRef = useRef(items.length > 0); if (items.length > 0) hasInitialItemsRef.current = true; + const reconcileOpeningMeasurementRef = useRef(reconcileOpeningMeasurement); + reconcileOpeningMeasurementRef.current = reconcileOpeningMeasurement; + const pendingMeasurementRef = useRef(false); + const publishMeasuredOffsetRef = useRef<(() => void) | null>(null); const writeViewportRef = useRef(writeViewport); writeViewportRef.current = writeViewport; /** @@ -427,6 +436,40 @@ export function useFlowChatVirtualizer({ count: items.length, getScrollElement: () => scrollerRef.current, observeElementRect: observeFlowChatViewportRect, + observeElementOffset: (instance, callback) => { + let synchronized = false; + const publish = () => { + const scroller = instance.scrollElement; + if (!scroller) return; + const actualOffset = scroller.scrollTop; + synchronized = true; + // false avoids a nested flushSync while React is attaching measured rows. + if (instance.scrollOffset !== actualOffset) callback(actualOffset, false); + }; + publishMeasuredOffsetRef.current = publish; + const cleanup = observeTanStackElementOffset(instance, (offset, isScrolling) => { + // TanStack's debounced scroll-end callback captures the last native-event + // offset. It must not undo a newer synchronous measurement reconciliation. + if (synchronized && !isScrolling && instance.scrollElement) offset = instance.scrollElement.scrollTop; + if (isScrolling) synchronized = false; + callback(offset, isScrolling); + }); + return () => { + cleanup?.(); + if (publishMeasuredOffsetRef.current === publish) publishMeasuredOffsetRef.current = null; + }; + }, + onChange: (_instance, sync) => { + if (sync || !pendingMeasurementRef.current) return; + pendingMeasurementRef.current = false; + // A trace showed rows 22..26 shrinking by 729px while range selection + // still used 8669px. They unmounted, then remounted on the delayed 7940px + // scroll event (113.8ms style work on removal). Reconcile through follow, + // then publish the real offset before React selects the next window. + // Retest: five row cleanups became zero; post-reveal sampling advanced + // from 786.8ms to 596.4ms (single desktop trace, not paint timing). + if (reconcileOpeningMeasurementRef.current?.()) publishMeasuredOffsetRef.current?.(); + }, estimateSize, getItemKey: resolveItemKey, // Items carry their own index attribute already; measuring reads it back. @@ -453,6 +496,7 @@ export function useFlowChatVirtualizer({ // An instance field rather than an option, so it is assigned here — before // any measurement callback can reach `resizeItem`. virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta) => { + pendingMeasurementRef.current = true; const scroller = scrollerRef.current; if (!scroller) return false; if (isViewportSuspendedRef.current()) return false; From 603957c6fbaa188773da730b290dd71c1929e1da Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 21 Sep 2026 20:01:00 +0800 Subject: [PATCH 5/6] perf(markdown): Scope streaming reveal highlights Limit the 32 reveal highlight rules to text parents with active arrivals. Keep markers stable across animation frames and release empty shared highlight buckets without interrupting other renderer owners. Preserve the 160 ms fade and settle history, replacements, and reduced motion. A complete local desktop trace reduced pre-reveal style work from 451.5 ms to 172.4 ms. The two row-batch style updates fell from 128.3/223.3 ms to 45.4/61.4 ms; overall opening latency remains variable. Validation: 10 reveal lifecycle tests and check:web passed. Motion audit completed. Remote scenarios were not exercised. --- .../src/infrastructure/markdown/Markdown.scss | 5 +- .../markdown/useStreamingTextReveal.test.tsx | 94 ++++++++++++++++++ .../markdown/useStreamingTextReveal.ts | 99 +++++++++++++++---- 3 files changed, 180 insertions(+), 18 deletions(-) diff --git a/src/web-ui/src/infrastructure/markdown/Markdown.scss b/src/web-ui/src/infrastructure/markdown/Markdown.scss index a963f5eab2..f8bf37a449 100644 --- a/src/web-ui/src/infrastructure/markdown/Markdown.scss +++ b/src/web-ui/src/infrastructure/markdown/Markdown.scss @@ -716,8 +716,11 @@ // Paint only the arriving glyph ranges. currentColor preserves links, syntax // colors and the active theme; no spans or layout-affecting row animations. +// Global highlight rules cause pseudo-style work even with no registered ranges. +// Scope to actual arriving text parents; the hook retains this marker only until +// their final arrival settles, including the last 160 ms after streaming ends. @for $level from 0 through 31 { - ::highlight(openbitfun-stream-reveal-#{$level}) { + [data-stream-reveal-active]::highlight(openbitfun-stream-reveal-#{$level}) { color: color-mix(in srgb, currentColor #{20% + $level * 2.5%}, transparent); } } diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx index 8a549fcf0b..82f5128203 100644 --- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx +++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.test.tsx @@ -45,8 +45,12 @@ describe('streaming text arrival paint', () => { expect(view.container.querySelector('p')!.firstChild).toBe(textNode); expect(view.container.querySelector('span')).toBeNull(); expect(visibleRanges().join('')).not.toContain('Copy'); + expect(view.container.querySelectorAll('[data-stream-reveal-active]')).toHaveLength(1); + expect(view.container.querySelector('p')!.hasAttribute('data-stream-reveal-active')).toBe(true); act(() => vi.advanceTimersByTime(STREAMING_TEXT_REVEAL_MS + 20)); expect(visibleRanges()).toEqual([]); + expect(highlights.size).toBe(0); + expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull(); }); it('does not restart earlier arrivals when another batch arrives or the stream completes', () => { const view = render(); @@ -55,6 +59,7 @@ describe('streaming text arrival paint', () => { view.rerender(); expect(visibleRanges().sort()).toEqual(['B', 'C']); view.rerender(); + expect(view.container.querySelector('p')!.hasAttribute('data-stream-reveal-active')).toBe(true); act(() => vi.advanceTimersByTime(80)); expect(visibleRanges()).toEqual(['C']); act(() => vi.advanceTimersByTime(100)); @@ -78,8 +83,10 @@ describe('streaming text arrival paint', () => { expect(visibleRanges()).toEqual(['B', 'Y']); first.unmount(); expect(visibleRanges()).toEqual(['Y']); + expect(second.container.querySelector('p')!.hasAttribute('data-stream-reveal-active')).toBe(true); second.unmount(); expect(visibleRanges()).toEqual([]); + expect(highlights.size).toBe(0); }); it('honors reduced motion and works without the highlight API', () => { vi.stubGlobal('matchMedia', () => ({ matches: true })); @@ -90,4 +97,91 @@ describe('streaming text arrival paint', () => { view.rerender(); expect(view.container.textContent).toBe('ABCCopy'); }); + + it('retains the parent marker across frames and new arrivals, removing it only on settlement', () => { + const view = render(); + const parent = view.container.querySelector('p')!; + const add = vi.spyOn(parent, 'setAttribute'); + const remove = vi.spyOn(parent, 'removeAttribute'); + view.rerender(); + act(() => vi.advanceTimersByTime(80)); + view.rerender(); + act(() => vi.advanceTimersByTime(96)); + expect(add.mock.calls.filter(([name]) => name === 'data-stream-reveal-active')).toHaveLength(1); + expect(remove.mock.calls.filter(([name]) => name === 'data-stream-reveal-active')).toHaveLength(0); + act(() => vi.advanceTimersByTime(100)); + expect(remove.mock.calls.filter(([name]) => name === 'data-stream-reveal-active')).toHaveLength(1); + expect(highlights.size).toBe(0); + }); + + it('scopes rich text to its actual parents and cleans detached parents on replacement', () => { + function Rich({ suffix, replaced = false }: { suffix: string; replaced?: boolean }) { + const ref = useRef(null); + useStreamingTextReveal(ref, replaced ? 'Replacement' : `History${suffix}`, true); + return
{replaced ?

Replacement

: <> +

History

{suffix}

+ }
; + } + const view = render(); + view.rerender(); + const link = view.container.querySelector('a')!; + expect([...view.container.querySelectorAll('[data-stream-reveal-active]')]).toEqual([link]); + expect(visibleRanges()).toEqual(['New']); + view.rerender(); + expect(link.hasAttribute('data-stream-reveal-active')).toBe(false); + expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull(); + expect(highlights.size).toBe(0); + }); + + it('clears active paint on visibility changes without deleting unrelated highlights', () => { + const foreign = new Set(); + highlights.set('search', foreign); + const view = render(); + view.rerender(); + const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); + act(() => document.dispatchEvent(new Event('visibilitychange'))); + expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull(); + expect([...highlights.keys()]).toEqual(['search']); + hidden.mockRestore(); + act(() => vi.advanceTimersByTime(200)); + expect([...highlights.keys()]).toEqual(['search']); + }); + + it('clears active paint when reduced motion becomes enabled', () => { + const media = new EventTarget() as EventTarget & { matches: boolean }; + media.matches = false; + vi.stubGlobal('matchMedia', () => media); + const view = render(); + view.rerender(); + media.matches = true; + act(() => media.dispatchEvent(new Event('change'))); + expect(highlights.size).toBe(0); + expect(view.container.querySelector('[data-stream-reveal-active]')).toBeNull(); + }); + + it('keeps a shared text-parent marker until its last renderer owner releases it', () => { + const element = document.createElement('div'); + document.body.append(element); + const ref = { current: element }; + function Owner({ source }: { source: string }) { + useStreamingTextReveal(ref, source, true); + return null; + } + element.textContent = 'A'; + const view = render(<>); + try { + element.textContent = 'AB'; + view.rerender(<>); + expect(visibleRanges()).toEqual(['B', 'B']); + view.rerender(<>); + expect(visibleRanges()).toEqual(['B']); + expect(element.hasAttribute('data-stream-reveal-active')).toBe(true); + view.unmount(); + expect(element.hasAttribute('data-stream-reveal-active')).toBe(false); + expect(highlights.size).toBe(0); + } finally { + view.unmount(); + element.remove(); + } + }); }); diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts index d92bde9ea5..b8b09f775c 100644 --- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts +++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts @@ -3,6 +3,9 @@ import { useLayoutEffect, useRef, type RefObject } from 'react'; export const STREAMING_TEXT_REVEAL_MS = 160; const LEVELS = 32; const NAME = 'openbitfun-stream-reveal-'; +const ACTIVE_ATTRIBUTE = 'data-stream-reveal-active'; +// Several renderer owners may share a text parent; only the last releases it. +const activeElementOwners = new WeakMap(); type TextHighlight = Set; type HighlightAPI = { CSS?: { highlights?: Map }; @@ -10,6 +13,18 @@ type HighlightAPI = { }; interface Arrival { start: number; end: number; at: number } interface TextRun { node: Text; start: number; end: number } +interface OwnedRange { + registry: Map; + name: string; + highlight: TextHighlight; + range: Range; +} + +function pruneEmptyBuckets(entries: OwnedRange[]): void { + for (const { registry, name, highlight } of entries) { + if (highlight.size === 0 && registry.get(name) === highlight) registry.delete(name); + } +} function readRuns(root: HTMLElement): { runs: TextRun[]; text: string } { const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT, { @@ -37,19 +52,49 @@ export function useStreamingTextReveal( ): void { const previous = useRef<{ source: string; text: string } | null>(null); const arrivals = useRef([]); - const owned = useRef<{ highlight: TextHighlight; range: Range }[]>([]); + const owned = useRef([]); + const activeElements = useRef(new Set()); const frame = useRef(null); - const stop = () => { - if (frame.current !== null) cancelAnimationFrame(frame.current); + const frameView = useRef(null); + const cancelFrame = () => { + if (frame.current !== null) frameView.current?.cancelAnimationFrame(frame.current); frame.current = null; - for (const { highlight, range } of owned.current) highlight.delete(range); + }; + const releaseRanges = () => { + const released = owned.current; + for (const { highlight, range } of released) highlight.delete(range); owned.current = []; + return released; + }; + const updateActiveElements = (next: Set) => { + for (const element of activeElements.current) { + if (next.has(element)) continue; + const owners = (activeElementOwners.get(element) ?? 1) - 1; + if (owners > 0) activeElementOwners.set(element, owners); + else { + activeElementOwners.delete(element); + element.removeAttribute(ACTIVE_ATTRIBUTE); + } + } + for (const element of next) { + if (activeElements.current.has(element)) continue; + const owners = activeElementOwners.get(element) ?? 0; + activeElementOwners.set(element, owners + 1); + if (owners === 0) element.setAttribute(ACTIVE_ATTRIBUTE, ''); + } + activeElements.current = next; + }; + const stop = () => { + cancelFrame(); + pruneEmptyBuckets(releaseRanges()); + updateActiveElements(new Set()); }; useLayoutEffect(() => { const root = rootRef.current; if (!root) return; const view = root.ownerDocument.defaultView; + frameView.current = view; const api = view as (Window & HighlightAPI) | null; const registry = api?.CSS?.highlights; const Highlight = api?.Highlight; @@ -59,19 +104,19 @@ export function useStreamingTextReveal( const current = readRuns(root); previous.current = { source, text: current.text }; const reduced = view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches; - if (!registry || !Highlight || reduced || root.ownerDocument.hidden) { + if (!view || !registry || !Highlight || reduced || root.ownerDocument.hidden) { stop(); arrivals.current = []; return; } const appended = before && source.startsWith(before.source) && source.length > before.source.length; - if (before && !source.startsWith(before.source)) arrivals.current = []; + if (before && (!source.startsWith(before.source) || !current.text.startsWith(before.text))) arrivals.current = []; // Reinterpreting Markdown may replace earlier nodes. Never replay those // letters; only a genuinely appended visible suffix gets a new arrival. if (streaming && appended && current.text.startsWith(before.text)) { - arrivals.current.push({ start: before.text.length, end: current.text.length, at: performance.now() }); + arrivals.current.push({ start: before.text.length, end: current.text.length, at: view.performance.now() }); } - stop(); + cancelFrame(); // Resolve ranges once per content commit. Animation frames only move those // ranges between paint buckets; they do not walk a long transcript again. const resolved = arrivals.current.map(arrival => { @@ -97,7 +142,14 @@ export function useStreamingTextReveal( return { arrival, ranges }; }); const paint = (now: number) => { - stop(); + cancelFrame(); + if (!root.isConnected || root.ownerDocument.hidden) { + stop(); + arrivals.current = []; + return; + } + const released = releaseRanges(); + const nextElements = new Set(); arrivals.current = arrivals.current.filter(arrival => now - arrival.at < STREAMING_TEXT_REVEAL_MS); for (const { arrival, ranges } of resolved) { if (now - arrival.at >= STREAMING_TEXT_REVEAL_MS) continue; @@ -110,27 +162,40 @@ export function useStreamingTextReveal( registry.set(name, highlight); } for (const range of ranges) { + const parent = range.startContainer.parentElement; + if (!parent || !root.contains(parent)) continue; highlight.add(range); - owned.current.push({ highlight, range }); + owned.current.push({ registry, name, highlight, range }); + nextElements.add(parent); } + if (highlight.size === 0 && registry.get(name) === highlight) registry.delete(name); } - if (arrivals.current.length) frame.current = requestAnimationFrame(paint); + // Keep scope stable across frames/batches. Only actual arriving text + // parents get the 32 pseudo styles, never the whole transcript. Global + // Local desktop trace: this scope reduced two opening style updates from + // 128.3/223.3 ms to 45.4/61.4 ms, and pre-reveal style work from 451.5 ms + // to 172.4 ms. Overall opening latency still includes other work. + updateActiveElements(nextElements); + // Prune after adding the new frame, preserving shared buckets still in use. + pruneEmptyBuckets(released); + if (arrivals.current.length) frame.current = view.requestAnimationFrame(paint); }; // Layout timing styles newly committed glyphs before their first paint. - paint(performance.now()); + paint(view.performance.now()); }, [source, streaming, rootRef]); useLayoutEffect(() => { - const media = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const document = rootRef.current?.ownerDocument; + const media = document?.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)'); const clear = () => { stop(); arrivals.current = []; }; const onPreference = () => { if (media?.matches) clear(); }; - const onVisibility = () => { if (document.hidden) clear(); }; + const onVisibility = () => { if (document?.hidden) clear(); }; media?.addEventListener?.('change', onPreference); - document.addEventListener('visibilitychange', onVisibility); + document?.addEventListener('visibilitychange', onVisibility); return () => { clear(); media?.removeEventListener?.('change', onPreference); - document.removeEventListener('visibilitychange', onVisibility); + document?.removeEventListener('visibilitychange', onVisibility); }; - }, []); + }, [rootRef]); } From f427d7799c6d97639c1065f73679f1f4079e87a1 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 21 Sep 2026 22:53:44 +0800 Subject: [PATCH 6/6] perf(flow-chat): sync opening follow offset Publish the actual scrollTop readback after an opening follow correction so the virtualizer can expand its range before the delayed native scroll event. Keep the update on the existing observer channel without flushSync, preserve viewport ownership and suspension guards, and cover clamping, stale events, and takeover behavior with focused tests.\n\nA desktop trace showed range expansion about 65 ms earlier and reveal about 15 ms earlier; these are diagnostic single-trace results rather than fixed runtime guarantees. --- .../modern/FLOWCHAT_VERIFICATION.md | 5 +- .../modern/FLOWCHAT_VIRTUALIZATION.md | 12 +++ .../components/modern/VirtualMessageList.tsx | 5 +- .../modern/useFlowChatFollowOutput.test.tsx | 99 ++++++++++++++++++- .../modern/useFlowChatFollowOutput.ts | 22 ++++- ...lowChatVirtualizer.initial-window.test.tsx | 48 +++++++++ .../modern/useFlowChatVirtualizer.ts | 16 ++- .../markdown/useStreamingTextReveal.ts | 8 +- 8 files changed, 199 insertions(+), 16 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 1155f74e1f..dda1220a22 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -31,19 +31,20 @@ each missing what the other had. | `modelRoundItemMemo.test.ts` | settled rows refresh continuation labels and tool grouping hints without invalidating equivalent hints | | `flowChatTailFollow.test.ts` | the three-quarter reservation and `hold-tail` geometry | | `flowChatCollapseMotion.test.ts` | collapse does not move earlier content | -| `useFlowChatFollowOutput.test.tsx` | one-shot new-Turn reveal, frame loop, blank crossing, resize realign | +| `useFlowChatFollowOutput.test.tsx` | one-shot new-Turn reveal, frame loop, blank crossing, resize realign, opening readback publication and ownership/lifecycle gates | | `../../tool-cards/useToolCardHeightContract.test.tsx` | tool cards reflow rather than compensate | | `flowChatHistoryBoundary.test.ts` | the screenful lead, and the latch's own predicate | | `flowChatLiveTailWindow.test.ts` | "does the transcript still reach the newest Turn" | | `flowChatViewportAnchor.test.ts` | anchor geometry and the DOM contract | | `useFlowChatViewportAnchor.test.tsx` | capture, restore, carry, the settle window | | `VirtualMessageList.session-boundary.test.tsx` | prepend compensation, the ask, navigation-target current Turn with gesture/follow/session handoff, and search placement only outside the readable viewport | +| `FlowChatOpeningBoundary.test.tsx` | opening-only activation/scroll isolation, bidirectional focus skipping, programmatic focus return, and reveal cleanup; DOM contracts only | | `ModernFlowChatContainer.history-state.test.tsx` | history presentation and the submission event | | `flowChatViewportOwnership.test.ts` | the priority order, preemption, expiry | | `../../../infrastructure/diagnostics/flowChatViewportDiagnostics.test.ts` | coalescing, placement sampling, the switch | | `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary | | `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer | -| `useFlowChatVirtualizer.initial-window.test.tsx` | tail-first window, empty hydration, head default, one-time seed, user-scroll takeover, measured-window reconciliation with delayed scroll/scroll-end delivery; supplied DOM geometry, not performance validation | +| `useFlowChatVirtualizer.initial-window.test.tsx` | tail-first window, empty hydration, head default, one-time seed, user-scroll takeover, opening readbacks before native events, suspended viewport, measured-window reconciliation with delayed scroll/scroll-end delivery; supplied DOM geometry, not performance validation | | `useFlowChatVirtualizer.aim.test.tsx` | the re-aim, and giving it up on takeover | | `VirtualMessageList.layout.test.ts` | the item-height estimate and the spacer | | `FlowChatTurnRail.test.tsx` | single-marker emphasis, neighboring hover fan, independent keyboard focus, reduced motion, and rail navigation | diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 56bb7e7495..24c399b3e5 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -79,6 +79,18 @@ from five to zero, and the post-reveal probe completed at 596.4ms instead of 786.8ms. This single-trace comparison does not establish paint timing or remote behavior; other main-thread stalls remain. +Opening follow corrections also publish their immediate `scrollTop` readback +through `syncViewportOffset`, including a target that is already reached. The +list connects the follow callback to this adapter method; follow never imports +the virtualizer. Only active, unsuspended opening follow with viewport ownership +publishes, and refused writes publish nothing. Equal offsets do not notify React. +This lets range selection proceed before the native scroll event without adding +a synchronous flush or clearing measured sizes. Measurement reconciliation uses +the same observer channel, with its pending flag cleared before calling follow +to avoid recursive correction. Native events remain enabled. Tests withhold them +and check window expansion, node retention, stale scroll-end delivery and user +takeover; runtime savings and remote behavior still require separate validation. + FlowChat virtualizes with **TanStack Virtual**, behind `useFlowChatVirtualizer.ts`. Nothing else imports it. The rest of FlowChat asks for offsets in scroller coordinates and gets them back; there is no index space of the virtualizer's own 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 7e1436d9eb..74275e126b 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -240,7 +240,7 @@ const FlowChatListHeader = forwardRef(({ previousHistoryBoundaryStatusNode }, ref) => (
- void; revealNewTurnTail?: (turnId: string) => boolean; isOpeningViewport?: boolean; + onOpeningOffset?: (actualOffsetPx: number) => void; + isViewportActive?: boolean; + isViewportSuspended?: boolean; + startAtTailOnMount?: boolean; onController: (controller: Controller) => void; /** The register the hook writes through, for a test that has to hold it. */ onViewportOwner?: (owner: FlowChatViewportOwnerApi) => void; @@ -72,6 +76,10 @@ function Harness({ scrollToContentEnd = () => {}, revealNewTurnTail = () => false, isOpeningViewport = false, + onOpeningOffset, + isViewportActive = true, + isViewportSuspended = false, + startAtTailOnMount = true, onController, onViewportOwner, }: HarnessProps) { @@ -86,13 +94,16 @@ function Harness({ dialogTurnCount, virtualItemCount: 2, isStreaming, - isViewportActive: true, + isViewportActive, + isViewportSuspended: () => isViewportSuspended, + startAtTailOnMount, scrollerRef, // Sized from live layout, exactly as the component's state does. getTailSpacerPx: () => tailSpacerPxForViewport(scroller.clientHeight, BOTTOM_INSET), scrollToContentEnd, revealNewTurnTail, isOpeningViewport: () => isOpeningViewport, + onOpeningOffset, viewportOwner, }); onController(controller); @@ -307,6 +318,92 @@ describe('useFlowChatFollowOutput', () => { expect(scroller.scrollTop).toBe(1000); }); + describe('opening offset publication', () => { + const mountOpening = (props: Partial = {}) => { + act(() => root.render( { controller = next; }} + {...props} + />)); + }; + + beforeEach(() => { + setScrollerMetrics(scroller, { + scrollHeight: 1500 + TAIL_SPACER, clientHeight: VIEWPORT, scrollTop: 0, + }); + }); + + it('publishes the clamped readback in the follow frame without a scroll event', () => { + let actualTop = 0; + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => actualTop, + set: (value: number) => { actualTop = Math.min(value, 900); }, + }); + const onOpeningOffset = vi.fn(); + mountOpening({ onOpeningOffset }); + expect(onOpeningOffset).toHaveBeenCalledWith(900); + onOpeningOffset.mockClear(); + actualTop = 0; + runNextFrame(); + expect(onOpeningOffset).toHaveBeenCalledExactlyOnceWith(900); + expect(scroller.scrollTop).toBe(900); + }); + + it('publishes an already-reached target and uses the latest callback', () => { + scroller.scrollTop = 1000; + const previous = vi.fn(); + const current = vi.fn(); + mountOpening({ onOpeningOffset: previous }); + previous.mockClear(); + mountOpening({ onOpeningOffset: current }); + current.mockClear(); + runNextFrame(); + expect(previous).not.toHaveBeenCalled(); + expect(current).toHaveBeenCalledExactlyOnceWith(1000); + }); + + it.each([0, 1000])('does not publish under a higher-priority owner at offset %s', offset => { + scroller.scrollTop = offset; + const onOpeningOffset = vi.fn(); + let owner: FlowChatViewportOwnerApi; + mountOpening({ onOpeningOffset, onViewportOwner: next => { owner = next; } }); + act(() => owner.claim('one-shot-navigation', { holdForMs: 1000 })); + expect(owner!.currentOwner()).toBe('one-shot-navigation'); + scroller.scrollTop = offset; + onOpeningOffset.mockClear(); + act(() => controller?.scheduleFollowToLatest()); + expect(scroller.scrollTop).toBe(offset); + expect(onOpeningOffset).not.toHaveBeenCalled(); + }); + + it.each([ + { isOpeningViewport: false }, + { isViewportActive: false }, + { isViewportSuspended: true }, + { startAtTailOnMount: false }, + ])('does not publish outside active opening follow: %j', boundary => { + const onOpeningOffset = vi.fn(); + mountOpening({ onOpeningOffset, ...boundary }); + act(() => controller?.scheduleFollowToLatest()); + expect(onOpeningOffset).not.toHaveBeenCalled(); + }); + + it('stops publishing when the reader takes over', () => { + const onOpeningOffset = vi.fn(); + mountOpening({ onOpeningOffset }); + runNextFrame(); + onOpeningOffset.mockClear(); + act(() => controller?.handleUserScrollIntent()); + scroller.scrollTop = 100; + act(() => controller?.scheduleFollowToLatest()); + expect(scroller.scrollTop).toBe(100); + expect(onOpeningOffset).not.toHaveBeenCalled(); + }); + }); + it('does not strand the viewport inside the tail spacer after opening', () => { // Regression: the gap tolerance is a streaming allowance. Applied to a // foreign forward move it parked the content end mid-viewport forever, diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index ae8941bd22..03919d3b12 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -84,6 +84,8 @@ interface UseFlowChatFollowOutputOptions { revealNewTurnTail: (turnId: string) => boolean; /** True while the transcript is still hidden for the opening reveal. */ isOpeningViewport: () => boolean; + /** Immediate position readback after an opening follow correction. */ + onOpeningOffset?: (actualOffsetPx: number) => void; /** * Who is moving the viewport. Every write below goes through it, so that * nothing else has to carry a private opinion about when this hook is busy. @@ -210,6 +212,7 @@ export function useFlowChatFollowOutput({ scrollToContentEnd, revealNewTurnTail, isOpeningViewport, + onOpeningOffset, viewportOwner, viewportId = 0, }: UseFlowChatFollowOutputOptions): UseFlowChatFollowOutputResult { @@ -220,6 +223,8 @@ export function useFlowChatFollowOutput({ const latestTurnIdRef = useRef(latestTurnId); const isViewportSuspendedRef = useRef(isViewportSuspended); isViewportSuspendedRef.current = isViewportSuspended; + const onOpeningOffsetRef = useRef(onOpeningOffset); + onOpeningOffsetRef.current = onOpeningOffset; const followFrameRef = useRef(null); const previousSessionIdRef = useRef(activeSessionId); const previousLatestTurnIdRef = useRef(latestTurnId); @@ -589,7 +594,9 @@ export function useFlowChatFollowOutput({ settleFramesRef.current = SETTLE_FRAMES; } - const onTarget = Math.abs(next.target - scroller.scrollTop) <= BOTTOM_EPSILON_PX; + let actualOffsetPx = scroller.scrollTop; + let writeGranted = true; + const onTarget = Math.abs(next.target - actualOffsetPx) <= BOTTOM_EPSILON_PX; /* * What the loop decided this frame, coalesced by the decision. * @@ -690,7 +697,7 @@ export function useFlowChatFollowOutput({ }) ? nextEasedScrollTopPx(fromPx, next.target) : { offsetPx: next.target, outcome: 'snapped' as const }; - viewportOwner.write({ owner: 'follow-output', topPx: step.offsetPx }); + writeGranted = viewportOwner.write({ owner: 'follow-output', topPx: step.offsetPx }); /* * Read back rather than taken from the step. The register can refuse * this write outright, and a refused follow moves nothing — believing @@ -698,7 +705,8 @@ export function useFlowChatFollowOutput({ * smoothest one in the session, and would book the frame below forever * over travel that never happens. */ - const movedPx = scroller.scrollTop - fromPx; + actualOffsetPx = scroller.scrollTop; + const movedPx = actualOffsetPx - fromPx; /* * An ease in flight is a reason to run again, and the only one it has * once the target stops moving: the budget is refreshed by the *target* @@ -719,6 +727,14 @@ export function useFlowChatFollowOutput({ }); } } + // Native scroll delivery can lag a frame behind this write. Publish the + // DOM readback through the existing virtualizer observer channel so range + // selection catches up in the same task without a synchronous flush. + if (writeGranted && isOpeningViewport() && isFollowingOutputRef.current + && followPhaseRef.current === 'following-tail' && isViewportActiveRef.current + && !document.hidden && viewportOwner.currentOwner() === 'follow-output') { + onOpeningOffsetRef.current?.(actualOffsetPx); + } }, [ endSmoothScrollYield, getTailSpacerPx, diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx index 9e04bda52a..6f68774c3c 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx @@ -10,6 +10,7 @@ const windows: number[][] = []; let latestApi: FlowChatVirtualizer; let reconcileEnabled = false; let shortOverscan = false; +let viewportSuspended = false; function Harness({ count, tail }: { count: number; tail: boolean }) { const scrollerRef = useRef(null); const headerRef = useRef(null); @@ -19,10 +20,13 @@ function Harness({ count, tail }: { count: number; tail: boolean }) { getItemKey: String, estimateItemHeightPx: () => 100, startAtTailOnMount: tail, + isViewportSuspended: () => viewportSuspended, reconcileOpeningMeasurement: () => { const scroller = scrollerRef.current; if (!reconcileEnabled || !scroller) return false; scroller.scrollTop = Math.max(0, scroller.scrollHeight - 500); + // Opening follow publishes from inside measurement reconciliation too. + latestApi.syncViewportOffset(scroller.scrollTop); return true; }, scrollPaddingStartPx: 0, @@ -51,6 +55,7 @@ describe('initial virtual window with the real virtualizer', () => { windows.length = 0; reconcileEnabled = false; shortOverscan = false; + viewportSuspended = false; vi.useFakeTimers(); vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); vi.stubGlobal('cancelAnimationFrame', vi.fn()); @@ -122,6 +127,49 @@ describe('initial virtual window with the real virtualizer', () => { expect(windows.find(window => window.length)?.[0]).toBe(0); }); + it('expands the opening window from a readback before any native scroll arrives', () => { + shortOverscan = true; + render(34, true); + reconcileEnabled = true; + expect(windows.at(-1)![0]).toBe(27); + const scroller = host.querySelector('[data-scroller]')!; + act(() => { + latestApi.scrollToOffset(scroller.scrollHeight - 500, { owner: 'follow-output' }); + latestApi.syncViewportOffset(scroller.scrollTop); + }); + const first = windows.at(-1)![0]; + expect(first).toBeLessThan(27); + expect(windows.at(-1)!.at(-1)).toBe(33); + const row = host.querySelector(`[data-virtual-index="${first}"]`); + const commits = windows.length; + act(() => latestApi.syncViewportOffset(scroller.scrollTop)); + expect(windows).toHaveLength(commits); + act(() => { + scroller.dispatchEvent(new Event('scroll')); + vi.advanceTimersByTime(200); + }); + expect(windows.at(-1)![0]).toBe(first); + expect(host.querySelector(`[data-virtual-index="${first}"]`)).toBe(row); + reconcileEnabled = false; + act(() => { + scroller.scrollTop = 0; + scroller.dispatchEvent(new Event('scroll')); + }); + expect(windows.at(-1)![0]).toBe(0); + }); + + it('does not publish readbacks while the viewport is suspended', () => { + render(34, true); + const commits = windows.length; + const scroller = host.querySelector('[data-scroller]')!; + viewportSuspended = true; + act(() => latestApi.syncViewportOffset(scroller.scrollTop)); + expect(windows).toHaveLength(commits); + viewportSuspended = false; + act(() => latestApi.syncViewportOffset(scroller.scrollTop)); + expect(windows.at(-1)![0]).toBe(0); + }); + it.each([false, true])('reconciles measured overscan before delayed events (enabled=%s)', enabled => { shortOverscan = true; render(34, true); 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 854bb542e8..02cbc5f5fe 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.ts @@ -212,6 +212,8 @@ export interface FlowChatVirtualizer { * the items, before the library's own measurement has caught up. */ measureRenderedItems: () => void; + /** Publish an immediate DOM offset readback without moving the viewport. */ + syncViewportOffset: (actualOffsetPx: number) => void; /** * The items intersecting the viewport right now, read from live geometry. * @@ -346,7 +348,10 @@ export function useFlowChatVirtualizer({ const reconcileOpeningMeasurementRef = useRef(reconcileOpeningMeasurement); reconcileOpeningMeasurementRef.current = reconcileOpeningMeasurement; const pendingMeasurementRef = useRef(false); - const publishMeasuredOffsetRef = useRef<(() => void) | null>(null); + const publishMeasuredOffsetRef = useRef<((actualOffsetPx?: number) => void) | null>(null); + const syncViewportOffset = useCallback((actualOffsetPx: number) => { + if (Number.isFinite(actualOffsetPx)) publishMeasuredOffsetRef.current?.(actualOffsetPx); + }, []); const writeViewportRef = useRef(writeViewport); writeViewportRef.current = writeViewport; /** @@ -438,10 +443,10 @@ export function useFlowChatVirtualizer({ observeElementRect: observeFlowChatViewportRect, observeElementOffset: (instance, callback) => { let synchronized = false; - const publish = () => { - const scroller = instance.scrollElement; - if (!scroller) return; - const actualOffset = scroller.scrollTop; + const publish = (actualOffsetPx?: number) => { + const scroller = instance.scrollElement; + if (!scroller || scroller !== scrollerRef.current || isViewportSuspendedRef.current()) return; + const actualOffset = actualOffsetPx ?? scroller.scrollTop; synchronized = true; // false avoids a nested flushSync while React is attaching measured rows. if (instance.scrollOffset !== actualOffset) callback(actualOffset, false); @@ -705,6 +710,7 @@ export function useFlowChatVirtualizer({ measureRowElement, getItemBounds, measureRenderedItems, + syncViewportOffset, getVisibleItemRange, scrollItemIntoView, scrollToOffset, diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts index b8b09f775c..e241da7f5a 100644 --- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts +++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts @@ -89,6 +89,8 @@ export function useStreamingTextReveal( pruneEmptyBuckets(releaseRanges()); updateActiveElements(new Set()); }; + const stopRef = useRef(stop); + stopRef.current = stop; useLayoutEffect(() => { const root = rootRef.current; @@ -105,7 +107,7 @@ export function useStreamingTextReveal( previous.current = { source, text: current.text }; const reduced = view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches; if (!view || !registry || !Highlight || reduced || root.ownerDocument.hidden) { - stop(); + stopRef.current(); arrivals.current = []; return; } @@ -144,7 +146,7 @@ export function useStreamingTextReveal( const paint = (now: number) => { cancelFrame(); if (!root.isConnected || root.ownerDocument.hidden) { - stop(); + stopRef.current(); arrivals.current = []; return; } @@ -187,7 +189,7 @@ export function useStreamingTextReveal( useLayoutEffect(() => { const document = rootRef.current?.ownerDocument; const media = document?.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)'); - const clear = () => { stop(); arrivals.current = []; }; + const clear = () => { stopRef.current(); arrivals.current = []; }; const onPreference = () => { if (media?.matches) clear(); }; const onVisibility = () => { if (document?.hidden) clear(); }; media?.addEventListener?.('change', onPreference);