From c690585cdde89dfc046186a5bd31f2452acd3f0c Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 21 May 2026 21:04:59 -0400 Subject: [PATCH 1/3] fix(desktop): anchor inbox thread links in channel timeline Open routed thread targets after channel reset and keep target navigation from being overridden by timeline scroll restoration. Co-authored-by: Cursor --- .../src/app/navigation/useAppNavigation.ts | 2 +- .../features/channels/ui/ChannelScreen.tsx | 19 ++- .../channels/ui/useChannelAgentSessions.ts | 77 --------- .../channels/ui/useChannelRouteTarget.ts | 153 ++++++++++++++++++ .../src/features/messages/ui/MessageRow.tsx | 4 +- .../features/messages/ui/MessageTimeline.tsx | 5 +- .../messages/ui/TimelineMessageList.tsx | 13 +- .../messages/ui/useTimelineScrollManager.ts | 60 ++++--- 8 files changed, 229 insertions(+), 104 deletions(-) create mode 100644 desktop/src/features/channels/ui/useChannelRouteTarget.ts diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 792966bd33..f08e9f2ae2 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -147,7 +147,7 @@ export function useAppNavigation() { }, { replace: options?.replace, - resetScroll: options?.messageId ? false : undefined, + resetScroll: options?.messageId ? true : undefined, }, ), [commitNavigation], diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 64902e2fe0..d13d391758 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -56,6 +56,7 @@ import { } from "./useChannelActivityTyping"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; +import { useChannelRouteTarget } from "./useChannelRouteTarget"; type ChannelScreenProps = { activeChannel: Channel | null; currentIdentity?: Identity; @@ -359,8 +360,6 @@ export function ChannelScreen({ setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, - targetMessageId, - timelineMessages, }); const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = @@ -393,10 +392,22 @@ export function ChannelScreen({ const handleThreadScrollTargetResolved = React.useCallback(() => { setThreadScrollTargetId(null); }, []); - React.useEffect(() => { resetComposerTargets(activeChannelId); }, [activeChannelId, resetComposerTargets]); + const mainTimelineTargetMessageId = useChannelRouteTarget({ + activeChannel, + activeChannelId, + closeAgentSession: handleCloseAgentSession, + setEditTargetId, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + targetMessageId, + timelineMessages, + }); React.useEffect(() => { if (openThreadHeadId && !openThreadHeadMessage) { setOpenThreadHeadId(null); @@ -507,7 +518,7 @@ export function ChannelScreen({ profilePanelPubkey={profilePanelPubkey} personaLookup={personaLookup} profiles={messageProfiles} - targetMessageId={targetMessageId} + targetMessageId={mainTimelineTargetMessageId} threadHeadMessage={openThreadHeadMessage} threadMessages={threadMessages} threadTypingPubkeys={threadTypingPubkeys} diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index ab603c6f3c..64b32e4ab7 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -30,8 +30,6 @@ type UseChannelAgentSessionsOptions = { setProfilePanelPubkey: (value: string | null) => void; setThreadReplyTargetId: (value: string | null) => void; setThreadScrollTargetId: (value: string | null) => void; - targetMessageId: string | null; - timelineMessages: TimelineMessage[]; }; function relayStatusToManagedStatus( @@ -160,13 +158,10 @@ export function useChannelAgentSessions({ setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, - targetMessageId, - timelineMessages, }: UseChannelAgentSessionsOptions) { const [openAgentSessionPubkey, setOpenAgentSessionPubkey] = React.useState< string | null >(null); - const handledThreadTargetIdRef = React.useRef(null); const channelAgentSessionAgents = React.useMemo( () => @@ -214,78 +209,6 @@ export function useChannelAgentSessions({ [handleOpenThread, setProfilePanelPubkey], ); - React.useEffect(() => { - if (!targetMessageId) { - handledThreadTargetIdRef.current = null; - return; - } - - const targetKey = `${activeChannelId ?? "none"}:${targetMessageId}`; - if ( - handledThreadTargetIdRef.current !== null && - handledThreadTargetIdRef.current !== targetKey - ) { - handledThreadTargetIdRef.current = null; - } - - if ( - handledThreadTargetIdRef.current === targetKey || - !activeChannel || - activeChannel.channelType === "forum" - ) { - return; - } - - const targetMessage = - timelineMessages.find((message) => message.id === targetMessageId) ?? - null; - - if (!targetMessage?.parentId) { - return; - } - - const threadHeadId = targetMessage.rootId ?? targetMessage.parentId; - const messageById = new Map( - timelineMessages.map((message) => [message.id, message]), - ); - - if (!messageById.has(threadHeadId)) { - return; - } - - const expandedReplyIds = new Set(); - let ancestorId: string | null = targetMessage.parentId; - let guard = 0; - - while ( - ancestorId && - ancestorId !== threadHeadId && - guard < timelineMessages.length - ) { - expandedReplyIds.add(ancestorId); - ancestorId = messageById.get(ancestorId)?.parentId ?? null; - guard += 1; - } - - setOpenAgentSessionPubkey(null); - setProfilePanelPubkey(null); - setOpenThreadHeadId(threadHeadId); - setThreadReplyTargetId(threadHeadId); - setThreadScrollTargetId(targetMessageId); - setExpandedThreadReplyIds(expandedReplyIds); - handledThreadTargetIdRef.current = targetKey; - }, [ - activeChannel, - activeChannelId, - setExpandedThreadReplyIds, - setOpenThreadHeadId, - setProfilePanelPubkey, - setThreadReplyTargetId, - setThreadScrollTargetId, - targetMessageId, - timelineMessages, - ]); - React.useEffect(() => { if ( openAgentSessionPubkey && diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts new file mode 100644 index 0000000000..85209207fd --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -0,0 +1,153 @@ +import * as React from "react"; + +import type { TimelineMessage } from "@/features/messages/types"; +import type { Channel } from "@/shared/api/types"; + +function isBroadcastReply(message: TimelineMessage): boolean { + return ( + message.tags?.some((tag) => tag[0] === "broadcast" && tag[1] === "1") ?? + false + ); +} + +function getThreadRouteTarget( + targetMessage: TimelineMessage, + messageById: ReadonlyMap, +): { expandedReplyIds: Set; threadHeadId: string } | null { + const threadHeadId = targetMessage.rootId ?? targetMessage.parentId ?? null; + if (!threadHeadId || !messageById.has(threadHeadId)) { + return null; + } + + const expandedReplyIds = new Set(); + let ancestorId = targetMessage.parentId ?? null; + let guard = 0; + const maxHops = messageById.size + 1; + + while (ancestorId && ancestorId !== threadHeadId && guard < maxHops) { + const ancestor = messageById.get(ancestorId); + if (!ancestor) { + return null; + } + + expandedReplyIds.add(ancestor.id); + ancestorId = ancestor.parentId ?? null; + guard += 1; + } + + if (ancestorId !== threadHeadId) { + return null; + } + + return { expandedReplyIds, threadHeadId }; +} + +function getRouteMainTimelineTargetId( + targetMessageId: string | null, + targetMessage: TimelineMessage | null, +): string | null { + if (!targetMessageId) { + return null; + } + + if (!targetMessage?.parentId || isBroadcastReply(targetMessage)) { + return targetMessageId; + } + + return targetMessage.rootId ?? targetMessage.parentId; +} + +export function useChannelRouteTarget({ + activeChannel, + activeChannelId, + closeAgentSession, + setEditTargetId, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + targetMessageId, + timelineMessages, +}: { + activeChannel: Channel | null; + activeChannelId: string | null; + closeAgentSession: () => void; + setEditTargetId: React.Dispatch>; + setExpandedThreadReplyIds: React.Dispatch>>; + setOpenThreadHeadId: React.Dispatch>; + setProfilePanelPubkey: React.Dispatch>; + setThreadReplyTargetId: React.Dispatch>; + setThreadScrollTargetId: React.Dispatch>; + targetMessageId: string | null; + timelineMessages: TimelineMessage[]; +}) { + const timelineMessageById = React.useMemo( + () => new Map(timelineMessages.map((message) => [message.id, message])), + [timelineMessages], + ); + const targetTimelineMessage = targetMessageId + ? (timelineMessageById.get(targetMessageId) ?? null) + : null; + const mainTimelineTargetMessageId = getRouteMainTimelineTargetId( + targetMessageId, + targetTimelineMessage, + ); + const handledThreadRouteTargetRef = React.useRef(null); + + React.useEffect(() => { + if (!targetMessageId) { + handledThreadRouteTargetRef.current = null; + return; + } + + const targetKey = `${activeChannelId ?? "none"}:${targetMessageId}`; + if (handledThreadRouteTargetRef.current !== targetKey) { + handledThreadRouteTargetRef.current = null; + } + + if ( + handledThreadRouteTargetRef.current === targetKey || + !activeChannel || + activeChannel.channelType === "forum" + ) { + return; + } + + const targetMessage = timelineMessageById.get(targetMessageId) ?? null; + if (!targetMessage?.parentId || isBroadcastReply(targetMessage)) { + return; + } + + const routeTarget = getThreadRouteTarget( + targetMessage, + timelineMessageById, + ); + if (!routeTarget) { + return; + } + + closeAgentSession(); + setProfilePanelPubkey(null); + setEditTargetId(null); + setOpenThreadHeadId(routeTarget.threadHeadId); + setThreadReplyTargetId(routeTarget.threadHeadId); + setThreadScrollTargetId(targetMessageId); + setExpandedThreadReplyIds(routeTarget.expandedReplyIds); + handledThreadRouteTargetRef.current = targetKey; + }, [ + activeChannel, + activeChannelId, + closeAgentSession, + setEditTargetId, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + targetMessageId, + timelineMessageById, + ]); + + return mainTimelineTargetMessageId; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index c834115434..50a0766a40 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -301,7 +301,9 @@ export const MessageRow = React.memo( className={cn( "group/message relative rounded-2xl px-2 py-1 transition-colors", "flex items-start gap-2.5", - highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "", + highlighted + ? "-mx-4 rounded-none px-6 before:absolute before:-inset-y-1.5 before:inset-x-0 before:bg-primary/10 before:content-[''] sm:-mx-6 sm:px-8" + : "", )} data-message-id={message.id} data-testid="message-row" diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 4c85c09985..dde15573ea 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -73,6 +73,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ }: MessageTimelineProps) { const scrollContainerRef = React.useRef(null); const topSentinelRef = React.useRef(null); + const scrollRestorationId = targetMessageId + ? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}` + : `message-timeline:${channelId ?? "none"}`; const { bottomAnchorRef, @@ -129,7 +132,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
+
{ @@ -342,14 +356,6 @@ export function useTimelineScrollManager({ return; } - const settleOnTarget = () => { - handledTargetMessageIdRef.current = targetMessageId; - unpinFromBottom(timeline.scrollTop); - setHighlightedMessageId(targetMessageId); - setNewMessageCount(0); - onTargetReached?.(targetMessageId); - }; - const targetElement = timeline.querySelector( `[data-message-id="${targetMessageId}"]`, ); @@ -357,11 +363,29 @@ export function useTimelineScrollManager({ return; } - targetElement.scrollIntoView({ - block: "center", - behavior: "smooth", - }); - settleOnTarget(); + handledTargetMessageIdRef.current = targetMessageId; + unpinFromBottom(timeline.scrollTop); + setHighlightedMessageId(targetMessageId); + setNewMessageCount(0); + + const alignToTarget = (remainingFrames: number) => { + targetElement.scrollIntoView({ + block: "center", + behavior: "auto", + }); + previousScrollTopRef.current = timeline.scrollTop; + + if (remainingFrames > 0) { + requestAnimationFrame(() => { + alignToTarget(remainingFrames - 1); + }); + return; + } + + onTargetReached?.(targetMessageId); + }; + + alignToTarget(2); const timeout = window.setTimeout(() => { setHighlightedMessageId((current) => From f6243f2ec28c34f81c4eedcdc58caaf3e8a36585 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 21 May 2026 21:27:07 -0400 Subject: [PATCH 2/3] fix(desktop): fade routed message highlight Fade the existing routed message highlight without changing thread navigation or scroll handling. Co-authored-by: Cursor --- desktop/src/features/messages/ui/MessageRow.tsx | 2 +- .../src/features/messages/ui/TimelineMessageList.tsx | 2 +- desktop/src/shared/styles/globals.css | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 50a0766a40..222a5e29fe 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -302,7 +302,7 @@ export const MessageRow = React.memo( "group/message relative rounded-2xl px-2 py-1 transition-colors", "flex items-start gap-2.5", highlighted - ? "-mx-4 rounded-none px-6 before:absolute before:-inset-y-1.5 before:inset-x-0 before:bg-primary/10 before:content-[''] sm:-mx-6 sm:px-8" + ? "-mx-4 rounded-none px-6 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-8" : "", )} data-message-id={message.id} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 3fac54fde1..96b2388c84 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -106,7 +106,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ className={cn( "relative flex flex-col gap-0", isHighlighted && - "-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:bg-primary/10 before:content-[''] sm:-mx-6 sm:px-6", + "-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6", )} > Date: Thu, 21 May 2026 21:52:23 -0400 Subject: [PATCH 3/3] fix(desktop): square off inbox highlight background Render the inbox selected-message highlight from the row wrapper so it spans the full detail pane without changing the scroll target. Co-authored-by: Cursor --- .../src/features/home/ui/InboxMessageRow.tsx | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 714bde5d4d..16f7bfe8c0 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -60,7 +60,18 @@ export function InboxMessageRow({ } = useReactionHandler(timelineMessage, onToggleReaction); return ( -
+
+ {message.isSelected ? ( +