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/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 2830aefabb..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,18 +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, 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 fda42037b6..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 @@ -48,6 +48,49 @@ 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. 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. + +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/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.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..1ef7cbcfa7 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%; @@ -92,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..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 @@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({ followsNow: false, 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. @@ -127,7 +129,11 @@ vi.mock('./useFlowChatVirtualizer', async () => { items: Array>; 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), @@ -367,6 +373,43 @@ 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); + 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(); + mocks.followsNow = true; + mocks.viewportOwner!.claim('follow-output'); + expect(mocks.reconcileOpeningMeasurement?.()).toBe(false); + }); + it('renders only the current input layout inset in the Footer', () => { act(() => root.render()); const footer = container.querySelector('.message-list-footer'); @@ -903,21 +946,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 +971,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(); @@ -1509,6 +1556,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 97ac4d444d..74275e126b 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, @@ -521,8 +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, @@ -702,10 +706,19 @@ 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. @@ -2598,7 +2611,7 @@ const VirtualMessageListSession = forwardRef
-
+ ); }); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx index 9a321025e4..b6904c0848 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx @@ -59,6 +59,10 @@ interface HarnessProps { scrollToContentEnd?: (behavior: ScrollBehavior) => 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 new file mode 100644 index 0000000000..6f68774c3c --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatVirtualizer.initial-window.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment jsdom +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, type FlowChatVirtualizer } from './useFlowChatVirtualizer'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +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); + const api = useFlowChatVirtualizer({ + items: Array.from({ length: count }, (_, index) => index), + scrollerRef, headerRef, + 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, + 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; + }, + }); + latestApi = api; + 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; + reconcileEnabled = false; + shortOverscan = false; + viewportSuspended = false; + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + 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; + 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.children].reduce((sum, row) => sum + (row as HTMLElement).offsetHeight, 0) : 0; + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + 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); + }); + + 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); + 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 3c35e1e798..02cbc5f5fe 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, @@ -161,6 +162,11 @@ 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; + /** 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; /** @@ -206,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. * @@ -325,6 +333,8 @@ export function useFlowChatVirtualizer({ estimateItemHeightPx, estimateContext, estimateContextRevision, + startAtTailOnMount = false, + reconcileOpeningMeasurement, isViewportSuspended = () => false, scrollPaddingStartPx, writeViewport, @@ -332,6 +342,16 @@ 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 reconcileOpeningMeasurementRef = useRef(reconcileOpeningMeasurement); + reconcileOpeningMeasurementRef.current = reconcileOpeningMeasurement; + const pendingMeasurementRef = useRef(false); + 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; /** @@ -405,9 +425,56 @@ 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, + observeElementOffset: (instance, callback) => { + let synchronized = false; + 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); + }; + 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. @@ -434,6 +501,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; @@ -642,6 +710,7 @@ export function useFlowChatVirtualizer({ measureRowElement, getItemBounds, measureRenderedItems, + syncViewportOffset, getVisibleItemRange, scrollItemIntoView, scrollToOffset, 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..e241da7f5a 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,51 @@ 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()); + }; + const stopRef = useRef(stop); + stopRef.current = stop; 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 +106,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) { - stop(); + if (!view || !registry || !Highlight || reduced || root.ownerDocument.hidden) { + stopRef.current(); 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 +144,14 @@ export function useStreamingTextReveal( return { arrival, ranges }; }); const paint = (now: number) => { - stop(); + cancelFrame(); + if (!root.isConnected || root.ownerDocument.hidden) { + stopRef.current(); + 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 +164,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 clear = () => { stop(); arrivals.current = []; }; + const document = rootRef.current?.ownerDocument; + const media = document?.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)'); + const clear = () => { stopRef.current(); 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]); }