diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 792966bd3..f08e9f2ae 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 64902e2fe..d13d39175 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 ab603c6f3..64b32e4ab 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 000000000..85209207f --- /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/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 714bde5d4..16f7bfe8c 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 ? ( +