Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function ChannelScreen({
setTopbarSearchHidden,
} = useAppShell();
const {
clearMessageRouteTarget,
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -693,6 +697,7 @@ export function ChannelScreen({
handleThreadScrollTargetResolved
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
Expand Down
13 changes: 11 additions & 2 deletions desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,21 @@ 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 {
return value === "memories" || value === "channels" ? value : "summary";
}

export function useChannelPanelHistoryState() {
const { applyPatch, values } = useHistorySearchState(PANEL_SEARCH_KEYS);
const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS);

const setOpenThreadHeadId = React.useCallback<PanelValueSetter>(
(value, options) => applyPatch({ thread: value }, options),
Expand All @@ -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,
Expand Down
25 changes: 21 additions & 4 deletions desktop/src/features/channels/ui/useChannelRouteTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
71 changes: 71 additions & 0 deletions desktop/tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading