From 0c5ca1f565e4e985c6c93ff6f5571e39546d6540 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 28 Jun 2026 11:36:01 +0100 Subject: [PATCH] Fix task route targets and scroll checks --- desktop/src/app/routes/ChannelRouteScreen.tsx | 34 +++++++++ .../src/features/channels/ui/ChannelPane.tsx | 2 - .../features/channels/ui/ChannelScreen.tsx | 71 ++++++++++++++----- .../channels/ui/useChannelRouteTarget.ts | 27 ++++--- desktop/src/testing/e2eBridge.ts | 8 ++- desktop/tests/e2e/stream.spec.ts | 25 ++++++- 6 files changed, 132 insertions(+), 35 deletions(-) diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index dc828d75c..360bb01df 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -9,9 +9,14 @@ import { isBroadcastReply, } from "@/features/messages/lib/threading"; import { useProfileQuery } from "@/features/profile/hooks"; +import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { getEventById } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; +import { + CHANNEL_TIMELINE_CONTENT_KINDS, + CHANNEL_TIMELINE_STATE_KINDS, +} from "@/shared/constants/kinds"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { @@ -24,6 +29,7 @@ type ChannelRouteScreenProps = { }; const MAX_ROUTE_ANCESTOR_HOPS = 50; +const MAX_ROUTE_TASK_EVENTS = 1000; async function fetchRouteEvent(eventId: string): Promise { try { @@ -43,8 +49,10 @@ function getReplyParentId(event: RelayEvent): string | null { } async function fetchRouteTargetEvents( + channelId: string, eventIds: string[], targetMessageId: string | null, + targetAgentConversationReplyId: string | null, targetThreadRootId: string | null, ): Promise { const eventsById = new Map(); @@ -73,6 +81,29 @@ async function fetchRouteTargetEvents( addEvent(await fetchRouteEvent(threadRootId)); } + if (targetAgentConversationReplyId && threadRootId) { + try { + const taskEvents = await relayClient.fetchEvents({ + "#e": [threadRootId], + "#h": [channelId], + kinds: [ + ...CHANNEL_TIMELINE_CONTENT_KINDS, + ...CHANNEL_TIMELINE_STATE_KINDS, + ], + limit: MAX_ROUTE_TASK_EVENTS, + }); + for (const event of taskEvents) { + addEvent(event); + } + } catch (error) { + console.error( + "Failed to load route task conversation", + targetAgentConversationReplyId, + error, + ); + } + } + let parentId = getReplyParentId(targetEvent); let guard = 0; while ( @@ -171,8 +202,10 @@ export function ChannelRouteScreen({ ].filter((eventId): eventId is string => eventId !== null); void fetchRouteTargetEvents( + channelId, eventIds, targetAgentConversationReplyId ?? targetMessageId, + targetAgentConversationReplyId, targetThreadRootId, ).then((events) => { if (!isCancelled) { @@ -191,6 +224,7 @@ export function ChannelRouteScreen({ }; }, [ selectedPostId, + channelId, targetAgentConversationReplyId, targetMessageId, targetThreadRootId, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 01b97df6a..e399ed02b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -397,7 +397,6 @@ export const ChannelPane = React.memo(function ChannelPane({ if (isThreadedTask) { onOpenThread(threadMessage); - onSelectThreadReplyTarget(message); onThreadScrollTargetChange(message.id); return; } @@ -408,7 +407,6 @@ export const ChannelPane = React.memo(function ChannelPane({ [ onCloseThread, onOpenThread, - onSelectThreadReplyTarget, onSurfaceTabChange, onThreadScrollTargetChange, ], diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 0edae3d60..f29acc66c 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -728,9 +728,10 @@ export function ChannelScreen({ const handleThreadScrollTargetResolved = React.useCallback(() => { setThreadScrollTargetId(null); }, []); - const handleTargetReached = React.useCallback(() => { - clearMessageRouteTarget({ replace: true }); - }, [clearMessageRouteTarget]); + const [ + pendingMainTimelineRouteTargetId, + setPendingMainTimelineRouteTargetId, + ] = React.useState(null); React.useEffect(() => { resetComposerTargets(activeChannelId); }, [activeChannelId, resetComposerTargets]); @@ -768,19 +769,55 @@ export function ChannelScreen({ targetAgentConversationReplyId, timelineMessages, }); - const mainTimelineTargetMessageId = useChannelRouteTarget({ - activeChannel, - activeChannelId, - closeAgentSession: handleCloseAgentSession, - setEditTargetId, - setExpandedThreadReplyIds, - setOpenThreadHeadId, - setProfilePanelPubkey, - setThreadReplyTargetId, - setThreadScrollTargetId, - targetMessageId, - timelineMessages, - }); + const { mainTimelineTargetMessageId, rootThreadHeadTargetId } = + useChannelRouteTarget({ + activeChannel, + activeChannelId, + closeAgentSession: handleCloseAgentSession, + setEditTargetId, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + targetMessageId, + timelineMessages, + }); + const handleTargetReached = React.useCallback( + (messageId: string) => { + setPendingMainTimelineRouteTargetId((current) => + current === messageId ? null : current, + ); + if (rootThreadHeadTargetId === messageId) { + handleCloseAgentSession(); + setProfilePanelPubkey(null, { replace: true }); + setEditTargetId(null); + setOpenThreadHeadId(messageId, { replace: true }); + setThreadReplyTargetId(messageId); + setThreadScrollTargetId(null); + setExpandedThreadReplyIds(new Set()); + } + clearMessageRouteTarget({ replace: true }); + }, + [ + clearMessageRouteTarget, + handleCloseAgentSession, + rootThreadHeadTargetId, + setOpenThreadHeadId, + setProfilePanelPubkey, + ], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: activeChannelId is the reset trigger; the effect intentionally clears target state when the channel changes. + React.useEffect(() => { + setPendingMainTimelineRouteTargetId(null); + }, [activeChannelId]); + React.useEffect(() => { + if (mainTimelineTargetMessageId) { + setPendingMainTimelineRouteTargetId(mainTimelineTargetMessageId); + } + }, [mainTimelineTargetMessageId]); + const effectiveMainTimelineTargetMessageId = + pendingMainTimelineRouteTargetId ?? mainTimelineTargetMessageId; React.useEffect(() => { if (openThreadHeadId && !openThreadHeadMessage) { // While the timeline is still loading (e.g. a reload restoring the @@ -1063,7 +1100,7 @@ export function ChannelScreen({ onSurfaceTabChange={handleSurfaceTabChange} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} + targetMessageId={effectiveMainTimelineTargetMessageId} threadHeadMessage={displayedThreadHeadMessage} threadMessages={displayedThreadMessages} threadPanelWidthPx={threadPanelWidthPx} diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 0dc4b0e4d..bf288bba6 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -45,7 +45,11 @@ function getRouteMainTimelineTargetId( return null; } - if (!targetMessage?.parentId || isBroadcastReply(targetMessage.tags ?? [])) { + if (!targetMessage) { + return null; + } + + if (!targetMessage.parentId || isBroadcastReply(targetMessage.tags ?? [])) { return targetMessageId; } @@ -88,6 +92,10 @@ export function useChannelRouteTarget({ targetMessageId, targetTimelineMessage, ); + const rootThreadHeadTargetId = + targetTimelineMessage && !targetTimelineMessage.parentId + ? targetTimelineMessage.id + : null; const handledThreadRouteTargetRef = React.useRef(null); React.useEffect(() => { @@ -115,18 +123,9 @@ export function useChannelRouteTarget({ } if (!targetMessage.parentId) { - closeAgentSession(); - // Root message links should open the reply panel for that root. The - // timeline scroll/highlight target alone is not enough: root links have - // no parent/thread metadata, so the reply-only branch below cannot infer - // a thread head. - setProfilePanelPubkey(null, { replace: true }); - setEditTargetId(null); - setOpenThreadHeadId(targetMessage.id, { replace: true }); - setThreadReplyTargetId(targetMessage.id); - setThreadScrollTargetId(null); - setExpandedThreadReplyIds(new Set()); - handledThreadRouteTargetRef.current = targetKey; + // Root links still need to open the reply panel, but not until the main + // timeline has centered the row. Opening the panel first changes the main + // column layout mid-jump and can make the virtualized target abandon early. return; } @@ -166,5 +165,5 @@ export function useChannelRouteTarget({ timelineMessageById, ]); - return mainTimelineTargetMessageId; + return { mainTimelineTargetMessageId, rootThreadHeadTargetId }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index eaaf6eaa4..7f6cb86f5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6377,10 +6377,16 @@ async function handleGetEvent( }, config: E2eConfig | undefined, ) { + const mockEvent = Array.from(mockMessages.values()) + .flat() + .find((item) => item.id === args.eventId); + if (mockEvent) { + return JSON.stringify(mockEvent); + } + const identity = getIdentity(config); if (!identity) { const knownEvents: RelayEvent[] = [ - ...Array.from(mockMessages.values()).flat(), { id: "mock-engineering-shipped", pubkey: diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts index dfe02f092..a8766cadb 100644 --- a/desktop/tests/e2e/stream.spec.ts +++ b/desktop/tests/e2e/stream.spec.ts @@ -167,7 +167,30 @@ async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) { } } - throw new Error("Failed to scroll the timeline away from the bottom."); + await timeline.evaluate((element, distance) => { + const timeline = element as HTMLDivElement; + timeline.dispatchEvent( + new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: -800, + }), + ); + const maxScrollTop = Math.max( + 0, + timeline.scrollHeight - timeline.clientHeight, + ); + timeline.scrollTop = Math.max(0, maxScrollTop - distance - 80); + timeline.dispatchEvent(new Event("scroll", { bubbles: true })); + }, minDistance); + + await expect + .poll(async () => { + const metrics = await getTimelineMetrics(page); + const scrollToLatestVisible = await isScrollToLatestVisible(page); + return metrics.distanceFromBottom > minDistance && scrollToLatestVisible; + }) + .toBe(true); } test.beforeAll(async () => {