mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(webui): stabilize fast history scrolling
This commit is contained in:
@@ -273,6 +273,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
|
className={`${marginTop}${stableDeferOffscreenRender ? " thread-render-unit" : ""}`}
|
||||||
|
data-thread-display-unit
|
||||||
data-user-prompt-id={userPromptId}
|
data-user-prompt-id={userPromptId}
|
||||||
>
|
>
|
||||||
{unit.type === "activity" ? (
|
{unit.type === "activity" ? (
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ const SESSION_HANDOFF_OPACITY = 0.82;
|
|||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||||
|
|
||||||
|
interface HistoryScrollAnchor {
|
||||||
|
element: HTMLElement;
|
||||||
|
offsetTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
||||||
if (messages.length <= visibleCount) return messages;
|
if (messages.length <= visibleCount) return messages;
|
||||||
let start = Math.max(0, messages.length - visibleCount);
|
let start = Math.max(0, messages.length - visibleCount);
|
||||||
@@ -210,6 +215,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||||
const restoreScrollAfterPrependRef =
|
const restoreScrollAfterPrependRef =
|
||||||
useRef<{ height: number; top: number } | null>(null);
|
useRef<{ height: number; top: number } | null>(null);
|
||||||
|
const historyScrollAnchorRef = useRef<HistoryScrollAnchor | null>(null);
|
||||||
const composerInputScrollTopRef = useRef<number | null>(null);
|
const composerInputScrollTopRef = useRef<number | null>(null);
|
||||||
const composerDockHeightRef = useRef(0);
|
const composerDockHeightRef = useRef(0);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
@@ -298,7 +304,50 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
threadMotionRef.current?.takeUserControl();
|
threadMotionRef.current?.takeUserControl();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const captureHistoryScrollAnchor = useCallback(() => {
|
||||||
|
const scroller = scrollRef.current;
|
||||||
|
const content = messageContentRef.current;
|
||||||
|
if (!scroller || !content) {
|
||||||
|
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;
|
||||||
|
historyScrollAnchorRef.current = {
|
||||||
|
element,
|
||||||
|
offsetTop: bounds.top - scrollerTop,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reconcileHistoryScrollAnchor = useCallback(() => {
|
||||||
|
const scroller = scrollRef.current;
|
||||||
|
const anchor = historyScrollAnchorRef.current;
|
||||||
|
if (!scroller || !anchor) return false;
|
||||||
|
if (!anchor.element.isConnected) {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextOffset =
|
||||||
|
anchor.element.getBoundingClientRect().top
|
||||||
|
- scroller.getBoundingClientRect().top;
|
||||||
|
const delta = nextOffset - anchor.offsetTop;
|
||||||
|
if (Math.abs(delta) < 0.5) return true;
|
||||||
|
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||||
|
const nextTop = Math.min(maxScrollTop, Math.max(0, scroller.scrollTop + delta));
|
||||||
|
threadMotionRef.current?.jumpTo(nextTop);
|
||||||
|
return true;
|
||||||
|
}, [captureHistoryScrollAnchor]);
|
||||||
|
|
||||||
const scrollToBottomNow = useCallback((smooth = false) => {
|
const scrollToBottomNow = useCallback((smooth = false) => {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
const marker = bottomRef.current;
|
const marker = bottomRef.current;
|
||||||
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
||||||
@@ -328,10 +377,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const loadEarlierMessages = useCallback(() => {
|
const loadEarlierMessages = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) {
|
if (el) {
|
||||||
restoreScrollAfterPrependRef.current = {
|
if (captureHistoryScrollAnchor()) {
|
||||||
height: el.scrollHeight,
|
restoreScrollAfterPrependRef.current = null;
|
||||||
top: el.scrollTop,
|
} else {
|
||||||
};
|
restoreScrollAfterPrependRef.current = {
|
||||||
|
height: el.scrollHeight,
|
||||||
|
top: el.scrollTop,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
threadMotionRef.current?.takeUserControl();
|
threadMotionRef.current?.takeUserControl();
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
@@ -345,7 +398,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
|
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
|
||||||
void onLoadOlder();
|
void onLoadOlder();
|
||||||
}
|
}
|
||||||
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
|
}, [
|
||||||
|
captureHistoryScrollAnchor,
|
||||||
|
hasMoreBefore,
|
||||||
|
hiddenMessageCount,
|
||||||
|
loadingOlder,
|
||||||
|
messages.length,
|
||||||
|
onLoadOlder,
|
||||||
|
]);
|
||||||
|
|
||||||
const maybeLoadEarlierFromScroll = useCallback(() => {
|
const maybeLoadEarlierFromScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -360,6 +420,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
|
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
|
||||||
if (!scrollEl || !prompt) return false;
|
if (!scrollEl || !prompt) return false;
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
setAtBottom(false);
|
setAtBottom(false);
|
||||||
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
||||||
threadMotionRef.current?.navigateHistoryTo(
|
threadMotionRef.current?.navigateHistoryTo(
|
||||||
@@ -442,6 +503,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
conversationHandoffAnimationRef.current = null;
|
conversationHandoffAnimationRef.current = null;
|
||||||
conversationHandoffPendingRef.current = true;
|
conversationHandoffPendingRef.current = true;
|
||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
restoreScrollAfterPrependRef.current = null;
|
||||||
threadMotionRef.current?.reset();
|
threadMotionRef.current?.reset();
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
@@ -505,17 +568,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const pending = restoreScrollAfterPrependRef.current;
|
const pending = restoreScrollAfterPrependRef.current;
|
||||||
if (!pending) return;
|
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
restoreScrollAfterPrependRef.current = null;
|
restoreScrollAfterPrependRef.current = null;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
if (reconcileHistoryScrollAnchor()) return;
|
||||||
|
if (!pending) return;
|
||||||
const delta = el.scrollHeight - pending.height;
|
const delta = el.scrollHeight - pending.height;
|
||||||
const nextTop = Math.min(
|
const nextTop = Math.min(
|
||||||
Math.max(0, el.scrollHeight - el.clientHeight),
|
Math.max(0, el.scrollHeight - el.clientHeight),
|
||||||
Math.max(0, pending.top + delta),
|
Math.max(0, pending.top + delta),
|
||||||
);
|
);
|
||||||
threadMotionRef.current?.jumpTo(nextTop);
|
threadMotionRef.current?.jumpTo(nextTop);
|
||||||
}, [visibleMessages.length, messages.length]);
|
}, [reconcileHistoryScrollAnchor, visibleMessages.length, messages.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const promptId = pendingPromptJumpRef.current;
|
const promptId = pendingPromptJumpRef.current;
|
||||||
@@ -593,6 +657,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
threadMotionRef.current?.invalidateGeometry();
|
threadMotionRef.current?.invalidateGeometry();
|
||||||
};
|
};
|
||||||
const reconcileObservedGeometry = () => {
|
const reconcileObservedGeometry = () => {
|
||||||
|
reconcileHistoryScrollAnchor();
|
||||||
threadMotionRef.current?.reconcileObservedGeometry();
|
threadMotionRef.current?.reconcileObservedGeometry();
|
||||||
};
|
};
|
||||||
reconcileObservedGeometry();
|
reconcileObservedGeometry();
|
||||||
@@ -609,7 +674,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
window.removeEventListener("resize", invalidateGeometry);
|
window.removeEventListener("resize", invalidateGeometry);
|
||||||
};
|
};
|
||||||
}, [hasMessages]);
|
}, [hasMessages, reconcileHistoryScrollAnchor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -623,7 +688,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
setAtBottom((current) =>
|
setAtBottom((current) =>
|
||||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||||
);
|
);
|
||||||
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
|
if (owner === "user") {
|
||||||
|
captureHistoryScrollAnchor();
|
||||||
|
if (allowHistoryLoad) maybeLoadEarlierFromScroll();
|
||||||
|
} else if (near) {
|
||||||
|
historyScrollAnchorRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onScroll(false);
|
onScroll(false);
|
||||||
@@ -709,7 +779,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
el.removeEventListener("pointerdown", handlePointerDown);
|
el.removeEventListener("pointerdown", handlePointerDown);
|
||||||
el.removeEventListener("keydown", handleKeyDown);
|
el.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [hasMessages, maybeLoadEarlierFromScroll, yieldCameraToUser]);
|
}, [
|
||||||
|
captureHistoryScrollAnchor,
|
||||||
|
hasMessages,
|
||||||
|
maybeLoadEarlierFromScroll,
|
||||||
|
yieldCameraToUser,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -1481,6 +1481,64 @@ describe("ThreadViewport", () => {
|
|||||||
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the first visible history item fixed while deferred rows materialize", () => {
|
||||||
|
const resizeObserver = stubResizeObserver();
|
||||||
|
try {
|
||||||
|
const { container } = render(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={makeLongMessages(300)}
|
||||||
|
isStreaming={false}
|
||||||
|
composer={<div />}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scroller = getScroller(container);
|
||||||
|
let scrollHeight = 2_400;
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
scrollHeight: { configurable: true, get: () => scrollHeight },
|
||||||
|
clientHeight: { configurable: true, value: 600 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 80 },
|
||||||
|
getBoundingClientRect: {
|
||||||
|
configurable: true,
|
||||||
|
value: () => DOMRect.fromRect({ y: 0, width: 800, height: 600 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchor = screen.getByText("message 140")
|
||||||
|
.closest<HTMLElement>("[data-thread-display-unit]");
|
||||||
|
expect(anchor).not.toBeNull();
|
||||||
|
let anchorDocumentTop = 200;
|
||||||
|
Object.defineProperty(anchor, "getBoundingClientRect", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => DOMRect.fromRect({
|
||||||
|
y: anchorDocumentTop - scroller.scrollTop,
|
||||||
|
width: 800,
|
||||||
|
height: 40,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchUserScroll(scroller);
|
||||||
|
});
|
||||||
|
|
||||||
|
anchorDocumentTop += 180;
|
||||||
|
scrollHeight += 180;
|
||||||
|
const content = screen.getByTestId("thread-message-region").firstElementChild;
|
||||||
|
const observer = resizeObserver.observers.find((candidate) =>
|
||||||
|
content ? candidate.elements.includes(content) : false,
|
||||||
|
);
|
||||||
|
expect(observer).toBeDefined();
|
||||||
|
act(() => {
|
||||||
|
observer?.callback([], observer as unknown as ResizeObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scroller.scrollTop).toBe(260);
|
||||||
|
expect(anchor.getBoundingClientRect().top).toBe(120);
|
||||||
|
} finally {
|
||||||
|
resizeObserver.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("automatically requests older transcript pages near the top", () => {
|
it("automatically requests older transcript pages near the top", () => {
|
||||||
const onLoadOlder = vi.fn();
|
const onLoadOlder = vi.fn();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user