From 80cbd4c5b68c0a58dc377c11e87f014b7020d8a4 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Wed, 17 Jun 2026 14:59:12 -0400 Subject: [PATCH] fix(desktop): open root message links in thread panel Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../features/channels/ui/ChannelScreen.tsx | 5 ++ .../ui/useChannelPanelHistoryState.ts | 13 +++- .../channels/ui/useChannelRouteTarget.ts | 25 +++++-- desktop/tests/e2e/navigation.spec.ts | 71 +++++++++++++++++++ 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 094fc65679..d8b8bda8d1 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -94,6 +94,7 @@ export function ChannelScreen({ setTopbarSearchHidden, } = useAppShell(); const { + clearMessageRouteTarget, openAgentSessionPubkey, openThreadHeadId, profilePanelPubkey, @@ -493,6 +494,9 @@ export function ChannelScreen({ const handleThreadScrollTargetResolved = React.useCallback(() => { setThreadScrollTargetId(null); }, []); + const handleTargetReached = React.useCallback(() => { + clearMessageRouteTarget({ replace: true }); + }, [clearMessageRouteTarget]); React.useEffect(() => { resetComposerTargets(activeChannelId); }, [activeChannelId, resetComposerTargets]); @@ -693,6 +697,7 @@ export function ChannelScreen({ handleThreadScrollTargetResolved } onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={handleTargetReached} onToggleReaction={effectiveToggleReaction} openAgentSessionPubkey={openAgentSessionPubkey} openThreadHeadId={openThreadHeadId} diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 15df8fd591..53d236f432 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -23,11 +23,13 @@ export type PanelValueSetter = ( options?: PanelSetterOptions, ) => void; -const PANEL_SEARCH_KEYS = [ +const CHANNEL_SEARCH_KEYS = [ "agentSession", + "messageId", "profile", "profileView", "thread", + "threadRootId", ] as const; function asProfilePanelView(value: string | null): ProfilePanelView { @@ -35,7 +37,7 @@ function asProfilePanelView(value: string | null): ProfilePanelView { } export function useChannelPanelHistoryState() { - const { applyPatch, values } = useHistorySearchState(PANEL_SEARCH_KEYS); + const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); const setOpenThreadHeadId = React.useCallback( (value, options) => applyPatch({ thread: value }, options), @@ -61,7 +63,14 @@ export function useChannelPanelHistoryState() { [applyPatch], ); + const clearMessageRouteTarget = React.useCallback( + (options?: PanelSetterOptions) => + applyPatch({ messageId: null, threadRootId: null }, options), + [applyPatch], + ); + return { + clearMessageRouteTarget, openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index fcae0b0cf5..0dc4b0e4d6 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -110,10 +110,27 @@ export function useChannelRouteTarget({ } const targetMessage = timelineMessageById.get(targetMessageId) ?? null; - if ( - !targetMessage?.parentId || - isBroadcastReply(targetMessage.tags ?? []) - ) { + if (!targetMessage) { + return; + } + + 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; + return; + } + + if (isBroadcastReply(targetMessage.tags ?? [])) { return; } diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 6ebcf03b67..04f39de919 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -266,6 +266,77 @@ test("settings is a route: section survives reload, closing returns to the previ await expect(threadPanel).toBeVisible(); }); +test("message links to visible root messages open the thread panel", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Welcome to #general", + ); + + const link = + "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; + await page.getByTestId("message-input").fill(`Root link repro ${link}`); + await page.getByTestId("send-message").click(); + + const linkMessage = page + .getByTestId("message-row") + .filter({ hasText: "Root link repro" }) + .last(); + await expect(linkMessage).toBeVisible(); + await linkMessage + .getByRole("button", { name: "Open message in general" }) + .click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + await expect(page).toHaveURL(/thread=mock-general-welcome/); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + "Welcome to #general", + ); +}); + +test("message links reopen a closed thread when the same messageId is already in the URL", async ({ + page, +}) => { + await page.goto( + "/#/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50?messageId=mock-general-welcome", + ); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + "Welcome to #general", + ); + + await threadPanel.getByRole("button", { name: "Close thread" }).click(); + await expect(threadPanel).not.toBeVisible(); + + const link = + "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; + await page + .getByTestId("message-input") + .fill(`Reopen same root link repro ${link}`); + await page.getByTestId("send-message").click(); + + const linkMessage = page + .getByTestId("message-row") + .filter({ hasText: "Reopen same root link repro" }) + .last(); + await expect(linkMessage).toBeVisible(); + await linkMessage + .getByRole("button", { name: "Open message in general" }) + .click(); + + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + "Welcome to #general", + ); +}); + test("message deep links survive reload", async ({ page }) => { await page.goto( `/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`,