From 24f4383e076df94a5b2e8e81a84d1f007b484553 Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 18:23:25 -0400 Subject: [PATCH] fix(desktop): restore virtual timeline prepend anchors Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> --- .../features/messages/ui/MessageTimeline.tsx | 24 ++- .../messages/ui/TimelineMessageList.tsx | 194 ++++++++++++++++-- .../features/messages/ui/useAnchoredScroll.ts | 8 +- preview-features.json | 26 +-- 4 files changed, 213 insertions(+), 39 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index d5080cb4d1..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"; @@ -198,7 +199,7 @@ const MessageTimelineBase = React.forwardRef< React.useReducer((version: number) => version + 1, 0); const [timelineVirtualizerApi, setTimelineVirtualizerApi] = React.useState(null); - const useTimelineVirtualizer = true; + const useTimelineVirtualizer = useFeatureEnabled("virtuosoTimeline"); const activeScrollContainerRef = React.useMemo( () => ({ get current() { @@ -246,6 +247,7 @@ 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; @@ -390,6 +392,18 @@ const MessageTimelineBase = React.forwardRef< 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; } @@ -419,6 +433,11 @@ const MessageTimelineBase = React.forwardRef< messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages, }); + const handleVirtualizerRangeChanged = React.useCallback(() => { + bumpVirtualizerRenderVersion(); + onScroll(); + }, [onScroll]); + const timelineList = showMessageList ? ( void; onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; onVirtualizerRangeChanged?: () => void; + onVirtualizerScroll?: () => void; onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; }; @@ -142,6 +143,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onAtBottomStateChange, onVirtualizerApiChange, onVirtualizerRangeChanged, + onVirtualizerScroll, onVirtualizerScrollerChange, }: TimelineMessageListProps) { const entries = React.useMemo( @@ -292,6 +294,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onStartReached={onStartReached} onVirtualizerApiChange={onVirtualizerApiChange} onVirtualizerRangeChanged={onVirtualizerRangeChanged} + onVirtualizerScroll={onVirtualizerScroll} onVirtualizerScrollerChange={onVirtualizerScrollerChange} renderItem={renderItem} /> @@ -331,12 +334,68 @@ 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; }; @@ -347,10 +406,13 @@ function VirtualizedTimelineRows({ 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: [], @@ -367,15 +429,56 @@ function VirtualizedTimelineRows({ }; }, [dayGroups]); const { firstItemIndex, flattenedItems, groupCounts } = timelineModel; - const messageVirtualIndexById = React.useMemo(() => { + 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) => { - if (item.kind === "message" || item.kind === "system") { - byId.set(item.entry.message.id, 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; @@ -388,27 +491,67 @@ function VirtualizedTimelineRows({ }); }, scrollToMessage(messageId, options = {}) { - const index = messageVirtualIndexById.get(messageId); - if (index === undefined) return false; - virtuosoRef.current?.scrollToIndex({ - align: "center", - behavior: options.behavior === "smooth" ? "smooth" : "auto", - index, + 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); - }, [messageVirtualIndexById, onVirtualizerApiChange]); + }, [ + flattenedItems.length, + firstItemIndex, + messageItemIndexById, + onVirtualizerApiChange, + ]); const handleScrollerRef = React.useCallback( (ref: HTMLElement | Window | null) => { - onVirtualizerScrollerChange?.(ref instanceof HTMLDivElement ? ref : 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?.(); @@ -417,16 +560,17 @@ function VirtualizedTimelineRows({ ); return ( - + ref={virtuosoRef} className="h-full min-h-0 w-full" components={virtuosoComponents} computeItemKey={(index, item) => item === undefined - ? `timeline-missing-${index}` + ? (groupedInternalKeyByIndex.get(index) ?? + `timeline-missing-${index}`) : getTimelineItemKey(item) } - data={flattenedItems} + data={groupedInternalData} defaultItemHeight={72} firstItemIndex={firstItemIndex} followOutput="auto" @@ -455,9 +599,14 @@ function VirtualizedTimelineRows({ ); }} groupCounts={groupCounts} - itemContent={(_index, _groupIndex, item) => ( - {renderItem(item)} - )} + itemContent={(_index, _groupIndex, item) => { + if (!item) return null; + return ( + + {renderItem(item)} + + ); + }} rangeChanged={handleRangeChanged} scrollerRef={handleScrollerRef} startReached={onStartReached ? () => onStartReached() : undefined} @@ -465,7 +614,7 @@ function VirtualizedTimelineRows({ ); } -const virtuosoComponents: Components = { +const virtuosoComponents: Components = { Scroller: React.forwardRef< HTMLDivElement, ScrollerProps & { className?: string } @@ -496,12 +645,17 @@ const virtuosoComponents: Components = { function TimelineRowShell({ children, item, + useContentVisibility = true, }: { children: React.ReactNode; item: TimelineNonDayItem; + useContentVisibility?: boolean; }) { return ( -
+
{children}
); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index e58a988444..d603f50978 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -286,7 +286,10 @@ export function useAnchoredScroll({ if (!el) { if ( virtualScrollToMessage?.(messageId, { - behavior: options.behavior ?? "auto", + // 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 }; @@ -547,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; @@ -556,6 +559,7 @@ 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( 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"] } ] }