diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index c2eb0ce5f..ba7f371d2 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { getContextMessageDepth, + getInboxDefaultReplyParentEventId, getReactionTargetId, isInboxThreadContextEvent, matchesInboxFilter, @@ -184,6 +185,44 @@ test("getContextMessageDepth does not loop forever on a cycle", () => { assert.equal(getContextMessageDepth(a, map), 1); }); +// --- getInboxDefaultReplyParentEventId --- + +function feedItem(id, tags = []) { + return { + id, + pubkey: "x", + createdAt: 0, + kind: 9, + tags, + content: "", + category: "activity", + channelId: "channel-a", + channelName: "bugs", + }; +} + +test("getInboxDefaultReplyParentEventId uses the selected item's parent for reply rows", () => { + assert.equal( + getInboxDefaultReplyParentEventId( + feedItem("latest-reply", [ + ["h", "channel-a"], + ["e", "thread-root", "", "root"], + ["e", "parent-comment", "", "reply"], + ]), + ), + "parent-comment", + ); +}); + +test("getInboxDefaultReplyParentEventId falls back to the selected item for roots", () => { + assert.equal( + getInboxDefaultReplyParentEventId( + feedItem("thread-root", [["h", "channel-a"]]), + ), + "thread-root", + ); +}); + // --- isInboxThreadContextEvent --- function channelEvent(id, tags = []) { diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 94dcf871f..043fcd2e9 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -99,3 +99,14 @@ export function getReactionTargetId(tags: string[][]) { return null; } + +export function getInboxDefaultReplyParentEventId(item: FeedItem): string { + const thread = getThreadReference(item.tags); + + // Inbox rows are grouped by thread and surface the latest activity. If the + // latest activity is itself a reply, sending from the bottom composer should + // continue the selected item's parent thread, not create a new child under + // that latest activity. Explicit per-message reply targets still override + // this default in InboxDetailPane. + return thread.parentId ?? thread.rootId ?? item.id; +} diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index dd45d879f..e10c60aff 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -12,6 +12,7 @@ import { type InboxDisplayMessage, InboxMessageRow, } from "@/features/home/ui/InboxMessageRow"; +import { getInboxDefaultReplyParentEventId } from "@/features/home/lib/inboxViewHelpers"; import { getThreadReference } from "@/features/messages/lib/threading"; import type { TimelineMessage } from "@/features/messages/types"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; @@ -210,7 +211,8 @@ export function InboxDetailPane({ ]; const replyTarget = displayMessages.find((message) => message.id === replyTargetId) ?? null; - const composerParentEventId = replyTarget?.id ?? item.id; + const composerParentEventId = + replyTarget?.id ?? getInboxDefaultReplyParentEventId(item.item); const composerReplyTarget = replyTarget && replyTarget.id !== item.id ? { diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 7e47b76ae..d2b05f153 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -185,6 +185,80 @@ test("inbox feed shows channel and agent activity sections", async ({ ); }); +test("inbox composer replies to the selected thread parent, not the latest comment", async ({ + page, +}) => { + await page.goto("/"); + + const { firstReplyId, latestReplyId, latestReplyContent } = + await page.evaluate(() => { + const emitMessage = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + const pushFeedItem = window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!emitMessage || !pushFeedItem) { + throw new Error("Mock Buzz bridge helpers are unavailable."); + } + + const root = emitMessage({ + channelName: "general", + content: "Inbox parent target root", + }); + const firstReply = emitMessage({ + channelName: "general", + content: "Inbox parent target first reply", + parentEventId: root.id, + }); + const latestReply = emitMessage({ + channelName: "general", + content: "Inbox parent target latest reply", + parentEventId: firstReply.id, + }); + + pushFeedItem({ + id: latestReply.id, + kind: latestReply.kind, + pubkey: latestReply.pubkey, + content: latestReply.content, + created_at: latestReply.created_at, + channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + channel_name: "general", + tags: latestReply.tags, + category: "activity", + }); + + return { + firstReplyId: firstReply.id, + latestReplyId: latestReply.id, + latestReplyContent: latestReply.content, + }; + }); + + const reply = `Inbox parent-target reply ${Date.now()}`; + const inboxList = page.getByTestId("home-inbox-list"); + await expect(inboxList).toContainText(latestReplyContent); + await inboxList.getByText(latestReplyContent).click(); + await expect(page.getByTestId("home-inbox-detail")).toContainText( + latestReplyContent, + ); + + await page.getByTestId("message-input").fill(reply); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("home-inbox-detail")).toContainText(reply); + + const sendPayload = await page.evaluate((content) => { + const entry = [...(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])] + .reverse() + .find( + (candidate) => + candidate.command === "send_channel_message" && + (candidate.payload as { content?: string }).content === content, + ); + return entry?.payload as { parentEventId?: string | null } | undefined; + }, reply); + + expect(sendPayload?.parentEventId).toBe(firstReplyId); + expect(sendPayload?.parentEventId).not.toBe(latestReplyId); +}); + test("opens a mocked forum activity item from the inbox feed", async ({ page, }) => {