diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 56cc7c8069..2802d1e11b 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -23,6 +23,7 @@ import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; import type { TimelineVirtualizerApi } from "./TimelineMessageList"; import { useAnchoredScroll } from "./useAnchoredScroll"; +import { resolveTimelineReadPresence } from "./anchoredScrollPolicy"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages"; import { @@ -372,8 +373,17 @@ const MessageTimelineBase = React.forwardRef< }); React.useEffect(() => { - onAtBottomChange?.(isAtBottom); - }, [isAtBottom, onAtBottomChange]); + // Read presence follows the semantic tail, not the physical virtualizer + // floor. While live arrivals are buffered, freezing the rendered tail can + // make the shortened DOM report physically at-bottom even though the + // reader is still scrolled away from the new messages. + onAtBottomChange?.( + resolveTimelineReadPresence({ + isPhysicallyAtBottom: isAtBottom, + isSemanticallyAtBottom, + }), + ); + }, [isAtBottom, isSemanticallyAtBottom, onAtBottomChange]); const hasConfirmedVirtualizerBottomRef = React.useRef(false); const bottomConfirmationChannelRef = React.useRef(channelId); if (bottomConfirmationChannelRef.current !== channelId) { diff --git a/desktop/src/features/messages/ui/anchoredScrollPolicy.ts b/desktop/src/features/messages/ui/anchoredScrollPolicy.ts index 5dfec80b83..a632ae265a 100644 --- a/desktop/src/features/messages/ui/anchoredScrollPolicy.ts +++ b/desktop/src/features/messages/ui/anchoredScrollPolicy.ts @@ -42,6 +42,18 @@ export function settleProgrammaticBottomPin( ); } +export function resolveTimelineReadPresence({ + isPhysicallyAtBottom: _isPhysicallyAtBottom, + isSemanticallyAtBottom, +}: { + isPhysicallyAtBottom: boolean; + isSemanticallyAtBottom: boolean; +}): boolean { + // A buffered timeline can shorten the rendered model enough that the + // virtualizer reports its physical floor while newer messages remain hidden. + return isSemanticallyAtBottom; +} + export function shouldSettleForSplitPanel({ isAtBottom, splitPanelOpen, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs index 28273f08bb..7e04eeb8e3 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { getPinnedCenterDrift, + resolveTimelineReadPresence, settleProgrammaticBottomPin, shouldIgnorePinnedCenterScroll, shouldSettleForSplitPanel, @@ -23,6 +24,23 @@ function fakeContainer({ clientHeight, scrollHeight, scrollTop }) { }; } +test("buffered arrivals do not become read when the shortened timeline reports its physical floor", () => { + assert.equal( + resolveTimelineReadPresence({ + isPhysicallyAtBottom: true, + isSemanticallyAtBottom: false, + }), + false, + ); + assert.equal( + resolveTimelineReadPresence({ + isPhysicallyAtBottom: true, + isSemanticallyAtBottom: true, + }), + true, + ); +}); + test("split panel settles only an already-bottomed timeline", () => { assert.equal( shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: true }),