diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index 01e77ee82..00d41bd42 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -273,6 +273,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
<>
{unit.type === "activity" ? (
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index 71eab3d2e..b540a9218 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -72,6 +72,11 @@ const SESSION_HANDOFF_OPACITY = 0.82;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
+interface HistoryScrollAnchor {
+ element: HTMLElement;
+ offsetTop: number;
+}
+
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
if (messages.length <= visibleCount) return messages;
let start = Math.max(0, messages.length - visibleCount);
@@ -210,6 +215,7 @@ export const ThreadViewport = forwardRef(null);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
+ const historyScrollAnchorRef = useRef(null);
const composerInputScrollTopRef = useRef(null);
const composerDockHeightRef = useRef(0);
const [atBottom, setAtBottom] = useState(true);
@@ -298,7 +304,50 @@ export const ThreadViewport = forwardRef {
+ 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("[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) => {
+ historyScrollAnchorRef.current = null;
const el = scrollRef.current;
const marker = bottomRef.current;
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
@@ -328,10 +377,14 @@ export const ThreadViewport = forwardRef {
const el = scrollRef.current;
if (el) {
- restoreScrollAfterPrependRef.current = {
- height: el.scrollHeight,
- top: el.scrollTop,
- };
+ if (captureHistoryScrollAnchor()) {
+ restoreScrollAfterPrependRef.current = null;
+ } else {
+ restoreScrollAfterPrependRef.current = {
+ height: el.scrollHeight,
+ top: el.scrollTop,
+ };
+ }
}
threadMotionRef.current?.takeUserControl();
setAtBottom(false);
@@ -345,7 +398,14 @@ export const ThreadViewport = forwardRef count + HISTORY_WINDOW_INCREMENT);
void onLoadOlder();
}
- }, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
+ }, [
+ captureHistoryScrollAnchor,
+ hasMoreBefore,
+ hiddenMessageCount,
+ loadingOlder,
+ messages.length,
+ onLoadOlder,
+ ]);
const maybeLoadEarlierFromScroll = useCallback(() => {
const el = scrollRef.current;
@@ -360,6 +420,7 @@ export const ThreadViewport = forwardRef {
const pending = restoreScrollAfterPrependRef.current;
- if (!pending) return;
const el = scrollRef.current;
restoreScrollAfterPrependRef.current = null;
if (!el) return;
+ if (reconcileHistoryScrollAnchor()) return;
+ if (!pending) return;
const delta = el.scrollHeight - pending.height;
const nextTop = Math.min(
Math.max(0, el.scrollHeight - el.clientHeight),
Math.max(0, pending.top + delta),
);
threadMotionRef.current?.jumpTo(nextTop);
- }, [visibleMessages.length, messages.length]);
+ }, [reconcileHistoryScrollAnchor, visibleMessages.length, messages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
@@ -593,6 +657,7 @@ export const ThreadViewport = forwardRef {
+ reconcileHistoryScrollAnchor();
threadMotionRef.current?.reconcileObservedGeometry();
};
reconcileObservedGeometry();
@@ -609,7 +674,7 @@ export const ThreadViewport = forwardRef {
const el = scrollRef.current;
@@ -623,7 +688,12 @@ export const ThreadViewport = forwardRef
current === logicallyAtBottom ? current : logicallyAtBottom,
);
- if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
+ if (owner === "user") {
+ captureHistoryScrollAnchor();
+ if (allowHistoryLoad) maybeLoadEarlierFromScroll();
+ } else if (near) {
+ historyScrollAnchorRef.current = null;
+ }
};
onScroll(false);
@@ -709,7 +779,12 @@ export const ThreadViewport = forwardRef
diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx
index f9eb7582f..4535b62dd 100644
--- a/webui/src/tests/thread-viewport.test.tsx
+++ b/webui/src/tests/thread-viewport.test.tsx
@@ -1481,6 +1481,64 @@ describe("ThreadViewport", () => {
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(
+ }
+ />,
+ );
+
+ 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("[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", () => {
const onLoadOlder = vi.fn();