From 81dfd8473e2f50df9eaaf5449af1cbd4e56a7879 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 16 Jun 2026 22:48:13 -0700 Subject: [PATCH] fix(timeline): virtualize channel intro as terminal row 0 Move the channel intro into the virtualized row model so it only mounts when scrolled to the genuine history top, eliminating the header/list sandwich during first-load and deferred render streaming. - buildVirtualTimelineRows.ts: add optional intro row type and includeIntro option - MessageTimeline.tsx: gate introReady on deferred settle and hasOlderMessages; render via VirtualizedTimelineList - VirtualizedTimelineList.tsx: add renderIntro prop for intro row dispatch - useVirtualTimelineScroll.ts: remove introRevealed opacity-gate state - timelineSnapshot.ts: remove isAtTop helpers superseded by virtual row gating - useVirtualScrollMargin.ts: stop treating intro as above-list scroll-margin content - Add unit/DOM tests for intro row ordering, lookup, and rendering Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../lib/buildVirtualTimelineRows.test.mjs | 28 ++ .../messages/lib/buildVirtualTimelineRows.ts | 35 ++- .../messages/lib/timelineSnapshot.test.mjs | 16 - .../features/messages/lib/timelineSnapshot.ts | 26 -- .../features/messages/ui/MessageTimeline.tsx | 278 +++++++++--------- .../ui/VirtualizedTimelineList.dom.test.tsx | 22 ++ .../messages/ui/VirtualizedTimelineList.tsx | 9 +- .../messages/ui/useVirtualScrollMargin.ts | 12 +- .../messages/ui/useVirtualTimelineScroll.ts | 42 +-- 9 files changed, 237 insertions(+), 231 deletions(-) diff --git a/desktop/src/features/messages/lib/buildVirtualTimelineRows.test.mjs b/desktop/src/features/messages/lib/buildVirtualTimelineRows.test.mjs index 7d1fe044b..345c83088 100644 --- a/desktop/src/features/messages/lib/buildVirtualTimelineRows.test.mjs +++ b/desktop/src/features/messages/lib/buildVirtualTimelineRows.test.mjs @@ -26,6 +26,34 @@ test("empty snapshot produces no rows", () => { assert.deepEqual(buildVirtualTimelineRows([]), []); }); +test("empty snapshot with includeIntro emits only the intro row", () => { + const rows = buildVirtualTimelineRows([], { includeIntro: true }); + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, "intro"); + assert.equal(rows[0].key, "channel-intro"); +}); + +test("includeIntro prepends the intro row before dividers and messages", () => { + const rows = buildVirtualTimelineRows([message({ id: "a" })], { + includeIntro: true, + }); + assert.deepEqual( + rows.map((row) => row.kind), + ["intro", "day-divider", "message"], + ); +}); + +test("findVirtualRowIndexForMessage still resolves after an intro row is prepended", () => { + const messages = [ + message({ id: "a", createdAt: DAY_1 }), + message({ id: "b", createdAt: DAY_2 }), + ]; + const rows = buildVirtualTimelineRows(messages, { includeIntro: true }); + // layout: [intro, div, a, div, b] + assert.equal(findVirtualRowIndexForMessage(rows, "a", messages), 2); + assert.equal(findVirtualRowIndexForMessage(rows, "b", messages), 4); +}); + test("single message emits one divider then the message row", () => { const rows = buildVirtualTimelineRows([message({ id: "a" })]); assert.equal(rows.length, 2); diff --git a/desktop/src/features/messages/lib/buildVirtualTimelineRows.ts b/desktop/src/features/messages/lib/buildVirtualTimelineRows.ts index 82d7d0aaf..42ab8fa14 100644 --- a/desktop/src/features/messages/lib/buildVirtualTimelineRows.ts +++ b/desktop/src/features/messages/lib/buildVirtualTimelineRows.ts @@ -14,7 +14,8 @@ * reuses `buildDayGroupBoundaries` so divider placement is byte-identical to the * current render — no second source of truth for "where does a day start". * - * Two row kinds come out, in render order: + * Row kinds come out, in render order: + * - { kind: "intro", key: "channel-intro" } — optional terminal header row * - { kind: "day-divider", key, label-source timestamp, messageIndex: -1 } * - { kind: "message", key, messageIndex } — index back into the snapshot * @@ -30,6 +31,21 @@ import type { TimelineMessage } from "@/features/messages/types"; import { buildDayGroupBoundaries } from "./timelineSnapshot"; +/** Options for flattening a snapshot into virtual rows. */ +export type BuildVirtualTimelineRowsOptions = { + /** When true, prepend a channel-intro row before dividers and messages. */ + includeIntro?: boolean; +}; + +/** The channel intro header — the terminal row at the true top of history. */ +export type VirtualIntroRow = { + kind: "intro"; + /** Stable virtualizer key. */ + key: "channel-intro"; + /** Always -1 — the intro does not map to a message. */ + messageIndex: -1; +}; + /** A divider row — one per calendar-day boundary. Carries no message. */ export type VirtualDayDividerRow = { kind: "day-divider"; @@ -50,10 +66,13 @@ export type VirtualMessageRow = { messageIndex: number; }; -export type VirtualTimelineRow = VirtualDayDividerRow | VirtualMessageRow; +export type VirtualTimelineRow = + | VirtualIntroRow + | VirtualDayDividerRow + | VirtualMessageRow; /** - * Flatten a message snapshot into ordered virtual rows (dividers + messages). + * Flatten a message snapshot into ordered virtual rows (intro + dividers + messages). * * Walks the snapshot once; emits a divider row at each day-group start index * (computed by `buildDayGroupBoundaries`, the same helper the live render uses), @@ -63,8 +82,18 @@ export type VirtualTimelineRow = VirtualDayDividerRow | VirtualMessageRow; */ export function buildVirtualTimelineRows( messages: readonly TimelineMessage[], + options?: BuildVirtualTimelineRowsOptions, ): VirtualTimelineRow[] { const rows: VirtualTimelineRow[] = []; + + if (options?.includeIntro) { + rows.push({ + kind: "intro", + key: "channel-intro", + messageIndex: -1, + }); + } + const dayStartIndices = new Set( buildDayGroupBoundaries(messages).map((boundary) => boundary.startIndex), ); diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index 0f7a02f13..f16c6e7a9 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -3,9 +3,7 @@ import test from "node:test"; import { BOTTOM_THRESHOLD_PX, - TOP_THRESHOLD_PX, buildDayGroupBoundaries, - isAtTopMetrics, isNearBottomMetrics, resolveDeepLinkTarget, selectDeferredListRenderState, @@ -79,20 +77,6 @@ test("isNearBottomMetrics: false when scrolled up beyond the threshold", () => { ); }); -// --- earned channel-intro header (at-top reveal gate) ------------------------- - -test("isAtTopMetrics: true when within threshold of the top", () => { - assert.equal(isAtTopMetrics({ scrollTop: 4 }), true); -}); - -test("isAtTopMetrics: true exactly at the threshold boundary", () => { - assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX }), true); -}); - -test("isAtTopMetrics: false when scrolled down beyond the threshold", () => { - assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX + 1 }), false); -}); - test("selectLatestMessageKey: prefers renderKey, falls back to id, undefined when empty", () => { assert.equal(selectLatestMessageKey([]), undefined); assert.equal( diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 5eeedf947..c0fdff16d 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -43,32 +43,6 @@ export function isNearBottom(container: HTMLDivElement): boolean { }); } -/** Distance (px) from the top within which the timeline counts as "at top". */ -export const TOP_THRESHOLD_PX = 8; - -/** - * Is the timeline scrolled close enough to the top to count as "at top"? - * - * This gates the channel-intro header's VISUAL reveal. The intro is the - * terminal header of a bottom-anchored list — it must surface only once the - * user has genuinely arrived at the true top, never get painted up front while - * the list is still streaming in from the bottom (a standard overflow container - * rests at scrollTop 0 during the estimate→measure→settle window, so "scrollTop - * is 0" alone is NOT a trustworthy at-top signal until the first-load bottom pin - * has landed). Pure over geometry so the threshold math is unit-testable without - * a DOM — the surrounding flexbox layout is not, jsdom does no layout. - */ -export function isAtTopMetrics( - metrics: Pick, -): boolean { - return metrics.scrollTop <= TOP_THRESHOLD_PX; -} - -/** Reads live scroll geometry off a container and applies the top-threshold rule. */ -export function isAtTop(container: HTMLDivElement): boolean { - return isAtTopMetrics({ scrollTop: container.scrollTop }); -} - /** * Identity of the last message in a snapshot, used to detect "a new latest * message arrived" for autoscroll. Prefers `renderKey` (stable across optimistic diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index ca3f0f28d..bb76ffa1b 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -32,6 +32,7 @@ import { VirtualizedTimelineList } from "./VirtualizedTimelineList"; // no fixed-height assumption. const ESTIMATED_MESSAGE_HEIGHT = 64; const ESTIMATED_DIVIDER_HEIGHT = 32; +const ESTIMATED_INTRO_HEIGHT = 320; const VIRTUAL_OVERSCAN = 8; // Fallback escape hatch for find-in-page: when find is open, optionally bypass @@ -169,7 +170,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ const scrollContainerRef = externalScrollRef ?? internalScrollRef; const topSentinelRef = React.useRef(null); // Wraps the virtualized list; its offset within the scroll container is the - // virtualizer's `scrollMargin` (content above it: sentinel, spinner, intro). + // virtualizer's `scrollMargin` (content above it: sentinel, spinner). const listOuterRef = React.useRef(null); // Gate the heavy timeline render (each row runs a synchronous @@ -201,9 +202,18 @@ export const MessageTimeline = React.memo(function MessageTimeline({ () => entries.map((entry) => entry.message), [entries], ); + // The channel intro is the terminal header at the true top of history. Include + // it as virtual row 0 only once the deferred list has settled AND we've reached + // the genuine history boundary (no older pages remain). + const introReady = + !isLoading && + channelIntro !== null && + directMessageIntro === null && + !isRenderPending && + (deferredMessages.length === 0 || !hasOlderMessages); const rows = React.useMemo( - () => buildVirtualTimelineRows(entryMessages), - [entryMessages], + () => buildVirtualTimelineRows(entryMessages, { includeIntro: introReady }), + [entryMessages, introReady], ); // When the render-all escape hatch is enabled AND find is open, expand the @@ -216,9 +226,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ : VIRTUAL_OVERSCAN; // Offset of the virtualized list within the scroll container — content above - // it (sentinel, "load older" spinner, intro banner) lives in the SAME - // scrollable element, so the virtualizer must know that offset or rows paint - // at the wrong scrollTop (header/list sandwich + anchor drift on fill). + // it (sentinel, "load older" spinner) lives in the SAME scrollable element, + // so the virtualizer must know that offset or rows paint at the wrong + // scrollTop (header/list sandwich + anchor drift on fill). const scrollMargin = useVirtualScrollMargin( scrollContainerRef, listOuterRef, @@ -226,7 +236,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({ isLoading, isFetchingOlder, deferredMessages.length, - channelIntro, directMessageIntro, rows.length, ], @@ -235,24 +244,32 @@ export const MessageTimeline = React.memo(function MessageTimeline({ const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => - rows[index]?.kind === "day-divider" - ? ESTIMATED_DIVIDER_HEIGHT - : ESTIMATED_MESSAGE_HEIGHT, + estimateSize: (index) => { + const row = rows[index]; + if (!row) { + return ESTIMATED_MESSAGE_HEIGHT; + } + if (row.kind === "day-divider") { + return ESTIMATED_DIVIDER_HEIGHT; + } + if (row.kind === "intro") { + return ESTIMATED_INTRO_HEIGHT; + } + return ESTIMATED_MESSAGE_HEIGHT; + }, // Stable per-row identity. THIS is what lets a top-prepend (older page) // retain scroll position natively — surviving rows keep their key, so the // measurement cache survives and the virtualizer re-anchors itself. No // before/after scrollHeight delta math, no double-rAF correction. getItemKey: (index) => rows[index]?.key ?? index, overscan, - // Account for the sentinel/spinner/intro above the list inside the same - // scroll container, so item offsets line up with where they actually paint. + // Account for the sentinel/spinner above the list inside the same scroll + // container, so item offsets line up with where they actually paint. scrollMargin: scrollMargin.value, }); const { highlightedMessageId, - introRevealed, isAtBottom, newMessageCount, scrollToBottom, @@ -318,6 +335,13 @@ export const MessageTimeline = React.memo(function MessageTimeline({ [renderContext], ); + const renderChannelIntro = React.useCallback(() => { + if (!channelIntro) { + return null; + } + return ; + }, [channelIntro]); + // Pagination trigger only — the virtualizer holds scroll position on prepend // natively (stable keys), so there is no position-restore plumbing to pass. useLoadOlderOnScroll({ @@ -329,27 +353,17 @@ export const MessageTimeline = React.memo(function MessageTimeline({ }); const showDirectMessageIntro = !isLoading && directMessageIntro !== null; - const showChannelIntro = - !isLoading && channelIntro !== null && directMessageIntro === null; - const showIntro = showDirectMessageIntro || showChannelIntro; - // The channel intro is the TERMINAL header of a bottom-anchored list — the - // thing you reach only once you've genuinely arrived at the true top. It must - // NOT be force-painted flush at the viewport top during first-load, because - // at that point "where the top is" isn't even determined yet (the list is - // still streaming in from the bottom). So the intro lives in-flow ABOVE the - // virtualized list and shares the bottom-pin (`min-h-full` + `mt-auto`): the - // intro+list block fills the container and pins to the bottom, leaving the - // intro scrolled off the top until the user scrolls up to it or the top - // naturally loads into view. The header is then EARNED by reaching the top, - // not asserted up front. (The earlier `topAlignIntro` flush-top flag was a - // different flavor of the step-5 lie — asserting "this is the top" before the - // top was determined — and is gone.) const showGenericEmpty = !isLoading && deferredMessages.length === 0 && directMessageIntro === null && channelIntro === null; - const showMessageList = !isLoading && deferredMessages.length > 0; + const showVirtualList = + !isLoading && (deferredMessages.length > 0 || introReady); + const useMinHeightFill = + showDirectMessageIntro || + showGenericEmpty || + (introReady && deferredMessages.length === 0); const timelineSkeletonRows = useTimelineSkeletonRows({ channelId, isLoading, @@ -373,7 +387,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ className={cn( "flex w-full flex-col gap-2", channelChrome.contentPadding, - (showIntro || showGenericEmpty) && "min-h-full", + useMinHeightFill && "min-h-full", )} >
@@ -387,12 +401,12 @@ export const MessageTimeline = React.memo(function MessageTimeline({ } @@ -418,107 +432,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
) : null} - {showChannelIntro ? ( -
-
- {channelIntro.icon ?? ( - - )} -
-

- #{channelIntro.channelName} -

-

- This is the beginning of the{" "} - - {channelIntro.channelKindLabel} - - . -

- {channelIntro.description ? ( -

- {channelIntro.description} -

- ) : null} - {channelIntro.actions?.length ? ( -
- {channelIntro.actions.map((action) => { - const hasDescription = Boolean(action.description); - - return ( - - ); - })} -
- ) : null} -
- ) : null} - {showGenericEmpty ? (
) : null} - {showMessageList ? ( + {showVirtualList ? (
) : null} - {showMessageList ? ( + {showVirtualList ? ( +
+ {intro.icon ?? } +
+

+ #{intro.channelName} +

+

+ This is the beginning of the{" "} + + {intro.channelKindLabel} + + . +

+ {intro.description ? ( +

+ {intro.description} +

+ ) : null} + {intro.actions?.length ? ( +
+ {intro.actions.map((action) => { + const hasDescription = Boolean(action.description); + + return ( + + ); + })} +
+ ) : null} +
+ ); +} + function DirectMessageIntroAvatarStack({ participants, }: { diff --git a/desktop/src/features/messages/ui/VirtualizedTimelineList.dom.test.tsx b/desktop/src/features/messages/ui/VirtualizedTimelineList.dom.test.tsx index 414c4bd80..d27867bda 100644 --- a/desktop/src/features/messages/ui/VirtualizedTimelineList.dom.test.tsx +++ b/desktop/src/features/messages/ui/VirtualizedTimelineList.dom.test.tsx @@ -60,6 +60,10 @@ function renderEntryStub(entry: MainTimelineEntry) { return {entry.message.body}; } +function renderIntroStub() { + return
intro
; +} + function divider(timestamp: number): VirtualTimelineRow { return { kind: "day-divider", @@ -169,3 +173,21 @@ test("positions rows at start minus scrollMargin (content-above offset)", () => // 200 - 128 = 72 assert.match(wrapper.style.transform, /translateY\(72px\)/); }); + +test("renders an intro row through the injected intro renderer", () => { + const rows: VirtualTimelineRow[] = [ + { kind: "intro", key: "channel-intro", messageIndex: -1 }, + divider(DAY_1), + ]; + render( + , + ); + assert.ok(screen.getByTestId("message-channel-intro")); +}); diff --git a/desktop/src/features/messages/ui/VirtualizedTimelineList.tsx b/desktop/src/features/messages/ui/VirtualizedTimelineList.tsx index b66a56631..6a59ba079 100644 --- a/desktop/src/features/messages/ui/VirtualizedTimelineList.tsx +++ b/desktop/src/features/messages/ui/VirtualizedTimelineList.tsx @@ -13,7 +13,7 @@ type VirtualizedTimelineListProps = { entries: MainTimelineEntry[]; /** * The virtualizer's `scrollMargin` — the list's offset within the scroll - * container (content above it: sentinel, spinner, intro). `virtualItem.start` + * container (content above it: sentinel, spinner). `virtualItem.start` * is in scroll-element coords (includes this margin), so rows are positioned * at `start - scrollMargin` within the spacer, which sits at that offset. */ @@ -25,6 +25,8 @@ type VirtualizedTimelineListProps = { * `renderTimelineEntry` bound to its render context. */ renderEntry: (entry: MainTimelineEntry) => React.ReactNode; + /** Renders the channel intro header when an intro row is in the virtual list. */ + renderIntro?: () => React.ReactNode; }; /** @@ -43,6 +45,7 @@ export const VirtualizedTimelineList = React.memo( entries, scrollMargin, renderEntry, + renderIntro, }: VirtualizedTimelineListProps) { const virtualItems = virtualizer.getVirtualItems(); @@ -74,7 +77,9 @@ export const VirtualizedTimelineList = React.memo( transform: `translateY(${virtualItem.start - scrollMargin}px)`, }} > - {row.kind === "day-divider" ? ( + {row.kind === "intro" ? ( + renderIntro?.() + ) : row.kind === "day-divider" ? ( ) : entry ? ( renderEntry(entry) diff --git a/desktop/src/features/messages/ui/useVirtualScrollMargin.ts b/desktop/src/features/messages/ui/useVirtualScrollMargin.ts index 5250b4095..1da8d09b7 100644 --- a/desktop/src/features/messages/ui/useVirtualScrollMargin.ts +++ b/desktop/src/features/messages/ui/useVirtualScrollMargin.ts @@ -5,16 +5,16 @@ import * as React from "react"; * scrollable content, to feed `useVirtualizer({ scrollMargin })`. * * The main timeline's scroll container holds content ABOVE the virtualized list - * inside the SAME scrollable element: the pagination sentinel, the - * "load older" spinner, and the channel/DM intro banner. `@tanstack/react-virtual` + * inside the SAME scrollable element: the pagination sentinel and the + * "load older" spinner. `@tanstack/react-virtual` * positions items at `paddingStart + scrollMargin`, so without this the * virtualizer assumes row 0 sits at scrollTop 0 — but it's actually painted * `scrollMargin` px lower. That mismatch is what makes freshly-loaded rows * sandwich into the header/list seam and the viewport drift while rows fill. * - * We re-measure whenever the above-content can change height (intro mount/ - * unmount, spinner toggle) AND via a ResizeObserver on the scroll container, so - * the margin stays correct as content streams in. + * We re-measure whenever the above-content can change height (spinner toggle) + * AND via a ResizeObserver on the scroll container, so the margin stays correct + * as content streams in. * * Returns both the margin and a `measured` flag. The flag matters because a * legitimate margin can be `0` (nothing above the list), so callers that must @@ -71,7 +71,7 @@ export function useVirtualScrollMargin( return; } // The above-content lives inside the container; observe the container so a - // height change in the sentinel/spinner/intro re-measures the margin. + // height change in the sentinel/spinner re-measures the margin. const observer = new ResizeObserver(measure); observer.observe(container); return () => observer.disconnect(); diff --git a/desktop/src/features/messages/ui/useVirtualTimelineScroll.ts b/desktop/src/features/messages/ui/useVirtualTimelineScroll.ts index d9e8c3a8d..4e772d405 100644 --- a/desktop/src/features/messages/ui/useVirtualTimelineScroll.ts +++ b/desktop/src/features/messages/ui/useVirtualTimelineScroll.ts @@ -6,7 +6,6 @@ import { type VirtualTimelineRow, } from "@/features/messages/lib/buildVirtualTimelineRows"; import { - isAtTop, isNearBottom, resolveDeepLinkTarget, selectLatestMessageKey, @@ -79,14 +78,6 @@ export function useVirtualTimelineScroll({ const [highlightedMessageId, setHighlightedMessageId] = React.useState< string | null >(null); - // Drives the channel-intro header's VISUAL reveal. The intro is the terminal - // header of a bottom-anchored list: it reserves its space (scrollMargin), but - // must only become VISIBLE once the first-load bottom pin has landed AND the - // user has genuinely arrived at the true top — never painted up front while - // the list streams in from the bottom. A standard overflow container rests at - // scrollTop 0 during the estimate→measure→settle window, so we gate on - // `hasInitialized && isAtTop`, not "scrollTop is 0" alone. - const [introRevealed, setIntroRevealed] = React.useState(false); const lastRowIndex = rows.length - 1; @@ -119,19 +110,8 @@ export function useVirtualTimelineScroll({ setIsAtBottom(true); setNewMessageCount(0); setHighlightedMessageId(null); - setIntroRevealed(false); }, [channelId]); - // Recompute whether the channel intro should be visible: only once the - // first-load pin has landed (`hasInitialized`) AND the container is genuinely - // at the top. Cheap geometry read, only flips state on a real change. - const syncIntroRevealed = React.useCallback(() => { - const container = scrollContainerRef.current; - const revealed = - hasInitializedRef.current && container !== null && isAtTop(container); - setIntroRevealed((current) => (current === revealed ? current : revealed)); - }, [scrollContainerRef]); - // Track bottom-pinned state off the native scroll event. The virtualizer owns // the scrollTop; we only read it to decide whether to keep auto-following. const syncScrollState = React.useCallback(() => { @@ -145,8 +125,7 @@ export function useVirtualTimelineScroll({ if (atBottom) { setNewMessageCount(0); } - syncIntroRevealed(); - }, [scrollContainerRef, syncIntroRevealed]); + }, [scrollContainerRef]); const latestMessage = messages.length > 0 ? messages[messages.length - 1] : undefined; @@ -173,10 +152,6 @@ export function useVirtualTimelineScroll({ hasInitializedRef.current = true; previousLastMessageKeyRef.current = latestMessageKey; previousMessageCountRef.current = messages.length; - // The first-load pin just landed: recompute reveal so a short channel - // (everything fits → top is genuinely also the bottom) surfaces its intro, - // while a long channel pinned off-top stays hidden. - syncIntroRevealed(); return; } @@ -216,7 +191,6 @@ export function useVirtualTimelineScroll({ messages.length, scrollMarginReady, scrollToBottom, - syncIntroRevealed, targetMessageId, ]); @@ -245,18 +219,7 @@ export function useVirtualTimelineScroll({ } lastPinnedTotalSizeRef.current = totalSize; virtualizer.scrollToIndex(lastRowIndex, { align: "end" }); - // Re-anchor changed the scroll position: recompute reveal so the intro - // tracks the new resting place (stays hidden off-top, surfaces only if the - // grown content still leaves us genuinely at the top). - syncIntroRevealed(); - }, [ - totalSize, - isLoading, - targetMessageId, - lastRowIndex, - syncIntroRevealed, - virtualizer, - ]); + }, [totalSize, isLoading, targetMessageId, lastRowIndex, virtualizer]); // Deep-link jump-to-message. Drives the virtualizer to mount and center the // target row, replacing the bespoke querySelector + scrollIntoView path that @@ -340,7 +303,6 @@ export function useVirtualTimelineScroll({ return { highlightedMessageId, - introRevealed, isAtBottom, newMessageCount, scrollToBottom,