diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index ff129445..97875e0e 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -26,6 +26,13 @@ import { SystemMessage } from './SystemMessage'; const EMPTY_MESSAGES: MessageWithUser[] = []; const EMPTY_PENDING_BUBBLES: PendingBubble[] = []; +// Constant-height slot rendered above messages whenever hasMore === true. +// Value derived from the pagination skeleton's analytical rendered height +// (pt-4 + 3 × (h-10 row) + 2 × mb-5 = 176px after stripping the last row's +// mb-5), rounded UP to the nearest 4-pixel step for a buffer. See +// docs/systems/message-list.md "Top-of-list reservation slot". +const PAGINATION_SLOT_HEIGHT_PX = 200; + interface MessageListProps { channelId: string; jumpToMessageId?: string | null; @@ -597,10 +604,24 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess suppressLoadMore = true; } - if (!suppressLoadMore && container.scrollTop < 50 && hasMore && !isLoadingMore) { + // scrollTop >= 0 guards against iOS Safari rubber-band overscroll producing + // briefly-negative scrollTop values, which would otherwise satisfy the + // upper bound and fire a spurious load during a rubber-band gesture. + if ( + !suppressLoadMore && + container.scrollTop >= 0 && + container.scrollTop < PAGINATION_SLOT_HEIGHT_PX + 50 && + hasMore && + !isLoadingMore + ) { const requestChannelId = channelId; setIsLoadingMore(true); + // Capture BOTH synchronously, before the await — `prevScrollTop` must + // be the pre-await value for the anchor-from-bottom formula in the + // rAF callback below to hold. Moving this capture inside the rAF or + // after the await silently breaks the math. const prevScrollHeight = container.scrollHeight; + const prevScrollTop = container.scrollTop; try { const loaded = await loadMoreMessages(requestChannelId); if (!loaded) return; @@ -615,7 +636,12 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess if (currentChannelIdRef.current !== requestChannelId) return; const c = containerRef.current; if (!c) return; - c.scrollTop = c.scrollHeight - prevScrollHeight; + // Anchor-from-bottom: keep the user's viewport at the same distance + // from the new bottom of content as it was from the old bottom. + // `(c.scrollHeight - prevScrollHeight)` is the height of freshly + // prepended messages; adding it to `prevScrollTop` keeps the visible + // content stationary across the prepend. + c.scrollTop = prevScrollTop + (c.scrollHeight - prevScrollHeight); }); } finally { setIsLoadingMore(false); @@ -649,17 +675,25 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess className="h-full overflow-y-auto overflow-x-hidden no-scrollbar" onScroll={handleScroll} > - {showPaginationSkeleton && ( -
- {Array.from({ length: 3 }, (_, i) => ( -
-
-
-
-
-
+ {hasMore && ( +
+ {showPaginationSkeleton && ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+
+
+
+
+
+
+ ))}
- ))} + )}
)}