diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 517fa07287..965f1e3b3a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -8,7 +8,10 @@ import { MessageThreadPanel, MessageThreadPanelSkeleton, } from "@/features/messages/ui/MessageThreadPanel"; -import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; +import { + MessageTimeline, + type MessageTimelineHandle, +} from "@/features/messages/ui/MessageTimeline"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; import { @@ -239,6 +242,7 @@ export const ChannelPane = React.memo(function ChannelPane({ typingPubkeys, }: ChannelPaneProps) { const timelineScrollRef = React.useRef(null); + const messageTimelineRef = React.useRef(null); const composerWrapperRef = React.useRef(null); const completedWelcomeBannerChannelIdsRef = React.useRef(new Set()); const welcomeComposerDismissTimerRef = React.useRef(null); @@ -414,6 +418,7 @@ export const ChannelPane = React.memo(function ChannelPane({ (containsWelcomePersonaMention(content) || mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); + messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage(content, mentionPubkeys, mediaTags); if (shouldCompleteWelcomeBanner) { @@ -636,6 +641,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} { ); }); +test("selectLatestMessageAutoScrollBehavior: keeps sticky timelines pinned automatically", () => { + assert.equal( + selectLatestMessageAutoScrollBehavior({ + hasExplicitBottomRequest: false, + isAtBottom: false, + shouldStickToBottom: true, + targetMessageId: null, + }), + "auto", + ); + assert.equal( + selectLatestMessageAutoScrollBehavior({ + hasExplicitBottomRequest: false, + isAtBottom: true, + shouldStickToBottom: false, + targetMessageId: null, + }), + "auto", + ); +}); + +test("selectLatestMessageAutoScrollBehavior: explicit send requests smooth bottom scroll", () => { + assert.equal( + selectLatestMessageAutoScrollBehavior({ + hasExplicitBottomRequest: true, + isAtBottom: false, + shouldStickToBottom: false, + targetMessageId: null, + }), + "smooth", + ); +}); + +test("selectLatestMessageAutoScrollBehavior: self-authored inserts do not imply scroll", () => { + assert.equal( + selectLatestMessageAutoScrollBehavior({ + hasExplicitBottomRequest: false, + isAtBottom: false, + shouldStickToBottom: false, + targetMessageId: null, + }), + null, + ); +}); + +test("selectLatestMessageAutoScrollBehavior: target navigation suppresses latest-message autoscroll", () => { + assert.equal( + selectLatestMessageAutoScrollBehavior({ + hasExplicitBottomRequest: true, + isAtBottom: true, + shouldStickToBottom: true, + targetMessageId: "message-a", + }), + null, + ); +}); + // --- day dividers ------------------------------------------------------------- test("buildDayGroupBoundaries: empty snapshot has no groups", () => { diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index d807862fc8..e63c2ea0d0 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -58,6 +58,34 @@ export function selectLatestMessageKey( return latest.renderKey ?? latest.id; } +export type LatestMessageAutoScrollBehavior = "auto" | "smooth" | null; + +export function selectLatestMessageAutoScrollBehavior({ + hasExplicitBottomRequest, + isAtBottom, + shouldStickToBottom, + targetMessageId, +}: { + hasExplicitBottomRequest: boolean; + isAtBottom: boolean; + shouldStickToBottom: boolean; + targetMessageId?: string | null; +}): LatestMessageAutoScrollBehavior { + if (targetMessageId) { + return null; + } + + if (hasExplicitBottomRequest) { + return "smooth"; + } + + if (shouldStickToBottom || isAtBottom) { + return "auto"; + } + + return null; +} + /** A single day boundary in the timeline: where it starts and how many messages it covers. */ export type DayGroupBoundary = { /** Stable key for the day section. */ diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 3313927ad7..73e96dfa77 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -20,6 +20,10 @@ import { TimelineMessageList } from "./TimelineMessageList"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; import { useTimelineScrollManager } from "./useTimelineScrollManager"; +export type MessageTimelineHandle = { + scrollToBottomOnNextUpdate: () => void; +}; + type MessageTimelineProps = { agentPubkeys?: ReadonlySet; channelId?: string | null; @@ -110,45 +114,51 @@ type DirectMessageIntroParticipant = { pubkey: string; }; -export const MessageTimeline = React.memo(function MessageTimeline({ - agentPubkeys, - channelId, - channelIntro = null, - directMessageIntro = null, - messages, - isLoading = false, - emptyTitle = "No messages yet", - emptyDescription = "Send the first message to start the thread.", - currentPubkey, - fetchOlder, - hasComposerOverlay = true, - hasOlderMessages = true, - isFetchingOlder = false, - followThreadById, - isFollowingThreadById, - messageFooters, - personaLookup, - profiles, - onDelete, - onEdit, - onMarkUnread, - onReply, - channelName, - channelType, - isSendingVideoReviewComment = false, - onSendVideoReviewComment, - onToggleReaction, - unfollowThreadById, - scrollContainerRef: externalScrollRef, - searchActiveMessageId = null, - searchMatchingMessageIds, - searchQuery, - targetMessageId = null, - onTargetReached, - firstUnreadMessageId = null, - unreadCount = 0, - threadUnreadCounts, -}: MessageTimelineProps) { +const MessageTimelineBase = React.forwardRef< + MessageTimelineHandle, + MessageTimelineProps +>(function MessageTimeline( + { + agentPubkeys, + channelId, + channelIntro = null, + directMessageIntro = null, + messages, + isLoading = false, + emptyTitle = "No messages yet", + emptyDescription = "Send the first message to start the thread.", + currentPubkey, + fetchOlder, + hasComposerOverlay = true, + hasOlderMessages = true, + isFetchingOlder = false, + followThreadById, + isFollowingThreadById, + messageFooters, + personaLookup, + profiles, + onDelete, + onEdit, + onMarkUnread, + onReply, + channelName, + channelType, + isSendingVideoReviewComment = false, + onSendVideoReviewComment, + onToggleReaction, + unfollowThreadById, + scrollContainerRef: externalScrollRef, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, + targetMessageId = null, + onTargetReached, + firstUnreadMessageId = null, + unreadCount = 0, + threadUnreadCounts, + }: MessageTimelineProps, + ref, +) { const internalScrollRef = React.useRef(null); const scrollContainerRef = externalScrollRef ?? internalScrollRef; const topSentinelRef = React.useRef(null); @@ -200,6 +210,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ newMessageCount, restoreScrollPosition, scrollToBottom, + scrollToBottomOnNextUpdate, scrollToMessage, syncScrollState, } = useTimelineScrollManager({ @@ -211,6 +222,14 @@ export const MessageTimeline = React.memo(function MessageTimeline({ targetMessageId, }); + React.useImperativeHandle( + ref, + () => ({ + scrollToBottomOnNextUpdate, + }), + [scrollToBottomOnNextUpdate], + ); + // 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 @@ -540,6 +559,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({ ); }); +export const MessageTimeline = React.memo(MessageTimelineBase); + function DirectMessageIntroAvatarStack({ participants, }: { diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts index 584f3596b8..af8b66c889 100644 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ b/desktop/src/features/messages/ui/useTimelineScrollManager.ts @@ -3,6 +3,7 @@ import * as React from "react"; import { isNearBottom, resolveDeepLinkTarget, + selectLatestMessageAutoScrollBehavior, selectLatestMessageKey, } from "@/features/messages/lib/timelineSnapshot"; import type { TimelineMessage } from "@/features/messages/types"; @@ -41,6 +42,7 @@ export function useTimelineScrollManager({ const previousLastMessageKeyRef = React.useRef(undefined); const previousMessageCountRef = React.useRef(0); const handledTargetMessageIdRef = React.useRef(null); + const scrollToBottomOnNextUpdateRef = React.useRef(false); // Mirror isLoading into a ref so the ResizeObservers (which subscribe once) // can skip reacting while the skeleton is up — reacting to height churn under // a streaming-in list is what makes the timeline thrash on entry. @@ -63,6 +65,7 @@ export function useTimelineScrollManager({ previousLastMessageKeyRef.current = undefined; previousMessageCountRef.current = 0; handledTargetMessageIdRef.current = null; + scrollToBottomOnNextUpdateRef.current = false; setIsAtBottom(true); setHighlightedMessageId(null); setNewMessageCount(0); @@ -108,6 +111,10 @@ export function useTimelineScrollManager({ messages.length > 0 ? messages[messages.length - 1] : undefined; const latestMessageKey = selectLatestMessageKey(messages); + const scrollToBottomOnNextUpdate = React.useCallback(() => { + scrollToBottomOnNextUpdateRef.current = true; + }, []); + // 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; @@ -330,13 +337,19 @@ export function useTimelineScrollManager({ return; } - if ( - !targetMessageId && - (shouldStickToBottomRef.current || - isAtBottomRef.current || - latestMessage.accent) - ) { - scrollToBottom(latestMessage.accent ? "smooth" : "auto"); + const shouldHonorExplicitBottomRequest = + scrollToBottomOnNextUpdateRef.current; + scrollToBottomOnNextUpdateRef.current = false; + + const autoScrollBehavior = selectLatestMessageAutoScrollBehavior({ + hasExplicitBottomRequest: shouldHonorExplicitBottomRequest, + isAtBottom: isAtBottomRef.current, + shouldStickToBottom: shouldStickToBottomRef.current, + targetMessageId, + }); + + if (autoScrollBehavior) { + scrollToBottom(autoScrollBehavior); } else { setNewMessageCount((current) => { const addedMessages = Math.max( @@ -444,6 +457,7 @@ export function useTimelineScrollManager({ newMessageCount, restoreScrollPosition, scrollToBottom, + scrollToBottomOnNextUpdate, scrollToMessage, syncScrollState, };