From e76c182f51df61251bab38f34bcc99d67393bd9f Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 08:58:08 -0600
Subject: [PATCH 01/11] refactor(timeline): de-virtualize message list,
 re-enable native scroll anchoring

Render all loaded timeline rows directly via a `.map` wrapped in the
`ln-auto` (content-visibility) utility instead of `VirtualizedList`, and
remove `[overflow-anchor:none]` from the scroll container so the browser's
native scroll anchoring holds the reading position across reflow/prepend.

Collapses the virtualizer-consumer wiring in MessageTimeline: drops the
index-model deep-link convergence, the index bottom-pin, and the
virtualizer option fed into the scroll hooks. Jump-to-message is now purely
DOM-based (all rows are mounted). The floating active-day header (which was
portaled and driven by the virtualizer's top-visible index) is removed here;
a scroll-driven replacement lands in a follow-up.

First commit of the timeline scroll rework. Hook-internal cleanup
(useAnchoredScroll / useLoadOlderOnScroll virtualizer branches, the orphaned
convergence modules) and the JS prepend-delta removal follow in later
commits, gated on the native-anchoring de-risk probe.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../features/messages/ui/MessageTimeline.tsx  | 147 +-----------------
 .../messages/ui/TimelineMessageList.tsx       |  52 +------
 2 files changed, 12 insertions(+), 187 deletions(-)

diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 38279eadd5..d3e68f30c9 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -10,21 +10,17 @@ import {
 import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
 import type { TimelineMessage } from "@/features/messages/types";
 import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
-import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
 import type { UserProfileLookup } from "@/features/profile/lib/identity";
 import type { ChannelType } from "@/shared/api/types";
-import { buildTimelineItems } from "@/features/messages/lib/timelineItems";
 import { cn } from "@/shared/lib/cn";
 import { channelChrome } from "@/shared/layout/chromeLayout";
 import { Spinner } from "@/shared/ui/spinner";
 import { TooltipProvider } from "@/shared/ui/tooltip";
 import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
 import { UserAvatar } from "@/shared/ui/UserAvatar";
-import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
 import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
 import { TimelineMessageList } from "./TimelineMessageList";
 import { useAnchoredScroll } from "./useAnchoredScroll";
-import { useConvergentScrollToMessage } from "./useConvergentScrollToMessage";
 import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
 
 export type MessageTimelineHandle = {
@@ -190,43 +186,6 @@ const MessageTimelineBase = React.forwardRef<
   const scrollContainerRef = externalScrollRef ?? internalScrollRef;
   const contentRef = React.useRef<HTMLDivElement>(null);
   const topSentinelRef = React.useRef<HTMLDivElement>(null);
-  // The floating active-day header portals here — a non-scrolling sibling of
-  // the scroll container — so it pins to a fixed offset and never drifts.
-  const activeDayHeaderRef = React.useRef<HTMLDivElement>(null);
-
-  // The convergence fallback for a windowed-out deep-link target. It's defined
-  // below (it depends on the anchored-scroll result), so `useAnchoredScroll`
-  // reads it through a ref via a stable wrapper — letting the hook stay
-  // virtualizer-agnostic while the consumer owns the convergence machinery.
-  const convergeToTargetRef = React.useRef<(messageId: string) => boolean>(
-    () => false,
-  );
-  const convergeToTarget = React.useCallback((messageId: string) => {
-    return convergeToTargetRef.current(messageId);
-  }, []);
-
-  // The mount bottom-pin driven through the virtualizer. Defined here as a
-  // stable wrapper over a ref (assigned below, once the virtualizer/item count
-  // are known) so `useAnchoredScroll` stays virtualizer-agnostic — same
-  // indirection as `convergeToTarget`. Returns `true` once it issued the
-  // index scroll; `false` (thread panel, no virtualizer) → the hook falls back
-  // to its raw bottom pin.
-  const pinToBottomByIndexRef = React.useRef<() => boolean>(() => false);
-  const pinToBottomByIndex = React.useCallback(() => {
-    return pinToBottomByIndexRef.current();
-  }, []);
-
-  // The virtualizer instance is owned by the child TimelineMessageList (which
-  // mounts the VirtualizedList) and reported up here via a ref so the scroll
-  // manager can drive index-model scroll paths. The virtualizer arrives via
-  // VirtualizedList's onVirtualizer layout effect (child layout effects fire
-  // before parent layout effects), so getVirtualizer() is live by the time
-  // useAnchoredScroll's mount pin runs.
-  const virtualizerRef = React.useRef<ListVirtualizer | null>(null);
-  const handleVirtualizer = React.useCallback((instance: ListVirtualizer) => {
-    virtualizerRef.current = instance;
-  }, []);
-  const getVirtualizer = React.useCallback(() => virtualizerRef.current, []);
 
   // Gate the heavy timeline render (each row runs a synchronous
   // react-markdown parse) behind React concurrency. `useDeferredValue` lets the
@@ -251,33 +210,6 @@ const MessageTimelineBase = React.forwardRef<
     EMPTY_TIMELINE_SNAPSHOT,
   );
   const deferredMessages = deferredSnapshot.messages;
-  // The flattened item stream mirrors what TimelineMessageList renders: use the
-  // hoisted mainEntries when the deferred snapshot is current (same identity as
-  // the live messages), fall back to building entries from deferredMessages when
-  // the deferred value is stale. This keeps the scroll manager's index map in
-  // sync with what's actually painted without a state-update round-trip.
-  const deferredEntries = React.useMemo(
-    () =>
-      (deferredMessages === messages ? mainEntries : undefined) ??
-      buildMainTimelineEntries(deferredMessages),
-    [mainEntries, deferredMessages, messages],
-  );
-  const timelineItems = React.useMemo(
-    () => buildTimelineItems(deferredEntries, firstUnreadMessageId),
-    [deferredEntries, firstUnreadMessageId],
-  );
-  const virtualizerOption = React.useMemo(
-    () =>
-      timelineItems.items.length > 0
-        ? {
-            getVirtualizer,
-            indexByMessageId: timelineItems.indexByMessageId,
-            itemCount: timelineItems.items.length,
-            liveMessageCount: messages.length,
-          }
-        : null,
-    [getVirtualizer, timelineItems, messages.length],
-  );
   const isDeferredSnapshotStale = isDeferredTimelineSnapshotStale({
     deferredSnapshot,
     liveSnapshot,
@@ -314,11 +246,9 @@ const MessageTimelineBase = React.forwardRef<
   } = useAnchoredScroll({
     channelId,
     contentRef,
-    convergeToTarget,
     isLoading: showTimelineSkeleton,
     messages: deferredMessages,
     onTargetReached,
-    pinToBottomByIndex,
     scrollContainerRef,
     targetMessageId,
   });
@@ -350,65 +280,14 @@ const MessageTimelineBase = React.forwardRef<
     [scrollToBottomOnNextUpdate],
   );
 
-  // Role 3 — jump-to-message into windowed-out history. The DOM-based
-  // `scrollToMessage` no-ops when the target row isn't mounted (virtualized
-  // out), so when it fails and the timeline is virtualized we drive the
-  // convergence adapter: it scrolls the virtualizer to the target index,
-  // re-aiming each frame as rows mount and measure, then on settle the row is
-  // in the DOM and `scrollToMessage` centers + highlights it. When there's no
-  // virtualizer (e.g. the thread panel), there's nothing to converge — the DOM
-  // path is the whole story and a missing row simply isn't reachable.
-  const { scrollToMessage: convergeToMessage, cancel: cancelConvergence } =
-    useConvergentScrollToMessage(getVirtualizer, {
-      indexByMessageId: timelineItems.indexByMessageId,
-      align: "center",
-      onConverged: (messageId) => {
-        scrollToMessage(messageId, { highlight: true });
-        onTargetReached?.(messageId);
-      },
-      onAbandoned: (messageId) => onTargetReached?.(messageId),
-    });
+  // Jump-to-message is purely DOM-based now: all loaded rows are mounted, so
+  // `scrollToMessage` always finds the target row. No virtualizer convergence.
   const jumpToMessage = React.useCallback(
     (messageId: string, options?: { behavior?: ScrollBehavior }) => {
-      if (scrollToMessage(messageId, { highlight: true, ...options })) {
-        return;
-      }
-      if (virtualizerOption) {
-        convergeToMessage(messageId);
-      }
+      scrollToMessage(messageId, { highlight: true, ...options });
     },
-    [convergeToMessage, scrollToMessage, virtualizerOption],
+    [scrollToMessage],
   );
-  // Feed the windowed-out deep-link fallback back into `useAnchoredScroll`,
-  // which calls it when a target row isn't in the DOM. Gated on the virtualizer
-  // so the thread panel (no virtualizer) never converges. Assigned in an effect
-  // because `useAnchoredScroll` reads it asynchronously from a post-mount effect.
-  React.useEffect(() => {
-    convergeToTargetRef.current = virtualizerOption
-      ? convergeToMessage
-      : () => false;
-  }, [convergeToMessage, virtualizerOption]);
-  // Drive the mount bottom-pin through the virtualizer when one is present.
-  // Assigned during render (not an effect) because `useAnchoredScroll`'s
-  // mount pin runs in a layout effect on the same commit — a passive effect
-  // would assign too late. `virtualizerOption` is derived from `timelineItems`
-  // via useMemo (no state update, no extra render), so it is non-null from the
-  // first deferred commit that carries real messages. `getVirtualizer()` is
-  // live by pin time because VirtualizedList publishes the instance in a
-  // layout effect, and child layout effects fire before parent layout effects.
-  pinToBottomByIndexRef.current = virtualizerOption
-    ? () => {
-        const virtualizer = getVirtualizer();
-        const lastIndex = virtualizerOption.itemCount - 1;
-        if (!virtualizer || lastIndex < 0) return false;
-        virtualizer.scrollToIndex(lastIndex, { align: "end" });
-        return true;
-      }
-    : () => false;
-  // Abandon any in-flight convergence on channel switch so a stale loop can't
-  // hijack the new channel's scroll position.
-  // biome-ignore lint/correctness/useExhaustiveDependencies: cancel on channel switch only
-  React.useEffect(() => cancelConvergence, [channelId, cancelConvergence]);
 
   // The unread pill is a transient, per-open affordance: dismiss it once the
   // user acts on it (jumps to the oldest unread) or catches up by reaching the
@@ -470,7 +349,6 @@ const MessageTimelineBase = React.forwardRef<
     getAnchorIsAtBottom,
     scrollContainerRef,
     sentinelRef: topSentinelRef,
-    virtualizer: virtualizerOption,
   });
 
   const timelineSkeletonRows = useTimelineSkeletonRows({
@@ -482,18 +360,6 @@ const MessageTimelineBase = React.forwardRef<
   return (
     <TooltipProvider delayDuration={200}>
       <div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
-        {/* Non-scrolling overlay anchored to the outer (non-scrolling) box: the
-            floating active-day header portals in here, so it pins to a fixed
-            offset and cannot drift as older history prepends. Sits below the
-            unread pill / fetch spinner (z-20) in the stack. */}
-        <div
-          aria-hidden
-          className={cn(
-            "pointer-events-none absolute inset-x-0 z-[6] flex translate-y-3 justify-center px-4",
-            channelChrome.top,
-          )}
-          ref={activeDayHeaderRef}
-        />
         {showUnreadPill ? (
           <div
             className={cn(
@@ -527,7 +393,7 @@ const MessageTimelineBase = React.forwardRef<
         ) : null}
         <div
           className={cn(
-            "absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-none px-2 pt-1 [overflow-anchor:none]",
+            "absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-none px-2 pt-1",
             hasComposerOverlay ? "pb-24" : "pb-4",
           )}
           data-scroll-restoration-id={scrollRestorationId}
@@ -730,9 +596,6 @@ const MessageTimelineBase = React.forwardRef<
                     searchQuery={searchQuery}
                     threadUnreadCounts={threadUnreadCounts}
                     unfollowThreadById={unfollowThreadById}
-                    scrollContainerRef={scrollContainerRef}
-                    headerOverlayRef={activeDayHeaderRef}
-                    onVirtualizer={handleVirtualizer}
                   />
                 </div>
               ) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 011c8ab10c..30bbe9d935 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -4,7 +4,6 @@ import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
 import {
   buildTimelineItems,
   getTimelineItemKey,
-  resolveActiveDayTimestamp,
   type TimelineItem,
 } from "@/features/messages/lib/timelineItems";
 import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
@@ -18,12 +17,7 @@ import type { TimelineMessage } from "@/features/messages/types";
 import type { UserProfileLookup } from "@/features/profile/lib/identity";
 import type { ChannelType } from "@/shared/api/types";
 import { cn } from "@/shared/lib/cn";
-import {
-  type ListVirtualizer,
-  VirtualizedList,
-} from "@/shared/ui/VirtualizedList";
 import { DayDivider } from "./DayDivider";
-import { ActiveDayHeader } from "./ActiveDayHeader";
 import { MessageRow } from "./MessageRow";
 import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
 import { SystemMessageRow } from "./SystemMessageRow";
@@ -78,13 +72,6 @@ type TimelineMessageListProps = {
   searchQuery?: string;
   /** Per-thread unread counts keyed by thread root id. */
   threadUnreadCounts?: ReadonlyMap<string, number>;
-  /** Caller-owned scroll container the virtualizer measures and scrolls. */
-  scrollContainerRef: React.RefObject<HTMLElement | null>;
-  /** Non-scrolling overlay container the floating active-day header portals
-   *  into — sits OUTSIDE the scroll container so the header cannot drift. */
-  headerOverlayRef: React.RefObject<HTMLElement | null>;
-  /** Receives the virtualizer instance for index-model scroll paths. */
-  onVirtualizer?: (virtualizer: ListVirtualizer) => void;
 };
 
 export const TimelineMessageList = React.memo(function TimelineMessageList({
@@ -117,9 +104,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
   searchQuery,
   threadUnreadCounts,
   unfollowThreadById,
-  scrollContainerRef,
-  headerOverlayRef,
-  onVirtualizer,
 }: TimelineMessageListProps) {
   const entries = React.useMemo(
     () => mainEntries ?? buildMainTimelineEntries(messages),
@@ -260,36 +244,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
     ],
   );
 
-  // The floating active-day header is portaled by VirtualizedList into the
-  // non-scrolling overlay container (outside the scroll element) and is
-  // re-invoked with the topmost visible row index on every scroll. We resolve
-  // that index back to the day it belongs to so the label tracks the viewport
-  // even after that day's in-stream divider has been windowed out.
-  const renderStickyHeader = React.useCallback(
-    (topVisibleIndex: number | null) => {
-      const headingTimestamp = resolveActiveDayTimestamp(
-        itemsResult.items,
-        topVisibleIndex,
-      );
-      if (headingTimestamp === null) {
-        return null;
-      }
-      return <ActiveDayHeader label={formatDayHeading(headingTimestamp)} />;
-    },
-    [itemsResult.items],
-  );
-
   return (
-    <VirtualizedList
-      getItemKey={getTimelineItemKey}
-      headerOverlayRef={headerOverlayRef}
-      innerClassName="flex flex-col"
-      items={itemsResult.items}
-      onVirtualizer={onVirtualizer}
-      renderItem={renderItem}
-      scrollRef={scrollContainerRef}
-      stickyHeader={renderStickyHeader}
-    />
+    <div className="flex flex-col">
+      {itemsResult.items.map((item) => (
+        <div className="ln-auto" key={getTimelineItemKey(item)}>
+          {renderItem(item)}
+        </div>
+      ))}
+    </div>
   );
 });
 

From 55520812a8e8086cb453cd0e2b00f24a647503ad Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 09:01:37 -0600
Subject: [PATCH 02/11] refactor(timeline): drop JS prepend-delta restore, rely
 on native anchoring
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

With the timeline de-virtualized and `overflow-anchor` re-enabled, the browser
holds the reading position across an older-history prepend on its own. The
non-virtualized load-older handler's `capture height -> double-rAF ->
restoreScrollPosition(prev + delta)` was a second scroll writer that would
double-correct the same reflow once native anchoring is live. Remove it: the
sentinel now only triggers `fetchOlder` and re-arms the observer.

This is the native-anchoring de-risk gate — the no-shift probe re-runs against
this commit to confirm the <=2px hold survives with no JS scroll write masking
it. The dead virtualizer branch in this hook is removed in a follow-up cleanup.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../features/messages/ui/useLoadOlderOnScroll.ts | 16 ++++------------
 1 file changed, 4 insertions(+), 12 deletions(-)

diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
index ca33d88d54..7f7e72b03f 100644
--- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
+++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
@@ -282,19 +282,11 @@ export function useLoadOlderOnScroll({
             return;
           }
 
-          const previousHeight = container.scrollHeight;
-          const previousScrollTop = container.scrollTop;
+          // Native scroll anchoring (overflow-anchor) holds the reading
+          // position across the prepend now that all rows stay in the DOM, so
+          // the sentinel's only job is to trigger the fetch and re-arm.
           void fetchOlder().then(() => {
-            requestAnimationFrame(() => {
-              requestAnimationFrame(() => {
-                const newHeight = container.scrollHeight;
-                const delta = newHeight - previousHeight;
-                if (delta > 0) {
-                  restoreScrollPositionRef.current(previousScrollTop + delta);
-                }
-                observe();
-              });
-            });
+            observe();
           });
         },
         { root: container, rootMargin: "200px 0px 0px 0px" },

From b4af74fc46fb1721277da88e0f5698083d711704 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 09:05:29 -0600
Subject: [PATCH 03/11] refactor(timeline): delete dead virtualizer scroll
 machinery

The native-anchoring de-risk gate passed (no-shift probe: 0px drift, 0 missing
samples, 240 frames, with no JS prepend restore), so the windowing-era scroll
machinery is now dead code. Remove it:

- useLoadOlderOnScroll: drop the entire virtualizer prepend-restore branch
  (index re-aim, abandon-to-bottom, ResizeObserver cede plumbing) and the
  options it needed. The hook is now just an IntersectionObserver that triggers
  fetchOlder and re-arms; native anchoring holds the position.
- Delete useConvergentScrollToMessage and scrollConvergence (+ its test):
  deep-link is pure DOM scrollIntoView now that all rows are mounted.
- MessageTimeline: stop consuming the now-removed hook return values.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../messages/lib/scrollConvergence.test.mjs   | 210 ----------------
 .../messages/lib/scrollConvergence.ts         | 151 -----------
 .../features/messages/ui/MessageTimeline.tsx  |   6 -
 .../ui/useConvergentScrollToMessage.ts        | 192 --------------
 .../messages/ui/useLoadOlderOnScroll.ts       | 237 +-----------------
 5 files changed, 2 insertions(+), 794 deletions(-)
 delete mode 100644 desktop/src/features/messages/lib/scrollConvergence.test.mjs
 delete mode 100644 desktop/src/features/messages/lib/scrollConvergence.ts
 delete mode 100644 desktop/src/features/messages/ui/useConvergentScrollToMessage.ts

diff --git a/desktop/src/features/messages/lib/scrollConvergence.test.mjs b/desktop/src/features/messages/lib/scrollConvergence.test.mjs
deleted file mode 100644
index 53c96eaa66..0000000000
--- a/desktop/src/features/messages/lib/scrollConvergence.test.mjs
+++ /dev/null
@@ -1,210 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts";
-
-function input(overrides) {
-  return {
-    targetMessageId: "target",
-    indexByMessageId: new Map([["target", 100]]),
-    lastIssuedIndex: null,
-    librarySettled: false,
-    stalledOffTarget: false,
-    framesUsed: 0,
-    ...overrides,
-  };
-}
-
-// --- re-aim / staleness guard ------------------------------------------------
-
-test("convergenceStep: first frame aims at the resolved index, not yet done", () => {
-  const step = convergenceStep(input({ lastIssuedIndex: null }));
-  assert.equal(step.nextIndex, 100);
-  assert.equal(step.done, false);
-  assert.equal(step.converged, false);
-});
-
-test("convergenceStep: re-resolves a shifted index from the map each frame", () => {
-  // A prepend shifted the target from 100 to 105. The library is still chasing
-  // the old index (lastIssuedIndex 100); the reducer must aim at the NEW index
-  // so the adapter re-issues scrollToIndex(105). This is the staleness guard.
-  const step = convergenceStep(
-    input({
-      indexByMessageId: new Map([["target", 105]]),
-      lastIssuedIndex: 100,
-    }),
-  );
-  assert.equal(step.nextIndex, 105);
-  assert.equal(step.done, false);
-  assert.equal(step.converged, false);
-});
-
-test("convergenceStep: target removed mid-settle stops with converged=false", () => {
-  // Target deleted from the map while the loop was chasing it. Terminate so the
-  // adapter clears the highlight instead of chasing a vanished row.
-  const step = convergenceStep(
-    input({
-      indexByMessageId: new Map(), // target gone
-      lastIssuedIndex: 100,
-      framesUsed: 3,
-    }),
-  );
-  assert.equal(step.nextIndex, null);
-  assert.equal(step.done, true);
-  assert.equal(step.converged, false);
-});
-
-// --- settle ------------------------------------------------------------------
-
-test("convergenceStep: library settled while aiming at current index converges", () => {
-  const step = convergenceStep(
-    input({ lastIssuedIndex: 100, librarySettled: true }),
-  );
-  assert.equal(step.nextIndex, 100);
-  assert.equal(step.done, true);
-  assert.equal(step.converged, true);
-});
-
-test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => {
-  // The index just moved (105) but the library reports settled — that settle is
-  // on the OLD index (100), so it must NOT count as convergence. The reducer
-  // keeps going and aims at the new index.
-  const step = convergenceStep(
-    input({
-      indexByMessageId: new Map([["target", 105]]),
-      lastIssuedIndex: 100,
-      librarySettled: true,
-    }),
-  );
-  assert.equal(step.nextIndex, 105);
-  assert.equal(step.done, false);
-  assert.equal(step.converged, false);
-});
-
-test("convergenceStep: aiming at current but not yet settled keeps waiting", () => {
-  // Library is chasing the right index but its offset hasn't stabilized. The
-  // reducer returns the same index (so the adapter re-issues NOTHING — issuing
-  // would reset the library's stableFrames and prevent settling) and waits.
-  const step = convergenceStep(
-    input({ lastIssuedIndex: 100, librarySettled: false }),
-  );
-  assert.equal(step.nextIndex, 100);
-  assert.equal(step.reissue, false);
-  assert.equal(step.done, false);
-  assert.equal(step.converged, false);
-});
-
-// --- off-target stall (liveness) ---------------------------------------------
-
-test("convergenceStep: stalled off-target while aiming at current re-issues same index", () => {
-  // The library's offset stopped moving but never reached the current index's
-  // target (its internal reconcile deadlocked after rows re-measured). The
-  // reducer signals a same-index re-issue to kick it — the loop continues.
-  const step = convergenceStep(
-    input({ lastIssuedIndex: 100, stalledOffTarget: true }),
-  );
-  assert.equal(step.nextIndex, 100);
-  assert.equal(step.reissue, true);
-  assert.equal(step.done, false);
-  assert.equal(step.converged, false);
-});
-
-test("convergenceStep: a stall reported WHILE re-aiming does not re-issue", () => {
-  // The index just moved (105) but the library reports a stall on the OLD index
-  // (100). The reducer re-aims at the new index normally; the stale stall must
-  // NOT trigger a same-index kick (there is no current-index stall to kick).
-  const step = convergenceStep(
-    input({
-      indexByMessageId: new Map([["target", 105]]),
-      lastIssuedIndex: 100,
-      stalledOffTarget: true,
-    }),
-  );
-  assert.equal(step.nextIndex, 105);
-  assert.equal(step.reissue, false);
-  assert.equal(step.done, false);
-  assert.equal(step.converged, false);
-});
-
-test("convergenceStep: a settle takes priority over a concurrent stall flag", () => {
-  // Defensive: the adapter computes settle and stall as mutually exclusive, but
-  // if both arrive, a genuine settle must win (converge) rather than spin on a
-  // pointless re-issue.
-  const step = convergenceStep(
-    input({
-      lastIssuedIndex: 100,
-      librarySettled: true,
-      stalledOffTarget: true,
-    }),
-  );
-  assert.equal(step.done, true);
-  assert.equal(step.converged, true);
-  assert.equal(step.reissue, false);
-});
-
-// --- frame cap ---------------------------------------------------------------
-
-test("convergenceStep: terminates at the frame cap without converging", () => {
-  // A row that never settles (librarySettled stays false) must still stop at the
-  // cap rather than spin forever.
-  const step = convergenceStep(
-    input({
-      lastIssuedIndex: 100,
-      librarySettled: false,
-      framesUsed: CONVERGENCE_FRAME_CAP - 1,
-    }),
-  );
-  assert.equal(step.done, true);
-  assert.equal(step.converged, false);
-  assert.equal(step.nextIndex, 100);
-});
-
-test("convergenceStep: frame cap bounds a perpetually shifting target", () => {
-  // Drive the loop the way the adapter would: the target index moves every
-  // frame, so the library never settles. The loop must terminate at the cap.
-  let lastIssuedIndex = null;
-  let framesUsed = 0;
-  let done = false;
-  let converged = true;
-
-  while (framesUsed < CONVERGENCE_FRAME_CAP + 5) {
-    const movingIndex = 100 + framesUsed; // shifts every frame
-    const step = convergenceStep(
-      input({
-        indexByMessageId: new Map([["target", movingIndex]]),
-        lastIssuedIndex,
-        librarySettled: false,
-        framesUsed,
-      }),
-    );
-    lastIssuedIndex = step.nextIndex;
-    framesUsed += 1;
-    if (step.done) {
-      done = step.done;
-      converged = step.converged;
-      break;
-    }
-  }
-
-  assert.equal(done, true);
-  assert.equal(converged, false);
-  assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP);
-});
-
-test("convergenceStep: converges once a re-aimed index then settles", () => {
-  // Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library
-  // is chasing 100 and reports settled -> converged.
-  const aim = convergenceStep(input({ lastIssuedIndex: null }));
-  assert.equal(aim.nextIndex, 100);
-  assert.equal(aim.done, false);
-
-  const settle = convergenceStep(
-    input({
-      lastIssuedIndex: aim.nextIndex,
-      librarySettled: true,
-      framesUsed: 1,
-    }),
-  );
-  assert.equal(settle.done, true);
-  assert.equal(settle.converged, true);
-});
diff --git a/desktop/src/features/messages/lib/scrollConvergence.ts b/desktop/src/features/messages/lib/scrollConvergence.ts
deleted file mode 100644
index 0b8650a3f3..0000000000
--- a/desktop/src/features/messages/lib/scrollConvergence.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * Pure staleness + termination decision for scrolling a virtualized timeline to
- * a message that may be far off-screen.
- *
- * @tanstack/react-virtual already owns the OFFSET convergence: a single
- * `scrollToIndex(index)` captures that index in `scrollState`, and its internal
- * `reconcileScroll` rAF loop re-runs `getOffsetForIndex(index)` every frame —
- * re-aiming as off-screen rows mount and `measureElement` corrects their
- * heights — until the offset is stable (or a 5s safety valve fires). We do NOT
- * recompute offsets; duplicating `getOffsetForIndex` against the library's own
- * `measurementsCache`/`scrollMargin`/`scrollPadding` would only drift.
- *
- * What the library does NOT do: it chases the INDEX captured at call time, with
- * no concept of a message id. If the data shifts mid-settle — a prepend or a
- * delete above the target — the captured index now points at the wrong row and
- * the library happily settles on it. This reducer owns exactly that gap: each
- * frame it re-resolves the target's CURRENT index from the live map and decides
- * whether the adapter must re-aim the library, let it settle, or stop.
- *
- * Two correctness properties this enforces and the `.mjs` suite gates:
- *  - The target index is re-resolved by id every frame (never frozen), so a
- *    concurrent prepend/delete that shifts the target re-aims the library at the
- *    new index instead of stranding it on the old one.
- *  - If the target id leaves the data mid-settle (deleted), the loop terminates
- *    with `converged: false` rather than chasing a vanished row to the cap.
- *
- * Plus one liveness property: a large windowed-out jump can leave the library's
- * own reconcile deadlocked off-target (offset stable but short of the target
- * after rows re-measured under it). When that happens the reducer signals a
- * same-index re-issue (`reissue: true`) to restart the library's reconcile —
- * the single case where re-issuing an unchanged index is correct.
- */
-
-/** Where a scroll target should land in the viewport. Mirrors the library's align. */
-export type ConvergenceAlign = "start" | "center" | "end";
-
-export type ConvergenceInput = {
-  /** Id of the message to settle on — re-resolved against the map each frame. */
-  targetMessageId: string;
-  /** Live message-id -> item-index map; re-read every frame (staleness guard). */
-  indexByMessageId: Map<string, number>;
-  /**
-   * Index the library is currently chasing (the last index the adapter issued
-   * via `scrollToIndex`), or `null` before the first issue. Lets the reducer
-   * tell a re-aim (index moved) from a steady settle (index unchanged).
-   */
-  lastIssuedIndex: number | null;
-  /**
-   * Whether the library's offset reached the current index's target this frame
-   * and stopped moving. Only meaningful once the library is chasing the CURRENT
-   * index; a settle reported while re-aiming is ignored.
-   */
-  librarySettled: boolean;
-  /**
-   * Whether the library's offset has stopped moving but is NOT at the current
-   * index's target — it stalled mid-reconcile (its internal re-aim deadlocked
-   * after rows re-measured under it). The reducer kicks it with a fresh re-issue
-   * at the same index. Mutually exclusive with `librarySettled`.
-   */
-  stalledOffTarget: boolean;
-  /** Frames already spent in the loop (the adapter increments per rAF). */
-  framesUsed: number;
-};
-
-export type ConvergenceDecision = {
-  /**
-   * Index the adapter should be aiming the library at, or `null` when the
-   * target is gone. The adapter only re-issues `scrollToIndex` when this differs
-   * from `lastIssuedIndex`, so a steady settle issues no redundant scroll (which
-   * would reset the library's `stableFrames` and prevent it from ever settling).
-   */
-  nextIndex: number | null;
-  /**
-   * True when the adapter must re-issue `scrollToIndex(nextIndex)` even though
-   * the index is unchanged — used to kick the library out of an off-target
-   * stall. A normal steady settle leaves this false so no redundant scroll
-   * resets the library's `stableFrames`.
-   */
-  reissue: boolean;
-  /** True once the loop must stop (settled, target gone, or frame cap hit). */
-  done: boolean;
-  /** True only when the loop stopped because the target row actually settled. */
-  converged: boolean;
-};
-
-/**
- * Hard cap on frames so a perpetually re-measuring row, or a target whose index
- * keeps shifting, can't spin the loop forever. The library has its own 5s valve;
- * this is the adapter-side bound expressed in frames for deterministic testing.
- */
-export const CONVERGENCE_FRAME_CAP = 32;
-
-/**
- * One frame of the convergence loop. Pure: given the live map and the library's
- * settle state, decides the index to aim at and whether to stop.
- */
-export function convergenceStep(input: ConvergenceInput): ConvergenceDecision {
-  const currentIndex = input.indexByMessageId.get(input.targetMessageId);
-
-  // Target left the data mid-settle (deleted) — stop without converging so the
-  // adapter clears the highlight instead of chasing a vanished row.
-  if (currentIndex === undefined) {
-    return { nextIndex: null, reissue: false, done: true, converged: false };
-  }
-
-  const aimingAtCurrent = input.lastIssuedIndex === currentIndex;
-
-  // The library only settles meaningfully once it is chasing the CURRENT index.
-  // A settle reported while we are still re-aiming (index just moved) is stale.
-  if (aimingAtCurrent && input.librarySettled) {
-    return {
-      nextIndex: currentIndex,
-      reissue: false,
-      done: true,
-      converged: true,
-    };
-  }
-
-  // Frame cap: accept the best index we have rather than spin forever on a row
-  // whose height never settles or a target whose index keeps shifting.
-  if (input.framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
-    return {
-      nextIndex: currentIndex,
-      reissue: false,
-      done: true,
-      converged: false,
-    };
-  }
-
-  // Library stalled off-target: its offset stopped moving but never reached the
-  // current index (its internal reconcile deadlocked after rows re-measured).
-  // Re-issue the SAME index to restart its reconcile — the one case where a
-  // same-index re-issue is correct rather than a stableFrames-resetting bug.
-  if (aimingAtCurrent && input.stalledOffTarget) {
-    return {
-      nextIndex: currentIndex,
-      reissue: true,
-      done: false,
-      converged: false,
-    };
-  }
-
-  // Either the index moved (adapter will re-issue scrollToIndex) or the library
-  // is still settling on the current index (adapter issues nothing, just waits).
-  return {
-    nextIndex: currentIndex,
-    reissue: false,
-    done: false,
-    converged: false,
-  };
-}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index d3e68f30c9..2cbc8caa74 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -237,12 +237,9 @@ const MessageTimelineBase = React.forwardRef<
     isAtBottom,
     newMessageCount,
     onScroll,
-    restoreScrollPosition,
     scrollToBottom,
     scrollToBottomOnNextUpdate,
     scrollToMessage,
-    setLoadOlderRestoreInFlight,
-    getAnchorIsAtBottom,
   } = useAnchoredScroll({
     channelId,
     contentRef,
@@ -344,9 +341,6 @@ const MessageTimelineBase = React.forwardRef<
     fetchOlder,
     hasOlderMessages,
     isLoading: showTimelineSkeleton,
-    restoreScrollPosition,
-    setLoadOlderRestoreInFlight,
-    getAnchorIsAtBottom,
     scrollContainerRef,
     sentinelRef: topSentinelRef,
   });
diff --git a/desktop/src/features/messages/ui/useConvergentScrollToMessage.ts b/desktop/src/features/messages/ui/useConvergentScrollToMessage.ts
deleted file mode 100644
index 4a51c8c634..0000000000
--- a/desktop/src/features/messages/ui/useConvergentScrollToMessage.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import * as React from "react";
-
-import {
-  type ConvergenceAlign,
-  CONVERGENCE_FRAME_CAP,
-  convergenceStep,
-} from "@/features/messages/lib/scrollConvergence";
-import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
-
-/** Offset (px) within which the library is considered to have reached the target. */
-const SETTLE_TOLERANCE_PX = 2;
-
-type ConvergentScrollOptions = {
-  /** Live message-id -> item-index map, rebuilt with the flattened item stream. */
-  indexByMessageId: Map<string, number>;
-  /** Where the target should land in the viewport. */
-  align: ConvergenceAlign;
-  /** Fired on the settled frame once the target row has converged. */
-  onConverged?: (messageId: string) => void;
-  /** Fired when the loop stops without converging (target deleted, or frame cap). */
-  onAbandoned?: (messageId: string) => void;
-};
-
-type ConvergentScrollController = {
-  /**
-   * Begins a convergence loop toward `messageId`. Returns `true` (the loop
-   * always starts and owns the target). When the id isn't yet in the data — a
-   * deep-link target the route screen fetches asynchronously — the loop warms
-   * up by polling the live map, then converges once it lands; if it never
-   * lands within the frame cap it abandons (clears the highlight), the same
-   * terminal state as a target deleted mid-settle. A new call cancels any
-   * in-flight loop.
-   */
-  scrollToMessage: (messageId: string) => boolean;
-  /** Cancels any in-flight convergence loop (e.g. on unmount or channel switch). */
-  cancel: () => void;
-};
-
-/**
- * Drives @tanstack/react-virtual to settle on an off-screen message by id.
- *
- * The library already converges OFFSETS: one `scrollToIndex(i)` captures index
- * `i` and its `reconcileScroll` rAF loop re-aims as rows mount and measure. But
- * it chases the INDEX captured at call time — a prepend/delete mid-settle leaves
- * it on the wrong row. This adapter closes that gap: each frame it re-resolves
- * the target's CURRENT index from the live map (the pure `convergenceStep`
- * reducer owns the decision) and re-issues `scrollToIndex` ONLY when the index
- * moved. In steady state it issues nothing, so it never resets the library's
- * internal stable-frame counter and the library settles in one frame.
- *
- * Settle detection is a trivial offset-equality check (NOT the convergence math,
- * which the library owns): the measured offset for the current index is within
- * tolerance of where the library would place it, and the offset is unchanged
- * from the prior frame.
- */
-export function useConvergentScrollToMessage(
-  getVirtualizer: () => ListVirtualizer | null,
-  {
-    indexByMessageId,
-    align,
-    onConverged,
-    onAbandoned,
-  }: ConvergentScrollOptions,
-): ConvergentScrollController {
-  // Mirror inputs into refs so the rAF loop closure always reads live values
-  // without re-subscribing the loop each render.
-  const mapRef = React.useRef(indexByMessageId);
-  mapRef.current = indexByMessageId;
-  const alignRef = React.useRef(align);
-  alignRef.current = align;
-  const onConvergedRef = React.useRef(onConverged);
-  onConvergedRef.current = onConverged;
-  const onAbandonedRef = React.useRef(onAbandoned);
-  onAbandonedRef.current = onAbandoned;
-
-  const rafIdRef = React.useRef<number | null>(null);
-
-  const cancel = React.useCallback(() => {
-    if (rafIdRef.current !== null) {
-      cancelAnimationFrame(rafIdRef.current);
-      rafIdRef.current = null;
-    }
-  }, []);
-
-  const scrollToMessage = React.useCallback(
-    (messageId: string) => {
-      cancel();
-
-      let lastIssuedIndex: number | null = null;
-      let previousOffset: number | null = null;
-      let framesUsed = 0;
-      // The id->index map is rebuilt one render after `messages` changes, so a
-      // freshly-spliced deep-link target (the route screen fetches it by id
-      // asynchronously) can be absent from the map on the frame this starts.
-      // Poll the live map during a bounded warmup instead of bailing; once it
-      // resolves, hand off to the normal convergence loop. If it never resolves
-      // within the cap, abandon — same terminal state as a deleted target.
-      let resolved = mapRef.current.has(messageId);
-
-      const frame = () => {
-        rafIdRef.current = null;
-        const virtualizer = getVirtualizer();
-        if (!virtualizer) {
-          return;
-        }
-
-        const currentIndex = mapRef.current.get(messageId);
-        if (!resolved) {
-          if (currentIndex === undefined) {
-            if (framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
-              onAbandonedRef.current?.(messageId);
-              return;
-            }
-            framesUsed += 1;
-            previousOffset = virtualizer.scrollOffset ?? 0;
-            rafIdRef.current = requestAnimationFrame(frame);
-            return;
-          }
-          resolved = true;
-        }
-
-        // The library has settled this frame when its offset reached the target
-        // index's offset (within tolerance) and stopped moving. `currentIndex`
-        // is re-read so a settle on a stale index never counts as converged.
-        let librarySettled = false;
-        let stalledOffTarget = false;
-        if (currentIndex !== undefined && lastIssuedIndex === currentIndex) {
-          const offset = virtualizer.scrollOffset ?? 0;
-          const target = virtualizer.getOffsetForIndex(
-            currentIndex,
-            alignRef.current,
-          );
-          const reachedTarget =
-            target !== undefined &&
-            Math.abs(offset - target[0]) <= SETTLE_TOLERANCE_PX;
-          const offsetStable =
-            previousOffset !== null &&
-            Math.abs(offset - previousOffset) <= SETTLE_TOLERANCE_PX;
-          librarySettled = reachedTarget && offsetStable;
-          stalledOffTarget = offsetStable && !reachedTarget;
-          previousOffset = offset;
-        } else {
-          previousOffset = virtualizer.scrollOffset ?? 0;
-        }
-
-        const decision = convergenceStep({
-          targetMessageId: messageId,
-          indexByMessageId: mapRef.current,
-          lastIssuedIndex,
-          librarySettled,
-          stalledOffTarget,
-          framesUsed,
-        });
-
-        if (
-          decision.nextIndex !== null &&
-          (decision.nextIndex !== lastIssuedIndex || decision.reissue)
-        ) {
-          // Issue when the index moved (re-aim at the new row) OR the reducer
-          // asks for a same-index re-issue to kick an off-target stall. Reset
-          // `previousOffset` so the next frame's stability check restarts from
-          // the post-issue offset rather than treating the stall as settled.
-          virtualizer.scrollToIndex(decision.nextIndex, {
-            align: alignRef.current,
-          });
-          lastIssuedIndex = decision.nextIndex;
-          previousOffset = null;
-        }
-
-        if (decision.done) {
-          if (decision.converged) {
-            onConvergedRef.current?.(messageId);
-          } else {
-            onAbandonedRef.current?.(messageId);
-          }
-          return;
-        }
-
-        framesUsed += 1;
-        rafIdRef.current = requestAnimationFrame(frame);
-      };
-
-      rafIdRef.current = requestAnimationFrame(frame);
-      return true;
-    },
-    [cancel, getVirtualizer],
-  );
-
-  React.useEffect(() => cancel, [cancel]);
-
-  return { scrollToMessage, cancel };
-}
diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
index 7f7e72b03f..ee66f40ffa 100644
--- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
+++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
@@ -1,120 +1,25 @@
 import * as React from "react";
 
-import { CONVERGENCE_FRAME_CAP } from "@/features/messages/lib/scrollConvergence";
-import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
-
 type UseLoadOlderOnScrollOptions = {
   fetchOlder?: () => Promise<void>;
   hasOlderMessages: boolean;
   isLoading: boolean;
-  restoreScrollPosition: (scrollTop: number) => void;
-  /**
-   * Brackets the index-restore loop's scroll ownership so the anchored hook's
-   * ResizeObserver cedes for the duration of the prepend. Without it, the
-   * prepended rows measuring late fire the observer with the windowed-out
-   * anchor and its all-gone fallback pins the view to the floor, stomping this
-   * loop's correct re-aim. Only the virtualized (index) path needs it — the
-   * non-virtualized path's restore is a single synchronous `scrollTop` write.
-   */
-  setLoadOlderRestoreInFlight?: (inFlight: boolean) => void;
-  /**
-   * Live read of whether the user is stuck-to-bottom. If the user abandons the
-   * fetch by jumping to bottom WHILE the index restore owns scroll, the restore
-   * loop must re-aim at the bottom rather than the captured mid-history anchor
-   * — otherwise it lands the old anchor offset (short of the true floor) and the
-   * ceded ResizeObserver never chases the last rows down. Only meaningful with a
-   * virtualizer; the non-virtualized path restores synchronously.
-   */
-  getAnchorIsAtBottom?: () => boolean;
   scrollContainerRef: React.RefObject<HTMLDivElement | null>;
   sentinelRef: React.RefObject<HTMLDivElement | null>;
-  /**
-   * When the timeline is virtualized, a prepend shifts every index and a large
-   * one pushes the anchored row out of the window before it can be re-measured.
-   * Supplying the virtualizer switches to an index anchor: we hold the
-   * first-visible row across the prepend by re-aiming `scrollToIndex` at its new
-   * index (resolved from `indexByMessageId`) until the library settles it.
-   */
-  virtualizer?: {
-    getVirtualizer: () => ListVirtualizer | null;
-    indexByMessageId: Map<string, number>;
-    itemCount: number;
-    /** Live (non-deferred) message count for growth detection during load-older. */
-    liveMessageCount?: number;
-  } | null;
 };
 
-/**
- * The scroll offset the prepend-restore loop should hold this frame. Returns
- * `undefined` when there's nothing to aim at (no virtualizer, no growth yet, or
- * the captured anchor row isn't resolvable). Resolved off the virtualizer's
- * live measurement each frame so the loop chases the offset as prepended rows
- * grow `getTotalSize()`.
- *   - `abandonedToBottom`: the user jumped to bottom mid-prepend → the last
- *     row's END offset (the true floor), not the stale mid-history anchor.
- *   - otherwise: the captured first-visible row's START offset minus the gap
- *     that was above it, holding the reader's eye-line across the prepend.
- */
-function resolveTarget({
-  instance,
-  abandonedToBottom,
-  lastIndex,
-  newIndex,
-  anchorTop,
-}: {
-  instance: ListVirtualizer | null;
-  abandonedToBottom: boolean;
-  lastIndex: number;
-  newIndex: number | undefined;
-  anchorTop: number;
-}): number | undefined {
-  if (!instance) return undefined;
-  if (abandonedToBottom) {
-    if (lastIndex < 0) return undefined;
-    return instance.getOffsetForIndex(lastIndex, "end")?.[0];
-  }
-  if (newIndex === undefined) return undefined;
-  const start = instance.getOffsetForIndex(newIndex, "start");
-  return start === undefined ? undefined : start[0] - anchorTop;
-}
-
 /**
  * Triggers `fetchOlder` when a sentinel element near the top of the scroll
- * container enters the viewport, then restores the scroll position so the
- * visible content doesn't jump.
+ * container enters the viewport. Native scroll anchoring (overflow-anchor)
+ * holds the reading position across the prepend — no JS scroll restore.
  */
 export function useLoadOlderOnScroll({
   fetchOlder,
   hasOlderMessages,
   isLoading,
-  restoreScrollPosition,
-  setLoadOlderRestoreInFlight,
-  getAnchorIsAtBottom,
   scrollContainerRef,
   sentinelRef,
-  virtualizer = null,
 }: UseLoadOlderOnScrollOptions) {
-  const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
-  React.useEffect(() => {
-    restoreScrollPositionRef.current = restoreScrollPosition;
-  });
-  // Mirror the cede setter so the long-lived Intersection observer reads the
-  // live callback without re-subscribing (same rationale as the restore ref).
-  const setInFlightRef = React.useRef(setLoadOlderRestoreInFlight);
-  React.useEffect(() => {
-    setInFlightRef.current = setLoadOlderRestoreInFlight;
-  });
-  // Mirror the at-bottom getter so the prepend loop reads the live abandon
-  // state without re-subscribing the long-lived observer per render.
-  const getAnchorIsAtBottomRef = React.useRef(getAnchorIsAtBottom);
-  React.useEffect(() => {
-    getAnchorIsAtBottomRef.current = getAnchorIsAtBottom;
-  });
-  // Mirror the virtualizer option into a ref so the long-lived Intersection
-  // observer reads the live getter + count without re-subscribing per render.
-  const virtualizerRef = React.useRef(virtualizer);
-  virtualizerRef.current = virtualizer;
-
   React.useEffect(() => {
     const sentinel = sentinelRef.current;
     const container = scrollContainerRef.current;
@@ -144,144 +49,6 @@ export function useLoadOlderOnScroll({
 
           currentObserver?.disconnect();
 
-          const virt = virtualizerRef.current;
-          if (virt) {
-            // Hold the first VISIBLE row across the prepend. After N older rows
-            // are prepended the anchored row's INDEX shifts by N and — with
-            // scrollTop unchanged near the top — it's pushed below the window
-            // and recycled out of the DOM, so a pure DOM re-read can't find it.
-            // We therefore drive the virtualizer: capture the row's id + its top
-            // offset in the viewport now, and after the prepend re-aim
-            // `scrollToIndex(newIndex, "start")` each frame (re-issued only when
-            // the resolved index moves, so the library's internal settle loop is
-            // never reset — same single-issue discipline as the convergence
-            // adapter). `scrollToIndex` re-aims internally as rows mount and
-            // measure, landing the row's TOP at the viewport top; once it settles
-            // we apply the captured intra-viewport gap with ONE scrollTop write.
-            // Single writer throughout: one mechanism re-aims, the gap is a final
-            // one-shot, never an overlapping second target.
-            const instance = virt.getVirtualizer();
-            const container = scrollContainerRef.current;
-            // Use liveMessageCount (live, non-deferred) rather than itemCount
-            // or instance.options.count — both are derived from deferredMessages
-            // and are stale during a fetch, so the growth check would never fire.
-            const previousCount = virt.liveMessageCount ?? virt.itemCount;
-
-            void fetchOlder().then(() => {
-              // Claim scroll ownership for the whole re-aim window so the
-              // anchored hook's ResizeObserver cedes while the prepended rows
-              // measure (released at every exit below via `finishPrepend`).
-              setInFlightRef.current?.(true);
-              // Capture the anchor at fetch-RESOLVE time, not sentinel-fire
-              // time: the history request can be in flight for a while and the
-              // user may keep scrolling during it (the e2e scrolls further down
-              // mid-fetch). The row+offset we must preserve is wherever the
-              // reader actually is the instant before the prepend commits, read
-              // from the live DOM here while every visible row is still mounted.
-              const containerRect = container?.getBoundingClientRect();
-              const containerTop = containerRect?.top ?? 0;
-              const containerBottom = containerRect?.bottom ?? 0;
-              // First row intersecting the viewport — the reader's eye-line row.
-              // Geometry matches the test's getFirstVisibleMessage exactly: its
-              // bottom is below the viewport top and its top is above the
-              // viewport bottom.
-              const anchorRow = container
-                ? Array.from(
-                    container.querySelectorAll<HTMLElement>(
-                      "[data-message-id]",
-                    ),
-                  ).find((row) => {
-                    const rect = row.getBoundingClientRect();
-                    return (
-                      rect.bottom > containerTop && rect.top < containerBottom
-                    );
-                  })
-                : undefined;
-              const anchorId = anchorRow?.dataset.messageId ?? null;
-              // The anchored row's top relative to the viewport top — held
-              // constant across the prepend.
-              const anchorTop = anchorRow
-                ? anchorRow.getBoundingClientRect().top - containerTop
-                : 0;
-
-              // The timeline drives its rows off a `useDeferredValue` of the
-              // message list, so the prepended items commit on a LOW-priority
-              // render that can land several frames after `fetchOlder` resolves.
-              // Poll rAF until the live id->index map actually shifts the anchor
-              // (the prepend is observable), capped so an empty fetch can't spin.
-              const maxFrames = CONVERGENCE_FRAME_CAP;
-              let frame = 0;
-              let lastTarget: number | null = null;
-              let stableFrames = 0;
-              // Release scroll ownership (re-enabling the ResizeObserver) and
-              // re-arm the sentinel observer. Called at both loop exits.
-              const finishPrepend = () => {
-                setInFlightRef.current?.(false);
-                observe();
-              };
-              const waitForPrepend = () => {
-                const after = virtualizerRef.current;
-                const grew =
-                  (after?.liveMessageCount ?? previousCount) > previousCount;
-                // Resolve this frame's target offset. Two cases, one mechanism:
-                //   - Abandon: the user jumped to bottom while this loop owned
-                //     scroll. Hold the BOTTOM (last row's end offset), not the
-                //     captured mid-history anchor — that old offset sits short of
-                //     the true floor and would strand the view there, since the
-                //     ResizeObserver re-pin is ceded to this loop for the whole
-                //     window.
-                //   - Normal: hold the captured first-visible row at its viewport
-                //     gap (its start offset minus the gap that was above it).
-                // Either way we drive `scrollToOffset` — NOT `scrollToIndex` — so
-                // the library's reconcile holds a FIXED offset instead of
-                // re-resolving each frame and overwriting it. As the prepended
-                // rows measure, `getOffsetForIndex` grows and we recompute and
-                // re-issue. Re-issue ONLY when the target moves — re-issuing an
-                // unchanged offset resets the library's stable-frame counter and
-                // spins. Same single-issue discipline as the convergence adapter.
-                const abandonedToBottom =
-                  getAnchorIsAtBottomRef.current?.() ?? false;
-                const newIndex =
-                  anchorId !== null
-                    ? after?.indexByMessageId.get(anchorId)
-                    : undefined;
-                const target = resolveTarget({
-                  instance: grew ? instance : null,
-                  abandonedToBottom,
-                  lastIndex: (after?.liveMessageCount ?? previousCount) - 1,
-                  newIndex,
-                  anchorTop,
-                });
-                if (instance && target !== undefined) {
-                  if (target !== lastTarget) {
-                    instance.scrollToOffset(target, { align: "start" });
-                    lastTarget = target;
-                    stableFrames = 0;
-                  } else if ((instance.scrollOffset ?? 0) >= target) {
-                    // Reached the target and it stopped moving (measurement
-                    // settled). `>=` not `===`: the abandon path aims at the
-                    // last possible offset, which the container clamps, so the
-                    // realized `scrollOffset` can sit at-or-past it. Two stable
-                    // frames guard against ending before the last row measures.
-                    stableFrames += 1;
-                    if (stableFrames >= 2) {
-                      finishPrepend();
-                      return;
-                    }
-                  }
-                }
-                frame += 1;
-                if (frame >= maxFrames) {
-                  finishPrepend();
-                  return;
-                }
-                requestAnimationFrame(waitForPrepend);
-              };
-              requestAnimationFrame(waitForPrepend);
-            });
-            return;
-          }
-
           // Native scroll anchoring (overflow-anchor) holds the reading
           // position across the prepend now that all rows stay in the DOM, so
           // the sentinel's only job is to trigger the fetch and re-arm.

From d7c2db021c8cea9deca9f9c97f6f3a1bfa76073f Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 09:12:44 -0600
Subject: [PATCH 04/11] refactor(timeline): collapse useAnchoredScroll to
 native-anchoring model

With native scroll anchoring proven (no-shift probe: 0px through prepend +
above-anchor reflow, JS delta gone), the hook's JS message-anchor restore is
the redundant second writer the one-owner rule forbids. Remove it and the
windowing-era machinery it implied:

- delete restoreAnchorToMessage / findNearestNewerMessageId (the
  computeAnchor -> scrollBy(0, delta) restore) and both call sites (layout
  effect + ResizeObserver);
- drop the convergence cede, the load-older cede, the isPrepend cede, and the
  settle-window (settlingRef);
- drop the convergeToTarget / pinToBottomByIndex options and the
  restoreScrollPosition / setLoadOlderRestoreInFlight / getAnchorIsAtBottom
  result surface (all unused after the prior cleanup).

Preserved behaviors (native anchoring does NOT cover these): mount bottom-pin,
stick-to-bottom-on-append, send-snap, at-bottom re-pin on reflow, DOM deep-link
+ its retry-on-commit target effect, isAtBottom / new-message-count.

822 -> 439 lines. tsc + biome clean. Gated on the final no-shift probe pass.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../features/messages/ui/useAnchoredScroll.ts | 427 +-----------------
 1 file changed, 22 insertions(+), 405 deletions(-)

diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts
index df040d257c..8e542876e6 100644
--- a/desktop/src/features/messages/ui/useAnchoredScroll.ts
+++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts
@@ -31,24 +31,6 @@ type UseAnchoredScrollOptions = {
   /** When set, scroll to and highlight this message on mount and on change. */
   targetMessageId?: string | null;
   onTargetReached?: (messageId: string) => void;
-  /** Optional convergence fallback for a `targetMessageId` whose row is not in
-   *  the DOM (windowed out of a virtualized list). When the DOM lookup fails,
-   *  the hook delegates to this instead of waiting for a later commit that may
-   *  never render the row. The consumer drives the virtualizer to the target,
-   *  warming up if it's still being fetched, and fires `onTargetReached` itself
-   *  on settle. Returns `true` once it owns the target (the hook marks it
-   *  handled, no further dispatch). Absent (thread panel) → DOM-only retry. */
-  convergeToTarget?: (messageId: string) => boolean;
-  /** Optional: pin the initial mount view to the last row through the
-   *  virtualizer's own `scrollToIndex(lastIndex, {align:"end"})`, rather than a
-   *  raw `scrollTo(scrollHeight)`. At mount the virtualizer's rows are still
-   *  estimate-sized, so `scrollHeight` is short of the true total and a raw pin
-   *  lands above the real bottom (and re-pins chase a moving floor). Driving the
-   *  last index through the library lets it own landing that row at the bottom
-   *  across its own measurement. Returns `true` once it issued (caller keeps the
-   *  at-bottom anchor); `false` when no virtualizer is available (thread panel),
-   *  so the hook falls back to the raw bottom pin. */
-  pinToBottomByIndex?: () => boolean;
 };
 
 type UseAnchoredScrollResult = {
@@ -72,20 +54,6 @@ type UseAnchoredScrollResult = {
     messageId: string,
     options?: { highlight?: boolean; behavior?: ScrollBehavior },
   ) => boolean;
-  /** Single-writer scroll restore for the load-older index path. Sets
-   *  `scrollTop` directly (no scroll event fires for a programmatic write),
-   *  then re-seats the anchor + at-bottom bookkeeping so the next passive
-   *  restore and `isAtBottom` read agree with where we put the scroll. */
-  restoreScrollPosition: (scrollTop: number) => void;
-  /** Brackets the load-older index restore's scroll ownership. While `true`,
-   *  the ResizeObserver cedes — the index path is the sole `scrollTop` writer
-   *  across the prepend, mirroring the `convergingTargetIdRef` cede. */
-  setLoadOlderRestoreInFlight: (inFlight: boolean) => void;
-  /** Live read of whether the anchor is stuck-to-bottom. The load-older index
-   *  restore loop polls this each frame so a mid-prepend jump-to-bottom
-   *  (abandon) re-aims it at the bottom instead of the captured mid-history
-   *  anchor. */
-  getAnchorIsAtBottom: () => boolean;
 };
 
 function isAtBottomNow(container: HTMLDivElement) {
@@ -138,104 +106,6 @@ function computeAnchor(container: HTMLDivElement): AnchorState {
   return { kind: "at-bottom" };
 }
 
-/**
- * Find the rendered message id that is closest in chronological order to
- * the anchor, scanning forward in `messages`. Used as the fallback when the
- * anchor's row is gone post-render (e.g. message deleted).
- */
-function findNearestNewerMessageId(
-  container: HTMLDivElement,
-  messages: TimelineMessage[],
-  anchorId: string,
-): string | null {
-  const anchorIndex = messages.findIndex((m) => m.id === anchorId);
-  if (anchorIndex < 0) return null;
-
-  for (let i = anchorIndex + 1; i < messages.length; i++) {
-    const candidate = messages[i];
-    const el = container.querySelector(`[data-message-id="${candidate.id}"]`);
-    if (el) return candidate.id;
-  }
-  return null;
-}
-
-/**
- * Restore a message-kind anchor's on-screen offset after a layout shift.
- *
- * Finds the anchor row (or the nearest newer rendered row if the anchor
- * itself was removed), measures its current top-relative offset, and
- * `scrollBy(0, delta)` if the offset has drifted. Returns the new anchor
- * state the caller should write back:
- * - `{ kind: "message", ... }` — anchor (or its fallback) is in the DOM
- *   and now sits at its previous offset.
- * - `{ kind: "at-bottom" }` — anchor and all newer rendered rows are gone;
- *   caller should pin to the bottom and update at-bottom state.
- *
- * `scrollBy` is intentional over `scrollTop = ...`: relative adjustment
- * composes with the browser's own scroll anchoring and doesn't fight a
- * smooth-scroll in flight. Same rationale as the layout-effect restore.
- *
- * Used by both the post-commit layout effect (prepend / append / spinner
- * toggle / etc.) and the ResizeObserver (in-viewport reflow from image
- * decode, embed expansion, font load). Keeping them on one primitive
- * preserves the single-owner invariant of the hook.
- */
-function restoreAnchorToMessage(
-  container: HTMLDivElement,
-  messages: TimelineMessage[],
-  anchor: Extract<AnchorState, { kind: "message" }>,
-): AnchorState {
-  let anchorEl = container.querySelector<HTMLElement>(
-    `[data-message-id="${anchor.messageId}"]`,
-  );
-  let usedAnchor: AnchorState = anchor;
-  if (!anchorEl) {
-    const fallbackId = findNearestNewerMessageId(
-      container,
-      messages,
-      anchor.messageId,
-    );
-    if (fallbackId) {
-      anchorEl = container.querySelector<HTMLElement>(
-        `[data-message-id="${fallbackId}"]`,
-      );
-      if (anchorEl) {
-        usedAnchor = {
-          kind: "message",
-          messageId: fallbackId,
-          topOffset: anchor.topOffset,
-        };
-      }
-    }
-  }
-
-  if (!anchorEl) {
-    // The anchor row is not in the DOM. Two distinct causes — only one means
-    // "go to bottom":
-    //   1. Deleted: the anchor and all newer rows are gone from `messages`
-    //      (e.g. message deletion). Nothing newer to anchor to → pin to bottom.
-    //   2. Windowed out: the virtualizer dropped the anchor row from its
-    //      rendered window because the user scrolled it off-screen (it is still
-    //      present in `messages`). Pinning to bottom here would yank the view
-    //      back down mid-scroll — the wheel-up-snaps-back regression. Leave the
-    //      scroll where the user put it and keep the anchor; the virtualizer
-    //      will render the row again when it re-enters the window.
-    if (messages.some((m) => m.id === anchor.messageId)) {
-      return anchor;
-    }
-    container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
-    return { kind: "at-bottom" };
-  }
-
-  const containerTop = container.getBoundingClientRect().top;
-  const currentTop = anchorEl.getBoundingClientRect().top - containerTop;
-  const delta = currentTop - usedAnchor.topOffset;
-  if (Math.abs(delta) > 0.5) {
-    container.scrollBy(0, delta);
-  }
-  return usedAnchor;
-}
-
 export function useAnchoredScroll({
   scrollContainerRef,
   contentRef,
@@ -245,18 +115,11 @@ export function useAnchoredScroll({
 
   targetMessageId = null,
   onTargetReached,
-  convergeToTarget,
-  pinToBottomByIndex,
 }: UseAnchoredScrollOptions): UseAnchoredScrollResult {
   // Anchor lives in a ref because it must survive renders and is updated
   // both on scroll (commit-time read) and in the layout effect (post-render
   // restoration). useState would force re-renders we don't want.
   const anchorRef = React.useRef<AnchorState>({ kind: "at-bottom" });
-  // Latest `messages` mirrored to a ref so the ResizeObserver effect can read
-  // the current list without re-subscribing the observer on every commit
-  // (which would also drop any in-flight resize callbacks). Kept fresh by a
-  // layout effect below so the read is consistent with what's in the DOM.
-  const messagesRef = React.useRef<TimelineMessage[]>(messages);
   const [isAtBottom, setIsAtBottom] = React.useState(true);
   const [newMessageCount, setNewMessageCount] = React.useState(0);
   const [highlightedMessageId, setHighlightedMessageId] = React.useState<
@@ -264,39 +127,9 @@ export function useAnchoredScroll({
   >(null);
 
   const hasInitializedRef = React.useRef(false);
-  // Mirror the convergence fallback into a ref so the target effects read the
-  // live callback without re-subscribing on every consumer render.
-  const convergeToTargetRef = React.useRef(convergeToTarget);
-  convergeToTargetRef.current = convergeToTarget;
-  // Mirror the index-driven mount pin into a ref for the same reason — the
-  // layout effect reads the live callback without re-subscribing on every
-  // consumer render. Absent (thread panel, no virtualizer) → falls back to the
-  // raw bottom pin.
-  const pinToBottomByIndexRef = React.useRef(pinToBottomByIndex);
-  pinToBottomByIndexRef.current = pinToBottomByIndex;
   const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
-  // Tracks the FRONT (oldest) rendered id so the restore effect can detect a
-  // load-older prepend (front changed, tail unchanged) and cede it to the
-  // index path — see IMPORTANT #1 in the restore effect below.
-  const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
   const prevMessageCountRef = React.useRef(0);
   const handledTargetIdRef = React.useRef<string | null>(null);
-  // Set while a convergence loop owns the scroll position (jump-to-message into
-  // windowed-out history). The library's reconcile loop is the sole writer
-  // during convergence, so the anchored restore below must cede — otherwise its
-  // at-bottom `scrollTo` would yank the view back as the target row splices in,
-  // the same two-writer contention the prepend bail prevents. Cleared when the
-  // target settles (consumer clears the route param → `targetMessageId` null).
-  const convergingTargetIdRef = React.useRef<string | null>(null);
-  // Set while `useLoadOlderOnScroll`'s index-restore loop owns scroll across a
-  // load-older prepend. That loop drives the virtualizer to re-aim the anchored
-  // row as the prepended rows measure; the ResizeObserver must cede for the
-  // same reason it cedes during convergence — otherwise the prepended rows
-  // growing `scrollHeight` fire the observer with the (now windowed-out) anchor,
-  // its all-gone fallback pins to the floor, and stomps the index restore's
-  // correct offset. The layout effect already cedes the prepend (isPrepend
-  // bail); this is the matching cede for the non-React-driven observer writer.
-  const loadOlderRestoreInFlightRef = React.useRef(false);
   const highlightTimeoutRef = React.useRef<number | null>(null);
   // Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled
   // on channel switch (the channelId reset effect clears it).
@@ -306,12 +139,6 @@ export function useAnchoredScroll({
   // appends, we snap to bottom even if they had scrolled up to read history.
   // Consumed (and cleared) by the next append in the restoration effect.
   const forceBottomOnNextAppendRef = React.useRef(false);
-  // True from the initial bottom pin until the virtualizer's row measurement
-  // settles and the view reaches a true at-bottom. During this window `onScroll`
-  // ignores the transient gap the settle opens (see the guard there). A `ref`,
-  // not state — the guard runs on a native scroll event, outside React's render
-  // cycle.
-  const settlingRef = React.useRef(false);
 
   // Reset everything when the channel changes — the layout effect that runs
   // immediately after this reset is responsible for either jumping to bottom
@@ -324,13 +151,9 @@ export function useAnchoredScroll({
     setHighlightedMessageId(null);
     hasInitializedRef.current = false;
     prevLastMessageIdRef.current = undefined;
-    prevFirstMessageIdRef.current = undefined;
     prevMessageCountRef.current = 0;
     handledTargetIdRef.current = null;
-    convergingTargetIdRef.current = null;
-    loadOlderRestoreInFlightRef.current = false;
     forceBottomOnNextAppendRef.current = false;
-    settlingRef.current = false;
     if (highlightTimeoutRef.current !== null) {
       window.clearTimeout(highlightTimeoutRef.current);
       highlightTimeoutRef.current = null;
@@ -346,17 +169,6 @@ export function useAnchoredScroll({
       const container = scrollContainerRef.current;
       if (!container) return;
       anchorRef.current = { kind: "at-bottom" };
-      // A smooth (animated) jump-to-latest is not atomic: the browser scrolls
-      // over several frames, and each intermediate `scroll` event would drive
-      // `onScroll` → `computeAnchor`, which latches a mid-history message anchor
-      // because the view is transiently NOT at the bottom. The ResizeObserver
-      // then "restores" that stale anchor and strands the view mid-list. Arm
-      // the same settle window the mount pin uses so `onScroll` holds the
-      // at-bottom anchor until the animation lands a true at-bottom; the
-      // observer's at-bottom re-pin then keeps the view glued as late row
-      // measurement grows `scrollHeight`. (An instant "auto" scroll completes
-      // before any scroll event, so it needs no guard.)
-      if (behavior === "smooth") settlingRef.current = true;
       container.scrollTo({ top: container.scrollHeight, behavior });
       setIsAtBottom(true);
       setNewMessageCount(0);
@@ -432,77 +244,12 @@ export function useAnchoredScroll({
     [scrollContainerRef],
   );
 
-  // Re-seat the anchor + at-bottom bookkeeping after a programmatic scrollTop
-  // write. A programmatic write fires no scroll event, so `onScroll` won't run
-  // to refresh `anchorRef`/`isAtBottom` — we run the same derivation here so
-  // the next passive restore and at-bottom read agree with the new position.
-  // We deliberately do NOT touch `newMessageCount`: a load-older restore keeps
-  // the reader mid-history, so the unread-count affordance must be untouched.
-  const syncAnchorAfterProgrammaticScroll = React.useCallback(
-    (container: HTMLDivElement) => {
-      anchorRef.current = computeAnchor(container);
-      const atBottom = anchorRef.current.kind === "at-bottom";
-      setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
-    },
-    [],
-  );
-
-  // Single-writer restore for the load-older index path (IMPORTANT #2). The
-  // index path resolves the exact target `scrollTop` off the virtualizer's
-  // settled measurement cache (`getOffsetForIndex`), so a bare assignment is
-  // correct on the first write — no rAF re-assert loop, no manager scroll-state
-  // machine. Re-seating the anchor afterwards keeps this the sole owner.
-  const restoreScrollPosition = React.useCallback(
-    (scrollTop: number) => {
-      const container = scrollContainerRef.current;
-      if (!container) return;
-      container.scrollTop = scrollTop;
-      syncAnchorAfterProgrammaticScroll(container);
-    },
-    [scrollContainerRef, syncAnchorAfterProgrammaticScroll],
-  );
-
-  // Let the load-older index path mark its scroll-ownership window so the
-  // ResizeObserver cedes to it (see `loadOlderRestoreInFlightRef`).
-  const setLoadOlderRestoreInFlight = React.useCallback((inFlight: boolean) => {
-    loadOlderRestoreInFlightRef.current = inFlight;
-  }, []);
-
-  // Live read of whether the anchor is stuck-to-bottom, for the load-older
-  // restore loop. If the user jumps to bottom (abandon) WHILE that loop owns
-  // scroll, the loop is re-aiming `scrollToOffset` at the captured mid-history
-  // anchor and would land there, not at the bottom the user asked for — and the
-  // ResizeObserver re-pin is ceded to the loop for the whole window, so nothing
-  // chases the last rows to the true floor. The loop reads this each frame to
-  // detect the abandon and re-aim to the bottom instead.
-  const getAnchorIsAtBottom = React.useCallback(
-    () => anchorRef.current.kind === "at-bottom",
-    [],
-  );
-
   // Scroll handler: recompute anchor + bottom state from the current
   // scroll position. Cheap enough to run on every scroll event — a single
   // `getBoundingClientRect` walk plus rect reads.
   const onScroll = React.useCallback(() => {
     const container = scrollContainerRef.current;
     if (!container) return;
-    // A virtualizer settle grows `scrollHeight` (rows below the fold measure a
-    // frame or two after the initial bottom pin) and emits scroll events while
-    // `scrollTop` holds at the old floor — opening a transient gap above the
-    // true bottom. `computeAnchor` would read that gap as a deliberate
-    // scroll-up and latch a message anchor, freezing the view short of the
-    // bottom (the non-virtualized list had full height at pin time, so it never
-    // produced this gap). While settling, keep the anchor at-bottom so the
-    // resize observer's re-pin can finish chasing the floor; clear the window
-    // once a scroll event reads a genuine at-bottom. Scoped to the initial
-    // settle so post-settle user scroll-up re-evaluates the anchor normally.
-    if (settlingRef.current) {
-      if (isAtBottomNow(container)) {
-        settlingRef.current = false;
-      } else {
-        return;
-      }
-    }
     anchorRef.current = computeAnchor(container);
     const atBottom = anchorRef.current.kind === "at-bottom";
     setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
@@ -512,51 +259,27 @@ export function useAnchoredScroll({
   }, [scrollContainerRef]);
 
   // ---------------------------------------------------------------------------
-  // Anchor restoration: after every render, if the anchor was a message,
-  // realign so that message sits at the same top-relative offset it had
-  // before the render. This keeps scroll stable across appends, image loads,
-  // and embed expansions. Load-older prepends are NOT handled here — they are
-  // ceded to `useLoadOlderOnScroll`'s index anchor (see the prepend bail
-  // below) so a single writer owns `scrollTop` on the prepend commit.
+  // Anchor restoration: after every render, stick to the bottom if the user is
+  // there. The reading position across prepend / in-viewport reflow is held by
+  // the browser's native scroll anchoring (overflow-anchor) now that every
+  // loaded row stays in the DOM, so there is no JS message-anchor restore.
   // ---------------------------------------------------------------------------
 
   React.useLayoutEffect(() => {
     const container = scrollContainerRef.current;
     if (!container) return;
 
-    // Mirror the current messages list into the ref read by the
-    // ResizeObserver's restore path. Must happen before any early return so
-    // a non-React layout shift sees the same array the next restoration
-    // would use.
-    messagesRef.current = messages;
-
     // First render after a reset (channel switch or initial mount): jump
     // to the requested target message, or to the bottom by default.
     if (!hasInitializedRef.current) {
       if (isLoading) return;
-      // Mount bottom pin. Prefer the virtualizer's own
-      // `scrollToIndex(lastIndex, {align:"end"})` (via `pinToBottomByIndex`):
-      // at mount the rows are estimate-sized, so a raw `scrollTo(scrollHeight)`
-      // lands above the true bottom and the last rows may not render into the
-      // window. Driving the last index through TanStack lands that row at the
-      // bottom across its own measurement. Falls back to the raw pin when no
-      // virtualizer is present (thread panel) or it declines.
-      // Track a pending rAF so the cleanup can cancel it if the effect
-      // re-runs (channel switch) before the frame fires.
+      // Defer the scroll out of the layout effect so the current paint commits
+      // first; cancelled on channel switch via the reset effect's rAF guard.
       const pinToBottomOnMount = () => {
-        // Set the anchor synchronously so settlingRef is correct below.
         anchorRef.current = { kind: "at-bottom" };
-        // Defer the actual scroll call out of the layout effect. TanStack's
-        // virtualizer calls flushSync(rerender) on scroll events when
-        // useFlushSync=true (the default), which would trigger a synchronous
-        // React re-render inside this layout effect and mutate the DOM before
-        // Playwright (and the browser) finish measuring layout. Deferring to
-        // rAF lets the current paint commit complete first.
         mountPinRafIdRef.current = requestAnimationFrame(() => {
           mountPinRafIdRef.current = null;
-          if (!pinToBottomByIndexRef.current?.()) {
-            scrollToBottomImperative("auto");
-          }
+          scrollToBottomImperative("auto");
         });
       };
       if (targetMessageId) {
@@ -567,11 +290,6 @@ export function useAnchoredScroll({
         // `messages`) retry once the row lands, rather than marking it handled.
         if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
           handledTargetIdRef.current = targetMessageId;
-          // Consumers clear the route target (`messageId` URL param) on this
-          // callback. The post-mount target effect below also fires it, but
-          // for a target already in the DOM on first commit that effect bails
-          // (handled ref is set), so the initial path must fire it too — else
-          // the param sticks and re-clicking the same deep link is a no-op.
           onTargetReached?.(targetMessageId);
         } else {
           pinToBottomOnMount();
@@ -580,21 +298,14 @@ export function useAnchoredScroll({
         pinToBottomOnMount();
       }
       hasInitializedRef.current = true;
-      // Arm the settle window only when we pinned to bottom — that's the path
-      // that suffers the late spacer growth. A successful deep-link target
-      // leaves a message anchor and must not be treated as a settling bottom.
-      settlingRef.current = anchorRef.current.kind === "at-bottom";
       prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
-      prevFirstMessageIdRef.current = messages[0]?.id;
       prevMessageCountRef.current = messages.length;
       return;
     }
 
     const anchor = anchorRef.current;
     const lastMessage = messages[messages.length - 1];
-    const firstMessage = messages[0];
     const prevLastId = prevLastMessageIdRef.current;
-    const prevFirstId = prevFirstMessageIdRef.current;
     const prevCount = prevMessageCountRef.current;
     const newLatestArrived =
       lastMessage !== undefined && lastMessage.id !== prevLastId;
@@ -603,36 +314,6 @@ export function useAnchoredScroll({
     // same-second row, so the list grows without the *last* id changing —
     // `newLatestArrived` misses that case and the unread counter never bumps.
     const messagesArrived = messages.length - prevCount;
-    // A convergence loop owns the scroll position while jumping to a windowed-out
-    // target (its library reconcile is the sole writer). Cede every restore
-    // branch to it — an at-bottom `scrollTo` here would chase the view back to
-    // the bottom as the target's neighbours splice into the window mid-converge.
-    // Refresh the tracked refs so the first post-settle commit isn't misread as
-    // a prepend/append. Cleared when the target settles (targetMessageId null).
-    if (convergingTargetIdRef.current !== null) {
-      prevLastMessageIdRef.current = lastMessage?.id;
-      prevFirstMessageIdRef.current = firstMessage?.id;
-      prevMessageCountRef.current = messages.length;
-      return;
-    }
-    // A load-older prepend grows the list at the FRONT while the tail is
-    // unchanged. `useLoadOlderOnScroll` owns the prepend restore via its index
-    // anchor (the single `scrollTop` writer). If this restore effect also ran
-    // its anchored `scrollBy` on the same commit, two writers would fight over
-    // `scrollTop`. So cede the prepend to the index path: refresh the tracked
-    // refs and bail before the anchored branch. (Append and in-window reflow
-    // leave the front id unchanged, so they fall through as before.)
-    const isPrepend =
-      firstMessage !== undefined &&
-      prevFirstId !== undefined &&
-      firstMessage.id !== prevFirstId &&
-      !newLatestArrived;
-    if (isPrepend) {
-      prevLastMessageIdRef.current = lastMessage?.id;
-      prevFirstMessageIdRef.current = firstMessage.id;
-      prevMessageCountRef.current = messages.length;
-      return;
-    }
 
     // One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
     // resulting append lands, snap to bottom regardless of the current anchor,
@@ -645,33 +326,21 @@ export function useAnchoredScroll({
       setIsAtBottom(true);
       setNewMessageCount(0);
       prevLastMessageIdRef.current = lastMessage?.id;
-      prevFirstMessageIdRef.current = firstMessage?.id;
       prevMessageCountRef.current = messages.length;
       return;
     }
 
     if (anchor.kind === "at-bottom") {
-      // Stick to bottom. Use scrollTo to avoid relying on scroll anchoring.
+      // Stick to bottom across the append.
       container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
       if (newLatestArrived) setNewMessageCount(0);
-    } else {
-      // Anchored to a specific message. The shared helper finds it (with a
-      // nearest-newer fallback if the row was removed) and corrects the
-      // offset via `scrollBy`. If both the anchor and all newer rendered
-      // rows are gone, it pins to the bottom and returns `at-bottom`.
-      const restored = restoreAnchorToMessage(container, messages, anchor);
-      anchorRef.current = restored;
-      if (restored.kind === "at-bottom") {
-        setIsAtBottom(true);
-      }
-
-      if (messagesArrived > 0) {
-        setNewMessageCount((current) => current + messagesArrived);
-      }
+    } else if (messagesArrived > 0) {
+      // Anchored mid-history. Native scroll anchoring holds the reading row's
+      // offset across the commit; we only bump the unread counter here.
+      setNewMessageCount((current) => current + messagesArrived);
     }
 
     prevLastMessageIdRef.current = lastMessage?.id;
-    prevFirstMessageIdRef.current = firstMessage?.id;
     prevMessageCountRef.current = messages.length;
   }, [
     isLoading,
@@ -684,16 +353,12 @@ export function useAnchoredScroll({
   ]);
 
   // ---------------------------------------------------------------------------
-  // Content resize: when fonts load late, an image decodes, an embed expands,
-  // or any in-viewport reflow happens that React isn't driving (so the
-  // layout-effect doesn't fire), the anchor row's on-screen offset drifts.
-  //
-  // When stuck-to-bottom we re-pin to bottom. When anchored to a message we
-  // call the same restore primitive the layout effect uses, so an in-viewport
-  // reflow above the reader's eye shifts back into place. Without this,
-  // anything that resizes without changing `messages` (link-card decode,
-  // async embed expand, late font load, markdown that expands) silently
-  // pushes the reading row around.
+  // Content resize: while stuck to the bottom, an in-viewport reflow (image
+  // decode, embed expand, late font load) that React isn't driving grows
+  // `scrollHeight` without a `messages` change, so the layout effect doesn't
+  // fire — re-pin to the new floor here to stay glued. When anchored
+  // mid-history, native scroll anchoring (overflow-anchor) holds the reading
+  // row across the reflow, so there's nothing to do.
   // ---------------------------------------------------------------------------
   // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one.
   React.useEffect(() => {
@@ -702,41 +367,8 @@ export function useAnchoredScroll({
     const observer = new ResizeObserver(() => {
       const container = scrollContainerRef.current;
       if (!container) return;
-      // Cede entirely while a convergence loop owns scroll (jump to a
-      // windowed-out target). Mid-jump the anchor is transiently `at-bottom`
-      // — `computeAnchor` finds no crossing row until the virtualizer renders
-      // rows at the new offset — so an unconditional re-pin here would yank
-      // the in-flight jump down to the floor as rows measure. The convergence
-      // loop is the sole writer until it settles (mirrors the layout effect's
-      // `convergingTargetIdRef` bail).
-      if (convergingTargetIdRef.current !== null) return;
-      // Cede while the load-older index restore owns scroll. The prepended rows
-      // measuring late is exactly what grows `scrollHeight` and fires this
-      // observer; if it re-pinned here the anchor row is already windowed out,
-      // so the all-gone fallback would pin to the floor and stomp the index
-      // restore's correct offset. The index loop is the sole writer until it
-      // settles (mirrors the layout effect's isPrepend bail).
-      if (loadOlderRestoreInFlightRef.current) return;
-      const anchor = anchorRef.current;
-      if (anchor.kind === "at-bottom") {
-        // Stuck to bottom: re-pin to the new floor. Virtualizer measurement
-        // grows `scrollHeight` after the initial pin (rows below the fold
-        // measure a frame or two late) without any `messages` change to drive
-        // the layout effect, so this observer is the only thing that keeps the
-        // view glued to the bottom as content settles.
+      if (anchorRef.current.kind === "at-bottom") {
         container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
-        return;
-      }
-      // Use the same restore primitive as the layout effect so the
-      // single-owner model holds across non-React-driven layout shifts.
-      const restored = restoreAnchorToMessage(
-        container,
-        messagesRef.current,
-        anchor,
-      );
-      anchorRef.current = restored;
-      if (restored.kind === "at-bottom") {
-        setIsAtBottom(true);
       }
     });
     observer.observe(content);
@@ -759,7 +391,6 @@ export function useAnchoredScroll({
   React.useEffect(() => {
     if (!targetMessageId) {
       handledTargetIdRef.current = null;
-      convergingTargetIdRef.current = null;
       return;
     }
     if (handledTargetIdRef.current === targetMessageId || isLoading) return;
@@ -771,20 +402,9 @@ export function useAnchoredScroll({
       `[data-message-id="${targetMessageId}"]`,
     );
     if (!el) {
-      // Row not in the DOM. In a virtualized list it may be windowed out and
-      // never render from a passive commit, so delegate to the convergence
-      // fallback: it drives the virtualizer to the target (warming up if a
-      // deep-link target is still being fetched in) and, on settle, centers +
-      // highlights it and fires `onTargetReached`. We mark the target handled
-      // here so this effect stops re-dispatching, but deliberately do NOT fire
-      // `onTargetReached` yet — clearing the route param now would cancel the
-      // in-flight target fetch the loop is waiting on. Without a fallback
-      // (thread panel), leave the target for a later `messages` commit.
-      const converge = convergeToTargetRef.current;
-      if (converge?.(targetMessageId)) {
-        handledTargetIdRef.current = targetMessageId;
-        convergingTargetIdRef.current = targetMessageId;
-      }
+      // Row not in the DOM yet. A cold deep-link target is fetched by id and
+      // spliced into `messages` a render or two later; this effect re-runs on
+      // each `messages` commit and retries until the row exists.
       return;
     }
     handledTargetIdRef.current = targetMessageId;
@@ -815,8 +435,5 @@ export function useAnchoredScroll({
     scrollToBottom: scrollToBottomImperative,
     scrollToBottomOnNextUpdate,
     scrollToMessage: scrollToMessageImperative,
-    restoreScrollPosition,
-    setLoadOlderRestoreInFlight,
-    getAnchorIsAtBottom,
   };
 }

From 605674be6c36268a8e1f9427ef33b151fec8dfdd Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 09:37:39 -0600
Subject: [PATCH 05/11] fix(timeline): commit older pages in one step; drop
 thread anchor opt-out

Accumulate every relay batch of a scrollback pass and commit to the cache
once, so a reply-heavy window grows in a single bounded step instead of
painting in several small increments under one spinner. Remove the
[overflow-anchor:none] opt-out from the thread panel body so native scroll
anchoring can hold the reading position there too.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../messages/lib/pageOlderMessages.ts         | 58 ++++++++-----------
 .../messages/ui/MessageThreadPanel.tsx        |  4 +-
 2 files changed, 26 insertions(+), 36 deletions(-)

diff --git a/desktop/src/features/messages/lib/pageOlderMessages.ts b/desktop/src/features/messages/lib/pageOlderMessages.ts
index 47fdaf00e2..6cc89e3f97 100644
--- a/desktop/src/features/messages/lib/pageOlderMessages.ts
+++ b/desktop/src/features/messages/lib/pageOlderMessages.ts
@@ -22,23 +22,10 @@ export const MIN_TOP_LEVEL_ROWS_PER_FETCH = 30;
 
 // Hard ceiling on relay pages fetched in one pass. On reply-heavy channels a
 // batch yields only a few visible rows, so the row floor alone could dig through
-// hundreds of messages behind one spinner and commit them at once (a sudden
-// "flood" with a tiny scrollbar). Capping per-pass keeps each fetch a bounded
-// page; the scroll observer re-arms to page further while in view.
+// hundreds of messages behind one spinner. Capping per-pass keeps each fetch a
+// bounded page; the scroll observer re-arms to page further while in view.
 const MAX_BATCHES_PER_FETCH = 3;
 
-// Yield a frame so the rows just merged paint before the next round-trip;
-// otherwise a multi-batch pass renders as one bulk commit at the loop's end.
-function yieldToPaint(): Promise<void> {
-  return new Promise((resolve) => {
-    if (typeof requestAnimationFrame === "function") {
-      requestAnimationFrame(() => resolve());
-    } else {
-      setTimeout(resolve, 0);
-    }
-  });
-}
-
 export type PageOlderResult = {
   /** False once a short relay page proves history is exhausted. */
   hasOlderMessages: boolean;
@@ -68,16 +55,15 @@ export async function pageOlderMessagesUntilRowFloor(
   let hasOlderMessages = true;
   let batchesFetched = 0;
 
-  while (hasOlderMessages && shouldContinue()) {
-    const before = queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
-    if (before.length === 0) {
-      break;
-    }
+  // Accumulate every batch of this pass and commit to the cache once at the
+  // end. Committing per batch paints the timeline in several small steps under
+  // one spinner — on reply-heavy windows each 200-event batch adds only a few
+  // visible rows, so the user sees the loader dribble messages in 1-5 at a
+  // time. One commit = one bounded growth step.
+  const fetched: RelayEvent[] = [];
+  let oldestTimestamp = baseline[0].created_at;
 
-    // `until` is inclusive — the relay returns the boundary message again, but
-    // sortMessages dedupes by id. Subtracting 1 risks skipping same-second
-    // messages.
-    const oldestTimestamp = before[0].created_at;
+  while (hasOlderMessages && shouldContinue()) {
     const olderMessages = await relayClient.fetchChannelHistoryBefore(
       channelId,
       oldestTimestamp,
@@ -97,25 +83,24 @@ export async function pageOlderMessagesUntilRowFloor(
     }
 
     if (olderMessages.length > 0) {
-      queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
-        mergeTimelineHistoryMessages(current, olderMessages),
-      );
-      void backfillAuxForMessages(queryClient, channelId, olderMessages);
+      fetched.push(...olderMessages);
     }
 
     // Progress guard, not exhaustion: if the oldest timestamp didn't move back
     // (empty page, or all-duplicate), stop this pass to avoid re-fetching the
     // same `until`.
-    const oldestAfterMerge = (queryClient.getQueryData<RelayEvent[]>(
-      queryKey,
-    ) ?? [])[0]?.created_at;
-    if (oldestAfterMerge === undefined || oldestAfterMerge >= oldestTimestamp) {
+    const oldestFetched = fetched.reduce(
+      (min, event) => (event.created_at < min ? event.created_at : min),
+      oldestTimestamp,
+    );
+    if (oldestFetched >= oldestTimestamp) {
       break;
     }
+    oldestTimestamp = oldestFetched;
 
     const rowsGained =
       countTopLevelTimelineRows(
-        queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [],
+        mergeTimelineHistoryMessages(baseline, fetched),
       ) - baselineRowCount;
     if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) {
       break;
@@ -124,8 +109,13 @@ export async function pageOlderMessagesUntilRowFloor(
     if (batchesFetched >= MAX_BATCHES_PER_FETCH) {
       break;
     }
+  }
 
-    await yieldToPaint();
+  if (fetched.length > 0 && shouldContinue()) {
+    queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
+      mergeTimelineHistoryMessages(current, fetched),
+    );
+    void backfillAuxForMessages(queryClient, channelId, fetched);
   }
 
   return { hasOlderMessages };
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index 214c1386d7..b7e4172295 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -269,7 +269,7 @@ export function MessageThreadPanelSkeleton({
   const threadBody = (
     <div
       className={cn(
-        "min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
+        "min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24",
         isSplitLayout && auxiliaryPanelContentPaddingClass,
         !isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
       )}
@@ -622,7 +622,7 @@ export function MessageThreadPanel({
   const threadScrollRegion = (
     <div
       className={cn(
-        "min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
+        "min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24",
         isSplitLayout && auxiliaryPanelContentPaddingClass,
         !isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
       )}

From e8416bf04adb0bba15fa049bffc6c54f88d31f03 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 10:19:40 -0600
Subject: [PATCH 06/11] fix(timeline): hold reading row + page once per gesture
 on scrollback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The de-virtualization rework relied on native `overflow-anchor` to hold scroll
position across an older-history prepend, but the browser suppresses scroll
anchoring at the top edge (no anchor node above the viewport at scrollTop ~0),
so a prepend left the user pinned at offset 0 staring at the oldest of the new
page. And because nothing pushed scrollTop off 0, the load-older observer
re-armed into the still-intersecting sentinel and cascaded page-after-page to
the channel start.

Three coordinated changes, one concern each:

- useAnchoredScroll: when a prepend lands while anchored mid-history, re-pin the
  anchored row to its saved offset by id (single scroll writer for the prepend).
  Runs in the post-commit layout effect, after the deferred snapshot commits, so
  the row's true position is known. Native anchoring keeps in-viewport reflow.

- useLoadOlderOnScroll: one long-lived observer that fires once on entering the
  trigger band then disarms until the sentinel leaves and re-enters — a fresh
  scroll-up gesture. Replaces the re-arm-on-resolve that double-fired because the
  prepend commits a few frames late. One gesture -> one page.

- Widen the trigger band to 600px (PRELOAD_MARGIN_PX) so the next page preloads
  while scrolling. Kept below a typical prepend height so the landed page still
  pushes the sentinel out of the band and re-arms the gate.

Adds a regression test asserting the reading row stays fixed across a prepend,
and a fetch-count probe in the e2e bridge for the one-page-per-gesture test.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../features/messages/ui/useAnchoredScroll.ts |  23 +-
 .../messages/ui/useLoadOlderOnScroll.ts       |  72 +++---
 desktop/src/testing/e2eBridge.ts              |   5 +
 desktop/tests/e2e/scroll-history.spec.ts      | 220 ++++++++++++++++++
 4 files changed, 288 insertions(+), 32 deletions(-)

diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts
index 8e542876e6..1362da0b16 100644
--- a/desktop/src/features/messages/ui/useAnchoredScroll.ts
+++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts
@@ -335,8 +335,27 @@ export function useAnchoredScroll({
       container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
       if (newLatestArrived) setNewMessageCount(0);
     } else if (messagesArrived > 0) {
-      // Anchored mid-history. Native scroll anchoring holds the reading row's
-      // offset across the commit; we only bump the unread counter here.
+      // Anchored mid-history. An older-history prepend grows the content above
+      // the reading row; the browser's native scroll anchoring does NOT correct
+      // this at the top edge (no anchor node above the viewport when scrollTop
+      // is ~0), so re-pin the anchored row to its saved offset by id. This is
+      // the single scroll writer for the prepend — the load-older observer only
+      // triggers the fetch. We run it in this post-commit layout effect (not the
+      // observer's promise callback) because the prepended rows commit on a
+      // deferred snapshot a few frames later, so the row's true position is only
+      // known here.
+      const row = container.querySelector<HTMLElement>(
+        `[data-message-id="${CSS.escape(anchor.messageId)}"]`,
+      );
+      if (row) {
+        const currentTopOffset =
+          row.getBoundingClientRect().top -
+          container.getBoundingClientRect().top;
+        const drift = currentTopOffset - anchor.topOffset;
+        if (Math.abs(drift) > 0.5) {
+          container.scrollBy(0, drift);
+        }
+      }
       setNewMessageCount((current) => current + messagesArrived);
     }
 
diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
index ee66f40ffa..5c2521ecc7 100644
--- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
+++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts
@@ -8,10 +8,26 @@ type UseLoadOlderOnScrollOptions = {
   sentinelRef: React.RefObject<HTMLDivElement | null>;
 };
 
+// How far above the viewport top the sentinel triggers a fetch. Larger = the
+// next page preloads sooner as you scroll up. Kept comfortably below a typical
+// prepend height (one fetch lands >=30 rows) so the landed page still pushes
+// the sentinel out of the band and re-arms the once-per-gesture gate; a band
+// taller than the prepend would leave the sentinel inside it and stall paging.
+const PRELOAD_MARGIN_PX = 600;
+
 /**
  * Triggers `fetchOlder` when a sentinel element near the top of the scroll
- * container enters the viewport. Native scroll anchoring (overflow-anchor)
- * holds the reading position across the prepend — no JS scroll restore.
+ * container enters the viewport.
+ *
+ * A single long-lived observer drives this: it fires `fetchOlder` once when the
+ * sentinel enters the trigger band, then *disarms* and will not fire again until
+ * the sentinel has left the band and re-entered it — i.e. a fresh scroll-up
+ * gesture. Re-arming with a new observer the instant the fetch resolved (the
+ * previous approach) fired a second fetch immediately: the prepended rows commit
+ * on a deferred snapshot a few frames later, so the sentinel is still inside the
+ * band when the new observer first reports it, cascading page-after-page off one
+ * gesture. Gating on the leave→enter transition needs no scroll-position
+ * coupling and pages exactly once per gesture.
  */
 export function useLoadOlderOnScroll({
   fetchOlder,
@@ -34,38 +50,34 @@ export function useLoadOlderOnScroll({
     }
 
     let disposed = false;
-    let currentObserver: IntersectionObserver | null = null;
-
-    const observe = () => {
-      if (disposed) {
-        return;
-      }
-
-      currentObserver = new IntersectionObserver(
-        ([entry]) => {
-          if (!entry.isIntersecting || disposed) {
-            return;
-          }
+    // Armed when the sentinel is outside the band; a single entry into the band
+    // fires one fetch and disarms until the sentinel leaves again.
+    let armed = true;
 
-          currentObserver?.disconnect();
-
-          // Native scroll anchoring (overflow-anchor) holds the reading
-          // position across the prepend now that all rows stay in the DOM, so
-          // the sentinel's only job is to trigger the fetch and re-arm.
-          void fetchOlder().then(() => {
-            observe();
-          });
-        },
-        { root: container, rootMargin: "200px 0px 0px 0px" },
-      );
-
-      currentObserver.observe(sentinel);
-    };
+    const observer = new IntersectionObserver(
+      ([entry]) => {
+        if (disposed) {
+          return;
+        }
+        if (!entry.isIntersecting) {
+          // Sentinel left the band (the prepend pushed it down, or the user
+          // scrolled away). Re-arm so the next scroll-up pages once more.
+          armed = true;
+          return;
+        }
+        if (!armed) {
+          return;
+        }
+        armed = false;
+        void fetchOlder();
+      },
+      { root: container, rootMargin: `${PRELOAD_MARGIN_PX}px 0px 0px 0px` },
+    );
 
-    observe();
+    observer.observe(sentinel);
     return () => {
       disposed = true;
-      currentObserver?.disconnect();
+      observer.disconnect();
     };
   }, [
     fetchOlder,
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 115b202222..e820d96527 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -2522,6 +2522,11 @@ function emitMockHistory(
   const delayMs = getConfig()?.mock?.historyDelayMs ?? 0;
   const isVisibleOlderHistoryPage =
     subId.startsWith("history-") && filter.until !== undefined && !filter["#e"];
+  if (isVisibleOlderHistoryPage) {
+    const counter = window as unknown as { __HISTORY_FETCH_COUNT__?: number };
+    counter.__HISTORY_FETCH_COUNT__ =
+      (counter.__HISTORY_FETCH_COUNT__ ?? 0) + 1;
+  }
   if (delayMs > 0 && isVisibleOlderHistoryPage) {
     const probe = window as unknown as {
       __HISTORY_INFLIGHT__?: number;
diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts
index acc5bd271b..d74e4713f6 100644
--- a/desktop/tests/e2e/scroll-history.spec.ts
+++ b/desktop/tests/e2e/scroll-history.spec.ts
@@ -1620,3 +1620,223 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll"
   });
   expect(withinViewport).toBe(true);
 });
+
+// Regression for Wes's "scroll to the top once and it loads the WHOLE channel":
+// a single scroll-up gesture must page older history ONCE (a bounded step), not
+// cascade page-after-page to the channel start with no further user scroll. The
+// cascade came from the load-older observer disconnecting and recreating itself
+// after each fetch — the fresh observer re-reported the still-intersecting
+// sentinel and re-fired immediately. A persistent observer only re-fires when
+// the sentinel re-enters the top band, i.e. on a NEW scroll gesture.
+//
+// Distinct from the "never overlap" test (which asserts no CONCURRENT in-flight
+// fetches): a cascade keeps peak in-flight at 1 while firing many SEQUENTIAL
+// pages. This asserts the cumulative fetch count stays bounded after one
+// gesture, and the timeline does not dig to the oldest seeded root on its own.
+test("one scroll-up gesture pages older history once, not to the channel top", async ({
+  page,
+}, testInfo) => {
+  testInfo.setTimeout(60_000);
+  await installMockBridge(page);
+  await page.goto("/");
+  await page.waitForFunction(
+    () =>
+      typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
+      typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
+  );
+
+  // Deep seed: a current window plus ~1200 older roots, far more than a single
+  // older page yields. If pagination auto-cascades, it will dig all the way to
+  // "mock older 0"; if it pages once per gesture, it stops well short.
+  await page.evaluate(() => {
+    for (let index = 0; index < 60; index += 1) {
+      window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: "general",
+        content: `recent ${index}`,
+      });
+    }
+    window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
+      channelName: "general",
+      count: 1200,
+      lineCount: 2,
+    });
+    (
+      window as unknown as { __HISTORY_FETCH_COUNT__?: number }
+    ).__HISTORY_FETCH_COUNT__ = 0;
+  });
+
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+  const timeline = page.getByTestId("message-timeline");
+  await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
+  await page.waitForFunction(() => {
+    const element = document.querySelector(
+      '[data-testid="message-timeline"]',
+    ) as HTMLDivElement | null;
+    return element ? element.scrollHeight > element.clientHeight + 1000 : false;
+  });
+
+  // The cold load may itself page once to fill the row floor; ignore anything
+  // before the user gesture by resetting the counter at the settled bottom.
+  await page.evaluate(() => {
+    (
+      window as unknown as { __HISTORY_FETCH_COUNT__?: number }
+    ).__HISTORY_FETCH_COUNT__ = 0;
+  });
+
+  const fetchCount = () =>
+    page.evaluate(
+      () =>
+        (window as unknown as { __HISTORY_FETCH_COUNT__?: number })
+          .__HISTORY_FETCH_COUNT__ ?? 0,
+    );
+  const oldestRenderedIndex = () =>
+    timeline.evaluate((element) => {
+      let min = Number.POSITIVE_INFINITY;
+      for (const row of (
+        element as HTMLDivElement
+      ).querySelectorAll<HTMLElement>("[data-message-id]")) {
+        const match = row.textContent?.match(/mock older (\d+)/);
+        if (match) min = Math.min(min, Number(match[1]));
+      }
+      return Number.isFinite(min) ? min : null;
+    });
+
+  // ONE scroll-up gesture to the top band, then HANDS OFF. A single wheel tick
+  // is enough to bring the sentinel into the 200px rootMargin.
+  await timeline.hover();
+  await page.mouse.wheel(0, -4000);
+
+  // Let any cascade run unimpeded for a generous window. With the bug, the
+  // observer re-arms after each page and digs through all ~1200 older roots in
+  // this window; with the fix it pages once and waits for the next gesture.
+  await page.waitForTimeout(2_500);
+
+  const pagesFetched = await fetchCount();
+  const deepest = await oldestRenderedIndex();
+
+  // One gesture should yield a small, bounded number of pages — not dozens.
+  // pageOlderMessagesUntilRowFloor may fetch up to MAX_BATCHES_PER_FETCH (3)
+  // relay pages to satisfy one visible row floor, so allow that ceiling plus a
+  // little slack; a cascade blows far past it.
+  expect(pagesFetched).toBeLessThanOrEqual(4);
+  // And it must NOT have reached the oldest seeded root on its own.
+  expect(deepest ?? Number.POSITIVE_INFINITY).toBeGreaterThan(50);
+});
+
+// Regression for Wes's "after a page loads I'm yanked to the oldest of the new
+// page instead of staying where I was reading" (event 6, timeline-scroll-rework
+// thread). De-virtualizing the list re-enabled native `overflow-anchor`, but the
+// browser does NOT scroll-anchor an older-history prepend at the top edge (no
+// anchor node above the viewport at scrollTop ~0). The reading row must stay
+// fixed across the prepend; the JS anchor restore in useAnchoredScroll owns it.
+test("older-history prepend keeps the reading row fixed (no jump to oldest)", async ({
+  page,
+}, testInfo) => {
+  testInfo.setTimeout(60_000);
+  await installMockBridge(page);
+  await page.goto("/");
+  await page.waitForFunction(
+    () =>
+      typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
+      typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
+  );
+
+  await page.evaluate(() => {
+    for (let index = 0; index < 60; index += 1) {
+      window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: "general",
+        content: `recent ${index}`,
+      });
+    }
+    window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
+      channelName: "general",
+      count: 1200,
+      lineCount: 2,
+    });
+  });
+
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+  const timeline = page.getByTestId("message-timeline");
+  await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
+  await page.waitForFunction(() => {
+    const element = document.querySelector(
+      '[data-testid="message-timeline"]',
+    ) as HTMLDivElement | null;
+    return element ? element.scrollHeight > element.clientHeight + 1000 : false;
+  });
+
+  // Pace the older fetch BEFORE scrolling up, so the very first older page the
+  // top sentinel triggers stays in flight long enough to read the anchor before
+  // and after it lands. No climb loop — the cold load left ~900 older roots
+  // behind the cursor, so one scroll-up to the top band fires a genuine page.
+  await page.evaluate(() => {
+    window.__BUZZ_E2E__ = {
+      ...window.__BUZZ_E2E__,
+      mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_500 },
+    };
+    (
+      window as unknown as { __HISTORY_INFLIGHT__?: number }
+    ).__HISTORY_INFLIGHT__ = 0;
+  });
+  const inflightCount = () =>
+    page.evaluate(
+      () =>
+        (window as unknown as { __HISTORY_INFLIGHT__?: number })
+          .__HISTORY_INFLIGHT__ ?? 0,
+    );
+  const oldestRenderedIndex = () =>
+    timeline.evaluate((element) => {
+      let min = Number.POSITIVE_INFINITY;
+      for (const row of (
+        element as HTMLDivElement
+      ).querySelectorAll<HTMLElement>("[data-message-id]")) {
+        const match = row.textContent?.match(/mock older (\d+)/);
+        if (match) min = Math.min(min, Number(match[1]));
+      }
+      return Number.isFinite(min) ? min : null;
+    });
+
+  const oldestBeforeLanding = await oldestRenderedIndex();
+  expect(oldestBeforeLanding).not.toBeNull();
+
+  // One scroll-up gesture to the top band fires the (delayed) older page.
+  await timeline.hover();
+  for (let attempt = 0; attempt < 40; attempt += 1) {
+    if ((await inflightCount()) > 0) break;
+    await page.mouse.wheel(0, -3000);
+    await page.waitForTimeout(50);
+  }
+  expect(await inflightCount()).toBeGreaterThan(0);
+
+  // Capture the reading row WHILE the page is still in flight (prepend lands
+  // ~1.5s later). This is the row the restore must hold.
+  const anchorBeforeLanding = await getFirstVisibleMessage(page);
+  expect(anchorBeforeLanding).not.toBeNull();
+
+  // Poll until a genuinely older page has landed (inflight back to 0 AND a new
+  // older index appeared), then assert the anchored row barely moved. With the
+  // bug, the prepend lands above the row and shoves it down by the prepend
+  // height (hundreds of px); with the fix it holds within a couple px.
+  await expect
+    .poll(
+      async () => {
+        const [inflight, oldestNow, anchor] = await Promise.all([
+          inflightCount(),
+          oldestRenderedIndex(),
+          getMessagePosition(page, anchorBeforeLanding?.id ?? ""),
+        ]);
+        const landed =
+          inflight === 0 &&
+          oldestNow !== null &&
+          oldestNow < (oldestBeforeLanding ?? 0);
+        if (!landed || !anchor) {
+          return Number.POSITIVE_INFINITY;
+        }
+        return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
+      },
+      { timeout: 4_000 },
+    )
+    .toBeLessThanOrEqual(2);
+});

From 79076fe9a364f46d0bc52d2099b2b4f1c2ba5af8 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 10:30:49 -0600
Subject: [PATCH 07/11] fix(timeline): bottom-pin sub-viewport channels on cold
 load
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A channel shorter than the viewport painted top-anchored: the scroll
content column only got min-h-full for the intro/empty surfaces, so the
message-list case had no slack for its mt-auto to consume and the list
sat snug at the top. Extend min-h-full to the list surface so mt-auto
bottom-pins a short channel from the first paint, matching a full
channel — and so a late older-history prepend lands above the content
without shifting the rows the reader is on.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 desktop/src/features/messages/ui/MessageTimeline.tsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 2cbc8caa74..585daf87cd 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -400,7 +400,8 @@ const MessageTimelineBase = React.forwardRef<
             className={cn(
               "flex w-full flex-col gap-2",
               channelChrome.contentPadding,
-              (showIntro || showGenericEmpty) && "min-h-full",
+              (showIntro || showGenericEmpty || showMessageList) &&
+                "min-h-full",
             )}
             ref={contentRef}
           >

From 081d2ae2a52b3fb53333c02c762396ac88550188 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 10:51:09 -0600
Subject: [PATCH 08/11] refactor(timeline): drop virtualizer-era dead code
 stranded by de-virtualization

The de-virtualization rework left behind machinery that only existed to
serve the old windowed renderer, now unreferenced:

- ActiveDayHeader: a floating day label needed only because in-stream day
  dividers unmounted when scrolled out of the virtual window. Rows persist
  in the DOM now, so the sticky in-stream DayDivider holds the viewport top
  again and the portaled label is dead. Delete the component.
- resolveActiveDayTimestamp + the indexByMessageId map: the former fed the
  floating label; the latter was the virtualizer's O(1) deep-link resolver.
  Scroll is DOM-based now (querySelector), so nothing consumes either.
  Remove both, simplify buildTimelineItems to return { items }, and drop
  their unit tests. Refresh the stale "virtualizer" doc comments.

Also fix scroll-history e2e "preserves user scroll while older channel
history loads": its floating-day-header assertions targeted the removed
ActiveDayHeader and failed on a null lookup. Drop those assertions and the
getActiveDayHeader helper; the scroll-preservation poll (the test's real
guarantee) is unchanged and still passes.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../messages/lib/timelineItems.test.mjs       | 135 +-----------------
 .../features/messages/lib/timelineItems.ts    |  42 +-----
 .../features/messages/ui/ActiveDayHeader.tsx  |  26 ----
 .../messages/ui/TimelineMessageList.tsx       |   7 +-
 desktop/tests/e2e/scroll-history.spec.ts      |  42 ------
 5 files changed, 9 insertions(+), 243 deletions(-)
 delete mode 100644 desktop/src/features/messages/ui/ActiveDayHeader.tsx

diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs
index dcb8678aa5..58ea21722b 100644
--- a/desktop/src/features/messages/lib/timelineItems.test.mjs
+++ b/desktop/src/features/messages/lib/timelineItems.test.mjs
@@ -2,11 +2,7 @@ import assert from "node:assert/strict";
 import test from "node:test";
 
 import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
-import {
-  buildTimelineItems,
-  getTimelineItemKey,
-  resolveActiveDayTimestamp,
-} from "./timelineItems.ts";
+import { buildTimelineItems, getTimelineItemKey } from "./timelineItems.ts";
 
 function dayAt(year, month, day, hour = 12) {
   return Math.floor(
@@ -90,71 +86,9 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => {
   assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
 });
 
-test("buildTimelineItems: empty entries produce no items and an empty map", () => {
-  const { items, indexByMessageId } = buildTimelineItems([], null);
+test("buildTimelineItems: empty entries produce no items", () => {
+  const { items } = buildTimelineItems([], null);
   assert.equal(items.length, 0);
-  assert.equal(indexByMessageId.size, 0);
-});
-
-// --- index map correctness ---------------------------------------------------
-
-test("buildTimelineItems: map points each message id at its flattened item index", () => {
-  const entries = [
-    entry({ id: "d1", createdAt: dayAt(2026, 6, 12) }),
-    entry({ id: "d2", createdAt: dayAt(2026, 6, 13) }),
-  ];
-  const { items, indexByMessageId } = buildTimelineItems(entries, null);
-
-  // dividers occupy indices 0 and 2; messages land at 1 and 3.
-  assert.equal(indexByMessageId.get("d1"), 1);
-  assert.equal(indexByMessageId.get("d2"), 3);
-  assert.equal(items[1].entry.message.id, "d1");
-  assert.equal(items[3].entry.message.id, "d2");
-});
-
-test("buildTimelineItems: appending a new message keeps prior indices stable", () => {
-  const base = [entry({ id: "a", createdAt: dayAt(2026, 6, 14) })];
-  const before = buildTimelineItems(base, null).indexByMessageId;
-
-  const appended = [
-    ...base,
-    entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
-  ];
-  const after = buildTimelineItems(appended, null).indexByMessageId;
-
-  assert.equal(after.get("a"), before.get("a"));
-  assert.equal(after.get("b"), 2);
-});
-
-test("buildTimelineItems: prepending an older-day message shifts later indices", () => {
-  const original = [entry({ id: "b", createdAt: dayAt(2026, 6, 14) })];
-  const beforeIdx = buildTimelineItems(original, null).indexByMessageId.get(
-    "b",
-  );
-
-  // Prepend a message on an earlier day → adds its own day-divider + message,
-  // pushing "b" (now on a new day boundary too) further down.
-  const prepended = [
-    entry({ id: "a", createdAt: dayAt(2026, 6, 13) }),
-    entry({ id: "b", createdAt: dayAt(2026, 6, 14) }),
-  ];
-  const afterIdx = buildTimelineItems(prepended, null).indexByMessageId.get(
-    "b",
-  );
-  assert.ok(afterIdx > beforeIdx);
-});
-
-test("buildTimelineItems: deleting a message drops it from the map", () => {
-  const entries = [
-    entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
-    entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
-  ];
-  const afterDelete = buildTimelineItems(
-    entries.filter((e) => e.message.id !== "a"),
-    null,
-  ).indexByMessageId;
-  assert.equal(afterDelete.has("a"), false);
-  assert.equal(afterDelete.get("b"), 1);
 });
 
 test("getTimelineItemKey: keys are unique across the stream", () => {
@@ -166,66 +100,3 @@ test("getTimelineItemKey: keys are unique across the stream", () => {
   const keys = items.map(getTimelineItemKey);
   assert.equal(new Set(keys).size, keys.length);
 });
-
-test("resolveActiveDayTimestamp: null index returns null (nothing in view yet)", () => {
-  const { items } = buildTimelineItems(
-    [entry({ id: "a", createdAt: dayAt(2026, 6, 14) })],
-    null,
-  );
-  assert.equal(resolveActiveDayTimestamp(items, null), null);
-});
-
-test("resolveActiveDayTimestamp: empty stream returns null", () => {
-  assert.equal(resolveActiveDayTimestamp([], 0), null);
-});
-
-test("resolveActiveDayTimestamp: a top row resolves to its own day's heading", () => {
-  const ts = dayAt(2026, 6, 14, 9);
-  const { items } = buildTimelineItems(
-    [entry({ id: "a", createdAt: ts })],
-    null,
-  );
-  // items[0] is the day-divider, items[1] is the message. The message at index
-  // 1 resolves back to the divider above it.
-  assert.equal(resolveActiveDayTimestamp(items, 1), items[0].headingTimestamp);
-});
-
-test("resolveActiveDayTimestamp: a later-day row whose divider scrolled out still resolves to that later day", () => {
-  const day1 = dayAt(2026, 6, 13, 9);
-  const day2 = dayAt(2026, 6, 14, 9);
-  const { items } = buildTimelineItems(
-    [entry({ id: "a", createdAt: day1 }), entry({ id: "b", createdAt: day2 })],
-    null,
-  );
-  // Stream: [div(day1), a, div(day2), b]. Topmost visible = b (index 3); the
-  // day2 divider at index 2 is the nearest divider at or above it.
-  const dividerDay2 = items[2];
-  assert.equal(dividerDay2.kind, "day-divider");
-  assert.equal(
-    resolveActiveDayTimestamp(items, 3),
-    dividerDay2.headingTimestamp,
-  );
-});
-
-test("resolveActiveDayTimestamp: index past the end clamps to the last item", () => {
-  const ts = dayAt(2026, 6, 14, 9);
-  const { items } = buildTimelineItems(
-    [entry({ id: "a", createdAt: ts })],
-    null,
-  );
-  assert.equal(
-    resolveActiveDayTimestamp(items, 999),
-    items[0].headingTimestamp,
-  );
-});
-
-test("resolveActiveDayTimestamp: a divider row at the top resolves to its own heading", () => {
-  const ts = dayAt(2026, 6, 14, 9);
-  const { items } = buildTimelineItems(
-    [entry({ id: "a", createdAt: ts })],
-    null,
-  );
-  // Topmost visible is the divider itself (index 0) — it resolves to itself.
-  assert.equal(items[0].kind, "day-divider");
-  assert.equal(resolveActiveDayTimestamp(items, 0), items[0].headingTimestamp);
-});
diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts
index a28e08b8bb..bb20ac2163 100644
--- a/desktop/src/features/messages/lib/timelineItems.ts
+++ b/desktop/src/features/messages/lib/timelineItems.ts
@@ -1,13 +1,9 @@
 /**
  * Flattens the heterogeneous day-grouped timeline tree into a flat
- * discriminated-union item stream that a virtualizer can window over, and
- * builds the `messageId -> itemIndex` map every DOM-query scroll path now
- * resolves against instead of `querySelector`.
+ * discriminated-union item stream the list renders one row per entry.
  *
  * Kept pure (no React, no DOM) so it is covered by the lib-level `*.test.mjs`
- * suite. The list and the index map are produced together from the SAME walk,
- * so they can never drift: a stale map would scroll deep-links to the wrong
- * row, the exact failure virtualization risks.
+ * suite.
  */
 
 import {
@@ -32,8 +28,6 @@ export type TimelineItem =
 
 export type TimelineItemsResult = {
   items: TimelineItem[];
-  /** Maps a top-level message id to its index in `items`. */
-  indexByMessageId: Map<string, number>;
 };
 
 /** Stable per-item key, unique across the flattened stream. */
@@ -41,30 +35,6 @@ export function getTimelineItemKey(item: TimelineItem): string {
   return item.key;
 }
 
-/**
- * The `headingTimestamp` of the day the topmost visible row belongs to: the
- * nearest day-divider at or above `topVisibleIndex`. The floating active-day
- * header reads this so it always shows the current day even when that day's
- * in-stream divider has scrolled out of the virtual window. Returns `null`
- * before any row is in view or when no divider precedes the visible row (an
- * empty stream).
- */
-export function resolveActiveDayTimestamp(
-  items: TimelineItem[],
-  topVisibleIndex: number | null,
-): number | null {
-  if (topVisibleIndex === null) {
-    return null;
-  }
-  for (let i = Math.min(topVisibleIndex, items.length - 1); i >= 0; i--) {
-    const item = items[i];
-    if (item.kind === "day-divider") {
-      return item.headingTimestamp;
-    }
-  }
-  return null;
-}
-
 function entryRenderKey(entry: MainTimelineEntry): string {
   return entry.message.renderKey ?? entry.message.id;
 }
@@ -72,16 +42,13 @@ function entryRenderKey(entry: MainTimelineEntry): string {
 /**
  * Walks the (already top-level-filtered) entries once, emitting a day-divider
  * at each calendar-day boundary and an unread-divider above the first unread
- * message, then the message/system row itself. The index map records where
- * each message landed in the flat stream so scroll targets resolve in O(1)
- * without touching the DOM.
+ * message, then the message/system row itself.
  */
 export function buildTimelineItems(
   entries: MainTimelineEntry[],
   firstUnreadMessageId: string | null,
 ): TimelineItemsResult {
   const items: TimelineItem[] = [];
-  const indexByMessageId = new Map<string, number>();
 
   // Index boundaries by their start position so the walk below can look up the
   // prepend-stable section key (start-of-local-day). Keying the divider by
@@ -112,9 +79,8 @@ export function buildTimelineItems(
     }
 
     const kind = message.kind === KIND_SYSTEM_MESSAGE ? "system" : "message";
-    indexByMessageId.set(message.id, items.length);
     items.push({ kind, key: renderKey, entry });
   }
 
-  return { items, indexByMessageId };
+  return { items };
 }
diff --git a/desktop/src/features/messages/ui/ActiveDayHeader.tsx b/desktop/src/features/messages/ui/ActiveDayHeader.tsx
deleted file mode 100644
index 4e81efddb5..0000000000
--- a/desktop/src/features/messages/ui/ActiveDayHeader.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Floating active-day label for the virtualized timeline.
- *
- * The in-stream `DayDivider` rows mark each calendar boundary, but once a row
- * scrolls out of the virtual window it unmounts — so a `position: sticky`
- * divider can no longer hold the viewport top, and a sticky element inside the
- * scroll container drifts at scrollTop 0 (it reveals its natural flow offset
- * instead of the pinned offset). This label is therefore portaled by
- * `VirtualizedList` into a non-scrolling overlay container OUTSIDE the scroll
- * element (see `MessageTimeline`), where it pins to a fixed viewport offset and
- * cannot drift as older history prepends above the anchor. It is fed the day of
- * the topmost visible row so the label is always present regardless of which
- * dividers are currently mounted.
- */
-export function ActiveDayHeader({ label }: { label: string }) {
-  return (
-    <p
-      aria-hidden
-      className="pointer-events-none mx-auto w-fit rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm"
-      data-testid="message-timeline-active-day-header"
-      data-day-label={label}
-    >
-      {label}
-    </p>
-  );
-}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 30bbe9d935..bca08ae776 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -155,11 +155,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
     reviewCommentsByRootId,
   ]);
 
-  // The flattened item stream and its messageId -> itemIndex map are produced
-  // together from ONE memo, keyed on the entries and the unread boundary (the
-  // unread divider is its own item, so it shifts indices). A separate memo with
-  // diverging deps would let the map go stale and scroll deep-links to the wrong
-  // row — the exact failure virtualization risks.
+  // The flattened item stream, memoized on the entries and the unread boundary
+  // (the unread divider is its own item, so it shifts subsequent rows).
   const itemsResult = React.useMemo(
     () => buildTimelineItems(entries, firstUnreadMessageId),
     [entries, firstUnreadMessageId],
diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts
index d74e4713f6..d42dbfad36 100644
--- a/desktop/tests/e2e/scroll-history.spec.ts
+++ b/desktop/tests/e2e/scroll-history.spec.ts
@@ -61,30 +61,6 @@ async function getMessagePosition(
   }, messageId);
 }
 
-// The floating active-day header (ActiveDayHeader) is portaled OUTSIDE the
-// scroll container into a non-scrolling overlay, so it pins to a fixed offset
-// instead of drifting at scrollTop 0. We read its label + offset relative to
-// the scroll container's top so a test can assert it neither drifts nor flips
-// day as older history prepends above the anchor.
-async function getActiveDayHeader(page: import("@playwright/test").Page) {
-  return page.evaluate(() => {
-    const timeline = document.querySelector<HTMLElement>(
-      '[data-testid="message-timeline"]',
-    );
-    const pill = document.querySelector<HTMLElement>(
-      '[data-testid="message-timeline-active-day-header"]',
-    );
-    if (!timeline || !pill) {
-      return null;
-    }
-    return {
-      label: pill.dataset.dayLabel ?? pill.textContent ?? "",
-      top:
-        pill.getBoundingClientRect().top - timeline.getBoundingClientRect().top,
-    };
-  });
-}
-
 test("first channel load holds skeleton instead of showing older-history spinner", async ({
   page,
 }) => {
@@ -243,15 +219,6 @@ test("preserves user scroll while older channel history loads", async ({
   const anchorBeforeLanding = await getFirstVisibleMessage(page);
   expect(anchorBeforeLanding).not.toBeNull();
 
-  // The floating active-day header is pinned at the viewport top and reflects
-  // the topmost visible row's day. As older history prepends ABOVE the anchor,
-  // the header must neither move (it's pinned, not part of the prepended flow)
-  // nor flip to a different day (the visible row's day is unchanged). Capture
-  // its label + pinned offset alongside the anchor so the same landing that
-  // proves the message anchor holds also proves the header holds.
-  const headerBeforeLanding = await getActiveDayHeader(page);
-  expect(headerBeforeLanding).not.toBeNull();
-
   // The poll must observe the anchor holding AS a genuine older page lands
   // above it. It only counts a drift reading once BOTH hold in the same sample:
   // the delayed fetch has RESOLVED (inflight back to 0) AND it brought a real
@@ -280,15 +247,6 @@ test("preserves user scroll while older channel history loads", async ({
       },
     )
     .toBeLessThanOrEqual(2);
-
-  // The older page has now landed (the poll above only resolves on it). The
-  // floating day-header must not have drifted or changed day across it.
-  const headerAfterLanding = await getActiveDayHeader(page);
-  expect(headerAfterLanding).not.toBeNull();
-  expect(headerAfterLanding?.label).toBe(headerBeforeLanding?.label);
-  expect(
-    Math.abs((headerAfterLanding?.top ?? 0) - (headerBeforeLanding?.top ?? 0)),
-  ).toBeLessThanOrEqual(2);
 });
 
 // Criterion 2: abandon-to-bottom mid-fetch.

From 2be6e6364a07cded9608a8ccf356c4383575ebf6 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 14:05:51 -0600
Subject: [PATCH 09/11] perf(timeline): reduce cold channel mount window

Open channel timelines with a smaller recent-history window so native-scroll timelines do not synchronously build hundreds of rows on cold entry. Older history still streams in via the existing scroll-up pagination path.

Fetch/splice find-in-channel hits that live outside the initial window so search targets can still be scrolled into view, and retry DOM target scrolling after the row commits. Keep the no-shift coverage aligned with the smaller initial window.

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 desktop/playwright.config.ts                  |   2 +
 .../features/channels/ui/ChannelScreen.tsx    |  31 +-
 desktop/src/features/messages/hooks.ts        |   4 +-
 .../features/messages/ui/MessageTimeline.tsx  |  16 +-
 .../features/messages/ui/useAnchoredScroll.ts |   7 +-
 desktop/src/features/search/useChannelFind.ts |  39 +-
 desktop/tests/e2e/scroll-history.spec.ts      |   2 +-
 desktop/tests/e2e/timeline-no-shift.spec.ts   | 396 ++++++++++++++++++
 8 files changed, 472 insertions(+), 25 deletions(-)
 create mode 100644 desktop/tests/e2e/timeline-no-shift.spec.ts

diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 6761960449..ac32461247 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -54,6 +54,8 @@ export default defineConfig({
         "**/reminders.spec.ts",
         "**/virtualization.spec.ts",
         "**/scroll-history.spec.ts",
+        "**/cold-switch-longtask.perf.ts",
+        "**/timeline-no-shift.spec.ts",
       ],
       use: {
         ...devices["Desktop Chrome"],
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 17d789c109..7d422b9ce0 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -1,5 +1,6 @@
 import * as React from "react";
 import { useAppShell } from "@/app/AppShellContext";
+import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
 import { useAppNavigation } from "@/app/navigation/useAppNavigation";
 import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
 import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
@@ -50,7 +51,7 @@ import { useChannelTyping } from "@/features/messages/useChannelTyping";
 import type { TimelineMessage } from "@/features/messages/types";
 import { useUsersBatchQuery } from "@/features/profile/hooks";
 import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
-import type { RespondToMode } from "@/shared/api/types";
+import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
 import { useChannelFind } from "@/features/search/useChannelFind";
 import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
 import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
@@ -242,13 +243,26 @@ export function ChannelScreen({
   const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
   const editMessageMutation = useEditMessageMutation(activeChannel);
   const joinChannelMutation = useJoinChannelMutation(activeChannelId);
+  const [findTargetEvents, setFindTargetEvents] = React.useState<RelayEvent[]>(
+    [],
+  );
+  // biome-ignore lint/correctness/useExhaustiveDependencies: clear spliced find results exactly when the active channel changes.
+  React.useEffect(() => {
+    setFindTargetEvents([]);
+  }, [activeChannelId]);
   const resolvedMessages = React.useMemo(() => {
     const currentMessages = messagesQuery.data ?? [];
-    if (!activeChannel || targetMessageEvents.length === 0) {
+    const extraEvents = [...targetMessageEvents, ...findTargetEvents];
+    if (!activeChannel || extraEvents.length === 0) {
       return currentMessages;
     }
-    return targetMessageEvents.reduce(mergeMessages, currentMessages);
-  }, [activeChannel, messagesQuery.data, targetMessageEvents]);
+    return extraEvents.reduce(mergeMessages, currentMessages);
+  }, [
+    activeChannel,
+    findTargetEvents,
+    messagesQuery.data,
+    targetMessageEvents,
+  ]);
   const messageAuthorPubkeys = React.useMemo(
     () => collectMessageAuthorPubkeys(resolvedMessages),
     [resolvedMessages],
@@ -417,9 +431,18 @@ export function ChannelScreen({
       resolvedMessages,
     ],
   );
+  const handleFindSearchHit = React.useCallback((hit: SearchHit) => {
+    const event = cacheSearchHitEvent(hit);
+    setFindTargetEvents((currentEvents) =>
+      currentEvents.some((currentEvent) => currentEvent.id === event.id)
+        ? currentEvents
+        : [...currentEvents, event],
+    );
+  }, []);
   const channelFind = useChannelFind({
     channelId: activeChannelId,
     messages: timelineMessages,
+    onSearchHit: handleFindSearchHit,
   });
   const {
     firstUnreadMessageId,
diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 5d62f1e70d..8ddfe38e82 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -48,7 +48,7 @@ type MessageQueryContext = {
   queryKey: ReturnType<typeof channelMessagesKey>;
 };
 
-const CHANNEL_HISTORY_LIMIT = 300;
+const CHANNEL_HISTORY_LIMIT = 60;
 
 function getLocalRenderKey(message: RelayEvent) {
   return message.localKey ?? message.id;
@@ -304,7 +304,7 @@ export function useChannelSubscription(channel: Channel | null) {
 
         cleanup = dispose;
         // No post-subscribe history refetch: useChannelMessagesQuery already
-        // loaded the latest CHANNEL_HISTORY_LIMIT (300) events, and the live
+        // loaded the latest CHANNEL_HISTORY_LIMIT events, and the live
         // subscription itself backfills up to 50 most-recent events via its
         // initial REQ (buildChannelFilter(id, 50)). Both write into the same
         // channelMessagesKey cache, so any window between the two REQs is
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 585daf87cd..0b522c478a 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -281,7 +281,7 @@ const MessageTimelineBase = React.forwardRef<
   // `scrollToMessage` always finds the target row. No virtualizer convergence.
   const jumpToMessage = React.useCallback(
     (messageId: string, options?: { behavior?: ScrollBehavior }) => {
-      scrollToMessage(messageId, { highlight: true, ...options });
+      return scrollToMessage(messageId, { highlight: true, ...options });
     },
     [scrollToMessage],
   );
@@ -324,6 +324,7 @@ const MessageTimelineBase = React.forwardRef<
   // the match) and, when virtualized, converges on the target through the index
   // model — the row may be windowed out of the DOM.
   const prevSearchActiveRef = React.useRef<string | null>(null);
+  const pendingSearchTargetRef = React.useRef<string | null>(null);
   React.useEffect(() => {
     if (showTimelineSkeleton) return;
     if (
@@ -334,9 +335,20 @@ const MessageTimelineBase = React.forwardRef<
       return;
     }
     prevSearchActiveRef.current = searchActiveMessageId;
-    jumpToMessage(searchActiveMessageId, { behavior: "smooth" });
+    if (!jumpToMessage(searchActiveMessageId, { behavior: "smooth" })) {
+      pendingSearchTargetRef.current = searchActiveMessageId;
+    }
   }, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]);
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits.
+  React.useEffect(() => {
+    const target = pendingSearchTargetRef.current;
+    if (!target || showTimelineSkeleton) return;
+    if (jumpToMessage(target, { behavior: "auto" })) {
+      pendingSearchTargetRef.current = null;
+    }
+  }, [deferredMessages, jumpToMessage, showTimelineSkeleton]);
+
   useLoadOlderOnScroll({
     fetchOlder,
     hasOlderMessages,
diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts
index 1362da0b16..0c484f2a2b 100644
--- a/desktop/src/features/messages/ui/useAnchoredScroll.ts
+++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts
@@ -210,13 +210,12 @@ export function useAnchoredScroll({
       const targetTopOffset =
         currentTopOffset - (targetScrollTop - container.scrollTop);
 
-      el.scrollIntoView({
-        block: "center",
+      container.scrollTo({
+        top: targetScrollTop,
         behavior: options.behavior ?? "auto",
       });
 
-      // `scrollIntoView({ behavior: "smooth" })` starts an async animation, so
-      // measuring after the call can still return the pre-animation position.
+      // Smooth scrolling starts an async animation, so measuring after the call can still return the pre-animation position.
       // Save the clamped destination offset instead; otherwise a concurrent
       // render/ResizeObserver restore can fight the smooth scroll back toward
       // where it started.
diff --git a/desktop/src/features/search/useChannelFind.ts b/desktop/src/features/search/useChannelFind.ts
index 7d992c1014..5e2d1138fa 100644
--- a/desktop/src/features/search/useChannelFind.ts
+++ b/desktop/src/features/search/useChannelFind.ts
@@ -2,6 +2,7 @@ import * as React from "react";
 
 import { useSearchMessagesQuery } from "@/features/search/hooks";
 import type { TimelineMessage } from "@/features/messages/types";
+import type { SearchHit } from "@/shared/api/types";
 import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
 
 const MIN_QUERY_LENGTH = 2;
@@ -10,9 +11,14 @@ const DEBOUNCE_MS = 300;
 type UseChannelFindOptions = {
   channelId: string | null;
   messages: TimelineMessage[];
+  onSearchHit?: (hit: SearchHit) => void;
 };
 
-export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
+export function useChannelFind({
+  channelId,
+  messages,
+  onSearchHit,
+}: UseChannelFindOptions) {
   const [isOpen, setIsOpen] = React.useState(false);
   const [query, setQuery] = React.useState("");
   const [debouncedQuery, setDebouncedQuery] = React.useState("");
@@ -64,22 +70,17 @@ export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
     limit: 100,
   });
 
-  // Merge: start with client-side matches, then supplement with relay hits
-  // that are loaded in the timeline but were missed by exact substring match
-  // (e.g. Typesense stemming). Only loaded messages are kept so the match
-  // count stays accurate relative to what's visible on screen.
-  const loadedMessageIds = React.useMemo(
-    () => new Set(messages.map((m) => m.id)),
-    [messages],
-  );
-
+  // Merge: start with client-side matches, then supplement with relay hits.
+  // Relay hits may refer to older messages outside the initial cold window;
+  // keep them in the match list and ask the route-target splice path to load
+  // the active hit so the DOM-based timeline scroll can land it.
   const matchedIds = React.useMemo<string[]>(() => {
     const merged = [...clientMatchIds];
     const seen = new Set(merged);
 
     if (relaySearch.data?.hits) {
       for (const hit of relaySearch.data.hits) {
-        if (!seen.has(hit.eventId) && loadedMessageIds.has(hit.eventId)) {
+        if (!seen.has(hit.eventId)) {
           merged.push(hit.eventId);
           seen.add(hit.eventId);
         }
@@ -87,7 +88,7 @@ export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
     }
 
     return merged;
-  }, [clientMatchIds, relaySearch.data?.hits, loadedMessageIds]);
+  }, [clientMatchIds, relaySearch.data?.hits]);
 
   // Clamp active index when results change.
   React.useEffect(() => {
@@ -100,6 +101,20 @@ export function useChannelFind({ channelId, messages }: UseChannelFindOptions) {
   const activeMatch =
     matchedIds.length > 0 ? { messageId: matchedIds[activeIndex] } : null;
 
+  const relayHitById = React.useMemo(() => {
+    const hits = new Map<string, SearchHit>();
+    for (const hit of relaySearch.data?.hits ?? []) {
+      hits.set(hit.eventId, hit);
+    }
+    return hits;
+  }, [relaySearch.data?.hits]);
+
+  React.useEffect(() => {
+    if (!activeMatch) return;
+    const hit = relayHitById.get(activeMatch.messageId);
+    if (hit) onSearchHit?.(hit);
+  }, [activeMatch, onSearchHit, relayHitById]);
+
   const matchingMessageIds = React.useMemo(() => {
     return new Set(matchedIds);
   }, [matchedIds]);
diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts
index d42dbfad36..f3d005e4f5 100644
--- a/desktop/tests/e2e/scroll-history.spec.ts
+++ b/desktop/tests/e2e/scroll-history.spec.ts
@@ -1701,7 +1701,7 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
   );
 
   await page.evaluate(() => {
-    for (let index = 0; index < 60; index += 1) {
+    for (let index = 0; index < 40; index += 1) {
       window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
         channelName: "general",
         content: `recent ${index}`,
diff --git a/desktop/tests/e2e/timeline-no-shift.spec.ts b/desktop/tests/e2e/timeline-no-shift.spec.ts
new file mode 100644
index 0000000000..15a55cff72
--- /dev/null
+++ b/desktop/tests/e2e/timeline-no-shift.spec.ts
@@ -0,0 +1,396 @@
+import { expect, test } from "@playwright/test";
+import type { Locator, Page } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+type AnchorSnapshot = {
+  anchorId: string;
+  anchorTop: number;
+  visibleIds: string[];
+  rowsFromAnchor: string[];
+  oldestOlderIndex: number | null;
+  scrollHeight: number;
+};
+
+async function waitForMockTimelineBridge(page: Page) {
+  await page.waitForFunction(
+    () =>
+      typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
+      typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
+  );
+}
+
+async function seedNoShiftTimeline(page: Page) {
+  await page.evaluate(() => {
+    for (let index = 0; index < 40; index += 1) {
+      window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: "general",
+        content: `no-shift current ${index}\nsecond line ${index}\nthird line ${index}`,
+        createdAt: 1_700_100_000 + index,
+      });
+    }
+
+    window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
+      channelName: "general",
+      count: 600,
+      lineCount: 3,
+      createdAtStart: 1_700_000_000,
+    });
+  });
+}
+
+async function snapshotAnchor(timeline: Locator): Promise<AnchorSnapshot> {
+  return timeline.evaluate((element) => {
+    const scroller = element as HTMLDivElement;
+    const scrollerRect = scroller.getBoundingClientRect();
+    const rows = Array.from(
+      scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
+    );
+    const visibleRows = rows.filter((row) => {
+      const rect = row.getBoundingClientRect();
+      return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
+    });
+    const anchor =
+      visibleRows.find((row) => {
+        const rect = row.getBoundingClientRect();
+        return rect.top - scrollerRect.top >= 0;
+      }) ?? visibleRows[0];
+
+    if (!anchor) {
+      throw new Error("no visible message row to anchor");
+    }
+
+    const anchorIndex = rows.indexOf(anchor);
+    const oldestOlderIndex = rows.reduce<number | null>((oldest, row) => {
+      const match = row.textContent?.match(/mock older (\d+) line/);
+      if (!match) return oldest;
+      const index = Number(match[1]);
+      return oldest === null ? index : Math.min(oldest, index);
+    }, null);
+
+    return {
+      anchorId: anchor.dataset.messageId ?? "",
+      anchorTop: anchor.getBoundingClientRect().top - scrollerRect.top,
+      visibleIds: visibleRows
+        .map((row) => row.dataset.messageId ?? "")
+        .filter(Boolean),
+      rowsFromAnchor: rows
+        .slice(anchorIndex, anchorIndex + 5)
+        .map((row) => row.dataset.messageId ?? "")
+        .filter(Boolean),
+      oldestOlderIndex,
+      scrollHeight: scroller.scrollHeight,
+    };
+  });
+}
+
+async function getAnchorTop(
+  timeline: Locator,
+  anchorId: string,
+): Promise<number | null> {
+  return timeline.evaluate((element, id) => {
+    const scroller = element as HTMLDivElement;
+    const anchor = scroller.querySelector<HTMLElement>(
+      `[data-message-id="${CSS.escape(id)}"]`,
+    );
+    if (!anchor) return null;
+    return (
+      anchor.getBoundingClientRect().top - scroller.getBoundingClientRect().top
+    );
+  }, anchorId);
+}
+
+async function startAnchorDriftSampler(
+  timeline: Locator,
+  anchorId: string,
+  baselineTop: number,
+) {
+  await timeline.evaluate(
+    (element, input) => {
+      const scroller = element as HTMLDivElement;
+      const win = window as typeof window & {
+        __TIMELINE_NO_SHIFT_PROBE__?: {
+          stop: boolean;
+          maxDrift: number;
+          missingSamples: number;
+          samples: number;
+        };
+      };
+      const probe = {
+        stop: false,
+        maxDrift: 0,
+        missingSamples: 0,
+        samples: 0,
+      };
+      win.__TIMELINE_NO_SHIFT_PROBE__ = probe;
+
+      const sample = () => {
+        if (probe.stop) return;
+        const anchor = scroller.querySelector<HTMLElement>(
+          `[data-message-id="${CSS.escape(input.anchorId)}"]`,
+        );
+        if (!anchor) {
+          probe.missingSamples += 1;
+        } else {
+          const top =
+            anchor.getBoundingClientRect().top -
+            scroller.getBoundingClientRect().top;
+          probe.maxDrift = Math.max(
+            probe.maxDrift,
+            Math.abs(top - input.baselineTop),
+          );
+        }
+        probe.samples += 1;
+        requestAnimationFrame(sample);
+      };
+
+      requestAnimationFrame(sample);
+    },
+    { anchorId, baselineTop },
+  );
+}
+
+async function stopAnchorDriftSampler(timeline: Locator) {
+  return timeline.evaluate((element) => {
+    const win = window as typeof window & {
+      __TIMELINE_NO_SHIFT_PROBE__?: {
+        stop: boolean;
+        maxDrift: number;
+        missingSamples: number;
+        samples: number;
+      };
+    };
+    const probe = win.__TIMELINE_NO_SHIFT_PROBE__;
+    if (!probe) throw new Error("no timeline no-shift sampler installed");
+    probe.stop = true;
+    return {
+      maxDrift: probe.maxDrift,
+      missingSamples: probe.missingSamples,
+      samples: probe.samples,
+      scrollTop: (element as HTMLDivElement).scrollTop,
+    };
+  });
+}
+
+async function growRowAboveAnchor(timeline: Locator, anchorId: string) {
+  return timeline.evaluate((element, id) => {
+    const scroller = element as HTMLDivElement;
+    const rows = Array.from(
+      scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
+    );
+    const anchorIndex = rows.findIndex((row) => row.dataset.messageId === id);
+    if (anchorIndex <= 0) return false;
+
+    const target = rows[Math.max(0, anchorIndex - 2)];
+    if (!target) return false;
+
+    const currentHeight = target.getBoundingClientRect().height;
+    target.style.minHeight = `${currentHeight + 96}px`;
+    target.dataset.noShiftReflowTarget = "true";
+    return true;
+  }, anchorId);
+}
+
+function expectAnchorOrderUnchanged(
+  before: AnchorSnapshot,
+  after: AnchorSnapshot,
+) {
+  expect(after.visibleIds).toContain(before.anchorId);
+  expect(after.rowsFromAnchor).toEqual(before.rowsFromAnchor);
+}
+
+test("timeline prepend plus late row reflow keeps the reading row stable", async ({
+  page,
+}, testInfo) => {
+  testInfo.setTimeout(45_000);
+
+  await installMockBridge(page);
+  await page.goto("/");
+  await waitForMockTimelineBridge(page);
+  await seedNoShiftTimeline(page);
+
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+  const timeline = page.getByTestId("message-timeline");
+  await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
+  await page.waitForFunction(() => {
+    const element = document.querySelector(
+      '[data-testid="message-timeline"]',
+    ) as HTMLDivElement | null;
+    return element && element.scrollHeight > element.clientHeight + 1_000;
+  });
+
+  await page.evaluate(() => {
+    window.__BUZZ_E2E__ = {
+      ...window.__BUZZ_E2E__,
+      mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_000 },
+    };
+    (
+      window as unknown as { __HISTORY_INFLIGHT__?: number }
+    ).__HISTORY_INFLIGHT__ = 0;
+  });
+
+  await timeline.evaluate((element) => {
+    const scroller = element as HTMLDivElement;
+    scroller.scrollTop = 150;
+    scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
+  });
+
+  await expect
+    .poll(
+      async () =>
+        page.evaluate(
+          () =>
+            (window as unknown as { __HISTORY_INFLIGHT__?: number })
+              .__HISTORY_INFLIGHT__ ?? 0,
+        ),
+      { timeout: 5_000 },
+    )
+    .toBeGreaterThan(0);
+
+  const before = await snapshotAnchor(timeline);
+  expect(before.anchorId).not.toBe("");
+  expect(before.oldestOlderIndex).not.toBeNull();
+  await startAnchorDriftSampler(timeline, before.anchorId, before.anchorTop);
+
+  await expect
+    .poll(
+      async () => {
+        const snapshot = await snapshotAnchor(timeline);
+        return snapshot.oldestOlderIndex !== null &&
+          before.oldestOlderIndex !== null &&
+          snapshot.oldestOlderIndex < before.oldestOlderIndex &&
+          snapshot.scrollHeight > before.scrollHeight + 1_000
+          ? "landed"
+          : "pending";
+      },
+      { timeout: 6_000 },
+    )
+    .toBe("landed");
+
+  const afterPrepend = await snapshotAnchor(timeline);
+  expect(afterPrepend.anchorId).toBe(before.anchorId);
+  expect(
+    Math.abs(afterPrepend.anchorTop - before.anchorTop),
+  ).toBeLessThanOrEqual(2);
+  expectAnchorOrderUnchanged(before, afterPrepend);
+
+  const grew = await growRowAboveAnchor(timeline, before.anchorId);
+  expect(grew).toBe(true);
+
+  await expect
+    .poll(
+      async () => {
+        const top = await getAnchorTop(timeline, before.anchorId);
+        return top === null
+          ? Number.POSITIVE_INFINITY
+          : Math.abs(top - before.anchorTop);
+      },
+      { timeout: 3_000 },
+    )
+    .toBeLessThanOrEqual(2);
+
+  const afterReflow = await snapshotAnchor(timeline);
+  expect(afterReflow.anchorId).toBe(before.anchorId);
+  expectAnchorOrderUnchanged(before, afterReflow);
+
+  const drift = await stopAnchorDriftSampler(timeline);
+  console.info("timeline-no-shift result", JSON.stringify(drift));
+  expect(drift.samples).toBeGreaterThan(0);
+  expect(drift.missingSamples).toBe(0);
+  expect(drift.maxDrift).toBeLessThanOrEqual(2);
+});
+
+test("thread panel late row reflow keeps the reading reply stable", async ({
+  page,
+}, testInfo) => {
+  testInfo.setTimeout(45_000);
+
+  await installMockBridge(page);
+  await page.goto("/");
+  await waitForMockTimelineBridge(page);
+
+  const rootId = await page.evaluate(() => {
+    const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+      channelName: "general",
+      content: "thread no-shift root",
+      createdAt: 1_700_300_000,
+    });
+    if (!root) throw new Error("Failed to seed thread no-shift root");
+
+    for (let index = 0; index < 48; index += 1) {
+      window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: "general",
+        content: `thread no-shift reply ${index}\nsecond line ${index}\nthird line ${index}`,
+        parentEventId: root.id,
+        createdAt: 1_700_300_001 + index,
+      });
+    }
+
+    return root.id;
+  });
+
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+  const timeline = page.getByTestId("message-timeline");
+  const summary = timeline.locator(
+    `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
+  );
+  await expect(summary).toBeVisible({ timeout: 5_000 });
+  await summary.click();
+
+  const threadPanel = page.getByTestId("message-thread-panel");
+  await expect(threadPanel).toBeVisible();
+  await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
+    "thread no-shift root",
+  );
+
+  const threadBody = threadPanel.getByTestId("message-thread-body");
+  await expect(threadBody.locator("[data-message-id]").first()).toBeVisible();
+  await page.waitForFunction(() => {
+    const element = document.querySelector(
+      '[data-testid="message-thread-body"]',
+    ) as HTMLDivElement | null;
+    return element && element.scrollHeight > element.clientHeight + 800;
+  });
+
+  await threadBody.evaluate((element) => {
+    const scroller = element as HTMLDivElement;
+    scroller.scrollTop = Math.floor(scroller.scrollHeight / 2);
+    scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
+  });
+  await page.waitForTimeout(100);
+
+  const before = await snapshotAnchor(threadBody);
+  expect(before.anchorId).not.toBe("");
+  await startAnchorDriftSampler(threadBody, before.anchorId, before.anchorTop);
+
+  const grew = await growRowAboveAnchor(threadBody, before.anchorId);
+  expect(grew).toBe(true);
+
+  await expect
+    .poll(
+      async () => {
+        const top = await getAnchorTop(threadBody, before.anchorId);
+        return top === null
+          ? Number.POSITIVE_INFINITY
+          : Math.abs(top - before.anchorTop);
+      },
+      { timeout: 3_000 },
+    )
+    .toBeLessThanOrEqual(2);
+
+  await page.waitForTimeout(500);
+
+  const afterReflow = await snapshotAnchor(threadBody);
+  expect(afterReflow.anchorId).toBe(before.anchorId);
+  expectAnchorOrderUnchanged(before, afterReflow);
+
+  const drift = await stopAnchorDriftSampler(threadBody);
+  console.info("thread-panel-no-shift result", JSON.stringify(drift));
+  expect(drift.samples).toBeGreaterThan(0);
+  expect(drift.missingSamples).toBe(0);
+  expect(drift.maxDrift).toBeLessThanOrEqual(2);
+});

