fix(desktop): keep thread replies from scrolling channel (#1109)

Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-17 22:05:53 -04:00
committed by GitHub
co-authored by npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
parent dd9ce09020
commit d24eac61a8
5 changed files with 174 additions and 47 deletions
@@ -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<HTMLDivElement>(null);
const messageTimelineRef = React.useRef<MessageTimelineHandle>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const completedWelcomeBannerChannelIdsRef = React.useRef(new Set<string>());
const welcomeComposerDismissTimerRef = React.useRef<number | null>(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({
</div>
) : null}
<MessageTimeline
ref={messageTimelineRef}
agentPubkeys={agentPubkeys}
channelId={activeChannel?.id}
channelIntro={channelIntro}
@@ -7,6 +7,7 @@ import {
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
selectLatestMessageAutoScrollBehavior,
selectLatestMessageKey,
selectTimelineBodySurface,
selectTimelineIntroSurface,
@@ -103,6 +104,63 @@ test("selectLatestMessageKey: detects a newly arrived latest message", () => {
);
});
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", () => {
@@ -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. */
@@ -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<string>;
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<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const topSentinelRef = React.useRef<HTMLDivElement>(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,
}: {
@@ -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<string | undefined>(undefined);
const previousMessageCountRef = React.useRef(0);
const handledTargetMessageIdRef = React.useRef<string | null>(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,
};