mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
perf(webui): smooth paged history scrolling
This commit is contained in:
@@ -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<HTMLElement>(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<HTMLElement>(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<ThreadViewportHandle, ThreadViewportPro
|
||||
historyScrollAnchorRef.current = null;
|
||||
return false;
|
||||
}
|
||||
const scrollerTop = scroller.getBoundingClientRect().top;
|
||||
const units = content.querySelectorAll<HTMLElement>("[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 = {
|
||||
key,
|
||||
offsetTop: element.getBoundingClientRect().top - viewport.top,
|
||||
};
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const reconcileHistoryScrollAnchor = useCallback(() => {
|
||||
@@ -417,7 +456,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const el = scrollRef.current;
|
||||
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
|
||||
if (!threadMotionRef.current?.isBrowsingHistory()) return;
|
||||
if (el.scrollTop > NEAR_TOP_PX) return;
|
||||
if (el.scrollTop > historyPrefetchDistance(el)) return;
|
||||
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
|
||||
loadEarlierMessages();
|
||||
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
|
||||
|
||||
@@ -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(
|
||||
<ThreadViewport
|
||||
messages={makeLongMessages(300)}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadViewport
|
||||
@@ -1507,6 +1557,7 @@ describe("ThreadViewport", () => {
|
||||
const anchor = screen.getByText("message 140")
|
||||
.closest<HTMLElement>("[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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user