From 16e88d36196e5a35960b7b327c800930516d602d Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Sun, 28 Jun 2026 16:02:47 -0600
Subject: [PATCH 10/11] fix(timeline): address scrollback and find review notes

Do not count older-history prepends as new bottom messages while keeping the anchored-row restore for scrollback pagination.

Clear stale pending find targets when the active match changes or clears, so a delayed splice cannot jump back to an inactive search hit.

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../src/features/messages/ui/MessageTimeline.tsx |  4 ++++
 .../features/messages/ui/useAnchoredScroll.ts    | 16 +++++++++++++++-
 desktop/tests/e2e/scroll-history.spec.ts         |  4 ++++
 3 files changed, 23 insertions(+), 1 deletion(-)

diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 0b522c478a..8d87562759 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -327,6 +327,9 @@ const MessageTimelineBase = React.forwardRef<
   const pendingSearchTargetRef = React.useRef<string | null>(null);
   React.useEffect(() => {
     if (showTimelineSkeleton) return;
+    if (!searchActiveMessageId) {
+      pendingSearchTargetRef.current = null;
+    }
     if (
       !searchActiveMessageId ||
       searchActiveMessageId === prevSearchActiveRef.current
@@ -334,6 +337,7 @@ const MessageTimelineBase = React.forwardRef<
       prevSearchActiveRef.current = searchActiveMessageId;
       return;
     }
+    pendingSearchTargetRef.current = null;
     prevSearchActiveRef.current = searchActiveMessageId;
     if (!jumpToMessage(searchActiveMessageId, { behavior: "smooth" })) {
       pendingSearchTargetRef.current = searchActiveMessageId;
diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts
index 0c484f2a2b..5c52f3be3b 100644
--- a/desktop/src/features/messages/ui/useAnchoredScroll.ts
+++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts
@@ -128,6 +128,7 @@ export function useAnchoredScroll({
 
   const hasInitializedRef = React.useRef(false);
   const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
+  const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
   const prevMessageCountRef = React.useRef(0);
   const handledTargetIdRef = React.useRef<string | null>(null);
   const highlightTimeoutRef = React.useRef<number | null>(null);
@@ -151,6 +152,7 @@ export function useAnchoredScroll({
     setHighlightedMessageId(null);
     hasInitializedRef.current = false;
     prevLastMessageIdRef.current = undefined;
+    prevFirstMessageIdRef.current = undefined;
     prevMessageCountRef.current = 0;
     handledTargetIdRef.current = null;
     forceBottomOnNextAppendRef.current = false;
@@ -298,13 +300,16 @@ export function useAnchoredScroll({
       }
       hasInitializedRef.current = true;
       prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
+      prevFirstMessageIdRef.current = messages[0]?.id;
       prevMessageCountRef.current = messages.length;
       return;
     }
 
     const anchor = anchorRef.current;
     const lastMessage = messages[messages.length - 1];
+    const firstMessage = messages[0];
     const prevLastId = prevLastMessageIdRef.current;
+    const prevFirstId = prevFirstMessageIdRef.current;
     const prevCount = prevMessageCountRef.current;
     const newLatestArrived =
       lastMessage !== undefined && lastMessage.id !== prevLastId;
@@ -313,6 +318,11 @@ export function useAnchoredScroll({
     // same-second row, so the list grows without the *last* id changing —
     // `newLatestArrived` misses that case and the unread counter never bumps.
     const messagesArrived = messages.length - prevCount;
+    const frontChanged =
+      firstMessage !== undefined &&
+      prevFirstId !== undefined &&
+      firstMessage.id !== prevFirstId;
+    const isPrepend = frontChanged && !newLatestArrived;
 
     // One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
     // resulting append lands, snap to bottom regardless of the current anchor,
@@ -325,6 +335,7 @@ export function useAnchoredScroll({
       setIsAtBottom(true);
       setNewMessageCount(0);
       prevLastMessageIdRef.current = lastMessage?.id;
+      prevFirstMessageIdRef.current = firstMessage?.id;
       prevMessageCountRef.current = messages.length;
       return;
     }
@@ -355,10 +366,13 @@ export function useAnchoredScroll({
           container.scrollBy(0, drift);
         }
       }
-      setNewMessageCount((current) => current + messagesArrived);
+      if (!isPrepend) {
+        setNewMessageCount((current) => current + messagesArrived);
+      }
     }
 
     prevLastMessageIdRef.current = lastMessage?.id;
+    prevFirstMessageIdRef.current = firstMessage?.id;
     prevMessageCountRef.current = messages.length;
   }, [
     isLoading,
diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts
index f3d005e4f5..5fdeb4dc81 100644
--- a/desktop/tests/e2e/scroll-history.spec.ts
+++ b/desktop/tests/e2e/scroll-history.spec.ts
@@ -1797,4 +1797,8 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
       { timeout: 4_000 },
     )
     .toBeLessThanOrEqual(2);
+
+  await expect(page.getByTestId("message-scroll-to-latest")).toContainText(
+    "Jump to latest",
+  );
 });

From e4d9cf8a68e1730cab8ed0fc02bcd65ebaf85f1d Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Mon, 29 Jun 2026 08:29:04 -0600
Subject: [PATCH 11/11] fix(timeline): enable content-visibility on
 de-virtualized rows without prepend drift

The row wrapper used an inert `ln-auto` class that was never defined, so
the de-virtualized timeline never actually got content-visibility. Swapping
in the shared `content-visibility-auto` utility (intrinsic-size 200px)
over-reserves the common ~54px row by ~2x, which shifts the prepend anchor
and breaks the no-shift reading-position guarantee.

Add a timeline-scoped `.timeline-row-cv` class with `contain-intrinsic-size:
auto 60px` (at the short-message floor; over-reserving breaks anchoring,
under-reserving self-corrects via the `auto` keyword after first render).
The shared 200px utility is left untouched for sidebar/forum surfaces.

Add a class-agnostic e2e guard asserting a timeline row resolves
content-visibility: auto via computed style, so a future typo/removal can't
ship inert again.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 .../messages/ui/TimelineMessageList.tsx       |  2 +-
 desktop/src/shared/styles/globals.css         |  7 ++++
 desktop/tests/e2e/timeline-no-shift.spec.ts   | 33 +++++++++++++++++++
 3 files changed, 41 insertions(+), 1 deletion(-)

diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index bca08ae776..e71559a3a0 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -244,7 +244,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
   return (
     <div className="flex flex-col">
       {itemsResult.items.map((item) => (
-        <div className="ln-auto" key={getTimelineItemKey(item)}>
+        <div className="timeline-row-cv" key={getTimelineItemKey(item)}>
           {renderItem(item)}
         </div>
       ))}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 0c015b1804..660982a6df 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -2047,6 +2047,13 @@
     contain-intrinsic-size: auto 200px;
   }
 
+  /* Timeline rows re-pin by measured offset on prepend; the 200px fallback
+     over-reserves the common row and shifts that anchor, so reserve low. */
+  .timeline-row-cv {
+    content-visibility: auto;
+    contain-intrinsic-size: auto 60px;
+  }
+
   .content-visibility-auto-interactive {
     content-visibility: auto;
     contain-intrinsic-size: auto 200px;
diff --git a/desktop/tests/e2e/timeline-no-shift.spec.ts b/desktop/tests/e2e/timeline-no-shift.spec.ts
index 15a55cff72..2398376878 100644
--- a/desktop/tests/e2e/timeline-no-shift.spec.ts
+++ b/desktop/tests/e2e/timeline-no-shift.spec.ts
@@ -302,6 +302,39 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async
   expect(drift.maxDrift).toBeLessThanOrEqual(2);
 });
 
+test("de-virtualized timeline rows apply content-visibility", async ({
+  page,
+}, testInfo) => {
+  testInfo.setTimeout(45_000);
+
+  await installMockBridge(page);
+  await page.goto("/");
+  await waitForMockTimelineBridge(page);
+  await seedNoShiftTimeline(page);
+
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+  const timeline = page.getByTestId("message-timeline");
+  await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
+
+  // Guards against a typo'd/removed wrapper class shipping inert — a bad
+  // utility name is invisible to typecheck.
+  const hasContentVisibility = await timeline
+    .locator("[data-message-id]")
+    .first()
+    .evaluate((element) => {
+      const scroller = element.closest('[data-testid="message-timeline"]');
+      let node: HTMLElement | null = element.parentElement;
+      while (node && node !== scroller) {
+        if (getComputedStyle(node).contentVisibility === "auto") return true;
+        node = node.parentElement;
+      }
+      return false;
+    });
+  expect(hasContentVisibility).toBe(true);
+});
+
 test("thread panel late row reflow keeps the reading reply stable", async ({
   page,
 }, testInfo) => {
