diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx
index e94d2285b6..4bd84423b8 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx
@@ -12,6 +12,12 @@ import { renderToStaticMarkup } from "react-dom/server";
 import { afterEach, describe, expect, it, vi } from "vitest";
 import { useState, type ComponentProps, type ReactElement } from "react";
 import { MemoryRouter, useNavigate } from "react-router-dom";
+import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport";
+import { getDefaultStore } from "jotai";
+import {
+  BottomAnchorContext,
+  type BottomAnchorContextValue,
+} from "@/components/ui/bottom-anchored-scroll-body";
 import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport";
 import { POINTER_COARSE_QUERY } from "@bb/shared-ui/hooks/use-pointer-coarse";
 import type { PluginMessageActionRegistration } from "@bb/plugin-sdk";
@@ -25,6 +31,7 @@ import {
   setPluginSlotRegistrations,
   type PluginRegistrationSet,
 } from "@/lib/plugin-slots";
+import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor";
 import { ThreadTimelineRows } from "./ThreadTimelineRows";
 
 function messageActionRegistrationSet(
@@ -268,11 +275,892 @@ function mockSelectionMenuMedia({
 
 afterEach(() => {
   cleanup();
+  getDefaultStore().set(
+    threadTimelineScrollAnchorAtomFamily("thr_large"),
+    null,
+  );
   resetPluginSlotStoreForTest();
   vi.restoreAllMocks();
+  vi.unstubAllGlobals();
 });
 
 describe("ThreadTimelineRows actions", () => {
+  it("keeps measured placeholders and preserves the visible row", async () => {
+    let intersectionCallback: IntersectionObserverCallback | null = null;
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(callback: IntersectionObserverCallback) {
+          intersectionCallback = callback;
+        }
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    const rows = Array.from({ length: 80 }, (_, index) =>
+      conversationRow({
+        id: `message_${index}`,
+        role: index % 2 === 0 ? "user" : "assistant",
+        text: `Timeline message ${index}`,
+        sourceSeqStart: index + 1,
+        sourceSeqEnd: index + 1,
+        threadId: "thr_large",
+      }),
+    );
+    const scrollElement = document.createElement("div");
+    let scrollTop = 17;
+    const setScrollTop = vi.fn((value: number) => {
+      scrollTop = value;
+    });
+    Object.defineProperty(scrollElement, "clientHeight", {
+      configurable: true,
+      value: 0,
+    });
+    Object.defineProperty(scrollElement, "scrollTop", {
+      configurable: true,
+      get: () => scrollTop,
+      set: setScrollTop,
+    });
+    Object.defineProperty(scrollElement, "scrollHeight", {
+      configurable: true,
+      value: 16_000,
+    });
+    scrollElement.getBoundingClientRect = () =>
+      ({ top: 0, bottom: 800 }) as DOMRect;
+
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: true,
+      scrollElementIntoView: vi.fn(),
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+    );
+
+    const windowedList = container.querySelector<HTMLElement>(
+      '[data-timeline-windowed="true"]',
+    );
+    expect(windowedList).not.toBeNull();
+    expect(windowedList?.closest('[style*="overflow-y: clip"]')).toBeNull();
+    expect(container.querySelectorAll("[data-timeline-row-id]").length).toBe(
+      rows.length,
+    );
+    expect(
+      container.querySelectorAll('[data-timeline-row-realized="true"]').length,
+    ).toBeLessThan(rows.length);
+
+    const firstWrapper = container.querySelector<HTMLElement>(
+      '[data-timeline-row-id="message_0"]',
+    );
+    const lastWrapper = container.querySelector<HTMLElement>(
+      '[data-timeline-row-id="message_79"]',
+    );
+    lastWrapper!.getBoundingClientRect = () => {
+      const top =
+        firstWrapper?.dataset.timelineRowRealized === "true" ? 320 : 200;
+      return { top, bottom: top + 100 } as DOMRect;
+    };
+    expect(firstWrapper?.dataset.timelineRowRealized).toBe("false");
+    expect(lastWrapper?.dataset.timelineRowRealized).toBe("true");
+
+    act(() => {
+      intersectionCallback?.(
+        [
+          {
+            target: lastWrapper!,
+            isIntersecting: false,
+            boundingClientRect: { height: 144 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+    await waitFor(() =>
+      expect(lastWrapper?.dataset.timelineRowRealized).toBe("false"),
+    );
+
+    await act(async () => {
+      intersectionCallback?.(
+        [
+          {
+            target: firstWrapper!,
+            isIntersecting: true,
+            boundingClientRect: { height: 120 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+    await waitFor(() =>
+      expect(firstWrapper?.dataset.timelineRowRealized).toBe("true"),
+    );
+    expect(scrollTop).toBe(137);
+
+    act(() => {
+      intersectionCallback?.(
+        [
+          {
+            target: firstWrapper!,
+            isIntersecting: false,
+            boundingClientRect: { height: 212 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+    await waitFor(() =>
+      expect(firstWrapper?.dataset.timelineRowRealized).toBe("false"),
+    );
+    expect(firstWrapper?.style.height).toBe("212px");
+    expect(scrollTop).toBe(17);
+    expect(setScrollTop).toHaveBeenCalledTimes(2);
+
+    // While a scroll is active, a placeholder fully below the viewport
+    // realizes immediately: its height change cannot shift visible content,
+    // so no compensating scrollTop write happens.
+    scrollElement.dataset.scrollbarScrolling = "true";
+    const belowViewportWrapper = container.querySelector<HTMLElement>(
+      '[data-timeline-row-id="message_40"]',
+    );
+    expect(belowViewportWrapper?.dataset.timelineRowRealized).toBe("false");
+    await act(async () => {
+      intersectionCallback?.(
+        [
+          {
+            target: belowViewportWrapper!,
+            isIntersecting: true,
+            boundingClientRect: { top: 900, height: 120 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+    await waitFor(() =>
+      expect(belowViewportWrapper?.dataset.timelineRowRealized).toBe("true"),
+    );
+    expect(scrollTop).toBe(17);
+    expect(setScrollTop).toHaveBeenCalledTimes(2);
+
+    // The first row has no donor placeholders above it, so its scroll-time
+    // realization reverts to the unchanged placeholder: nothing on screen
+    // moves, and no scrollTop write can kill momentum mid-gesture.
+    await act(async () => {
+      intersectionCallback?.(
+        [
+          {
+            target: firstWrapper!,
+            isIntersecting: true,
+            boundingClientRect: { top: -400, height: 212 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+    expect(firstWrapper?.dataset.timelineRowRealized).toBe("false");
+    expect(scrollTop).toBe(17);
+    expect(setScrollTop).toHaveBeenCalledTimes(2);
+
+    // The idle pass mounts it with anchor compensation once the scroll
+    // stops: the visible row keeps its on-screen position.
+    scrollElement.removeAttribute("data-scrollbar-scrolling");
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 450));
+    });
+    expect(firstWrapper?.dataset.timelineRowRealized).toBe("true");
+    expect(scrollTop).toBe(137);
+    expect(setScrollTop).toHaveBeenCalledTimes(3);
+  });
+
+  it("realizes above-viewport rows during a scroll by shrinking donor placeholders", () => {
+    let intersectionCallback: IntersectionObserverCallback | null = null;
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(callback: IntersectionObserverCallback) {
+          intersectionCallback = callback;
+        }
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    const rows = Array.from({ length: 80 }, (_, index) =>
+      conversationRow({
+        id: `message_${index}`,
+        role: index % 2 === 0 ? "user" : "assistant",
+        text: `Timeline message ${index}`,
+        sourceSeqStart: index + 1,
+        sourceSeqEnd: index + 1,
+        threadId: "thr_large",
+      }),
+    );
+    const scrollElement = document.createElement("div");
+    const setScrollTop = vi.fn();
+    Object.defineProperty(scrollElement, "scrollTop", {
+      configurable: true,
+      get: () => 500,
+      set: setScrollTop,
+    });
+    Object.defineProperty(scrollElement, "scrollHeight", {
+      configurable: true,
+      value: 16_000,
+    });
+    Object.defineProperty(scrollElement, "clientHeight", {
+      configurable: true,
+      value: 0,
+    });
+    scrollElement.getBoundingClientRect = () =>
+      ({ top: 0, bottom: 800 }) as DOMRect;
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: false,
+      scrollElementIntoView: vi.fn(),
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+    );
+
+    const wrapperOf = (id: string) =>
+      container.querySelector<HTMLElement>(`[data-timeline-row-id="${id}"]`);
+    const target = wrapperOf("message_30");
+    expect(target?.dataset.timelineRowRealized).toBe("false");
+    // The realized content measures 500px against a 120px placeholder, so a
+    // 380px delta must come out of donor placeholders above it.
+    target!.getBoundingClientRect = () => ({ height: 500 }) as DOMRect;
+
+    scrollElement.dataset.scrollbarScrolling = "true";
+    act(() => {
+      intersectionCallback?.(
+        [
+          {
+            target: target!,
+            isIntersecting: true,
+            boundingClientRect: { top: -600, height: 120 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+
+    expect(target?.dataset.timelineRowRealized).toBe("true");
+    // Donors shrink topmost-first: 120 + 120 + 120 + 20 covers the delta.
+    expect(wrapperOf("message_0")?.style.height).toBe("0px");
+    expect(wrapperOf("message_1")?.style.height).toBe("0px");
+    expect(wrapperOf("message_2")?.style.height).toBe("0px");
+    expect(wrapperOf("message_3")?.style.height).toBe("100px");
+    expect(wrapperOf("message_4")?.style.height).toBe("120px");
+    // No scrollTop write happened, so momentum scrolling survives.
+    expect(setScrollTop).not.toHaveBeenCalled();
+  });
+
+  it("grows a donor placeholder for short content and re-balances late growth", () => {
+    let intersectionCallback: IntersectionObserverCallback | null = null;
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(callback: IntersectionObserverCallback) {
+          intersectionCallback = callback;
+        }
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    let resizeCallback: ResizeObserverCallback | null = null;
+    vi.stubGlobal(
+      "ResizeObserver",
+      class ResizeObserverMock {
+        constructor(callback: ResizeObserverCallback) {
+          resizeCallback = callback;
+        }
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    const rows = Array.from({ length: 80 }, (_, index) =>
+      conversationRow({
+        id: `message_${index}`,
+        role: index % 2 === 0 ? "user" : "assistant",
+        text: `Timeline message ${index}`,
+        sourceSeqStart: index + 1,
+        sourceSeqEnd: index + 1,
+        threadId: "thr_large",
+      }),
+    );
+    const scrollElement = document.createElement("div");
+    const setScrollTop = vi.fn();
+    Object.defineProperty(scrollElement, "scrollTop", {
+      configurable: true,
+      get: () => 500,
+      set: setScrollTop,
+    });
+    Object.defineProperty(scrollElement, "scrollHeight", {
+      configurable: true,
+      value: 16_000,
+    });
+    Object.defineProperty(scrollElement, "clientHeight", {
+      configurable: true,
+      value: 0,
+    });
+    scrollElement.getBoundingClientRect = () =>
+      ({ top: 0, bottom: 800 }) as DOMRect;
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: false,
+      scrollElementIntoView: vi.fn(),
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+    );
+
+    const wrapperOf = (id: string) =>
+      container.querySelector<HTMLElement>(`[data-timeline-row-id="${id}"]`);
+    const target = wrapperOf("message_30");
+    expect(target?.dataset.timelineRowRealized).toBe("false");
+    // The realized content measures 0px (the jsdom default) against a 120px
+    // placeholder, so the topmost donor absorbs the 120px of slack.
+
+    scrollElement.dataset.scrollbarScrolling = "true";
+    act(() => {
+      intersectionCallback?.(
+        [
+          {
+            target: target!,
+            isIntersecting: true,
+            boundingClientRect: { top: -600, height: 120 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+
+    expect(target?.dataset.timelineRowRealized).toBe("true");
+    expect(wrapperOf("message_0")?.style.height).toBe("240px");
+    expect(wrapperOf("message_1")?.style.height).toBe("120px");
+    expect(setScrollTop).not.toHaveBeenCalled();
+
+    // Late growth while the scroll is active only updates the baseline —
+    // mutating geometry mid-gesture is what reads as snapping.
+    act(() => {
+      resizeCallback?.(
+        [
+          {
+            target: target!,
+            borderBoxSize: [{ blockSize: 90, inlineSize: 390 }],
+            contentRect: { height: 90 },
+          } as unknown as ResizeObserverEntry,
+        ],
+        {} as ResizeObserver,
+      );
+    });
+    expect(wrapperOf("message_0")?.style.height).toBe("240px");
+    expect(setScrollTop).not.toHaveBeenCalled();
+
+    // At idle, further growth above the viewport compensates with a direct
+    // scrollTop nudge: 150 − 90 = 60 on top of the current 500.
+    scrollElement.removeAttribute("data-scrollbar-scrolling");
+    act(() => {
+      resizeCallback?.(
+        [
+          {
+            target: target!,
+            borderBoxSize: [{ blockSize: 150, inlineSize: 390 }],
+            contentRect: { height: 150 },
+          } as unknown as ResizeObserverEntry,
+        ],
+        {} as ResizeObserver,
+      );
+    });
+    expect(target?.dataset.timelineRowRealized).toBe("true");
+    expect(wrapperOf("message_0")?.style.height).toBe("240px");
+    expect(setScrollTop).toHaveBeenCalledWith(560);
+  });
+
+  it("re-budgets estimate placeholders to the measured average at idle", async () => {
+    let intersectionCallback: IntersectionObserverCallback | null = null;
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(callback: IntersectionObserverCallback) {
+          intersectionCallback = callback;
+        }
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    const rows = Array.from({ length: 80 }, (_, index) =>
+      conversationRow({
+        id: `message_${index}`,
+        role: index % 2 === 0 ? "user" : "assistant",
+        text: `Timeline message ${index}`,
+        sourceSeqStart: index + 1,
+        sourceSeqEnd: index + 1,
+        threadId: "thr_large",
+      }),
+    );
+    const scrollElement = document.createElement("div");
+    const setScrollTop = vi.fn();
+    Object.defineProperty(scrollElement, "scrollTop", {
+      configurable: true,
+      get: () => 500,
+      set: setScrollTop,
+    });
+    Object.defineProperty(scrollElement, "scrollHeight", {
+      configurable: true,
+      value: 16_000,
+    });
+    Object.defineProperty(scrollElement, "clientHeight", {
+      configurable: true,
+      value: 0,
+    });
+    scrollElement.getBoundingClientRect = () =>
+      ({ top: 0, bottom: 800 }) as DOMRect;
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: false,
+      scrollElementIntoView: vi.fn(),
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+    );
+
+    const wrapperOf = (id: string) =>
+      container.querySelector<HTMLElement>(`[data-timeline-row-id="${id}"]`);
+
+    // Realize eight 500px rows above the viewport in one scroll-time batch:
+    // 8 × (500 − 120) = 3,040px of donor demand against the 3,600px pool in
+    // message_0..29 — solvent, so every row realizes with no scrollTop
+    // write.
+    scrollElement.dataset.scrollbarScrolling = "true";
+    const targets = Array.from({ length: 8 }, (_, offset) => {
+      const wrapper = wrapperOf(`message_${30 + offset}`);
+      wrapper!.getBoundingClientRect = () => ({ height: 500 }) as DOMRect;
+      return wrapper!;
+    });
+    act(() => {
+      intersectionCallback?.(
+        targets.map(
+          (target) =>
+            ({
+              target,
+              isIntersecting: true,
+              boundingClientRect: { top: -600, height: 120 },
+            }) as unknown as IntersectionObserverEntry,
+        ),
+        {} as IntersectionObserver,
+      );
+    });
+    expect(wrapperOf("message_30")?.dataset.timelineRowRealized).toBe("true");
+    expect(wrapperOf("message_0")?.style.height).toBe("0px");
+    expect(wrapperOf("message_26")?.style.height).toBe("120px");
+    expect(setScrollTop).not.toHaveBeenCalled();
+
+    // Once the scroll idles, never-realized placeholders re-seed to the
+    // measured 500px average, refilling the drained donor pool.
+    scrollElement.removeAttribute("data-scrollbar-scrolling");
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 450));
+    });
+    expect(wrapperOf("message_0")?.style.height).toBe("500px");
+    expect(wrapperOf("message_26")?.style.height).toBe("500px");
+    expect(wrapperOf("message_50")?.style.height).toBe("500px");
+    expect(setScrollTop).not.toHaveBeenCalled();
+  });
+
+  it("releases the oldest interaction pin once the cap is exceeded", async () => {
+    let intersectionCallback: IntersectionObserverCallback | null = null;
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(callback: IntersectionObserverCallback) {
+          intersectionCallback = callback;
+        }
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    const rows = Array.from({ length: 80 }, (_, index) =>
+      conversationRow({
+        id: `message_${index}`,
+        role: index % 2 === 0 ? "user" : "assistant",
+        text: `Timeline message ${index}`,
+        sourceSeqStart: index + 1,
+        sourceSeqEnd: index + 1,
+        threadId: "thr_large",
+      }),
+    );
+    const scrollElement = document.createElement("div");
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: true,
+      scrollElementIntoView: vi.fn(),
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+    );
+
+    const wrapperOf = (id: string) =>
+      container.querySelector<HTMLElement>(`[data-timeline-row-id="${id}"]`);
+    expect(wrapperOf("message_66")?.dataset.timelineRowRealized).toBe("true");
+    expect(wrapperOf("message_65")?.dataset.timelineRowRealized).toBe("true");
+
+    // Pin message_66 first, then message_65, then 23 more rows. The 25th pin
+    // exceeds the cap of 24, so the oldest pin (message_66) releases.
+    fireEvent.click(wrapperOf("message_66")!);
+    fireEvent.click(wrapperOf("message_65")!);
+    for (let index = 0; index < 23; index += 1) {
+      fireEvent.click(wrapperOf(`message_${index}`)!);
+    }
+
+    const exitEntry = (id: string) =>
+      ({
+        target: wrapperOf(id)!,
+        isIntersecting: false,
+        boundingClientRect: { height: 100 },
+      }) as unknown as IntersectionObserverEntry;
+    act(() => {
+      intersectionCallback?.(
+        [exitEntry("message_66"), exitEntry("message_65")],
+        {} as IntersectionObserver,
+      );
+    });
+    await waitFor(() =>
+      expect(wrapperOf("message_66")?.dataset.timelineRowRealized).toBe(
+        "false",
+      ),
+    );
+    // The still-pinned row survives the same exit.
+    expect(wrapperOf("message_65")?.dataset.timelineRowRealized).toBe("true");
+  });
+
+  it("keeps an interacted row mounted after it leaves the window", async () => {
+    let intersectionCallback: IntersectionObserverCallback | null = null;
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(callback: IntersectionObserverCallback) {
+          intersectionCallback = callback;
+        }
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    const rows = [
+      turnRow({
+        id: "expandable_turn",
+        children: [
+          conversationRow({
+            id: "expanded_child",
+            role: "assistant",
+            text: "Expanded row state stays mounted.",
+            threadId: "thr_large",
+          }),
+        ],
+        threadId: "thr_large",
+      }),
+      ...Array.from({ length: 79 }, (_, index) =>
+        conversationRow({
+          id: `message_${index + 1}`,
+          role: index % 2 === 0 ? "user" : "assistant",
+          text: `Timeline message ${index + 1}`,
+          sourceSeqStart: index + 20,
+          sourceSeqEnd: index + 20,
+          threadId: "thr_large",
+        }),
+      ),
+    ];
+    const scrollElement = document.createElement("div");
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: true,
+      scrollElementIntoView: vi.fn(),
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+    );
+
+    const wrapper = container.querySelector<HTMLElement>(
+      '[data-timeline-row-id="expandable_turn"]',
+    );
+    expect(wrapper?.dataset.timelineRowRealized).toBe("false");
+
+    await act(async () => {
+      intersectionCallback?.(
+        [
+          {
+            target: wrapper!,
+            isIntersecting: true,
+            boundingClientRect: { height: 120 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+    const toggle = await waitFor(() => {
+      const element = wrapper?.querySelector<HTMLButtonElement>(
+        'button[aria-expanded="false"]',
+      );
+      if (element === null || element === undefined) {
+        throw new Error("The realized row toggle was not rendered");
+      }
+      return element;
+    });
+    fireEvent.click(toggle);
+    expect(toggle.getAttribute("aria-expanded")).toBe("true");
+    expect(screen.getByText("Expanded row state stays mounted.")).toBeTruthy();
+
+    act(() => {
+      intersectionCallback?.(
+        [
+          {
+            target: wrapper!,
+            isIntersecting: false,
+            boundingClientRect: { height: 212 },
+          } as unknown as IntersectionObserverEntry,
+        ],
+        {} as IntersectionObserver,
+      );
+    });
+    await waitFor(() =>
+      expect(wrapper?.dataset.timelineRowRealized).toBe("true"),
+    );
+    expect(toggle.getAttribute("aria-expanded")).toBe("true");
+    expect(screen.getByText("Expanded row state stays mounted.")).toBeTruthy();
+  });
+
+  it("uses a saved anchor when search state belongs to another thread", () => {
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(_callback: IntersectionObserverCallback) {}
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    getDefaultStore().set(threadTimelineScrollAnchorAtomFamily("thr_large"), {
+      rowId: "message_60",
+      offsetWithinRow: 0,
+      atBottom: false,
+    });
+    const rows = Array.from({ length: 80 }, (_, index) =>
+      conversationRow({
+        id: `message_${index}`,
+        role: index % 2 === 0 ? "user" : "assistant",
+        text: `Timeline message ${index}`,
+        sourceSeqStart: index + 1,
+        sourceSeqEnd: index + 1,
+        threadId: "thr_large",
+      }),
+    );
+    const scrollElement = document.createElement("div");
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: false,
+      scrollElementIntoView: vi.fn(),
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+      [
+        {
+          pathname: "/thread",
+          state: { searchMessageSeq: 11, searchThreadId: "thr_other" },
+        },
+      ],
+    );
+
+    expect(
+      container.querySelector<HTMLElement>(
+        '[data-timeline-row-id="message_60"]',
+      )?.dataset.timelineRowRealized,
+    ).toBe("true");
+    expect(
+      container.querySelector<HTMLElement>(
+        '[data-timeline-row-id="message_10"]',
+      )?.dataset.timelineRowRealized,
+    ).toBe("false");
+  });
+
+  it("realizes a search target before it reveals a windowed timeline row", async () => {
+    vi.stubGlobal(
+      "IntersectionObserver",
+      class IntersectionObserverMock {
+        constructor(_callback: IntersectionObserverCallback) {}
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    vi.stubGlobal(
+      "ResizeObserver",
+      class ResizeObserverMock {
+        constructor(_callback: ResizeObserverCallback) {}
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+    vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
+      callback(performance.now());
+      return 1;
+    });
+    vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
+
+    const rows = Array.from({ length: 80 }, (_, index) =>
+      conversationRow({
+        id: `search_message_${index}`,
+        role: index % 2 === 0 ? "user" : "assistant",
+        text: `Search timeline message ${index}`,
+        sourceSeqStart: index + 1,
+        sourceSeqEnd: index + 1,
+        threadId: "thr_large_search",
+      }),
+    );
+    const scrollElement = document.createElement("div");
+    Object.defineProperty(scrollElement, "clientHeight", {
+      configurable: true,
+      value: 800,
+    });
+    const scrollElementIntoView = vi.fn();
+    const bottomAnchor: BottomAnchorContextValue = {
+      captureScrollAnchor: vi.fn(),
+      getScrollElement: () => scrollElement,
+      isAtBottom: false,
+      scrollElementIntoView,
+      scrollElementIntoViewClampedToMaxScroll: vi.fn(),
+      scrollToBottom: vi.fn(),
+    };
+
+    const { container } = renderWithRouter(
+      <BottomAnchorContext.Provider value={bottomAnchor}>
+        <CompactViewportOverrideProvider isCompactViewport>
+          <ThreadTimelineRows
+            threadId="thr_large_search"
+            timelineRows={rows}
+            threadRuntimeDisplayStatus="idle"
+            workspaceRootPath={undefined}
+          />
+        </CompactViewportOverrideProvider>
+      </BottomAnchorContext.Provider>,
+      [
+        {
+          pathname: "/thread",
+          state: {
+            searchMessageSeq: 11,
+            searchThreadId: "thr_large_search",
+          },
+        },
+      ],
+    );
+
+    const target = container.querySelector<HTMLElement>(
+      '[data-timeline-row-id="search_message_10"]',
+    );
+    expect(target?.dataset.timelineRowRealized).toBe("true");
+    await waitFor(() =>
+      expect(scrollElementIntoView).toHaveBeenCalledWith({
+        element: target,
+        options: { block: "center" },
+      }),
+    );
+  });
+
   it("uses inline mobile actions only for the last assistant message", () => {
     const { container } = renderWithRouter(
       <ThreadTimelineRows
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
index 0788f59eee..da976c6f53 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
@@ -4,13 +4,16 @@ import {
   useCallback,
   useContext,
   useEffect,
+  useLayoutEffect,
   useMemo,
   useRef,
   useState,
   useSyncExternalStore,
 } from "react";
 import type { ReactNode } from "react";
+import { flushSync } from "react-dom";
 import { useLocation } from "react-router-dom";
+import { useStore } from "jotai";
 import {
   isBackgroundAgentTaskType,
   isBackgroundCommandTaskType,
@@ -86,6 +89,8 @@ import { AutoHeightContainer } from "../../ui/height-transition.js";
 import { Icon, type IconName } from "@bb/shared-ui/icon";
 import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link";
 import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body.js";
+import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
+import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor.js";
 import {
   collectSearchedMessageAncestorRowIds,
   readSearchMessageTarget,
@@ -275,6 +280,45 @@ interface TimelineRowsListProps {
   unreadDividerPlacement: ThreadTimelineUnreadDividerPlacement | null;
 }
 
+interface TimelineWindowItemController {
+  handleIntersection: (entry: IntersectionObserverEntry) => void;
+  realize: () => void;
+  /** Revert a just-realized item to its unchanged placeholder. */
+  derealize: () => void;
+  /** The current placeholder height, or null while the item is realized. */
+  peekPlaceholderHeight: () => number | null;
+  /** Grow or shrink the placeholder (clamped at zero) to absorb a height delta. */
+  adjustPlaceholderHeight: (delta: number) => void;
+  /**
+   * Whether the item has ever mounted its real content. A false value means
+   * its placeholder height is still a pure estimate, eligible for
+   * re-budgeting; a true value means the placeholder carries a measurement.
+   */
+  hasEverRealized: () => boolean;
+  /** Release an interaction pin so the row can derealize on its next exit. */
+  unpin: () => void;
+}
+
+interface TimelineVisibleAnchor {
+  element: HTMLDivElement;
+  top: number;
+}
+
+interface TimelineWindowedListItemProps {
+  alwaysRealized: boolean;
+  children: ReactNode;
+  estimatedHeight: number;
+  initiallyRealized: boolean;
+  itemKey: string;
+  registerWrapper: (key: string, node: HTMLDivElement | null) => void;
+  registerController: (
+    key: string,
+    controller: TimelineWindowItemController | null,
+  ) => void;
+  registerInteractionPin: (key: string) => void;
+  rowId: string | undefined;
+}
+
 interface TimelineUnreadDividerProps {
   autoScroll: boolean;
 }
@@ -401,6 +445,86 @@ type TimelineRowsListItem =
       id: "thread-unread-divider";
     };
 
+const TIMELINE_WINDOWING_MIN_ITEM_COUNT = 40;
+const TIMELINE_WINDOW_MARGIN_PX = 1_000;
+const TIMELINE_WINDOW_FALLBACK_VIEWPORT_HEIGHT_PX = 800;
+// Interaction pins keep rows mounted so their local state (expansion,
+// long-message reveal) survives eviction. Cap them so a long session of taps
+// cannot keep every heavy row realized forever: the oldest pin releases
+// first, and its row derealizes on its next window exit.
+const TIMELINE_WINDOW_MAX_INTERACTION_PINS = 24;
+// Re-budgeting replaces never-measured placeholder estimates with the running
+// average of measured rows once the scroll idles, so the donor pool tracks
+// the timeline's real heights instead of draining monotonically.
+const TIMELINE_WINDOW_REBUDGET_MIN_SAMPLES = 8;
+const TIMELINE_WINDOW_REBUDGET_DRIFT_PX = 24;
+const TIMELINE_WINDOW_REBUDGET_IDLE_DELAY_MS = 300;
+const TIMELINE_WINDOW_REBUDGET_MAX_ESTIMATE_PX = 1_000;
+
+function timelineListItemKey(item: TimelineRowsListItem): string {
+  return item.kind === "row" ? item.row.id : item.id;
+}
+
+function estimateTimelineListItemHeight(item: TimelineRowsListItem): number {
+  if (item.kind === "unread-divider") {
+    return 24;
+  }
+  if (item.row.kind === "conversation") {
+    return 120;
+  }
+  return 40;
+}
+
+function collectTimelineWindowKeys({
+  centerIndex,
+  items,
+}: {
+  centerIndex: number;
+  items: readonly TimelineRowsListItem[];
+}): ReadonlySet<string> {
+  if (items.length === 0) {
+    return EMPTY_ROW_ID_SET;
+  }
+
+  const keys = new Set<string>();
+  const boundedCenterIndex = Math.max(
+    0,
+    Math.min(centerIndex, items.length - 1),
+  );
+  const coverage =
+    TIMELINE_WINDOW_MARGIN_PX + TIMELINE_WINDOW_FALLBACK_VIEWPORT_HEIGHT_PX;
+
+  let beforeHeight = 0;
+  for (
+    let index = boundedCenterIndex;
+    index >= 0 && beforeHeight <= coverage;
+    index -= 1
+  ) {
+    const item = items[index];
+    if (item === undefined) {
+      break;
+    }
+    keys.add(timelineListItemKey(item));
+    beforeHeight += estimateTimelineListItemHeight(item);
+  }
+
+  let afterHeight = 0;
+  for (
+    let index = boundedCenterIndex + 1;
+    index < items.length && afterHeight <= coverage;
+    index += 1
+  ) {
+    const item = items[index];
+    if (item === undefined) {
+      break;
+    }
+    keys.add(timelineListItemKey(item));
+    afterHeight += estimateTimelineListItemHeight(item);
+  }
+
+  return keys;
+}
+
 interface ConversationRowProps {
   row: TimelineConversationViewRow;
   showAssistantMessageActions: boolean;
@@ -1843,6 +1967,191 @@ function buildTimelineRowsListItems({
   return items;
 }
 
+function TimelineWindowedListItem({
+  alwaysRealized,
+  children,
+  estimatedHeight,
+  initiallyRealized,
+  itemKey,
+  registerWrapper,
+  registerController,
+  registerInteractionPin,
+  rowId,
+}: TimelineWindowedListItemProps) {
+  const [locallyRealized, setLocallyRealized] = useState(initiallyRealized);
+  const [placeholderHeight, setPlaceholderHeight] = useState(estimatedHeight);
+  // Mirrors the placeholder-height state so the window controller can read
+  // and adjust it synchronously between two flushSync commits in one task.
+  const placeholderHeightRef = useRef(estimatedHeight);
+  const locallyRealizedRef = useRef(locallyRealized);
+  const alwaysRealizedRef = useRef(alwaysRealized);
+  const everRealizedRef = useRef(initiallyRealized || alwaysRealized);
+  const interactionPinnedRef = useRef(false);
+  const lastIntersectionRef = useRef<boolean | null>(null);
+  useLayoutEffect(() => {
+    locallyRealizedRef.current = locallyRealized;
+    alwaysRealizedRef.current = alwaysRealized;
+  }, [alwaysRealized, locallyRealized]);
+
+  const updateLocallyRealized = useCallback((next: boolean) => {
+    if (next) {
+      everRealizedRef.current = true;
+    }
+    if (locallyRealizedRef.current === next) {
+      return;
+    }
+    locallyRealizedRef.current = next;
+    setLocallyRealized(next);
+  }, []);
+  const applyPlaceholderHeight = useCallback((next: number) => {
+    placeholderHeightRef.current = next;
+    setPlaceholderHeight(next);
+  }, []);
+  const controller = useMemo<TimelineWindowItemController>(
+    () => ({
+      handleIntersection: (entry) => {
+        lastIntersectionRef.current = entry.isIntersecting;
+        if (entry.isIntersecting) {
+          updateLocallyRealized(true);
+          return;
+        }
+        if (entry.boundingClientRect.height > 0) {
+          applyPlaceholderHeight(entry.boundingClientRect.height);
+        }
+        if (!alwaysRealizedRef.current && !interactionPinnedRef.current) {
+          updateLocallyRealized(false);
+        }
+      },
+      // Applies a realization outside an intersection entry (scroll-time
+      // realization or the idle flush). The item is intersecting when this
+      // runs, so the last-intersection state matches what handleIntersection
+      // would have set.
+      realize: () => {
+        lastIntersectionRef.current = true;
+        updateLocallyRealized(true);
+      },
+      derealize: () => {
+        updateLocallyRealized(false);
+      },
+      peekPlaceholderHeight: () =>
+        alwaysRealizedRef.current || locallyRealizedRef.current
+          ? null
+          : placeholderHeightRef.current,
+      adjustPlaceholderHeight: (delta) => {
+        applyPlaceholderHeight(
+          Math.max(0, placeholderHeightRef.current + delta),
+        );
+      },
+      hasEverRealized: () => everRealizedRef.current,
+      unpin: () => {
+        interactionPinnedRef.current = false;
+      },
+    }),
+    [applyPlaceholderHeight, updateLocallyRealized],
+  );
+  useLayoutEffect(() => {
+    registerController(itemKey, controller);
+    return () => registerController(itemKey, null);
+  }, [controller, itemKey, registerController]);
+  useLayoutEffect(() => {
+    if (
+      !alwaysRealized &&
+      lastIntersectionRef.current === false &&
+      !interactionPinnedRef.current
+    ) {
+      const frame = requestAnimationFrame(() => updateLocallyRealized(false));
+      return () => cancelAnimationFrame(frame);
+    }
+  }, [alwaysRealized, updateLocallyRealized]);
+
+  const pinInteractedItem = useCallback(() => {
+    interactionPinnedRef.current = true;
+    // Reporting every interaction (not just the first) keeps the list-level
+    // pin order in recency order, so the least-recently-touched pin evicts.
+    registerInteractionPin(itemKey);
+  }, [itemKey, registerInteractionPin]);
+  const handleWrapperRef = useCallback(
+    (node: HTMLDivElement | null) => registerWrapper(itemKey, node),
+    [itemKey, registerWrapper],
+  );
+  const isRealized = alwaysRealized || locallyRealized;
+
+  return (
+    <div
+      ref={handleWrapperRef}
+      data-timeline-row-id={rowId}
+      data-timeline-row-realized={isRealized ? "true" : "false"}
+      aria-hidden={isRealized ? undefined : true}
+      style={isRealized ? undefined : { height: placeholderHeight }}
+      onClickCapture={pinInteractedItem}
+      onFocusCapture={pinInteractedItem}
+    >
+      {isRealized ? children : null}
+    </div>
+  );
+}
+
+function captureTimelineVisibleAnchor({
+  orderedKeys,
+  scrollElement,
+  wrapperByKey,
+}: {
+  orderedKeys: readonly string[];
+  scrollElement: HTMLElement;
+  wrapperByKey: ReadonlyMap<string, HTMLDivElement>;
+}): TimelineVisibleAnchor | null {
+  const scrollRect = scrollElement.getBoundingClientRect();
+  let low = 0;
+  let high = orderedKeys.length - 1;
+  let firstVisibleIndex = orderedKeys.length;
+  while (low <= high) {
+    const middle = Math.floor((low + high) / 2);
+    const key = orderedKeys[middle];
+    const element = key === undefined ? undefined : wrapperByKey.get(key);
+    if (element === undefined) {
+      return null;
+    }
+    if (element.getBoundingClientRect().bottom > scrollRect.top) {
+      firstVisibleIndex = middle;
+      high = middle - 1;
+    } else {
+      low = middle + 1;
+    }
+  }
+  const key = orderedKeys[firstVisibleIndex];
+  const element = key === undefined ? undefined : wrapperByKey.get(key);
+  if (element === undefined) {
+    return null;
+  }
+  const rect = element.getBoundingClientRect();
+  return rect.top < scrollRect.bottom ? { element, top: rect.top } : null;
+}
+
+function restoreTimelineVisibleAnchor({
+  anchor,
+  scrollElement,
+  wasAtBottom,
+}: {
+  anchor: TimelineVisibleAnchor | null;
+  scrollElement: HTMLElement;
+  wasAtBottom: boolean;
+}): void {
+  if (wasAtBottom) {
+    scrollElement.scrollTop = Math.max(
+      0,
+      scrollElement.scrollHeight - scrollElement.clientHeight,
+    );
+    return;
+  }
+  if (anchor === null || !anchor.element.isConnected) {
+    return;
+  }
+  const topDelta = anchor.element.getBoundingClientRect().top - anchor.top;
+  if (Math.abs(topDelta) > 0.5) {
+    scrollElement.scrollTop += topDelta;
+  }
+}
+
 function TimelineRowsList({
   compactActivityIntents,
   hasOlderTimelineRows,
@@ -1857,13 +2166,12 @@ function TimelineRowsList({
   unreadDividerPlacement,
 }: TimelineRowsListProps) {
   const { threadId } = useTimelineRendererStaticContext();
+  const isCompactViewport = useIsCompactViewport();
+  const bottomAnchor = useBottomAnchoredScroll();
+  const store = useStore();
+  const location = useLocation();
   const searchExpandedRowIds = useTimelineSearchExpansionRowIds(rows);
   const stableSearchExpandedRowIds = useStableReadonlySet(searchExpandedRowIds);
-  useScrollToSearchedMessage(rows, threadId, {
-    hasOlderRows: hasOlderTimelineRows,
-    isLoadingOlderRows: isLoadingOlderTimelineRows,
-    onLoadOlderRows,
-  });
   const activeLatestBundleId = useMemo(
     () => findActiveLatestBundleId(rows),
     [rows],
@@ -1872,7 +2180,643 @@ function TimelineRowsList({
     () => buildTimelineRowsListItems({ rows, unreadDividerPlacement }),
     [rows, unreadDividerPlacement],
   );
-  return (
+  const itemKeys = useMemo(() => items.map(timelineListItemKey), [items]);
+  const shouldWindow =
+    spacing === "top-level" &&
+    isCompactViewport &&
+    bottomAnchor !== null &&
+    typeof IntersectionObserver !== "undefined" &&
+    items.length >= TIMELINE_WINDOWING_MIN_ITEM_COUNT;
+  const searchTarget = useMemo(
+    () => readSearchMessageTarget(location.state),
+    [location.state],
+  );
+  const searchTargetsTimeline =
+    searchTarget !== null &&
+    (threadId === undefined ||
+      searchTarget.threadId === null ||
+      searchTarget.threadId === threadId);
+  const initialWindowCenterIndex = useMemo(() => {
+    if (searchTarget !== null && searchTargetsTimeline) {
+      const searchIndex = items.findIndex(
+        (item) =>
+          item.kind === "row" &&
+          item.row.sourceSeqStart <= searchTarget.seq &&
+          searchTarget.seq <= item.row.sourceSeqEnd,
+      );
+      if (searchIndex >= 0) {
+        return searchIndex;
+      }
+    }
+
+    if (unreadDividerAutoScroll) {
+      const dividerIndex = items.findIndex(
+        (item) => item.kind === "unread-divider",
+      );
+      if (dividerIndex >= 0) {
+        return dividerIndex;
+      }
+    }
+
+    if (threadId !== undefined) {
+      const anchor = store.get(threadTimelineScrollAnchorAtomFamily(threadId));
+      if (anchor !== null && !anchor.atBottom && anchor.rowId.length > 0) {
+        const anchorIndex = items.findIndex(
+          (item) => item.kind === "row" && item.row.id === anchor.rowId,
+        );
+        if (anchorIndex >= 0) {
+          return anchorIndex;
+        }
+      }
+    }
+
+    return Math.max(0, items.length - 1);
+  }, [
+    items,
+    searchTarget,
+    searchTargetsTimeline,
+    store,
+    threadId,
+    unreadDividerAutoScroll,
+  ]);
+  const initiallyRealizedKeys = useMemo(
+    () =>
+      shouldWindow
+        ? collectTimelineWindowKeys({
+            centerIndex: initialWindowCenterIndex,
+            items,
+          })
+        : EMPTY_ROW_ID_SET,
+    [initialWindowCenterIndex, items, shouldWindow],
+  );
+  const wrapperByKeyRef = useRef(new Map<string, HTMLDivElement>());
+  const keyByWrapperRef = useRef(new Map<Element, string>());
+  const controllerByKeyRef = useRef(
+    new Map<string, TimelineWindowItemController>(),
+  );
+  const intersectionObserverRef = useRef<IntersectionObserver | null>(null);
+  const resizeObserverRef = useRef<ResizeObserver | null>(null);
+  // Last committed content height of each realized wrapper. Late growth
+  // (lazy images, async rendering) diffs against this baseline so it can be
+  // compensated the same way as the mount-time delta.
+  const realizedHeightByKeyRef = useRef(new Map<string, number>());
+  // Interaction-pinned keys in recency order, capped so pins cannot
+  // accumulate without bound over a long session.
+  const pinnedKeysRef = useRef<string[]>([]);
+  // The observers outlive row streaming, so their callbacks read the current
+  // key order through this ref instead of re-creating per rows change.
+  const itemKeysRef = useRef(itemKeys);
+  useLayoutEffect(() => {
+    itemKeysRef.current = itemKeys;
+  }, [itemKeys]);
+  const alwaysRealizedKeys = useMemo(() => {
+    if (!shouldWindow) {
+      return EMPTY_ROW_ID_SET;
+    }
+    const keys = new Set<string>();
+    if (searchTarget !== null && searchTargetsTimeline) {
+      const searchIndex = items.findIndex(
+        (item) =>
+          item.kind === "row" &&
+          item.row.sourceSeqStart <= searchTarget.seq &&
+          searchTarget.seq <= item.row.sourceSeqEnd,
+      );
+      const searchItem = items[searchIndex];
+      if (searchItem !== undefined) {
+        keys.add(timelineListItemKey(searchItem));
+      }
+    }
+    if (unreadDividerAutoScroll) {
+      const dividerIndex = items.findIndex(
+        (item) => item.kind === "unread-divider",
+      );
+      const divider = items[dividerIndex];
+      if (divider !== undefined) {
+        keys.add(timelineListItemKey(divider));
+      }
+    }
+    return keys;
+  }, [
+    items,
+    searchTarget,
+    searchTargetsTimeline,
+    shouldWindow,
+    unreadDividerAutoScroll,
+  ]);
+
+  const registerWrapper = useCallback(
+    (key: string, node: HTMLDivElement | null) => {
+      const previous = wrapperByKeyRef.current.get(key);
+      if (previous !== undefined && previous !== node) {
+        keyByWrapperRef.current.delete(previous);
+        intersectionObserverRef.current?.unobserve(previous);
+        resizeObserverRef.current?.unobserve(previous);
+      }
+      if (node === null) {
+        wrapperByKeyRef.current.delete(key);
+        realizedHeightByKeyRef.current.delete(key);
+        return;
+      }
+      wrapperByKeyRef.current.set(key, node);
+      keyByWrapperRef.current.set(node, key);
+      intersectionObserverRef.current?.observe(node);
+      resizeObserverRef.current?.observe(node);
+    },
+    [],
+  );
+  const registerController = useCallback(
+    (key: string, controller: TimelineWindowItemController | null) => {
+      if (controller === null) {
+        controllerByKeyRef.current.delete(key);
+        return;
+      }
+      controllerByKeyRef.current.set(key, controller);
+    },
+    [],
+  );
+  const registerInteractionPin = useCallback((key: string) => {
+    const pinned = pinnedKeysRef.current;
+    const existing = pinned.indexOf(key);
+    if (existing >= 0) {
+      pinned.splice(existing, 1);
+    }
+    pinned.push(key);
+    while (pinned.length > TIMELINE_WINDOW_MAX_INTERACTION_PINS) {
+      const evicted = pinned.shift();
+      if (evicted !== undefined) {
+        controllerByKeyRef.current.get(evicted)?.unpin();
+      }
+    }
+  }, []);
+
+  const getScrollElement = bottomAnchor?.getScrollElement;
+  useEffect(() => {
+    if (!shouldWindow || typeof IntersectionObserver === "undefined") {
+      return;
+    }
+    const scrollElement = getScrollElement?.() ?? null;
+    if (scrollElement === null) {
+      return;
+    }
+    const realizedHeightByKey = realizedHeightByKeyRef.current;
+
+    const applyWithScrollCompensation = (apply: () => void) => {
+      const maxScrollTop = Math.max(
+        0,
+        scrollElement.scrollHeight - scrollElement.clientHeight,
+      );
+      const wasAtBottom = maxScrollTop - scrollElement.scrollTop <= 2;
+      const anchor = wasAtBottom
+        ? null
+        : captureTimelineVisibleAnchor({
+            scrollElement,
+            orderedKeys: itemKeysRef.current,
+            wrapperByKey: wrapperByKeyRef.current,
+          });
+      flushSync(apply);
+      restoreTimelineVisibleAnchor({ anchor, scrollElement, wasAtBottom });
+    };
+
+    // Distributes one above-viewport height delta into placeholder donors
+    // that sit earlier in the list (and therefore also fully above the
+    // viewport). Positive deltas shrink donors topmost-first so placeholders
+    // nearest the viewport keep accurate heights; negative deltas hand the
+    // slack to the topmost donor. All-or-nothing: donors change only when
+    // the whole delta is covered, so the swap is exactly net zero. Must run
+    // inside flushSync so donor commits paint atomically with the change
+    // they balance.
+    const tryCompensateAboveViewportDelta = (
+      candidateIndex: number,
+      delta: number,
+    ): boolean => {
+      const keys = itemKeysRef.current;
+      if (delta < 0) {
+        for (let index = 0; index < candidateIndex; index += 1) {
+          const key = keys[index];
+          if (key === undefined) {
+            continue;
+          }
+          const controller = controllerByKeyRef.current.get(key);
+          if ((controller?.peekPlaceholderHeight() ?? null) !== null) {
+            controller?.adjustPlaceholderHeight(-delta);
+            return true;
+          }
+        }
+        return false;
+      }
+      let remaining = delta;
+      const takes: Array<[TimelineWindowItemController, number]> = [];
+      for (
+        let index = 0;
+        index < candidateIndex && remaining > 0.5;
+        index += 1
+      ) {
+        const key = keys[index];
+        if (key === undefined) {
+          continue;
+        }
+        const controller = controllerByKeyRef.current.get(key);
+        if (controller === undefined) {
+          continue;
+        }
+        const available = controller.peekPlaceholderHeight();
+        if (available === null || available <= 0) {
+          continue;
+        }
+        const take = Math.min(available, remaining);
+        takes.push([controller, take]);
+        remaining -= take;
+      }
+      if (remaining > 0.5) {
+        return false;
+      }
+      for (const [controller, take] of takes) {
+        controller.adjustPlaceholderHeight(-take);
+      }
+      return true;
+    };
+
+    // Realizations that found no donor capacity while scrolling. They keep
+    // their unchanged placeholders (nothing on screen moves) and mount at
+    // the next idle pass, where a compensating scrollTop write is free.
+    // During an active scroll the ONLY geometry this component changes is
+    // the exact net-zero donor swap — no scrollTop writes, no tolerated
+    // drift. Anything that cannot satisfy that invariant waits for idle.
+    const pendingRealizeKeys = new Set<string>();
+
+    // Running average of first-measured item heights. Never-realized
+    // placeholders start from a static estimate that real content usually
+    // exceeds, so donor capacity would otherwise drain monotonically during
+    // upward scrolling. Re-budgeting those estimates to the measured average
+    // at idle keeps the donor pool solvent, which keeps insolvency reverts
+    // (and their brief near-top blanks) rare.
+    const measuredSampleKeys = new Set<string>();
+    let measuredSampleSum = 0;
+    let lastRebudgetAverage: number | null = null;
+    const computeRebudgetAdjustments = (): Array<
+      [TimelineWindowItemController, number]
+    > => {
+      if (measuredSampleKeys.size < TIMELINE_WINDOW_REBUDGET_MIN_SAMPLES) {
+        return [];
+      }
+      const average = Math.min(
+        TIMELINE_WINDOW_REBUDGET_MAX_ESTIMATE_PX,
+        measuredSampleSum / measuredSampleKeys.size,
+      );
+      if (
+        lastRebudgetAverage !== null &&
+        Math.abs(average - lastRebudgetAverage) <
+          TIMELINE_WINDOW_REBUDGET_DRIFT_PX
+      ) {
+        return [];
+      }
+      lastRebudgetAverage = average;
+      const adjustments: Array<[TimelineWindowItemController, number]> = [];
+      for (const key of itemKeysRef.current) {
+        const controller = controllerByKeyRef.current.get(key);
+        if (controller === undefined || controller.hasEverRealized()) {
+          continue;
+        }
+        const current = controller.peekPlaceholderHeight();
+        if (current === null) {
+          continue;
+        }
+        const delta = average - current;
+        if (Math.abs(delta) < 1) {
+          continue;
+        }
+        adjustments.push([controller, delta]);
+      }
+      return adjustments;
+    };
+
+    // One idle pass covers both deferred works: mount the insolvent
+    // realizations and re-seed estimate placeholders, in a single
+    // anchor-compensated commit.
+    const runIdlePass = () => {
+      const pending = [...pendingRealizeKeys];
+      pendingRealizeKeys.clear();
+      const adjustments = computeRebudgetAdjustments();
+      if (pending.length === 0 && adjustments.length === 0) {
+        return;
+      }
+      applyWithScrollCompensation(() => {
+        for (const key of pending) {
+          controllerByKeyRef.current.get(key)?.realize();
+        }
+        for (const [controller, delta] of adjustments) {
+          controller.adjustPlaceholderHeight(delta);
+        }
+      });
+    };
+    let idlePassTimeout: number | null = null;
+    const scheduleIdlePass = () => {
+      if (idlePassTimeout !== null) {
+        window.clearTimeout(idlePassTimeout);
+      }
+      idlePassTimeout = window.setTimeout(() => {
+        idlePassTimeout = null;
+        if (scrollElement.dataset.scrollbarScrolling === "true") {
+          scheduleIdlePass();
+          return;
+        }
+        runIdlePass();
+      }, TIMELINE_WINDOW_REBUDGET_IDLE_DELAY_MS);
+    };
+    const recordMeasuredSample = (key: string, height: number) => {
+      if (height <= 0 || measuredSampleKeys.has(key)) {
+        return;
+      }
+      measuredSampleKeys.add(key);
+      measuredSampleSum += height;
+      scheduleIdlePass();
+    };
+
+    // Realize items whose top sits above the viewport while a scroll is
+    // active. Mount their content in one synchronous commit, measure each
+    // realized height, then balance every height delta against placeholder
+    // donors in a second commit in the same task (so nothing paints in
+    // between). The net height change above the viewport stays zero, which
+    // keeps visible content still without a momentum-killing scrollTop
+    // write. Donor placeholders self-correct: they re-measure whenever they
+    // realize or derealize later.
+    const realizeAboveViewportDuringScroll = (keys: readonly string[]) => {
+      const itemKeysNow = itemKeysRef.current;
+      const candidates = keys.flatMap((key) => {
+        const controller = controllerByKeyRef.current.get(key);
+        const wrapper = wrapperByKeyRef.current.get(key);
+        const index = itemKeysNow.indexOf(key);
+        const placeholderHeight = controller?.peekPlaceholderHeight() ?? null;
+        if (
+          controller === undefined ||
+          wrapper === undefined ||
+          index < 0 ||
+          placeholderHeight === null
+        ) {
+          return [];
+        }
+        return [{ controller, index, key, placeholderHeight, wrapper }];
+      });
+      if (candidates.length === 0) {
+        return;
+      }
+
+      flushSync(() => {
+        for (const candidate of candidates) {
+          candidate.controller.realize();
+        }
+      });
+
+      flushSync(() => {
+        for (const candidate of candidates) {
+          const measured = candidate.wrapper.getBoundingClientRect().height;
+          recordMeasuredSample(candidate.key, measured);
+          const delta = measured - candidate.placeholderHeight;
+          if (
+            delta !== 0 &&
+            !tryCompensateAboveViewportDelta(candidate.index, delta)
+          ) {
+            // No donor capacity (near the top of the timeline): revert to
+            // the unchanged placeholder — net zero, nothing painted in
+            // between — and mount at the idle pass instead.
+            candidate.controller.derealize();
+            pendingRealizeKeys.add(candidate.key);
+            scheduleIdlePass();
+            continue;
+          }
+          realizedHeightByKey.set(candidate.key, measured);
+        }
+      });
+    };
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        // A programmatic scrollTop write stops WebKit's native momentum
+        // scrolling, and WebKit — the only engine this compact-viewport list
+        // runs on for mobile — has no scroll anchoring to absorb layout
+        // shifts. While a scroll is active (the scroll owner keeps this
+        // marker present through the inertial tail), apply derealizations
+        // and at-or-below-viewport realizations directly: derealizations
+        // swap in their just-measured height, and an item whose top edge is
+        // at or below the viewport top only pushes layout below that edge
+        // when it grows. Items whose top is above the viewport realize with
+        // donor compensation instead.
+        if (scrollElement.dataset.scrollbarScrolling === "true") {
+          let viewportTop: number | null = null;
+          const growUpKeys: string[] = [];
+          for (const entry of entries) {
+            const key = keyByWrapperRef.current.get(entry.target);
+            if (key === undefined) {
+              continue;
+            }
+            const controller = controllerByKeyRef.current.get(key);
+            if (controller === undefined) {
+              continue;
+            }
+            if (!entry.isIntersecting) {
+              realizedHeightByKey.delete(key);
+              pendingRealizeKeys.delete(key);
+              controller.handleIntersection(entry);
+              continue;
+            }
+            const alreadyRealized =
+              wrapperByKeyRef.current.get(key)?.dataset
+                .timelineRowRealized === "true";
+            if (alreadyRealized) {
+              controller.handleIntersection(entry);
+              continue;
+            }
+            // rootBounds already includes the rootMargin expansion; strip it
+            // to recover the true viewport edge without forcing a layout.
+            const rootTop = entry.rootBounds?.top;
+            viewportTop ??=
+              rootTop !== undefined
+                ? rootTop + TIMELINE_WINDOW_MARGIN_PX
+                : scrollElement.getBoundingClientRect().top;
+            if (entry.boundingClientRect.top >= viewportTop) {
+              controller.handleIntersection(entry);
+              continue;
+            }
+            growUpKeys.push(key);
+          }
+          if (growUpKeys.length > 0) {
+            realizeAboveViewportDuringScroll(growUpKeys);
+          }
+          return;
+        }
+
+        applyWithScrollCompensation(() => {
+          for (const entry of entries) {
+            const key = keyByWrapperRef.current.get(entry.target);
+            if (key === undefined) {
+              continue;
+            }
+            if (entry.isIntersecting) {
+              pendingRealizeKeys.delete(key);
+            } else {
+              realizedHeightByKey.delete(key);
+              pendingRealizeKeys.delete(key);
+            }
+            controllerByKeyRef.current.get(key)?.handleIntersection(entry);
+          }
+        });
+      },
+      {
+        root: scrollElement,
+        rootMargin: `${TIMELINE_WINDOW_MARGIN_PX}px 0px`,
+      },
+    );
+    intersectionObserverRef.current = observer;
+
+    // Late content growth in a realized row above the viewport (a lazy image
+    // decoding, async rendering settling) shifts visible content, because
+    // WebKit has no scroll anchoring. Compensate with a direct scrollTop
+    // nudge — but only while the scroll is idle, where the write is free.
+    // During an active scroll only the baseline updates: mutating geometry
+    // mid-gesture is exactly what reads as snapping. Growth in or below the
+    // viewport stays uncompensated: a visible image expanding in place is
+    // expected content behavior.
+    let contentGrowthObserver: ResizeObserver | null = null;
+    if (typeof ResizeObserver !== "undefined") {
+      contentGrowthObserver = new ResizeObserver((entries) => {
+        let viewportTop: number | null = null;
+        let idleAdjustment = 0;
+        const scrolling =
+          scrollElement.dataset.scrollbarScrolling === "true";
+        for (const entry of entries) {
+          const key = keyByWrapperRef.current.get(entry.target);
+          if (key === undefined) {
+            continue;
+          }
+          const wrapper = wrapperByKeyRef.current.get(key);
+          if (wrapper === undefined) {
+            continue;
+          }
+          if (wrapper.dataset.timelineRowRealized !== "true") {
+            // Placeholder height changes are this component's own writes
+            // (donor adjustments, derealization measurements) — already
+            // balanced, never compensated again.
+            realizedHeightByKey.delete(key);
+            continue;
+          }
+          const height =
+            entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
+          const previous = realizedHeightByKey.get(key);
+          realizedHeightByKey.set(key, height);
+          recordMeasuredSample(key, height);
+          if (previous === undefined || scrolling) {
+            // First observation after an already-balanced realization, or a
+            // mid-scroll change that must not mutate geometry.
+            continue;
+          }
+          const delta = height - previous;
+          if (Math.abs(delta) < 0.5) {
+            continue;
+          }
+          viewportTop ??= scrollElement.getBoundingClientRect().top;
+          if (wrapper.getBoundingClientRect().bottom > viewportTop) {
+            continue;
+          }
+          idleAdjustment += delta;
+        }
+        if (idleAdjustment !== 0) {
+          scrollElement.scrollTop = Math.max(
+            0,
+            scrollElement.scrollTop + idleAdjustment,
+          );
+        }
+      });
+    }
+    resizeObserverRef.current = contentGrowthObserver;
+
+    for (const wrapper of wrapperByKeyRef.current.values()) {
+      observer.observe(wrapper);
+      contentGrowthObserver?.observe(wrapper);
+    }
+    // Scroll events push the pending idle pass out to the next quiet moment.
+    scrollElement.addEventListener("scroll", scheduleIdlePass, {
+      passive: true,
+    });
+
+    return () => {
+      intersectionObserverRef.current = null;
+      resizeObserverRef.current = null;
+      observer.disconnect();
+      contentGrowthObserver?.disconnect();
+      scrollElement.removeEventListener("scroll", scheduleIdlePass);
+      if (idlePassTimeout !== null) {
+        window.clearTimeout(idlePassTimeout);
+      }
+      pendingRealizeKeys.clear();
+      realizedHeightByKey.clear();
+    };
+  }, [getScrollElement, shouldWindow]);
+
+  const renderedRowsKey = shouldWindow
+    ? [...alwaysRealizedKeys].sort().join("\u0000")
+    : "all";
+
+  useScrollToSearchedMessage(rows, threadId, {
+    hasOlderRows: hasOlderTimelineRows,
+    isLoadingOlderRows: isLoadingOlderTimelineRows,
+    onLoadOlderRows,
+    renderedRowsKey,
+  });
+
+  const renderItem = (item: TimelineRowsListItem) => {
+    if (item.kind === "unread-divider") {
+      return <TimelineUnreadDivider autoScroll={unreadDividerAutoScroll} />;
+    }
+    return (
+      <MemoizedTimelineRowView
+        activeLatestBundleId={activeLatestBundleId}
+        row={item.row}
+        scopeActive={scopeActive}
+        showAssistantMessageActions={showAssistantMessageActions}
+        spacing={spacing}
+        compactActivityIntents={compactActivityIntents}
+      />
+    );
+  };
+
+  if (shouldWindow) {
+    return (
+      <TimelineSearchExpansionContext.Provider
+        value={stableSearchExpandedRowIds}
+      >
+        <div
+          className={cn(
+            "flex min-w-0 flex-col [&_button:not(:disabled)]:cursor-pointer",
+            timelineRowsListGapClassName(spacing),
+            className,
+          )}
+          data-timeline-row-list={spacing}
+          data-timeline-windowed="true"
+        >
+          {items.map((item) => {
+            const itemKey = timelineListItemKey(item);
+            return (
+              <TimelineWindowedListItem
+                key={itemKey}
+                alwaysRealized={alwaysRealizedKeys.has(itemKey)}
+                estimatedHeight={estimateTimelineListItemHeight(item)}
+                initiallyRealized={initiallyRealizedKeys.has(itemKey)}
+                itemKey={itemKey}
+                registerController={registerController}
+                registerInteractionPin={registerInteractionPin}
+                registerWrapper={registerWrapper}
+                rowId={item.kind === "row" ? item.row.id : undefined}
+              >
+                {renderItem(item)}
+              </TimelineWindowedListItem>
+            );
+          })}
+        </div>
+      </TimelineSearchExpansionContext.Provider>
+    );
+  }
+
+  const list = (
     <TimelineSearchExpansionContext.Provider value={stableSearchExpandedRowIds}>
       <div
         className={cn(
@@ -1893,21 +2837,23 @@ function TimelineRowsList({
           }
 
           return (
-            <div key={item.row.id} data-timeline-row-id={item.row.id}>
-              <MemoizedTimelineRowView
-                activeLatestBundleId={activeLatestBundleId}
-                row={item.row}
-                scopeActive={scopeActive}
-                showAssistantMessageActions={showAssistantMessageActions}
-                spacing={spacing}
-                compactActivityIntents={compactActivityIntents}
-              />
+            <div
+              key={item.row.id}
+              data-timeline-row-id={item.row.id}
+              data-timeline-row-realized="true"
+            >
+              {renderItem(item)}
             </div>
           );
         })}
       </div>
     </TimelineSearchExpansionContext.Provider>
   );
+  return spacing === "top-level" ? (
+    <AutoHeightContainer>{list}</AutoHeightContainer>
+  ) : (
+    list
+  );
 }
 
 function ThreadTimelineRowsComponent(props: ThreadTimelineRowsProps) {
@@ -1948,8 +2894,22 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
   const liveAutoExpandedRowIds = useStableReadonlySet(
     computedAutoExpansionRowIds.liveFrontierRowIds,
   );
+  // Terminal auto-expansion is a one-shot latch per row, and the latch lives
+  // in row-local state that windowed eviction unmounts. Accumulate every id
+  // that has ever latched so an evicted frontier-error row re-expands when it
+  // remounts. A row the user manually collapses stays collapsed: collapsing
+  // is an interaction, which pins the row against eviction, so its manual
+  // override survives.
+  const accumulatedTerminalRowIdsRef = useRef(new Set<string>());
+  const accumulatedTerminalRowIds = useMemo(() => {
+    const accumulated = accumulatedTerminalRowIdsRef.current;
+    for (const id of computedAutoExpansionRowIds.terminalFrontierRowIds) {
+      accumulated.add(id);
+    }
+    return new Set(accumulated);
+  }, [computedAutoExpansionRowIds.terminalFrontierRowIds]);
   const terminalAutoExpandedRowIds = useStableReadonlySet(
-    computedAutoExpansionRowIds.terminalFrontierRowIds,
+    accumulatedTerminalRowIds,
   );
   const initialAutoExpandedRowIds = useStableReadonlySet(
     props.initialExpanded ?? EMPTY_ROW_ID_SET,
@@ -2158,26 +3118,20 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
               value={latestActionableUserMessageId}
             >
               <TimelineTurnStateContext.Provider value={turnStateContextValue}>
-                <AutoHeightContainer>
-                  <TimelineRowsList
-                    hasOlderTimelineRows={props.hasOlderTimelineRows}
-                    isLoadingOlderTimelineRows={
-                      props.isLoadingOlderTimelineRows
-                    }
-                    onLoadOlderRows={props.onLoadOlderRows}
-                    rows={rows}
-                    scopeActive={scopeActive}
-                    showAssistantMessageActions={true}
-                    compactActivityIntents={false}
-                    spacing="top-level"
-                    unreadDividerAutoScroll={
-                      props.unreadDividerAutoScroll ?? true
-                    }
-                    unreadDividerPlacement={
-                      props.unreadDividerPlacement ?? null
-                    }
-                  />
-                </AutoHeightContainer>
+                <TimelineRowsList
+                  hasOlderTimelineRows={props.hasOlderTimelineRows}
+                  isLoadingOlderTimelineRows={props.isLoadingOlderTimelineRows}
+                  onLoadOlderRows={props.onLoadOlderRows}
+                  rows={rows}
+                  scopeActive={scopeActive}
+                  showAssistantMessageActions={true}
+                  compactActivityIntents={false}
+                  spacing="top-level"
+                  unreadDividerAutoScroll={
+                    props.unreadDividerAutoScroll ?? true
+                  }
+                  unreadDividerPlacement={props.unreadDividerPlacement ?? null}
+                />
                 {hasSelectionActions ? (
                   <TimelineSelectionMenu
                     selection={activeSelection?.selection ?? null}
diff --git a/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts b/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts
index e02892f08f..4d93491700 100644
--- a/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts
+++ b/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts
@@ -21,6 +21,8 @@ interface SearchMessagePaginationOptions {
   hasOlderRows?: boolean;
   isLoadingOlderRows?: boolean;
   onLoadOlderRows?: () => Promise<void> | void;
+  /** Changes when a virtual list mounts a different row range. */
+  renderedRowsKey?: string;
 }
 
 interface SeqRange {
@@ -195,6 +197,7 @@ export function useScrollToSearchedMessage(
     hasOlderRows = false,
     isLoadingOlderRows = false,
     onLoadOlderRows,
+    renderedRowsKey,
   }: SearchMessagePaginationOptions = {},
 ): void {
   const location = useLocation();
@@ -247,7 +250,11 @@ export function useScrollToSearchedMessage(
       return;
     }
     const selector = `[data-timeline-row-id="${escapeTimelineRowId(targetLeafRow.id)}"]`;
-    if (document.querySelector(selector) === null) {
+    const renderedTarget = document.querySelector<HTMLElement>(selector);
+    if (
+      renderedTarget === null ||
+      renderedTarget.dataset.timelineRowRealized === "false"
+    ) {
       return;
     }
     handledKeyRef.current = location.key;
@@ -291,6 +298,7 @@ export function useScrollToSearchedMessage(
     isLoadingOlderRows,
     location.key,
     onLoadOlderRows,
+    renderedRowsKey,
     rows,
     targetSeq,
     targetThreadId,
