desktop: open thread pane optimistically (#1143)

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-06-19 13:32:34 -04:00
committed by GitHub
co-authored by npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
parent 9c6852824e
commit 663b554c7c
3 changed files with 89 additions and 17 deletions
@@ -139,6 +139,7 @@ type ChannelPaneProps = {
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
openThreadHeadId: string | null;
shouldShowThreadSkeleton: boolean;
openAgentSessionPubkey: string | null;
onProfilePanelViewChange: (
view: ProfilePanelView,
@@ -225,6 +226,7 @@ export const ChannelPane = React.memo(function ChannelPane({
personaLookup,
profiles,
openThreadHeadId,
shouldShowThreadSkeleton,
openAgentSessionPubkey,
onProfilePanelViewChange,
profilePanelPubkey,
@@ -860,7 +862,7 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: openThreadHeadId && activeChannel
: shouldShowThreadSkeleton
? (() => {
const panel = (
<MessageThreadPanelSkeleton
@@ -119,8 +119,6 @@ export function ChannelScreen({
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const [channelContentRef, channelContentWidthPx] =
useElementWidth<HTMLDivElement>();
const isNotifiedForCurrentThread =
openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false;
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(
() => new Set<string>(),
);
@@ -131,9 +129,42 @@ export function ChannelScreen({
string | null
>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
// Thread panel state is URL-backed, but router navigation is intentionally
// deferred out of the click handler. Keep a tiny optimistic override so the
// auxiliary pane can open/close in the urgent render, then let the URL-backed
// state hydrate the real thread contents when it catches up.
const [optimisticOpenThreadHeadId, setOptimisticOpenThreadHeadId] =
React.useState<string | null | undefined>(undefined);
const clearOptimisticThreadOverride = React.useCallback(() => {
setOptimisticOpenThreadHeadId(undefined);
}, []);
const mainInsetRef = useMainInsetRef();
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
const effectiveOpenThreadHeadId =
optimisticOpenThreadHeadId === undefined
? openThreadHeadId
: optimisticOpenThreadHeadId;
const isNotifiedForOpenThread =
openThreadHeadId != null ? isNotifiedForThread(openThreadHeadId) : false;
const isNotifiedForEffectiveThread =
effectiveOpenThreadHeadId != null
? isNotifiedForThread(effectiveOpenThreadHeadId)
: false;
const previousActiveChannelIdRef = React.useRef(activeChannelId);
React.useEffect(() => {
const didChangeChannel =
previousActiveChannelIdRef.current !== activeChannelId;
previousActiveChannelIdRef.current = activeChannelId;
setOptimisticOpenThreadHeadId((current) => {
if (current === undefined) {
return current;
}
return didChangeChannel || openThreadHeadId === current
? undefined
: current;
});
}, [activeChannelId, openThreadHeadId]);
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
@@ -288,7 +319,7 @@ export function ChannelScreen({
activeChannelId,
channelMembers,
managedAgents,
openThreadHeadId,
openThreadHeadId: effectiveOpenThreadHeadId,
relayAgents,
typingEntries,
});
@@ -369,7 +400,7 @@ export function ChannelScreen({
openThreadHeadId,
threadReplyTargetId,
expandedThreadReplyIds,
isNotifiedForCurrentThread,
isNotifiedForCurrentThread: isNotifiedForOpenThread,
getChannelReadAt,
getThreadReadAt,
markChannelUnread,
@@ -404,7 +435,8 @@ export function ChannelScreen({
getReplyDescendantIdsForMessage,
getSubtreeMaxCreatedAt,
markThreadRead,
openThreadHeadId,
openThreadHeadId: effectiveOpenThreadHeadId,
onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId,
sendMessageMutation,
setExpandedThreadReplyIds,
setEditTargetId,
@@ -540,6 +572,7 @@ export function ChannelScreen({
if (isTimelineLoading) {
return;
}
clearOptimisticThreadOverride();
setOpenThreadHeadId(null, { replace: true });
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
@@ -558,6 +591,7 @@ export function ChannelScreen({
setEditTargetId(null);
}
}, [
clearOptimisticThreadOverride,
editTargetId,
editTargetMessage,
isTimelineLoading,
@@ -570,7 +604,23 @@ export function ChannelScreen({
useLoadMissingAncestors(activeChannel, resolvedMessages);
const hasAuxiliaryPanel = Boolean(
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
effectiveOpenThreadHeadId || openAgentSessionPubkey || profilePanelPubkey,
);
const displayedThreadHeadMessage =
openThreadHeadMessage?.id === effectiveOpenThreadHeadId
? openThreadHeadMessage
: null;
const displayedThreadMessages = displayedThreadHeadMessage
? threadMessages
: [];
const displayedThreadReplyTargetMessage = displayedThreadHeadMessage
? threadReplyTargetMessage
: null;
const displayedThreadFirstUnreadReplyId = displayedThreadHeadMessage
? threadFirstUnreadReplyId
: null;
const shouldShowThreadSkeleton = Boolean(
effectiveOpenThreadHeadId && activeChannel && !displayedThreadHeadMessage,
);
const isNarrowPanelViewport =
channelContentWidthPx > 0 &&
@@ -673,7 +723,7 @@ export function ChannelScreen({
followThreadById={followThread}
unfollowThreadById={unfollowThread}
isFollowingThreadById={isFollowingThread}
isFollowingThread={isNotifiedForCurrentThread}
isFollowingThread={isNotifiedForEffectiveThread}
isSending={sendMessageMutation.isPending}
isSinglePanelView={isSinglePanelView}
isTimelineLoading={isTimelineLoading}
@@ -681,13 +731,15 @@ export function ChannelScreen({
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onFollowThread={
openThreadHeadId != null && !isNotifiedForCurrentThread
? () => followThread(openThreadHeadId)
effectiveOpenThreadHeadId != null &&
!isNotifiedForEffectiveThread
? () => followThread(effectiveOpenThreadHeadId)
: undefined
}
onUnfollowThread={
openThreadHeadId != null && isNotifiedForCurrentThread
? () => unfollowThread(openThreadHeadId)
effectiveOpenThreadHeadId != null &&
isNotifiedForEffectiveThread
? () => unfollowThread(effectiveOpenThreadHeadId)
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
@@ -718,7 +770,8 @@ export function ChannelScreen({
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
openThreadHeadId={effectiveOpenThreadHeadId}
shouldShowThreadSkeleton={shouldShowThreadSkeleton}
onProfilePanelViewChange={setProfilePanelView}
profilePanelPubkey={profilePanelPubkey}
profilePanelView={profilePanelView}
@@ -727,15 +780,15 @@ export function ChannelScreen({
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
targetMessageId={mainTimelineTargetMessageId}
threadHeadMessage={openThreadHeadMessage}
threadMessages={threadMessages}
threadHeadMessage={displayedThreadHeadMessage}
threadMessages={displayedThreadMessages}
threadPanelWidthPx={threadPanelWidthPx}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetMessage={threadReplyTargetMessage}
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
threadUnreadCounts={threadUnreadCounts}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadFirstUnreadReplyId={threadFirstUnreadReplyId}
threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
typingPubkeys={humanTypingPubkeys}
@@ -1,5 +1,7 @@
import * as React from "react";
import { flushSync } from "react-dom";
import type {
useDeleteMessageMutation,
useEditMessageMutation,
@@ -24,6 +26,7 @@ export function useChannelPaneHandlers({
getReplyDescendantIdsForMessage,
getSubtreeMaxCreatedAt,
markThreadRead,
onOptimisticOpenThreadHeadIdChange,
openThreadHeadId,
sendMessageMutation,
setExpandedThreadReplyIds,
@@ -42,6 +45,9 @@ export function useChannelPaneHandlers({
getReplyDescendantIdsForMessage: (messageId: string) => string[];
getSubtreeMaxCreatedAt: (messageId: string) => number | null;
markThreadRead: (rootId: string, timestamp: number) => void;
onOptimisticOpenThreadHeadIdChange: React.Dispatch<
React.SetStateAction<string | null | undefined>
>;
openThreadHeadId: string | null;
sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
@@ -82,11 +88,15 @@ export function useChannelPaneHandlers({
}, [setThreadReplyTargetId]);
const handleCloseThread = React.useCallback(() => {
flushSync(() => {
onOptimisticOpenThreadHeadIdChange(null);
});
setOpenThreadHeadId(null);
setThreadReplyTargetId(null);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
}, [
onOptimisticOpenThreadHeadIdChange,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setThreadReplyTargetId,
@@ -127,6 +137,9 @@ export function useChannelPaneHandlers({
const handleOpenThread = React.useCallback(
(message: { id: string }) => {
if (openThreadHeadIdRef.current === message.id) {
flushSync(() => {
onOptimisticOpenThreadHeadIdChange(null);
});
setOpenThreadHeadId(null);
setThreadReplyTargetId(null);
setThreadScrollTargetId(null);
@@ -135,6 +148,9 @@ export function useChannelPaneHandlers({
return;
}
flushSync(() => {
onOptimisticOpenThreadHeadIdChange(message.id);
});
setOpenThreadHeadId(message.id);
setThreadReplyTargetId(message.id);
setThreadScrollTargetId(null);
@@ -142,6 +158,7 @@ export function useChannelPaneHandlers({
setEditTargetId(null);
},
[
onOptimisticOpenThreadHeadIdChange,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,