Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -636,6 +641,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
) : null}
<MessageTimeline
ref={messageTimelineRef}
agentPubkeys={agentPubkeys}
channelId={activeChannel?.id}
channelIntro={channelIntro}
Expand Down
58 changes: 58 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
selectLatestMessageAutoScrollBehavior,
selectLatestMessageKey,
selectTimelineBodySurface,
selectTimelineIntroSurface,
Expand Down Expand Up @@ -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", () => {
Expand Down
28 changes: 28 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
99 changes: 60 additions & 39 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -200,6 +210,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
newMessageCount,
restoreScrollPosition,
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
syncScrollState,
} = useTimelineScrollManager({
Expand All @@ -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
Expand Down Expand Up @@ -540,6 +559,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({
);
});

export const MessageTimeline = React.memo(MessageTimelineBase);

function DirectMessageIntroAvatarStack({
participants,
}: {
Expand Down
28 changes: 21 additions & 7 deletions desktop/src/features/messages/ui/useTimelineScrollManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -444,6 +457,7 @@ export function useTimelineScrollManager({
newMessageCount,
restoreScrollPosition,
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
syncScrollState,
};
Expand Down