Spike Virtuoso timeline one-writer integration

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
2026-07-09 09:00:53 -04:00
parent e0f76b0e9c
commit be9cf8ee17
5 changed files with 350 additions and 30 deletions
+1
View File
@@ -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",
@@ -21,6 +21,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 +192,10 @@ const MessageTimelineBase = React.forwardRef<
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const contentRef = React.useRef<HTMLDivElement>(null);
const topSentinelRef = React.useRef<HTMLDivElement>(null);
const [virtualizerScrollParent, setVirtualizerScrollParent] =
React.useState<HTMLDivElement | null>(null);
const [timelineVirtualizerApi, setTimelineVirtualizerApi] =
React.useState<TimelineVirtualizerApi | null>(null);
// Gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
@@ -230,6 +235,13 @@ const MessageTimelineBase = React.forwardRef<
// painted at a stale offset until the user's next scroll event forces layout.
const scrollContainerDomKey = channelId ?? "none";
React.useLayoutEffect(() => {
// Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node.
void scrollContainerDomKey;
setVirtualizerScrollParent(scrollContainerRef.current);
setTimelineVirtualizerApi(null);
}, [scrollContainerRef, scrollContainerDomKey]);
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
isLoading: isLoading || isDeferredSnapshotStale,
@@ -245,6 +257,7 @@ const MessageTimelineBase = React.forwardRef<
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
onVirtualizerAtBottomStateChange,
} = useAnchoredScroll({
channelId,
contentRef,
@@ -253,6 +266,9 @@ const MessageTimelineBase = React.forwardRef<
onTargetReached,
scrollContainerRef,
targetMessageId,
virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
virtualizerOwnsPrependAnchoring: virtualizerScrollParent !== null,
});
const timelineIntroSurface = selectTimelineIntroSurface({
@@ -358,8 +374,13 @@ const MessageTimelineBase = React.forwardRef<
}
}, [deferredMessages, jumpToMessage, showTimelineSkeleton]);
const loadOlderViaVirtualizer = React.useCallback(() => {
if (!fetchOlder || showTimelineSkeleton || !hasOlderMessages) return;
void fetchOlder();
}, [fetchOlder, hasOlderMessages, showTimelineSkeleton]);
useLoadOlderOnScroll({
fetchOlder,
fetchOlder: virtualizerScrollParent ? undefined : fetchOlder,
hasOlderMessages,
isLoading: showTimelineSkeleton,
scrollContainerRef,
@@ -606,12 +627,16 @@ const MessageTimelineBase = React.forwardRef<
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onStartReached={loadOlderViaVirtualizer}
onToggleReaction={onToggleReaction}
onVirtualizerApiChange={setTimelineVirtualizerApi}
onAtBottomStateChange={onVirtualizerAtBottomStateChange}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
scrollParent={virtualizerScrollParent}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
/>
@@ -1,4 +1,6 @@
import * as React from "react";
import { GroupedVirtuoso } from "react-virtuoso";
import type { Components, GroupedVirtuosoHandle } from "react-virtuoso";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate";
@@ -6,6 +8,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 +31,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<string>;
channelId?: string | null;
@@ -81,6 +92,11 @@ type TimelineMessageListProps = {
searchQuery?: string;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
/** Existing scroll container, reused by the virtualizer spike so MessageTimeline owns the scroll node. */
scrollParent?: HTMLElement | null;
onStartReached?: () => void;
onAtBottomStateChange?: (atBottom: boolean) => void;
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
};
export const TimelineMessageList = React.memo(function TimelineMessageList({
@@ -114,6 +130,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchQuery,
threadUnreadCounts,
unfollowThreadById,
scrollParent = null,
onStartReached,
onAtBottomStateChange,
onVirtualizerApiChange,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() =>
@@ -255,6 +275,19 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
],
);
if (scrollParent) {
return (
<VirtualizedTimelineRows
dayGroups={dayGroups}
onAtBottomStateChange={onAtBottomStateChange}
onStartReached={onStartReached}
onVirtualizerApiChange={onVirtualizerApiChange}
renderItem={renderItem}
scrollParent={scrollParent}
/>
);
}
return (
<div className="flex flex-col">
{dayGroups.map((group) => (
@@ -276,13 +309,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
)}
{group.items.map((item) => (
<div
className="timeline-row-cv"
key={getTimelineItemKey(item)}
style={timelineRowReserveStyle(item)}
>
<TimelineRowShell item={item} key={getTimelineItemKey(item)}>
{renderItem(item)}
</div>
</TimelineRowShell>
))}
</section>
))}
@@ -290,6 +319,178 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
);
});
const FIRST_ITEM_INDEX_BASE = 1_000_000;
type VirtualizedTimelineRowsProps = {
dayGroups: TimelineDayGroup[];
onAtBottomStateChange?: (atBottom: boolean) => void;
onStartReached?: () => void;
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
scrollParent: HTMLElement;
};
function VirtualizedTimelineRows({
dayGroups,
onAtBottomStateChange,
onStartReached,
onVirtualizerApiChange,
renderItem,
scrollParent,
}: VirtualizedTimelineRowsProps) {
const virtuosoRef = React.useRef<GroupedVirtuosoHandle>(null);
const firstItemIndexStateRef = React.useRef<StableFirstItemIndexState>({
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 messageVirtualIndexById = React.useMemo(() => {
const byId = new Map<string, number>();
flattenedItems.forEach((item, index) => {
if (item.kind === "message" || item.kind === "system") {
byId.set(item.entry.message.id, index);
}
});
return byId;
}, [flattenedItems]);
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 index = messageVirtualIndexById.get(messageId);
if (index === undefined) return false;
virtuosoRef.current?.scrollToIndex({
align: "center",
behavior: options.behavior === "smooth" ? "smooth" : "auto",
index,
});
return true;
},
};
onVirtualizerApiChange(api);
return () => onVirtualizerApiChange(null);
}, [messageVirtualIndexById, onVirtualizerApiChange]);
return (
<GroupedVirtuoso<TimelineNonDayItem>
ref={virtuosoRef}
className="min-h-full"
components={virtuosoComponents}
computeItemKey={(index, item) =>
item === undefined
? `timeline-missing-${index}`
: getTimelineItemKey(item)
}
customScrollParent={scrollParent}
data={flattenedItems}
defaultItemHeight={72}
firstItemIndex={firstItemIndex}
followOutput="auto"
atBottomStateChange={onAtBottomStateChange}
initialTopMostItemIndex={{ align: "end", index: "LAST" }}
groupContent={(groupIndex) => {
const group = dayGroups[groupIndex];
return (
<div
className={cn(
"relative flex flex-col",
group.headingTimestamp !== null &&
"before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']",
)}
data-day-label={
group.headingTimestamp === null
? undefined
: formatDayHeading(group.headingTimestamp)
}
data-testid="message-timeline-day-group"
>
{group.headingTimestamp === null ? null : (
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
)}
</div>
);
}}
groupCounts={groupCounts}
itemContent={(_index, _groupIndex, item) => (
<TimelineRowShell item={item}>{renderItem(item)}</TimelineRowShell>
)}
startReached={onStartReached ? () => onStartReached() : undefined}
/>
);
}
const virtuosoComponents: Components<TimelineNonDayItem> = {
List: React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
function TimelineVirtuosoList({ className, ...props }, ref) {
return (
<div {...props} className={cn("flex flex-col", className)} ref={ref} />
);
},
),
};
function TimelineRowShell({
children,
item,
}: {
children: React.ReactNode;
item: TimelineNonDayItem;
}) {
return (
<div className="timeline-row-cv" style={timelineRowReserveStyle(item)}>
{children}
</div>
);
}
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,
@@ -49,6 +49,14 @@ 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;
};
type UseAnchoredScrollResult = {
@@ -72,6 +80,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 +160,9 @@ export function useAnchoredScroll({
targetMessageId = null,
onTargetReached,
virtualScrollToMessage,
virtualScrollToBottom,
virtualizerOwnsPrependAnchoring = false,
}: 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 +235,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 +257,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 +280,19 @@ export function useAnchoredScroll({
const el = container.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`,
);
if (!el) return false;
if (!el) {
if (
virtualScrollToMessage?.(messageId, {
behavior: options.behavior ?? "auto",
})
) {
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
setIsAtBottom(false);
if (options.highlight) highlightMessage(messageId);
return true;
}
return false;
}
const rect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
@@ -279,21 +325,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 +346,9 @@ export function useAnchoredScroll({
if (settleProgrammaticBottomPin(container)) {
settlingRef.current = false;
} else {
if (virtualizerOwnsPrependAnchoring) {
settlingRef.current = false;
}
return;
}
}
@@ -320,7 +358,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 +434,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 +449,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 +494,8 @@ export function useAnchoredScroll({
scrollToBottomImperative,
scrollToMessageImperative,
targetMessageId,
virtualScrollToBottom,
virtualizerOwnsPrependAnchoring,
]);
// ---------------------------------------------------------------------------
@@ -466,13 +513,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
@@ -500,6 +555,13 @@ export function useAnchoredScroll({
const el = container.querySelector<HTMLElement>(
`[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 +578,7 @@ export function useAnchoredScroll({
scrollContainerRef,
scrollToMessageImperative,
targetMessageId,
virtualizerOwnsPrependAnchoring,
]);
React.useEffect(() => {
@@ -526,6 +589,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 +609,6 @@ export function useAnchoredScroll({
scrollToBottom: scrollToBottomImperative,
scrollToBottomOnNextUpdate,
scrollToMessage: scrollToMessageImperative,
onVirtualizerAtBottomStateChange,
};
}
+17
View File
@@ -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
@@ -286,6 +289,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-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -2809,6 +2815,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 +5692,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: