diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 1fa347a20..e47e8f2e3 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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, @@ -127,6 +132,20 @@ export const MessageTimeline = React.memo(function MessageTimeline({ const internalScrollRef = React.useRef(null); const scrollContainerRef = externalScrollRef ?? internalScrollRef; const topSentinelRef = React.useRef(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"}`; @@ -143,7 +162,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ } = useTimelineScrollManager({ channelId, isLoading, - messages, + messages: deferredMessages, onTargetReached, scrollContainerRef, targetMessageId, @@ -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 ( @@ -358,7 +377,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({ {showMessageList ? (