feat(desktop): virtualize timeline + thread pane onto single scroll owner

The two chat-message surfaces (MessageTimeline, MessageThreadPanel) ran
on a hand-rolled scroll manager with two ResizeObservers and a double-rAF
scrollTop band-aid that raced during prepend, causing the channel-jump
bug. Replace it with @tanstack/react-virtual as the single scroll owner.

virtual-core@3.17.0 ships the three hard behaviors natively, so they are
configuration not hand-built code: anchorTo:"end" holds the viewport on
load-older prepend, followOnAppend pins the bottom during a burst, and the
internal reconcile loop settles deep-link scrollToIndex once the target
row measures. The hook owns only what the library does not: the
short-channel bottom-align pad, the at-bottom/new-message pill state, and
the deep-link highlight.

Channel/thread switch resets via key-remount (fresh virtualizer = zero
stale measurement cache) rather than porting the old reset logic.
useTimelineScrollManager + useLoadOlderOnScroll deleted, zero callers.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-17 19:42:16 -04:00
parent 7ab6b4051c
commit 8a4ef01cff
12 changed files with 913 additions and 1056 deletions

View File

@@ -638,6 +638,7 @@ export const ChannelPane = React.memo(function ChannelPane({
<MessageTimeline
agentPubkeys={agentPubkeys}
channelId={activeChannel?.id}
key={activeChannel?.id}
channelIntro={channelIntro}
directMessageIntro={directMessageIntro}
scrollContainerRef={timelineScrollRef}
@@ -790,6 +791,7 @@ export const ChannelPane = React.memo(function ChannelPane({
agentPubkeys={agentPubkeys}
channel={activeChannel}
channelId={activeChannel?.id ?? null}
key={threadHeadMessage.id}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}

View File

@@ -16,8 +16,8 @@ export type MessageLinkInput = {
*
* Currently emitted into the URL but not consumed by the click handler
* or deep-link listener — both route via `goChannel(channelId,
* { messageId })` and let `useTimelineScrollManager` resolve the target.
* Reserved for future "open in thread view" routing.
* { messageId })` and let the timeline's `useChatScrollVirtualizer`
* resolve the target. Reserved for future "open in thread view" routing.
*/
threadRootId?: string | null;
};

View File

@@ -4,6 +4,7 @@ import test from "node:test";
import {
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
buildThreadReplyVirtualItems,
buildTimelineVirtualItems,
isNearBottomMetrics,
resolveDeepLinkTarget,
@@ -394,3 +395,71 @@ test("virtual-items: item count equals entries plus one day item per calendar da
["day", "message", "message", "day", "unread", "message"],
);
});
// --- thread reply flat-item model (thread-pane virtualization) --------------
//
// buildThreadReplyVirtualItems flattens reply entries + firstUnreadReplyId into
// the ordered, keyed row list the thread pane's virtualizer measures. The
// thread pane has no day grouping and renders the head OUTSIDE the list, so the
// only kinds are an unread boundary and a reply (summary rides on the reply).
test("thread-items: empty entries produce no items", () => {
assert.deepEqual(buildThreadReplyVirtualItems([]), []);
});
test("thread-items: each reply becomes one item, no day dividers", () => {
const items = buildThreadReplyVirtualItems([
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
entry({ id: "b", createdAt: dayAt(2026, 6, 15) }),
]);
assert.deepEqual(
items.map((item) => item.kind),
["reply", "reply"],
);
assert.deepEqual(
items.map((item) => item.key),
["a", "b"],
);
});
test("thread-items: unread divider precedes the first unread reply when not index 0", () => {
const items = buildThreadReplyVirtualItems(
[entry({ id: "a" }), entry({ id: "b" }), entry({ id: "c" })],
"b",
);
assert.deepEqual(
items.map((item) => item.kind),
["reply", "unread", "reply", "reply"],
);
assert.equal(items[1].key, "unread-b");
});
test("thread-items: unread divider suppressed when the first unread reply is index 0", () => {
const items = buildThreadReplyVirtualItems(
[entry({ id: "a" }), entry({ id: "b" })],
"a",
);
assert.deepEqual(
items.map((item) => item.kind),
["reply", "reply"],
);
});
test("thread-items: a reply carries its inline summary, never a separate item", () => {
const summary = {
threadHeadId: "a",
replyCount: 2,
lastReplyAt: null,
participants: [],
};
const items = buildThreadReplyVirtualItems([entry({ id: "a" }, summary)]);
assert.equal(items.length, 1);
assert.equal(items[0].summary, summary);
});
test("thread-items: reply key prefers renderKey for optimistic-send identity", () => {
const items = buildThreadReplyVirtualItems([
entry({ id: "server-id", renderKey: "local-key" }),
]);
assert.equal(items[0].key, "local-key");
});

View File

@@ -160,7 +160,7 @@ export function selectDeferredListRenderState(
* the virtualizer measures and positions, so every divider that the legacy
* `TimelineMessageList` rendered as a sibling node becomes its own item here.
*
* - "day" → a day-boundary heading; renders the sticky `DayDivider`
* - "day" → a day-boundary heading; renders the inline `DayDivider`
* - "unread" → the "New" read/unread boundary above the first unread message
* - "system" → a system message (join/leave); renders `SystemMessageRow`
* - "message" → a normal message; carries the thread `summary` it renders
@@ -227,3 +227,51 @@ export function buildTimelineVirtualItems(
return items;
}
/**
* One row in the flattened, virtualizer-ready thread reply list. The thread
* pane has a simpler shape than the main timeline — no day grouping, no system
* rows, and the head message renders OUTSIDE the virtualized region — so the
* only item kinds are an unread boundary and a reply (which carries the inline
* thread `summary` it renders beneath the row, never a separate item, to keep
* one measured unit per reply).
*
* `key` is byte-identical to the legacy reply-list keys
* (`message.renderKey ?? message.id`) so optimistic-send identity stays stable
* across the migration.
*/
export type ThreadReplyVirtualItem =
| { kind: "unread"; key: string }
| {
kind: "reply";
key: string;
message: TimelineMessage;
summary: TimelineThreadSummary | null;
};
/**
* Flattens thread reply entries into the ordered virtual-item list the thread
* pane's virtualizer consumes. Mirrors the legacy reply-list render walk: an
* unread divider precedes the first unread reply (suppressed at index 0, where
* there is nothing above it to divide), and every entry becomes a `reply` item.
* Pure over the snapshot so the divider rule stays lib-tested without a DOM.
*/
export function buildThreadReplyVirtualItems(
entries: readonly MainTimelineEntry[],
firstUnreadReplyId: string | null | undefined = null,
): ThreadReplyVirtualItem[] {
const items: ThreadReplyVirtualItem[] = [];
for (let i = 0; i < entries.length; i++) {
const { message, summary } = entries[i];
const replyKey = message.renderKey ?? message.id;
if (shouldRenderUnreadDivider(i, message.id, firstUnreadReplyId ?? null)) {
items.push({ kind: "unread", key: `unread-${replyKey}` });
}
items.push({ kind: "reply", key: replyKey, message, summary });
}
return items;
}

View File

@@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="sticky top-[92px] z-[5] flex justify-center py-1"
className="flex justify-center py-1"
data-testid="message-timeline-day-divider"
data-day-label={label}
>

View File

@@ -31,8 +31,12 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { UnreadDivider } from "./UnreadDivider";
import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
import { useChatScrollVirtualizer } from "./useChatScrollVirtualizer";
import {
buildThreadReplyVirtualItems,
selectDeferredListRenderState,
selectLatestMessageKey,
} from "@/features/messages/lib/timelineSnapshot";
type MessageThreadPanelProps = {
agentPubkeys?: ReadonlySet<string>;
@@ -95,6 +99,10 @@ type MessageThreadPanelProps = {
* `EMPTY_MESSAGES` in MessageTimeline. */
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
/** Vertical gap (px) the virtualizer inserts between reply rows — the spacing
* the legacy reply list expressed as `space-y-2.5`. */
const THREAD_REPLY_GAP_PX = 10;
type MessageThreadPanelSkeletonProps = {
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
@@ -317,8 +325,6 @@ export function MessageThreadPanel({
isSinglePanelView,
);
const threadHeadId = threadHead?.id ?? null;
const composerReplyTarget =
replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id
? {
@@ -333,13 +339,10 @@ export function MessageThreadPanel({
// the main thread and the OS would show the busy cursor. Gate the reply render
// behind `useDeferredValue`. `initialValue: []` keeps even the FIRST render on
// thread-open light; the heavy list streams in on a deferred, interruptible
// commit. We deliberately drive BOTH the scroll manager and the rendered list
// off the SAME deferred value — sticky-bottom / deep-link logic reads the DOM
// (`scrollIntoView`), so it must stay consistent with what's actually painted.
// You can't scroll to a reply that hasn't committed yet. The thread pane gets
// this no-tearing guarantee for free by routing through the same
// `useTimelineScrollManager` (and its `timelineSnapshot` helpers) as the main
// timeline.
// commit. We deliberately drive BOTH the virtualizer and the rendered list
// off the SAME deferred value — the deep-link index resolves against this
// snapshot, so a jump never targets a reply the DOM has not committed yet
// (the no-tearing guard). You can't scroll to a reply that hasn't rendered.
const deferredThreadReplies = React.useDeferredValue(
threadReplies,
EMPTY_THREAD_REPLIES,
@@ -354,32 +357,130 @@ export function MessageThreadPanel({
threadReplies.length,
);
const threadMessages = React.useMemo(
() => deferredThreadReplies.map((entry) => entry.message),
// Flat virtual-item list for the reply region (head renders outside it). The
// deep-link index map below is built off this SAME list — the no-tearing
// guard — so a `scrollTargetId` jump only fires once the target reply exists
// in the committed snapshot.
const replyItems = React.useMemo(
() =>
buildThreadReplyVirtualItems(deferredThreadReplies, firstUnreadReplyId),
[deferredThreadReplies, firstUnreadReplyId],
);
const indexByMessageId = React.useMemo(() => {
const map = new Map<string, number>();
for (let i = 0; i < replyItems.length; i++) {
const item = replyItems[i];
if (item.kind === "reply") {
map.set(item.message.id, i);
}
}
return map;
}, [replyItems]);
const latestReplyKey = React.useMemo(
() => selectLatestMessageKey(deferredThreadReplies.map((e) => e.message)),
[deferredThreadReplies],
);
const getReplyItemKey = React.useCallback(
(index: number) => replyItems[index].key,
[replyItems],
);
// Bottom-stick + deep-link only — a thread never grows at the head, so
// `anchorTo:"end"` makes prepend structurally absent (no flag needed). No
// short-channel `topPad`: replies render top-down under the head row, which
// is the legacy thread-pane layout.
const {
bottomAnchorRef,
contentRef,
virtualizer,
isAtBottom,
newMessageCount,
highlightedMessageId,
scrollToBottom,
syncScrollState,
} = useTimelineScrollManager({
channelId: threadHeadId,
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
isLoading: repliesRenderState === "pending",
messages: threadMessages,
scrollToItem,
} = useChatScrollVirtualizer({
count: replyItems.length,
scrollRef: threadBodyRef,
getItemKey: getReplyItemKey,
gap: THREAD_REPLY_GAP_PX,
latestMessageKey: latestReplyKey,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
targetMessageId: scrollTargetId,
});
// Deep-link to `scrollTargetId` once it resolves against the rendered
// snapshot. The library's reconcile loop settles the offset once the target
// reply measures — the same W1 deep-link settle the main timeline uses, with
// prepend off.
const lastJumpedTargetRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!scrollTargetId) {
lastJumpedTargetRef.current = null;
return;
}
if (scrollTargetId === lastJumpedTargetRef.current) {
return;
}
const index = indexByMessageId.get(scrollTargetId);
if (index === undefined) {
return;
}
lastJumpedTargetRef.current = scrollTargetId;
scrollToItem(index, scrollTargetId);
}, [scrollTargetId, indexByMessageId, scrollToItem]);
if (!threadHead) {
return null;
}
const renderReplyItem = (
item: (typeof replyItems)[number],
): React.ReactNode => {
if (item.kind === "unread") {
return <UnreadDivider />;
}
const { message, summary } = item;
return (
<div
className={cn(
"flex flex-col gap-1",
summary &&
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
message.id === highlightedMessageId &&
"before:absolute before:-inset-y-1 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none",
)}
>
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
hoverBackground={!summary}
layoutVariant="thread-reply"
message={message}
onDelete={
onDelete && canManageMessage(message, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit && canManageMessage(message, currentPubkey)
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{summary ? (
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onExpandReplies}
summary={summary}
unreadCount={threadReplyUnreadCounts?.get(message.id)}
/>
) : null}
</div>
);
};
const threadScrollRegion = (
<div
className={cn(
@@ -388,124 +489,88 @@ export function MessageThreadPanel({
!isSplitLayout && !isFloatingOverlay && "pt-[4.75rem]",
)}
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
>
<div ref={contentRef}>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
actionBarPlacement="inside"
agentPubkeys={agentPubkeys}
channelId={channelId}
isFollowingThread={isFollowingThread}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit && canManageMessage(threadHead, currentPubkey)
? onEdit
: undefined
}
onFollowThread={
onFollowThread ? (_msg) => onFollowThread() : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onUnfollowThread={
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
}
profiles={profiles}
videoReviewContext={threadHeadVideoReviewContext}
/>
</div>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
actionBarPlacement="inside"
agentPubkeys={agentPubkeys}
channelId={channelId}
isFollowingThread={isFollowingThread}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit && canManageMessage(threadHead, currentPubkey)
? onEdit
: undefined
}
onFollowThread={
onFollowThread ? (_msg) => onFollowThread() : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onUnfollowThread={
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
}
profiles={profiles}
videoReviewContext={threadHeadVideoReviewContext}
/>
</div>
</div>
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
{repliesRenderState === "list" ? (
<div
className={cn(
"space-y-2.5",
// While a deferred render is in flight the painted reply list
// lags the latest `threadReplies`. Dim it slightly so the
// streaming-in reads as intentional instead of frozen — mirrors
// the main timeline.
isRepliesPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRepliesPending ? "true" : undefined}
>
{deferredThreadReplies.map((entry, index) => {
const showUnreadDivider =
index > 0 && entry.message.id === firstUnreadReplyId;
return (
<div
className={cn(
"flex flex-col gap-1",
entry.summary &&
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
)}
key={entry.message.renderKey ?? entry.message.id}
>
{showUnreadDivider ? <UnreadDivider /> : null}
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
hoverBackground={!entry.summary}
layoutVariant="thread-reply"
message={entry.message}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit && canManageMessage(entry.message, currentPubkey)
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary ? (
<MessageThreadSummaryRow
depth={entry.message.depth}
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
unreadCount={threadReplyUnreadCounts?.get(
entry.message.id,
)}
/>
) : null}
</div>
);
})}
</div>
) : repliesRenderState === "empty" ? (
// Only show the empty state when the thread is GENUINELY empty.
// Keying off `deferredThreadReplies` would flash "No replies" for a
// frame while a non-empty list streams in on the deferred commit.
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Reply in the thread to continue this branch.
</p>
</div>
) : // "pending": deferred list is empty but the live list has content —
// rows are streaming in on the deferred commit. Paint nothing rather
// than flashing the empty state.
null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
{repliesRenderState === "list" ? (
<div
className={cn(
"relative w-full",
// While a deferred render is in flight the painted reply list
// lags the latest `threadReplies`. Dim it slightly so the
// streaming-in reads as intentional instead of frozen — mirrors
// the main timeline.
isRepliesPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRepliesPending ? "true" : undefined}
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
data-index={virtualRow.index}
key={virtualRow.key}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderReplyItem(replyItems[virtualRow.index])}
</div>
))}
</div>
) : repliesRenderState === "empty" ? (
// Only show the empty state when the thread is GENUINELY empty.
// Keying off `deferredThreadReplies` would flash "No replies" for a
// frame while a non-empty list streams in on the deferred commit.
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Reply in the thread to continue this branch.
</p>
</div>
) : // "pending": deferred list is empty but the live list has content —
// rows are streaming in on the deferred commit. Paint nothing rather
// than flashing the empty state.
null}
</div>
</div>
);

View File

@@ -2,6 +2,11 @@ import * as React from "react";
import { ArrowDown, ArrowUp, Hash } from "lucide-react";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import {
buildTimelineVirtualItems,
selectLatestMessageKey,
} from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
@@ -14,8 +19,7 @@ import { TooltipProvider } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
import { useChatScrollVirtualizer } from "./useChatScrollVirtualizer";
type MessageTimelineProps = {
agentPubkeys?: ReadonlySet<string>;
@@ -101,6 +105,10 @@ type ChannelIntro = {
* message list. Must be module-level so its identity never changes. */
const EMPTY_MESSAGES: TimelineMessage[] = [];
/** Vertical gap (px) the virtualizer inserts between rows — the spacing the
* legacy list expressed as the `gap-2.5` between its top-level row wrappers. */
const TIMELINE_ROW_GAP_PX = 10;
type DirectMessageIntroParticipant = {
avatarUrl: string | null;
displayName: string;
@@ -167,25 +175,79 @@ export const MessageTimeline = React.memo(function MessageTimeline({
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
: `message-timeline:${channelId ?? "none"}`;
// Flatten the deferred snapshot into the ordered virtual-item list the
// virtualizer measures. Deep-link/search index lookups below resolve against
// this SAME snapshot — a jump must target a row the DOM has committed, never
// a fresher message that hasn't rendered yet (the no-tearing guard).
const entries = React.useMemo(
() => buildMainTimelineEntries(deferredMessages),
[deferredMessages],
);
const items = React.useMemo(
() => buildTimelineVirtualItems(entries, firstUnreadMessageId),
[entries, firstUnreadMessageId],
);
// Map message id -> its flat-item index for deep-link jumps. Built off the
// same `items` so the index the virtualizer scrolls to and the row it renders
// are always the same snapshot.
const indexByMessageId = React.useMemo(() => {
const map = new Map<string, number>();
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "message" || item.kind === "system") {
map.set(item.message.id, i);
}
}
return map;
}, [items]);
const latestMessageKey = React.useMemo(
() => selectLatestMessageKey(deferredMessages),
[deferredMessages],
);
const getItemKey = React.useCallback(
(index: number) => items[index].key,
[items],
);
const {
bottomAnchorRef,
contentRef,
highlightedMessageId,
virtualizer,
topPad,
isAtBottom,
newMessageCount,
restoreScrollPosition,
highlightedMessageId,
scrollToBottom,
scrollToMessage,
syncScrollState,
} = useTimelineScrollManager({
channelId,
isLoading,
messages: deferredMessages,
scrollToItem,
} = useChatScrollVirtualizer({
count: items.length,
scrollRef: scrollContainerRef,
getItemKey,
gap: TIMELINE_ROW_GAP_PX,
latestMessageKey,
onTargetReached,
scrollContainerRef,
targetMessageId,
});
// Deep-link to `targetMessageId` once it resolves against the rendered
// snapshot. `resolveDeepLinkTarget` reads the same `deferredMessages` the
// rows came from, so the jump never fires against an uncommitted row; the
// library's reconcile loop settles the offset once the target measures.
const lastJumpedTargetRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!targetMessageId) {
lastJumpedTargetRef.current = null;
return;
}
if (targetMessageId === lastJumpedTargetRef.current) {
return;
}
const index = indexByMessageId.get(targetMessageId);
if (index === undefined) {
return;
}
lastJumpedTargetRef.current = targetMessageId;
scrollToItem(index, targetMessageId);
}, [targetMessageId, indexByMessageId, scrollToItem]);
// The unread pill is a transient, per-open affordance: dismiss it once the
// user acts on it (jumps to the oldest unread) or catches up by reaching the
// bottom of the timeline. Reset when the channel changes so a freshly opened
@@ -214,14 +276,19 @@ export const MessageTimeline = React.memo(function MessageTimeline({
if (showUnreadPill) hasShownPillRef.current = true;
const handleJumpToOldestUnread = React.useCallback(() => {
setIsUnreadPillDismissed(true);
if (firstUnreadMessageId) {
scrollToMessage(firstUnreadMessageId);
if (!firstUnreadMessageId) {
return;
}
}, [firstUnreadMessageId, scrollToMessage]);
const index = indexByMessageId.get(firstUnreadMessageId);
if (index !== undefined) {
scrollToItem(index, firstUnreadMessageId);
}
}, [firstUnreadMessageId, indexByMessageId, scrollToItem]);
// Scroll to the active search match when it changes.
// Scroll to the active find-in-channel match when it changes. Resolving the
// index off `indexByMessageId` (not `querySelector`) is required under
// virtualization — an off-screen match has no DOM node to scroll into view.
const prevSearchActiveRef = React.useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
React.useEffect(() => {
if (
!searchActiveMessageId ||
@@ -231,31 +298,50 @@ export const MessageTimeline = React.memo(function MessageTimeline({
return;
}
prevSearchActiveRef.current = searchActiveMessageId;
const container = scrollContainerRef.current;
if (!container) return;
const el = container.querySelector<HTMLElement>(
`[data-message-id="${searchActiveMessageId}"]`,
);
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
const index = indexByMessageId.get(searchActiveMessageId);
if (index !== undefined) {
scrollToItem(index, searchActiveMessageId);
}
}, [searchActiveMessageId]);
}, [searchActiveMessageId, indexByMessageId, scrollToItem]);
useLoadOlderOnScroll({
// Load older messages when the user scrolls near the top. The virtualizer's
// `anchorTo:"end"` holds the viewport across the prepend — unlike the legacy
// manager, this trigger does NOT touch scrollTop, killing the
// measure-and-restore race. The sentinel is the topmost rendered row.
React.useEffect(() => {
const sentinel = topSentinelRef.current;
const container = scrollContainerRef.current;
if (
!sentinel ||
!container ||
!fetchOlder ||
isLoading ||
isFetchingOlder ||
!hasOlderMessages
) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
void fetchOlder();
}
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
isFetchingOlder,
scrollContainerRef,
sentinelRef: topSentinelRef,
});
]);
const showDirectMessageIntro = !isLoading && directMessageIntro !== null;
const showChannelIntro =
!isLoading && channelIntro !== null && directMessageIntro === null;
const showIntro = showDirectMessageIntro || showChannelIntro;
const showGenericEmpty =
!isLoading &&
deferredMessages.length === 0 &&
@@ -298,16 +384,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
)}
data-scroll-restoration-id={scrollRestorationId}
data-testid="message-timeline"
onScroll={syncScrollState}
ref={scrollContainerRef}
>
<div
className={cn(
"flex w-full flex-col gap-2",
channelChrome.contentPadding,
(showIntro || showGenericEmpty) && "min-h-full",
)}
ref={contentRef}
className={cn("flex w-full flex-col", channelChrome.contentPadding)}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
@@ -317,191 +397,190 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
) : null}
<SkeletonReveal
className={cn(
"min-h-[18rem]",
(showIntro || showGenericEmpty) && "min-h-full",
showMessageList && !showIntro && "mt-auto",
)}
contentClassName={cn(
"flex min-w-0 flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
)}
loading={isLoading}
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
>
{showDirectMessageIntro ? (
<div
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
<DirectMessageIntroAvatarStack
participants={directMessageIntro.participants}
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{directMessageIntro.displayName}
</p>
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{directMessageIntro.displayName}
</span>
.
</p>
</div>
) : null}
{showChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
{/* Intro/empty/skeleton are NOT virtual rows — they render as plain
siblings of the virtualized spacer, gated so exactly one of them
or the list shows. `min-h-full` keeps the empty/intro states
filling the viewport (bottom-aligned via `mt-auto`); the message
list bottom-aligns through the virtualizer's `topPad` instead. */}
{showMessageList ? null : (
<SkeletonReveal
className={cn("min-h-[18rem] flex flex-col", "min-h-full")}
contentClassName="flex min-w-0 flex-col min-h-full"
loading={isLoading}
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
>
{showDirectMessageIntro ? (
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
{channelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{channelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{channelIntro.channelKindLabel}
</span>
.
</p>
{channelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{channelIntro.description}
<DirectMessageIntroAvatarStack
participants={directMessageIntro.participants}
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{directMessageIntro.displayName}
</p>
) : null}
{channelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{channelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{directMessageIntro.displayName}
</span>
.
</p>
</div>
) : null}
return (
<button
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-xl p-4",
)}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
<span
{showChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
>
{channelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{channelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{channelIntro.channelKindLabel}
</span>
.
</p>
{channelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{channelIntro.description}
</p>
) : null}
{channelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{channelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
return (
<button
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-xl p-4",
)}
data-testid={
action.testId
? `${action.testId}-icon`
: undefined
}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
{action.icon}
</span>
<span className="mt-auto min-w-0">
<span
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
)}
data-testid={
action.testId
? `${action.testId}-title`
? `${action.testId}-icon`
: undefined
}
>
{action.label}
{action.icon}
</span>
{action.description ? (
<span className="mt-auto min-w-0">
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
data-testid={
action.testId
? `${action.testId}-description`
? `${action.testId}-title`
: undefined
}
>
{action.description}
{action.label}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
{action.description ? (
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
data-testid={
action.testId
? `${action.testId}-description`
: undefined
}
>
{action.description}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
{showGenericEmpty ? (
<div
className="mt-auto rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{showGenericEmpty ? (
<div
className="mt-auto rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
</SkeletonReveal>
)}
{showMessageList ? (
<div
className={cn(
"flex flex-col gap-2",
!showIntro && "mt-auto",
// While a deferred render is in flight the painted
// list lags the latest `messages`. Dim it slightly so the
// streaming-in feels intentional instead of frozen.
isRenderPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
firstUnreadMessageId={firstUnreadMessageId}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
/>
</div>
) : null}
</SkeletonReveal>
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
{showMessageList ? (
<div
className={cn(
// While a deferred render is in flight the painted list lags
// the latest `messages`. Dim it slightly so the streaming-in
// feels intentional instead of frozen.
isRenderPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
firstUnreadMessageId={firstUnreadMessageId}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
items={items}
messageFooters={messageFooters}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
threadUnreadCounts={threadUnreadCounts}
topPad={topPad}
unfollowThreadById={unfollowThreadById}
virtualizer={virtualizer}
/>
</div>
) : null}
</div>
</div>

View File

@@ -1,20 +1,16 @@
import * as React from "react";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import {
buildMainTimelineEntries,
shouldRenderUnreadDivider,
} from "@/features/messages/lib/threadPanel";
import type { TimelineVirtualItem } from "@/features/messages/lib/timelineSnapshot";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
} from "@/features/messages/lib/videoReviewContext";
import { buildDayGroupBoundaries } from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import type { ChatVirtualizer } from "./useChatScrollVirtualizer";
import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -63,6 +59,20 @@ type TimelineMessageListProps = {
searchQuery?: string;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
/**
* The virtualizer that owns this surface's scroll. Built by
* `useChatScrollVirtualizer` at the timeline level so the single scroll owner
* also owns measurement; this component is a pure renderer of its rows.
*/
virtualizer: ChatVirtualizer;
/** The flat virtual-item list the virtualizer's `count` mirrors. */
items: TimelineVirtualItem[];
/**
* Top padding (px) that bottom-aligns a channel shorter than the viewport.
* Always `0` once content fills the viewport. Applied to the row spacer so it
* pushes the absolute rows down without entering the measured total.
*/
topPad: number;
};
export const TimelineMessageList = React.memo(function TimelineMessageList({
@@ -71,7 +81,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
channelName,
channelType,
currentPubkey,
firstUnreadMessageId = null,
followThreadById,
highlightedMessageId = null,
isFollowingThreadById,
@@ -90,11 +99,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchQuery,
threadUnreadCounts,
unfollowThreadById,
virtualizer,
items,
topPad,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() => buildMainTimelineEntries(messages),
[messages],
);
const reviewCommentsByRootId = React.useMemo(
() => buildVideoReviewCommentsByRootId(messages),
[messages],
@@ -137,161 +145,167 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
profiles,
reviewCommentsByRootId,
]);
const dayGroups: Array<{
key: string;
label: string;
elements: React.ReactNode[];
}> = [];
let currentDayGroup: (typeof dayGroups)[number] | null = null;
// Day-divider decision delegated to a pure, lib-tested helper: a new group
// starts at index 0 and whenever a message falls on a different calendar day
// than the one before it. We index the boundary start positions so the render
// loop below stays a straight walk while the grouping logic lives in `lib/`.
const dayGroupStartIndices = new Set(
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
(boundary) => boundary.startIndex,
),
);
const renderItem = (item: TimelineVirtualItem): React.ReactNode => {
switch (item.kind) {
case "day":
// The day separator scrolls inline with the rows (it is a normal
// virtual item, not sticky — sticky cannot work on an absolute row).
// The thin connector line that the legacy day-`<section>` drew behind
// the centered pill is re-homed onto this row so the divider keeps its
// rule-behind-label look without the group wrapper.
return (
<div className="relative before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']">
<DayDivider label={formatDayHeading(item.headingTimestamp)} />
</div>
);
case "unread":
return <UnreadDivider />;
case "system": {
const footer = messageFooters?.[item.message.id] ?? null;
return (
<div className="flex flex-col gap-1">
<SystemMessageRow
message={item.message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{footer}
</div>
);
}
case "message": {
const { message, summary } = item;
const footer = messageFooters?.[message.id] ?? null;
for (let i = 0; i < entries.length; i++) {
const { message, summary } = entries[i];
const messageRenderKey = message.renderKey ?? message.id;
if (summary && onReply) {
const isHighlighted = message.id === highlightedMessageId;
return (
<div
className={cn(
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
)}
>
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={false}
hoverBackground={false}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onFollowThread={
followThreadById
? () => followThreadById(message.id)
: undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
</div>
);
}
if (dayGroupStartIndices.has(i)) {
currentDayGroup = {
key: `day-${message.createdAt}`,
label: formatDayHeading(message.createdAt),
elements: [],
};
dayGroups.push(currentDayGroup);
const isSearchMatch =
searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
return (
<div className="flex flex-col gap-1">
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={
message.id === highlightedMessageId || isSearchActive
}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
{footer}
</div>
);
}
}
};
// The unread "New" divider only marks a read/unread boundary when there is
// a message above the first unread. When the first unread is the first
// rendered top-level entry (fresh/never-read channel), there is nothing
// above to separate from, so it is suppressed.
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
currentDayGroup?.elements.push(
<UnreadDivider key={`unread-${messageRenderKey}`} />,
);
}
if (message.kind === KIND_SYSTEM_MESSAGE) {
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<div key={messageRenderKey} className="flex flex-col gap-1">
<SystemMessageRow
message={message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{footer}
</div>,
);
} else if (summary && onReply) {
const footer = messageFooters?.[message.id] ?? null;
const isHighlighted = message.id === highlightedMessageId;
currentDayGroup?.elements.push(
<div
key={messageRenderKey}
className={cn(
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
)}
>
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={false}
hoverBackground={false}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
</div>,
);
} else {
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
const footer = messageFooters?.[message.id] ?? null;
currentDayGroup?.elements.push(
<div key={messageRenderKey} className="flex flex-col gap-1">
<MessageRow
agentPubkeys={agentPubkeys}
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
{footer}
</div>,
);
}
}
return dayGroups.map((group) => (
<section
className="relative flex flex-col gap-2.5 before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']"
key={group.key}
// The spacer is `box-sizing: border-box` (Tailwind global), so `topPad` is
// folded into `height` AND set as `paddingTop`: the height keeps the full
// scroll range while the padding pushes the absolute rows (positioned against
// the padding box, `top:0` = inner edge) down to bottom-align a short channel.
// `translateY` then uses the virtualizer's raw `start` — no double offset.
return (
<div
className="relative w-full"
style={{
height: `${virtualizer.getTotalSize() + topPad}px`,
paddingTop: topPad > 0 ? `${topPad}px` : undefined,
}}
>
<DayDivider label={group.label} />
{group.elements}
</section>
));
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
data-index={virtualRow.index}
key={virtualRow.key}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(items[virtualRow.index])}
</div>
))}
</div>
);
});

View File

@@ -1,8 +1,13 @@
import { type Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import * as React from "react";
import { BOTTOM_THRESHOLD_PX } from "@/features/messages/lib/timelineSnapshot";
export type ChatVirtualizer = Virtualizer<HTMLElement, Element>;
/** How long (ms) a deep-linked row stays highlighted before the glow fades. */
const HIGHLIGHT_DURATION_MS = 2_000;
/**
* Single scroll owner for the chat surfaces (main timeline + thread pane).
*
@@ -30,6 +35,12 @@ export type ChatVirtualizer = Virtualizer<HTMLElement, Element>;
* the LIVE `getTotalSize()` on every measurement pass (via `onChange`) so
* it collapses to 0 the instant content exceeds the viewport — see the
* ordering note below.
* - **The "at bottom" / new-message-count UI state** the scroll-to-latest
* pill reads. The library knows the geometry (`isAtEnd`); this hook lifts
* it to React state and counts messages that arrive while scrolled up.
* - **Deep-link highlight + completion callback.** `scrollToItem` drives the
* library jump, lights the target row for a beat, and fires
* `onTargetReached` once.
*/
export type ChatScrollVirtualizerOptions = {
@@ -41,13 +52,18 @@ export type ChatScrollVirtualizerOptions = {
getItemKey: (index: number) => string;
/** Estimated row height (px) before measurement. */
estimateSize?: number;
/** Vertical gap (px) the virtualizer inserts between rows. */
gap?: number;
/** Rows rendered outside the viewport on each side. */
overscan?: number;
/**
* Bottom-stick mode. The main timeline and thread pane both want to follow
* new messages, so both pass `"auto"`; pass `false` to disable following.
* Key of the latest message in the rendered snapshot. When it changes while
* the user is scrolled up, `newMessageCount` ticks up — drives the pill's
* "N new messages" label. Pass `undefined` for an empty surface.
*/
followOnAppend?: boolean | "auto" | "smooth" | "instant";
latestMessageKey?: string;
/** Called once when a deep-link jump lands on its target. */
onTargetReached?: (messageId: string) => void;
};
export type ChatScrollVirtualizer = {
@@ -58,6 +74,20 @@ export type ChatScrollVirtualizer = {
* once content fills the viewport.
*/
topPad: number;
/** True while the surface is scrolled to (or near) the bottom. */
isAtBottom: boolean;
/** Messages that arrived while scrolled up; reset on reaching the bottom. */
newMessageCount: number;
/** Message id currently highlighted by a deep-link jump, or null. */
highlightedMessageId: string | null;
/** Jump to the end (the pill action). */
scrollToBottom: (behavior: ScrollBehavior) => void;
/**
* Jump to a flat-item index, highlight `messageId`, and fire
* `onTargetReached` once the library settles. The caller resolves the index
* against the SAME snapshot the rows render from (the no-tearing guard).
*/
scrollToItem: (index: number, messageId: string) => void;
};
export function useChatScrollVirtualizer({
@@ -65,8 +95,10 @@ export function useChatScrollVirtualizer({
scrollRef,
getItemKey,
estimateSize = 80,
gap,
overscan = 6,
followOnAppend = "auto",
latestMessageKey,
onTargetReached,
}: ChatScrollVirtualizerOptions): ChatScrollVirtualizer {
// Read the element lazily so the virtualizer binds once the ref attaches;
// capturing `ref.current` at render time would freeze it at the first-render
@@ -77,17 +109,22 @@ export function useChatScrollVirtualizer({
);
const [topPad, setTopPad] = React.useState(0);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
>(null);
// Recompute the short-channel pad off the LIVE total whenever a row's
// measured size changes. virtual-core fires `onChange` directly from
// The bottom-state and pad recompute both read live geometry off the
// virtualizer on each `onChange`. virtual-core fires `onChange` directly from
// `resizeItem` on any size delta (not only on a visible-range change), which
// is exactly when `getTotalSize()` moves — so a short channel whose rows
// measure taller/shorter than the estimate re-pads with the settled total.
// Running it here, before paint and before the library re-applies its
// end-anchoring/follow in the same pass, orders "recompute pad -> re-pin
// bottom" and avoids a one-frame sliver when a short channel grows past the
// viewport.
const recomputePad = React.useCallback(
// measure taller/shorter than the estimate re-pads (and re-evaluates "at
// bottom") with the settled total. Running here, before paint and before the
// library re-applies its end-anchoring/follow in the same pass, orders
// "recompute pad -> re-pin bottom" and avoids a one-frame sliver when a short
// channel grows past the viewport.
const onChange = React.useCallback(
(instance: ChatVirtualizer) => {
const scrollEl = scrollRef.current;
if (!scrollEl) {
@@ -95,6 +132,14 @@ export function useChatScrollVirtualizer({
}
const pad = Math.max(0, scrollEl.clientHeight - instance.getTotalSize());
setTopPad((prev) => (prev === pad ? prev : pad));
// The app's "at bottom" rule is looser than the library's 1px default;
// reuse the timeline's 72px threshold so the pill matches the old feel.
const atBottom = instance.isAtEnd(BOTTOM_THRESHOLD_PX);
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
if (atBottom) {
setNewMessageCount((prev) => (prev === 0 ? prev : 0));
}
},
[scrollRef],
);
@@ -105,26 +150,89 @@ export function useChatScrollVirtualizer({
estimateSize: () => estimateSize,
getItemKey,
overscan,
gap,
// Hold the viewport on prepend and pin to the bottom anchor for follow —
// the two library-native behaviors that replace the manual scroll manager.
// Both surfaces follow new messages, so `followOnAppend` is always on; the
// library only re-pins when the user is already at the end.
anchorTo: "end",
followOnAppend,
onChange: recomputePad,
followOnAppend: "auto",
onChange,
});
// The pad also depends on the viewport height, which `onChange` does not
// track — a window/pane resize that changes `clientHeight` without resizing a
// row would leave a short channel's pad stale. Observe the container so the
// bottom-align holds across resizes too.
// The pad/bottom-state also depend on the viewport height, which `onChange`
// does not track — a window/pane resize that changes `clientHeight` without
// resizing a row would leave them stale. Observe the container so the
// bottom-align and pill state hold across resizes too.
React.useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl) {
return;
}
const observer = new ResizeObserver(() => recomputePad(virtualizer));
const observer = new ResizeObserver(() => onChange(virtualizer));
observer.observe(scrollEl);
return () => observer.disconnect();
}, [scrollRef, recomputePad, virtualizer]);
}, [scrollRef, onChange, virtualizer]);
return { virtualizer, topPad };
// Count messages that arrive while the user is scrolled up. When at the
// bottom `followOnAppend` keeps us pinned and `onChange` zeroes the count, so
// the increment only fires for genuinely-missed messages. Skips the first
// observed key so opening a channel doesn't show a phantom count.
const previousLatestKeyRef = React.useRef<string | undefined>(undefined);
React.useEffect(() => {
const previous = previousLatestKeyRef.current;
previousLatestKeyRef.current = latestMessageKey;
if (
previous === undefined ||
latestMessageKey === undefined ||
latestMessageKey === previous ||
isAtBottom
) {
return;
}
setNewMessageCount((prev) => prev + 1);
}, [latestMessageKey, isAtBottom]);
const scrollToBottom = React.useCallback(
(behavior: ScrollBehavior) => {
virtualizer.scrollToIndex(count - 1, { align: "end", behavior });
},
[virtualizer, count],
);
const highlightTimeoutRef = React.useRef<number | undefined>(undefined);
const scrollToItem = React.useCallback(
(index: number, messageId: string) => {
// align:center + the library's reconcile loop re-targets the index until
// the offset is stable once the (possibly never-measured) row measures —
// the W1 deep-link settle, library-owned.
virtualizer.scrollToIndex(index, { align: "center" });
setHighlightedMessageId(messageId);
setNewMessageCount(0);
onTargetReached?.(messageId);
window.clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedMessageId((current) =>
current === messageId ? null : current,
);
}, HIGHLIGHT_DURATION_MS);
},
[virtualizer, onTargetReached],
);
React.useEffect(
() => () => window.clearTimeout(highlightTimeoutRef.current),
[],
);
return {
virtualizer,
topPad,
isAtBottom,
newMessageCount,
highlightedMessageId,
scrollToBottom,
scrollToItem,
};
}

View File

@@ -1,92 +0,0 @@
import * as React from "react";
type UseLoadOlderOnScrollOptions = {
fetchOlder?: () => Promise<void>;
hasOlderMessages: boolean;
isLoading: boolean;
restoreScrollPosition: (scrollTop: number) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sentinelRef: React.RefObject<HTMLDivElement | null>;
};
/**
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
* container enters the viewport, then restores the scroll position so the
* visible content doesn't jump.
*/
export function useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
isLoading,
restoreScrollPosition,
scrollContainerRef,
sentinelRef,
}: UseLoadOlderOnScrollOptions) {
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
React.useEffect(() => {
restoreScrollPositionRef.current = restoreScrollPosition;
});
React.useEffect(() => {
const sentinel = sentinelRef.current;
const container = scrollContainerRef.current;
if (
!sentinel ||
!container ||
!fetchOlder ||
isLoading ||
!hasOlderMessages
) {
return;
}
let disposed = false;
let currentObserver: IntersectionObserver | null = null;
const observe = () => {
if (disposed) {
return;
}
currentObserver = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || disposed) {
return;
}
currentObserver?.disconnect();
const previousHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
void fetchOlder().then(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newHeight = container.scrollHeight;
const delta = newHeight - previousHeight;
if (delta > 0) {
restoreScrollPositionRef.current(previousScrollTop + delta);
}
observe();
});
});
});
},
{ root: container, rootMargin: "200px 0px 0px 0px" },
);
currentObserver.observe(sentinel);
};
observe();
return () => {
disposed = true;
currentObserver?.disconnect();
};
}, [
fetchOlder,
hasOlderMessages,
isLoading,
scrollContainerRef,
sentinelRef,
]);
}

View File

@@ -1,436 +0,0 @@
import * as React from "react";
import {
isNearBottom,
resolveDeepLinkTarget,
selectLatestMessageKey,
} from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
type UseTimelineScrollManagerOptions = {
channelId?: string | null;
isLoading: boolean;
messages: TimelineMessage[];
onTargetReached?: (messageId: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
targetMessageId?: string | null;
};
type PinToBottomOptions = {
clearNewMessageCount?: boolean;
};
export function useTimelineScrollManager({
channelId,
isLoading,
messages,
onTargetReached,
scrollContainerRef,
targetMessageId,
}: UseTimelineScrollManagerOptions) {
const timelineRef = scrollContainerRef;
const contentRef = React.useRef<HTMLDivElement>(null);
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
const hasInitializedRef = React.useRef(false);
const shouldStickToBottomRef = React.useRef(true);
const isAtBottomRef = React.useRef(true);
const isProgrammaticBottomScrollRef = React.useRef(false);
const previousTimelineHeightRef = React.useRef<number | null>(null);
const previousScrollTopRef = React.useRef(0);
const lockedScrollTopRef = React.useRef<number | null>(null);
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
const previousMessageCountRef = React.useRef(0);
const handledTargetMessageIdRef = React.useRef<string | null>(null);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
>(null);
const [newMessageCount, setNewMessageCount] = React.useState(0);
const resetScrollTracking = React.useCallback(() => {
hasInitializedRef.current = false;
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
isProgrammaticBottomScrollRef.current = false;
previousTimelineHeightRef.current = null;
previousScrollTopRef.current = 0;
lockedScrollTopRef.current = null;
previousLastMessageKeyRef.current = undefined;
previousMessageCountRef.current = 0;
handledTargetMessageIdRef.current = null;
setIsAtBottom(true);
setHighlightedMessageId(null);
setNewMessageCount(0);
}, []);
const pinToBottom = React.useCallback(
({ clearNewMessageCount = false }: PinToBottomOptions = {}) => {
shouldStickToBottomRef.current = true;
isAtBottomRef.current = true;
setIsAtBottom((current) => (current ? current : true));
if (clearNewMessageCount) {
setNewMessageCount(0);
}
},
[],
);
const setObservedBottomState = React.useCallback((atBottom: boolean) => {
shouldStickToBottomRef.current = atBottom;
isAtBottomRef.current = atBottom;
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
}, []);
const unpinFromBottom = React.useCallback((scrollTop: number) => {
shouldStickToBottomRef.current = false;
isAtBottomRef.current = false;
isProgrammaticBottomScrollRef.current = false;
previousScrollTopRef.current = scrollTop;
setIsAtBottom(false);
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
React.useLayoutEffect(() => {
resetScrollTracking();
}, [channelId, resetScrollTracking]);
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
const latestMessageKey = selectLatestMessageKey(messages);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
const syncScrollState = React.useCallback(() => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop;
const atBottom = isNearBottom(timeline);
const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current;
if (isProgrammaticBottomScrollRef.current) {
previousScrollTopRef.current = scrollTop;
if (movedAwayFromBottom) {
isProgrammaticBottomScrollRef.current = false;
} else if (!atBottom) {
pinToBottom();
return;
} else {
isProgrammaticBottomScrollRef.current = false;
pinToBottom({ clearNewMessageCount: true });
return;
}
}
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
previousScrollTopRef.current = scrollTop;
pinToBottom({ clearNewMessageCount: true });
return;
}
previousScrollTopRef.current = scrollTop;
setObservedBottomState(atBottom);
}, [pinToBottom, setObservedBottomState]);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const restoreScrollPosition = React.useCallback(
(scrollTop: number) => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
isProgrammaticBottomScrollRef.current = false;
lockedScrollTopRef.current = scrollTop;
const restore = (remainingFrames: number) => {
timeline.scrollTop = scrollTop;
if (remainingFrames > 0) {
requestAnimationFrame(() => {
restore(remainingFrames - 1);
});
return;
}
lockedScrollTopRef.current = null;
previousScrollTopRef.current = timeline.scrollTop;
syncScrollState();
};
restore(2);
},
[syncScrollState],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const scrollToBottom = React.useCallback(
(behavior: ScrollBehavior) => {
const timeline = timelineRef.current;
if (!timeline) {
return;
}
isProgrammaticBottomScrollRef.current = true;
const alignToBottom = (nextBehavior: ScrollBehavior) => {
bottomAnchorRef.current?.scrollIntoView({
block: "end",
behavior: nextBehavior,
});
timeline.scrollTo({
top: timeline.scrollHeight,
behavior: nextBehavior,
});
};
alignToBottom(behavior);
lockedScrollTopRef.current = null;
previousScrollTopRef.current = timeline.scrollTop;
pinToBottom({ clearNewMessageCount: true });
if (behavior === "smooth") {
requestAnimationFrame(() => {
previousScrollTopRef.current = timeline.scrollTop;
syncScrollState();
});
return;
}
const settleAlignment = (remainingFrames: number) => {
requestAnimationFrame(() => {
alignToBottom("auto");
previousScrollTopRef.current = timeline.scrollTop;
if (remainingFrames > 0) {
settleAlignment(remainingFrames - 1);
return;
}
syncScrollState();
});
};
settleAlignment(2);
},
[pinToBottom, syncScrollState],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
React.useEffect(() => {
const timeline = timelineRef.current;
if (!timeline || typeof ResizeObserver === "undefined") {
return;
}
previousTimelineHeightRef.current = timeline.clientHeight;
previousScrollTopRef.current = timeline.scrollTop;
const observer = new ResizeObserver(([entry]) => {
const previousTimelineHeight = previousTimelineHeightRef.current;
const nextTimelineHeight = entry.contentRect.height;
previousTimelineHeightRef.current = nextTimelineHeight;
if (
previousTimelineHeight === null ||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
) {
return;
}
if (shouldStickToBottomRef.current || isAtBottomRef.current) {
scrollToBottom("auto");
return;
}
restoreScrollPosition(previousScrollTopRef.current);
});
observer.observe(timeline);
return () => {
observer.disconnect();
};
}, [restoreScrollPosition, scrollToBottom]);
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
if (shouldStickToBottomRef.current) {
scrollToBottom("auto");
return;
}
syncScrollState();
});
observer.observe(content);
return () => {
observer.disconnect();
};
}, [scrollToBottom, syncScrollState]);
React.useLayoutEffect(() => {
if (!hasInitializedRef.current) {
if (isLoading) {
return;
}
if (targetMessageId) {
const timeline = timelineRef.current;
unpinFromBottom(timeline?.scrollTop ?? 0);
} else {
scrollToBottom("auto");
}
hasInitializedRef.current = true;
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
return;
}
const previousLastMessageKey = previousLastMessageKeyRef.current;
const previousMessageCount = previousMessageCountRef.current;
const hasNewLatestMessage =
latestMessage !== undefined &&
latestMessageKey !== previousLastMessageKey;
if (!hasNewLatestMessage) {
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
return;
}
if (
!targetMessageId &&
(shouldStickToBottomRef.current ||
isAtBottomRef.current ||
latestMessage.accent)
) {
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
} else {
setNewMessageCount((current) => {
const addedMessages = Math.max(
1,
messages.length - previousMessageCount,
);
return current + addedMessages;
});
}
previousLastMessageKeyRef.current = latestMessageKey;
previousMessageCountRef.current = messages.length;
}, [
isLoading,
latestMessage,
latestMessageKey,
messages.length,
scrollToBottom,
targetMessageId,
timelineRef,
unpinFromBottom,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
const scrollToMessage = React.useCallback(
(messageId: string) => {
const timeline = timelineRef.current;
if (!timeline) {
return false;
}
const targetElement = timeline.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`,
);
if (!targetElement) {
return false;
}
unpinFromBottom(timeline.scrollTop);
setHighlightedMessageId(messageId);
setNewMessageCount(0);
const alignToTarget = (remainingFrames: number) => {
targetElement.scrollIntoView({
block: "center",
behavior: "auto",
});
previousScrollTopRef.current = timeline.scrollTop;
if (remainingFrames > 0) {
requestAnimationFrame(() => {
alignToTarget(remainingFrames - 1);
});
return;
}
onTargetReached?.(messageId);
};
alignToTarget(2);
window.setTimeout(() => {
setHighlightedMessageId((current) =>
current === messageId ? null : current,
);
}, 2_000);
return true;
},
[onTargetReached, unpinFromBottom],
);
React.useEffect(() => {
if (!targetMessageId) {
handledTargetMessageIdRef.current = null;
setHighlightedMessageId(null);
return;
}
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
return;
}
// Deep-link decision delegated to a pure, lib-tested helper: only attempt the
// jump once the target actually exists in THIS (deferred) snapshot. If it
// doesn't, the row hasn't committed yet — bail and let the next snapshot that
// includes it drive the jump. This reads the same `messages` snapshot the
// list rendered, which closes the tearing race.
if (!resolveDeepLinkTarget(messages, targetMessageId).resolved) {
return;
}
if (!scrollToMessage(targetMessageId)) {
return;
}
handledTargetMessageIdRef.current = targetMessageId;
}, [isLoading, messages, scrollToMessage, targetMessageId]);
return {
bottomAnchorRef,
contentRef,
highlightedMessageId,
isAtBottom,
newMessageCount,
restoreScrollPosition,
scrollToBottom,
scrollToMessage,
syncScrollState,
};
}

View File

@@ -2093,8 +2093,8 @@ function MarkdownInner({
const onOpenMessageLink = React.useCallback(
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
// route already handles scroll-into-view + highlight via
// `useTimelineScrollManager` + `getEventById` backfill, and works for
// route already handles scroll-into-view + highlight via the timeline's
// `useChatScrollVirtualizer` + `getEventById` backfill, and works for
// both stream-message replies and forum threads. Detecting "the thread
// root is a forum post" up front would require an event lookup we don't
// currently have synchronously; the brief explicitly allows skipping