diff --git a/desktop/package.json b/desktop/package.json index ed17b84e33..0a9e9b9dbd 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -70,6 +70,7 @@ "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.10", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index e007e3f915..d9f60fb298 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -13,6 +13,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { Spinner } from "@/shared/ui/spinner"; @@ -21,6 +22,7 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; +import type { TimelineVirtualizerApi } from "./TimelineMessageList"; import { useAnchoredScroll } from "./useAnchoredScroll"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; @@ -191,6 +193,21 @@ const MessageTimelineBase = React.forwardRef< const scrollContainerRef = externalScrollRef ?? internalScrollRef; const contentRef = React.useRef(null); const topSentinelRef = React.useRef(null); + const [virtualizerScrollParent, setVirtualizerScrollParent] = + React.useState(null); + const [virtualizerRenderVersion, bumpVirtualizerRenderVersion] = + React.useReducer((version: number) => version + 1, 0); + const [timelineVirtualizerApi, setTimelineVirtualizerApi] = + React.useState(null); + const useTimelineVirtualizer = useFeatureEnabled("virtuosoTimeline"); + const activeScrollContainerRef = React.useMemo( + () => ({ + get current() { + return virtualizerScrollParent ?? scrollContainerRef.current; + }, + }), + [scrollContainerRef, virtualizerScrollParent], + ); // Gate the heavy timeline render (each row runs a synchronous // react-markdown parse) behind React concurrency. `useDeferredValue` lets the @@ -230,6 +247,16 @@ const MessageTimelineBase = React.forwardRef< // painted at a stale offset until the user's next scroll event forces layout. const scrollContainerDomKey = channelId ?? "none"; + // biome-ignore lint/correctness/useExhaustiveDependencies: this effect is scoped to scroll DOM swaps; when the Experiments flag hydrates, the virtualizer child registers its own scroller/API via callbacks, and re-running this reset on the flag transition can briefly clear that API during navigation tests. + React.useLayoutEffect(() => { + // Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node. + void scrollContainerDomKey; + if (!useTimelineVirtualizer) { + setVirtualizerScrollParent(scrollContainerRef.current); + } + setTimelineVirtualizerApi(null); + }, [scrollContainerRef, scrollContainerDomKey]); + const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, isLoading: isLoading || isDeferredSnapshotStale, @@ -245,14 +272,19 @@ const MessageTimelineBase = React.forwardRef< scrollToBottom, scrollToBottomOnNextUpdate, scrollToMessage, + onVirtualizerAtBottomStateChange, } = useAnchoredScroll({ channelId, contentRef, isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, - scrollContainerRef, + scrollContainerRef: activeScrollContainerRef, targetMessageId, + virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, + virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom, + virtualizerOwnsPrependAnchoring: useTimelineVirtualizer, + virtualizerRenderVersion, }); const timelineIntroSurface = selectTimelineIntroSurface({ @@ -266,11 +298,18 @@ const MessageTimelineBase = React.forwardRef< const showDirectMessageIntro = timelineIntroSurface === "direct-message-intro"; const showChannelIntro = timelineIntroSurface === "channel-intro"; - const activeDirectMessageIntro = showDirectMessageIntro - ? directMessageIntro - : null; - const activeChannelIntro = showChannelIntro ? channelIntro : null; - const showIntro = showDirectMessageIntro || showChannelIntro; + const activeDirectMessageIntro = + showDirectMessageIntro && + (!useTimelineVirtualizer || timelineBodySurface !== "list") + ? directMessageIntro + : null; + const activeChannelIntro = + showChannelIntro && + (!useTimelineVirtualizer || timelineBodySurface !== "list") + ? channelIntro + : null; + const showIntro = + activeDirectMessageIntro !== null || activeChannelIntro !== null; const showGenericEmpty = timelineBodySurface === "empty" && !showIntro; const showMessageList = timelineBodySurface === "list"; @@ -349,20 +388,42 @@ const MessageTimelineBase = React.forwardRef< } }, [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. + // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it. React.useEffect(() => { const target = pendingSearchTargetRef.current; if (!target || showTimelineSkeleton) return; + if ( + useTimelineVirtualizer && + !activeScrollContainerRef.current?.querySelector( + `[data-message-id="${CSS.escape(target)}"]`, + ) + ) { + // Phase 1: ask Virtuoso to realize the match's index. The retry effect + // runs again on range change and the DOM-visible path does the actual + // center + highlight once the row exists. + void jumpToMessage(target, { behavior: "auto" }); + return; + } if (jumpToMessage(target, { behavior: "auto" })) { pendingSearchTargetRef.current = null; } - }, [deferredMessages, jumpToMessage, showTimelineSkeleton]); + }, [ + deferredMessages, + jumpToMessage, + showTimelineSkeleton, + virtualizerRenderVersion, + ]); + + const loadOlderViaVirtualizer = React.useCallback(() => { + if (!fetchOlder || showTimelineSkeleton || !hasOlderMessages) return; + void fetchOlder(); + }, [fetchOlder, hasOlderMessages, showTimelineSkeleton]); useLoadOlderOnScroll({ - fetchOlder, + fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder, hasOlderMessages, isLoading: showTimelineSkeleton, - scrollContainerRef, + scrollContainerRef: activeScrollContainerRef, sentinelRef: topSentinelRef, }); @@ -372,6 +433,55 @@ const MessageTimelineBase = React.forwardRef< messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages, }); + const handleVirtualizerRangeChanged = React.useCallback(() => { + bumpVirtualizerRenderVersion(); + onScroll(); + }, [onScroll]); + + const timelineList = showMessageList ? ( + + ) : null; + return (
@@ -408,217 +518,205 @@ const MessageTimelineBase = React.forwardRef< ) : null}
-
-
- - {/* Fixed-height slot: an always-mounted height keeps the virtual - spacer's offset stable across the load-older fetch toggle, so - `scrollMargin` doesn't shift mid-fetch and yank the restore. The - visible fetch spinner lives in the absolute overlay above, which - does not occupy inline flow. */} -
- + {useTimelineVirtualizer && timelineList ? ( +
+ {timelineList} +
+ ) : (
- {showTimelineSkeleton ? ( - - ) : null} - {activeDirectMessageIntro ? ( -
- -

- {activeDirectMessageIntro.displayName} -

-

- This is the beginning of your direct message with{" "} - - {activeDirectMessageIntro.displayName} - - . -

-
- ) : null} - - {activeChannelIntro ? ( -
+
+ + {/* Fixed-height slot: an always-mounted height keeps the virtual + spacer's offset stable across the load-older fetch toggle, so + `scrollMargin` doesn't shift mid-fetch and yank the restore. The + visible fetch spinner lives in the absolute overlay above, which + does not occupy inline flow. */} +
+ +
+ {showTimelineSkeleton ? ( + + ) : null} + {activeDirectMessageIntro ? (
- {activeChannelIntro.icon ?? ( - - )} + +

+ {activeDirectMessageIntro.displayName} +

+

+ This is the beginning of your direct message with{" "} + + {activeDirectMessageIntro.displayName} + + . +

-

- #{activeChannelIntro.channelName} -

-

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

- {activeChannelIntro.description ? ( -

- {activeChannelIntro.description} + ) : null} + + {activeChannelIntro ? ( +

+
+ {activeChannelIntro.icon ?? ( + + )} +
+

+ #{activeChannelIntro.channelName}

- ) : null} - {activeChannelIntro.actions?.length ? ( -
- {activeChannelIntro.actions.map((action) => { - const hasDescription = Boolean(action.description); - - return ( - - ); - })} -
- ) : null} -
- ) : null} - - {showGenericEmpty ? ( -
-

- {emptyTitle} -

-

- {emptyDescription} -

-
- ) : null} - - {showMessageList ? ( -
- -
- ) : null} + {action.description ? ( + + {action.description} + + ) : null} + + + ); + })} +
+ ) : null} +
+ ) : null} + + {showGenericEmpty ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null} + + {showMessageList ? ( +
+ {timelineList} +
+ ) : null} +
-
+ )}
{!isAtBottom ? ( diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index fdc9679104..79c0bd1003 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,4 +1,11 @@ import * as React from "react"; +import { GroupedVirtuoso } from "react-virtuoso"; +import type { + Components, + GroupedVirtuosoHandle, + ListRange, + ScrollerProps, +} from "react-virtuoso"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; @@ -6,6 +13,7 @@ import { buildTimelineDayGroups, buildTimelineItems, getTimelineItemKey, + type TimelineDayGroup, type TimelineNonDayItem, } from "@/features/messages/lib/timelineItems"; import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; @@ -28,6 +36,14 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; import { UnreadDivider } from "./UnreadDivider"; +export type TimelineVirtualizerApi = { + scrollToBottom: (behavior?: ScrollBehavior) => void; + scrollToMessage: ( + messageId: string, + options?: { behavior?: ScrollBehavior }, + ) => boolean; +}; + type TimelineMessageListProps = { agentPubkeys?: ReadonlySet; channelId?: string | null; @@ -81,6 +97,14 @@ type TimelineMessageListProps = { searchQuery?: string; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + /** Existing scroll container, reused by the virtualizer spike so MessageTimeline owns the scroll node. */ + useVirtualizer?: boolean; + onStartReached?: () => void; + onAtBottomStateChange?: (atBottom: boolean) => void; + onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; + onVirtualizerRangeChanged?: () => void; + onVirtualizerScroll?: () => void; + onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -114,6 +138,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, + useVirtualizer = false, + onStartReached, + onAtBottomStateChange, + onVirtualizerApiChange, + onVirtualizerRangeChanged, + onVirtualizerScroll, + onVirtualizerScrollerChange, }: TimelineMessageListProps) { const entries = React.useMemo( () => @@ -255,6 +286,21 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ], ); + if (useVirtualizer) { + return ( + + ); + } + return (
{dayGroups.map((group) => ( @@ -276,13 +322,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ )} {group.items.map((item) => ( -
+ {renderItem(item)} -
+ ))} ))} @@ -290,6 +332,364 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); }); +const FIRST_ITEM_INDEX_BASE = 1_000_000; + +function timelineItemMessageId(item: TimelineNonDayItem): string | null { + return item.kind === "message" || item.kind === "system" + ? item.entry.message.id + : null; +} + +function groupedInternalIndexForItem( + groupCounts: readonly number[], + itemIndex: number, +): number { + let itemCount = 0; + + for (let groupIndex = 0; groupIndex < groupCounts.length; groupIndex += 1) { + if (itemIndex < itemCount + groupCounts[groupIndex]) { + return itemIndex + groupIndex + 1; + } + itemCount += groupCounts[groupIndex]; + } + + return itemIndex + groupCounts.length; +} + +function buildGroupedInternalKeyByIndex( + firstItemIndex: number, + dayGroups: readonly TimelineDayGroup[], +): Map { + const keyByIndex = new Map(); + let internalIndex = 0; + + for (const group of dayGroups) { + keyByIndex.set(firstItemIndex + internalIndex, `day-${group.key}`); + internalIndex += 1; + + for (const item of group.items) { + keyByIndex.set(firstItemIndex + internalIndex, getTimelineItemKey(item)); + internalIndex += 1; + } + } + + return keyByIndex; +} + +function buildGroupedInternalData( + dayGroups: readonly TimelineDayGroup[], +): Array { + const data: Array = []; + + for (const group of dayGroups) { + data.push(undefined); + data.push(...group.items); + } + + return data; +} + +type VirtualizedTimelineRowsProps = { + dayGroups: TimelineDayGroup[]; + onAtBottomStateChange?: (atBottom: boolean) => void; + onStartReached?: () => void; + onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; + onVirtualizerRangeChanged?: () => void; + onVirtualizerScroll?: () => void; + onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; + renderItem: (item: TimelineNonDayItem) => React.ReactNode; +}; + +function VirtualizedTimelineRows({ + dayGroups, + onAtBottomStateChange, + onStartReached, + onVirtualizerApiChange, + onVirtualizerRangeChanged, + onVirtualizerScroll, + onVirtualizerScrollerChange, + renderItem, +}: VirtualizedTimelineRowsProps) { + const virtuosoRef = React.useRef(null); + const [scrollerElement, setScrollerElement] = + React.useState(null); + const firstItemIndexStateRef = React.useRef({ + firstItemIndex: FIRST_ITEM_INDEX_BASE, + keys: [], + }); + const timelineModel = React.useMemo(() => { + const items = dayGroups.flatMap((group) => group.items); + return { + firstItemIndex: getStableFirstItemIndex( + firstItemIndexStateRef.current, + items, + ), + flattenedItems: items, + groupCounts: dayGroups.map((group) => group.items.length), + }; + }, [dayGroups]); + const { firstItemIndex, flattenedItems, groupCounts } = timelineModel; + const groupedInternalKeyByIndex = React.useMemo( + () => buildGroupedInternalKeyByIndex(firstItemIndex, dayGroups), + [dayGroups, firstItemIndex], + ); + const groupedInternalData = React.useMemo( + () => buildGroupedInternalData(dayGroups), + [dayGroups], + ); + const virtualizerModelRef = React.useRef<{ + firstItemIndex: number; + flattenedCount: number; + groupCounts: readonly number[]; + messageItemIndexById: Map; + }>({ + firstItemIndex, + flattenedCount: flattenedItems.length, + groupCounts, + messageItemIndexById: new Map(), + }); + const deferredScrollFrameRef = React.useRef(null); + React.useEffect( + () => () => { + if (deferredScrollFrameRef.current !== null) { + window.cancelAnimationFrame(deferredScrollFrameRef.current); + deferredScrollFrameRef.current = null; + } + }, + [], + ); + const messageItemIndexById = React.useMemo(() => { + const byId = new Map(); + flattenedItems.forEach((item, index) => { + const messageId = timelineItemMessageId(item); + if (messageId) byId.set(messageId, index); + }); + return byId; + }, [flattenedItems]); + React.useLayoutEffect(() => { + virtualizerModelRef.current = { + firstItemIndex, + flattenedCount: flattenedItems.length, + groupCounts, + messageItemIndexById, + }; + }, [ + firstItemIndex, + flattenedItems.length, + groupCounts, + messageItemIndexById, + ]); + + React.useLayoutEffect(() => { + if (!onVirtualizerApiChange) return; + const api: TimelineVirtualizerApi = { + scrollToBottom(behavior = "auto") { + virtuosoRef.current?.scrollToIndex({ + align: "end", + behavior: behavior === "smooth" ? "smooth" : "auto", + index: "LAST", + }); + }, + scrollToMessage(messageId, options = {}) { + const itemIndex = messageItemIndexById.get(messageId); + if (itemIndex === undefined) return false; + if (deferredScrollFrameRef.current !== null) { + window.cancelAnimationFrame(deferredScrollFrameRef.current); + } + const requested = { + count: flattenedItems.length, + firstItemIndex, + itemIndex, + messageId, + }; + deferredScrollFrameRef.current = window.requestAnimationFrame(() => { + deferredScrollFrameRef.current = null; + const current = virtualizerModelRef.current; + const currentIndex = current.messageItemIndexById.get(messageId); + if ( + currentIndex !== requested.itemIndex || + current.firstItemIndex !== requested.firstItemIndex || + current.flattenedCount !== requested.count + ) { + return; + } + virtuosoRef.current?.scrollToIndex({ + align: "center", + behavior: options.behavior === "smooth" ? "smooth" : "auto", + index: groupedInternalIndexForItem( + current.groupCounts, + requested.itemIndex, + ), + }); + }); + return true; + }, + }; + onVirtualizerApiChange(api); + return () => onVirtualizerApiChange(null); + }, [ + flattenedItems.length, + firstItemIndex, + messageItemIndexById, + onVirtualizerApiChange, + ]); + + const handleScrollerRef = React.useCallback( + (ref: HTMLElement | Window | null) => { + const scroller = ref instanceof HTMLDivElement ? ref : null; + onVirtualizerScrollerChange?.(scroller); + setScrollerElement(scroller); + }, + [onVirtualizerScrollerChange], + ); + + React.useEffect(() => { + if (!scrollerElement || !onVirtualizerScroll) return; + scrollerElement.addEventListener("scroll", onVirtualizerScroll, { + passive: true, + }); + return () => + scrollerElement.removeEventListener("scroll", onVirtualizerScroll); + }, [onVirtualizerScroll, scrollerElement]); + + const handleRangeChanged = React.useCallback( + (_range: ListRange) => { + onVirtualizerRangeChanged?.(); + }, + [onVirtualizerRangeChanged], + ); + + return ( + + ref={virtuosoRef} + className="h-full min-h-0 w-full" + components={virtuosoComponents} + computeItemKey={(index, item) => + item === undefined + ? (groupedInternalKeyByIndex.get(index) ?? + `timeline-missing-${index}`) + : getTimelineItemKey(item) + } + data={groupedInternalData} + defaultItemHeight={72} + firstItemIndex={firstItemIndex} + followOutput="auto" + atBottomStateChange={onAtBottomStateChange} + initialTopMostItemIndex={{ align: "end", index: "LAST" }} + groupContent={(groupIndex) => { + const group = dayGroups[groupIndex]; + return ( +
+ {group.headingTimestamp === null ? null : ( + + )} +
+ ); + }} + groupCounts={groupCounts} + itemContent={(_index, _groupIndex, item) => { + if (!item) return null; + return ( + + {renderItem(item)} + + ); + }} + rangeChanged={handleRangeChanged} + scrollerRef={handleScrollerRef} + startReached={onStartReached ? () => onStartReached() : undefined} + /> + ); +} + +const virtuosoComponents: Components = { + Scroller: React.forwardRef< + HTMLDivElement, + ScrollerProps & { className?: string } + >(function TimelineVirtuosoScroller({ className, ...props }, ref) { + return ( +
+ ); + }), + List: React.forwardRef>( + function TimelineVirtuosoList({ className, ...props }, ref) { + return ( +
+ ); + }, + ), +}; + +function TimelineRowShell({ + children, + item, + useContentVisibility = true, +}: { + children: React.ReactNode; + item: TimelineNonDayItem; + useContentVisibility?: boolean; +}) { + return ( +
+ {children} +
+ ); +} + +type StableFirstItemIndexState = { + firstItemIndex: number; + keys: string[]; +}; + +function getStableFirstItemIndex( + state: StableFirstItemIndexState, + items: TimelineNonDayItem[], +): number { + const keys = items.map(getTimelineItemKey); + let firstItemIndex = state.firstItemIndex; + + if (state.keys.length === 0 || keys.length === 0) { + firstItemIndex = FIRST_ITEM_INDEX_BASE; + } else { + const previousFirstKey = state.keys[0]; + const previousFirstIndex = keys.indexOf(previousFirstKey); + if (previousFirstIndex > 0) { + firstItemIndex -= previousFirstIndex; + } else if (previousFirstIndex === -1) { + firstItemIndex = FIRST_ITEM_INDEX_BASE; + } + } + + state.firstItemIndex = firstItemIndex; + state.keys = keys; + return firstItemIndex; +} + function SystemRow({ currentPubkey, entry, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2c0176df7f..d603f50978 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -49,6 +49,16 @@ type UseAnchoredScrollOptions = { /** When set, scroll to and highlight this message on mount and on change. */ targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; + virtualScrollToMessage?: ( + messageId: string, + options?: { behavior?: ScrollBehavior }, + ) => boolean; + /** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */ + virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; + /** Spike mode: let the virtualizer preserve prepend position via its own firstItemIndex path. */ + virtualizerOwnsPrependAnchoring?: boolean; + /** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */ + virtualizerRenderVersion?: number; }; type UseAnchoredScrollResult = { @@ -72,6 +82,8 @@ type UseAnchoredScrollResult = { messageId: string, options?: { highlight?: boolean; behavior?: ScrollBehavior }, ) => boolean; + /** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */ + onVirtualizerAtBottomStateChange: (atBottom: boolean) => void; }; function isAtBottomNow( @@ -150,6 +162,10 @@ export function useAnchoredScroll({ targetMessageId = null, onTargetReached, + virtualScrollToMessage, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring = false, + virtualizerRenderVersion = 0, }: 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 @@ -222,11 +238,19 @@ export function useAnchoredScroll({ // every imperative bottom jump so `onScroll` holds the at-bottom anchor // until it can snap to the true floor. settlingRef.current = true; - container.scrollTo({ top: container.scrollHeight, behavior }); + if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { + virtualScrollToBottom(behavior); + } else { + container.scrollTo({ top: container.scrollHeight, behavior }); + } setIsAtBottom(true); setNewMessageCount(0); }, - [scrollContainerRef], + [ + scrollContainerRef, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring, + ], ); // Arm a one-shot: the next append snaps to bottom regardless of where the @@ -236,6 +260,19 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = true; }, []); + const highlightMessage = React.useCallback((messageId: string) => { + if (highlightTimeoutRef.current !== null) { + window.clearTimeout(highlightTimeoutRef.current); + } + setHighlightedMessageId(messageId); + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + highlightTimeoutRef.current = null; + }, 2_000); + }, []); + const scrollToMessageImperative = React.useCallback( ( messageId: string, @@ -246,7 +283,25 @@ export function useAnchoredScroll({ const el = container.querySelector( `[data-message-id="${messageId}"]`, ); - if (!el) return false; + if (!el) { + if ( + virtualScrollToMessage?.(messageId, { + // Phase 1 only asks the virtualizer to realize an off-DOM row. + // Keep this jump synchronous; the DOM-visible phase below applies + // the caller's smooth/auto behavior once the target can be measured. + behavior: "auto", + }) + ) { + anchorRef.current = { kind: "message", messageId, topOffset: 0 }; + setIsAtBottom(false); + // In virtualized mode, this is only the phase-1 realization request: + // the target row is not in the DOM yet, so centering can still re-aim + // once Virtuoso mounts/measures it. Start the highlight clock only on + // the later DOM-visible path, otherwise it can fade before the row has + // settled in view. + } + return false; + } const rect = el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); @@ -279,21 +334,10 @@ export function useAnchoredScroll({ }; setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX); - if (options.highlight) { - if (highlightTimeoutRef.current !== null) { - window.clearTimeout(highlightTimeoutRef.current); - } - setHighlightedMessageId(messageId); - highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedMessageId((current) => - current === messageId ? null : current, - ); - highlightTimeoutRef.current = null; - }, 2_000); - } + if (options.highlight) highlightMessage(messageId); return true; }, - [scrollContainerRef], + [highlightMessage, scrollContainerRef, virtualScrollToMessage], ); // Scroll handler: recompute anchor + bottom state from the current @@ -311,6 +355,9 @@ export function useAnchoredScroll({ if (settleProgrammaticBottomPin(container)) { settlingRef.current = false; } else { + if (virtualizerOwnsPrependAnchoring) { + settlingRef.current = false; + } return; } } @@ -320,7 +367,7 @@ export function useAnchoredScroll({ if (atBottom) { setNewMessageCount(0); } - }, [scrollContainerRef]); + }, [scrollContainerRef, virtualizerOwnsPrependAnchoring]); // --------------------------------------------------------------------------- // Anchor restoration: after every render, stick to the bottom if the user is @@ -396,7 +443,11 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = false; anchorRef.current = { kind: "at-bottom" }; settlingRef.current = true; - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { + virtualScrollToBottom("auto"); + } else { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } setIsAtBottom(true); setNewMessageCount(0); prevLastMessageIdRef.current = lastMessage?.id; @@ -407,10 +458,13 @@ export function useAnchoredScroll({ } if (anchor.kind === "at-bottom") { - // Stick to bottom across the append. - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + // Stick to bottom across the append. In virtualized mode, Virtuoso's + // followOutput owns this write; do not slam the shared scroll parent. + if (!virtualizerOwnsPrependAnchoring) { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } if (newLatestArrived) setNewMessageCount(0); - } else if (messagesArrived > 0) { + } else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) { // 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 @@ -449,6 +503,8 @@ export function useAnchoredScroll({ scrollToBottomImperative, scrollToMessageImperative, targetMessageId, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring, ]); // --------------------------------------------------------------------------- @@ -466,13 +522,21 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if (anchorRef.current.kind === "at-bottom") { + if ( + anchorRef.current.kind === "at-bottom" && + !virtualizerOwnsPrependAnchoring + ) { container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); } }); observer.observe(content); return () => observer.disconnect(); - }, [channelId, contentRef, scrollContainerRef]); + }, [ + channelId, + contentRef, + scrollContainerRef, + virtualizerOwnsPrependAnchoring, + ]); // --------------------------------------------------------------------------- // Target message handling (deep link, jump-to-reply, etc.). Distinct from @@ -486,7 +550,7 @@ export function useAnchoredScroll({ // *without* marking the target handled until its row actually exists — each // subsequent message commit re-runs the effect and retries the centering. // --------------------------------------------------------------------------- - // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits. + // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits. React.useEffect(() => { if (!targetMessageId) { handledTargetIdRef.current = null; @@ -495,11 +559,19 @@ export function useAnchoredScroll({ if (handledTargetIdRef.current === targetMessageId || isLoading) return; if (!hasInitializedRef.current) return; // initial-mount path will handle. + void virtualizerRenderVersion; const container = scrollContainerRef.current; if (!container) return; const el = container.querySelector( `[data-message-id="${targetMessageId}"]`, ); + if (!el && virtualizerOwnsPrependAnchoring) { + if (scrollToMessageImperative(targetMessageId, { highlight: true })) { + handledTargetIdRef.current = targetMessageId; + onTargetReached?.(targetMessageId); + } + return; + } if (!el) { // 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 @@ -516,6 +588,8 @@ export function useAnchoredScroll({ scrollContainerRef, scrollToMessageImperative, targetMessageId, + virtualizerOwnsPrependAnchoring, + virtualizerRenderVersion, ]); React.useEffect(() => { @@ -526,6 +600,18 @@ export function useAnchoredScroll({ }; }, []); + const onVirtualizerAtBottomStateChange = React.useCallback( + (atBottom: boolean) => { + if (!virtualizerOwnsPrependAnchoring) return; + if (atBottom) { + anchorRef.current = { kind: "at-bottom" }; + setNewMessageCount(0); + } + setIsAtBottom(atBottom); + }, + [virtualizerOwnsPrependAnchoring], + ); + return { onScroll, isAtBottom, @@ -534,5 +620,6 @@ export function useAnchoredScroll({ scrollToBottom: scrollToBottomImperative, scrollToBottomOnNextUpdate, scrollToMessage: scrollToMessageImperative, + onVirtualizerAtBottomStateChange, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b678ba734b..aec089a35e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + react-virtuoso: + specifier: ^4.18.10 + version: 4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -2809,6 +2812,12 @@ packages: '@types/react': optional: true + react-virtuoso@4.18.10: + resolution: {integrity: sha512-P6GIZ7kWAPOYB2H16yRQNgy+VF9pJOuTFw1EUc1EAtCj5WxVSAF1Sql3x3fbLwaLeBFsiPnu+3U9o6sIOyTdFw==, tarball: https://global.block-artifacts.com/artifactory/api/npm/square-npm/react-virtuoso/-/react-virtuoso-4.18.10.tgz} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -5680,6 +5689,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + react-virtuoso@4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react@19.2.7: {} readable-stream@4.7.0: diff --git a/preview-features.json b/preview-features.json index bc5f6d16c0..501f23620d 100644 --- a/preview-features.json +++ b/preview-features.json @@ -5,41 +5,37 @@ "id": "workflows", "name": "Workflows", "description": "YAML-defined automations with approval gates", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "projects", "name": "Projects", "description": "Git repository browser and collaboration", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "pulse", "name": "Pulse", "description": "Activity feed with notes, social posts, and agent activity", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "workspaceRail", "name": "Workspace Rail", "description": "Far-left rail for switching relays with cross-relay unread indicators", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] }, { "id": "forum", "name": "Forum Channels", "description": "Forum-style threaded channels for long-form discussions", - "platforms": [ - "desktop" - ] + "platforms": ["desktop"] + }, + { + "id": "virtuosoTimeline", + "name": "Virtuoso Timeline", + "description": "Virtualized channel timeline using react-virtuoso", + "platforms": ["desktop"] } ] }