diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 761025060..3e38f8b83 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -61,7 +61,8 @@ interface ThreadViewportProps { } const NEAR_BOTTOM_PX = 48; -const NEAR_TOP_PX = 96; +const HISTORY_PREFETCH_MIN_PX = 160; +const HISTORY_PREFETCH_MAX_PX = 480; const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; @@ -77,6 +78,47 @@ interface HistoryScrollAnchor { offsetTop: number; } +const THREAD_DISPLAY_UNIT_SELECTOR = "[data-thread-display-unit]"; + +function historyPrefetchDistance(scroller: HTMLElement): number { + return Math.min( + HISTORY_PREFETCH_MAX_PX, + Math.max(HISTORY_PREFETCH_MIN_PX, scroller.clientHeight / 2), + ); +} + +function visibleHistoryUnit( + content: HTMLElement, + viewport: DOMRect, +): HTMLElement | null { + // Scroll is a hot path. Hit-testing keeps the common case O(1) instead of + // forcing layout for every mounted message while the trackpad is moving. + if (typeof document.elementsFromPoint === "function" && viewport.height > 0) { + const contentBounds = content.getBoundingClientRect(); + const left = Math.max(viewport.left, contentBounds.left); + const right = Math.min(viewport.right, contentBounds.right); + const x = left + Math.max(0, right - left) / 2; + const offsets = [1, Math.min(32, viewport.height / 3), viewport.height / 2]; + for (const offset of offsets) { + for (const target of document.elementsFromPoint(x, viewport.top + offset)) { + const unit = target instanceof Element + ? target.closest(THREAD_DISPLAY_UNIT_SELECTOR) + : null; + if (unit && content.contains(unit)) return unit; + } + } + } + + // Deterministic fallback for pre-layout states, tests, and older browsers. + const units = Array.from( + content.querySelectorAll(THREAD_DISPLAY_UNIT_SELECTOR), + ); + return units.find((unit) => { + const bounds = unit.getBoundingClientRect(); + return bounds.bottom > viewport.top && bounds.top < viewport.bottom; + }) ?? units[0] ?? null; +} + export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] { if (messages.length <= visibleCount) return messages; let start = Math.max(0, messages.length - visibleCount); @@ -311,21 +353,18 @@ export const ThreadViewport = forwardRef("[data-thread-display-unit]"); - for (const element of units) { - const bounds = element.getBoundingClientRect(); - if (bounds.bottom <= scrollerTop + 0.5) continue; - const key = element.dataset.threadDisplayUnit; - if (!key) continue; - historyScrollAnchorRef.current = { - key, - offsetTop: bounds.top - scrollerTop, - }; - return true; + const viewport = scroller.getBoundingClientRect(); + const element = visibleHistoryUnit(content, viewport); + const key = element?.dataset.threadDisplayUnit; + if (!element || !key) { + historyScrollAnchorRef.current = null; + return false; } - historyScrollAnchorRef.current = null; - return false; + historyScrollAnchorRef.current = { + key, + offsetTop: element.getBoundingClientRect().top - viewport.top, + }; + return true; }, []); const reconcileHistoryScrollAnchor = useCallback(() => { @@ -417,7 +456,7 @@ export const ThreadViewport = forwardRef NEAR_TOP_PX) return; + if (el.scrollTop > historyPrefetchDistance(el)) return; if (hiddenMessageCount <= 0 && !hasMoreBefore) return; loadEarlierMessages(); }, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]); diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 7c2ff51dd..67a5e0e29 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -123,6 +123,25 @@ function stubResizeObserver() { }; } +function stubElementsFromPoint(resolve: () => Element[]) { + const descriptor = Object.getOwnPropertyDescriptor(document, "elementsFromPoint"); + const mock = vi.fn(resolve); + Object.defineProperty(document, "elementsFromPoint", { + configurable: true, + value: mock, + }); + return { + mock, + restore: () => { + if (descriptor) { + Object.defineProperty(document, "elementsFromPoint", descriptor); + } else { + Reflect.deleteProperty(document, "elementsFromPoint"); + } + }, + }; +} + function makeLongMessages(count: number): UIMessage[] { return Array.from({ length: count }, (_, index) => ({ id: `m${index}`, @@ -1481,8 +1500,39 @@ describe("ThreadViewport", () => { expect(screen.getAllByText("message 299").length).toBeGreaterThan(0); }); + it("prefetches earlier history within half a viewport of the top", () => { + const { container } = render( + } + />, + ); + + const scroller = getScroller(container); + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 301 }, + }); + + act(() => { + dispatchUserScroll(scroller); + }); + expect(screen.queryByText("message 139")).not.toBeInTheDocument(); + + scroller.scrollTop = 250; + act(() => { + dispatchUserScroll(scroller); + }); + expect(screen.getByText("message 20")).toBeInTheDocument(); + expect(screen.queryByText("message 19")).not.toBeInTheDocument(); + }); + it("keeps the first visible history item fixed while deferred rows materialize", () => { const resizeObserver = stubResizeObserver(); + let hitTarget: Element | null = null; + const hitTest = stubElementsFromPoint(() => hitTarget ? [hitTarget] : []); try { const { container } = render( { const anchor = screen.getByText("message 140") .closest("[data-thread-display-unit]"); expect(anchor).not.toBeNull(); + hitTarget = anchor; let anchorDocumentTop = 200; Object.defineProperty(anchor, "getBoundingClientRect", { configurable: true, @@ -1520,6 +1571,7 @@ describe("ThreadViewport", () => { act(() => { dispatchUserScroll(scroller); }); + expect(hitTest.mock).toHaveBeenCalled(); const replacement = anchor.cloneNode(true) as HTMLElement; anchor.replaceWith(replacement); @@ -1545,6 +1597,7 @@ describe("ThreadViewport", () => { expect(scroller.scrollTop).toBe(260); expect(replacement.getBoundingClientRect().top).toBe(120); } finally { + hitTest.restore(); resizeObserver.restore(); } });