Skip to content
Draft
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
37 changes: 32 additions & 5 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ type ChannelIntro = {
icon?: React.ReactNode;
};

/** Stable empty reference used as the `useDeferredValue` initial value so the
* first render on channel entry stays light instead of blocking on the full
* message list. Must be module-level so its identity never changes. */
const EMPTY_MESSAGES: TimelineMessage[] = [];

export const MessageTimeline = React.memo(function MessageTimeline({
agentPubkeys,
channelId,
Expand Down Expand Up @@ -127,6 +132,20 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const internalScrollRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const topSentinelRef = React.useRef<HTMLDivElement>(null);

// Phase A perf: gate the heavy timeline render (each row runs a synchronous
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
// commit that rebuilds the message list yield to higher-priority work, so the
// main thread stops freezing and the OS no longer shows the busy cursor when
// entering a channel. We pass `initialValue: []` so even the FIRST render on
// channel entry stays light — the heavy list streams in on a deferred commit
// rather than blocking the initial paint. We deliberately drive BOTH the
// scroll manager and the rendered list off the same deferred value —
// scroll/autoscroll/deep-link logic reads the DOM (`scrollIntoView`,
// ResizeObserver on the content), so it must stay consistent with what's
// actually painted. You can't scroll to a row that hasn't committed yet.
const deferredMessages = React.useDeferredValue(messages, EMPTY_MESSAGES);
const isRenderPending = deferredMessages !== messages;
const scrollRestorationId = targetMessageId
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
: `message-timeline:${channelId ?? "none"}`;
Expand All @@ -143,7 +162,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
} = useTimelineScrollManager({
channelId,
isLoading,
messages,
messages: deferredMessages,
onTargetReached,
scrollContainerRef,
targetMessageId,
Expand Down Expand Up @@ -188,10 +207,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const showIntro = showDirectMessageIntro || showChannelIntro;
const showGenericEmpty =
!isLoading &&
messages.length === 0 &&
deferredMessages.length === 0 &&
directMessageIntro === null &&
channelIntro === null;
const showMessageList = !isLoading && messages.length > 0;
const showMessageList = !isLoading && deferredMessages.length > 0;

return (
<TooltipProvider delayDuration={200}>
Expand Down Expand Up @@ -358,7 +377,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({

{showMessageList ? (
<div
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
className={cn(
"flex flex-col gap-2",
!showIntro && "mt-auto",
// Phase A: while a deferred render is in flight the painted
// list lags the latest `messages`. Dim it slightly so the
// streaming-in feels intentional instead of frozen.
isRenderPending && "opacity-60 transition-opacity",
)}
data-render-pending={isRenderPending ? "true" : undefined}
>
<TimelineMessageList
agentPubkeys={agentPubkeys}
Expand All @@ -370,7 +397,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={messages}
messages={deferredMessages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
Expand Down