From 0230f2d4383c8650bf8286727dae055be945145a Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Sat, 9 May 2026 15:17:34 -0400 Subject: [PATCH 1/2] fix(desktop): refine thread panel reply layout Co-authored-by: Cursor --- .../messages/lib/threadPanel.test.mjs | 68 ++++++++++++++++++- .../src/features/messages/lib/threadPanel.ts | 2 +- .../src/features/messages/ui/MessageRow.tsx | 6 +- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 3ddf745bb..6daadef43 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildMainTimelineEntries } from "./threadPanel.ts"; +import { + buildMainTimelineEntries, + buildThreadPanelData, +} from "./threadPanel.ts"; function message(overrides) { return { @@ -56,3 +59,66 @@ test("buildMainTimelineEntries includes broadcast replies", () => { ["root", "broadcast-reply"], ); }); + +test("buildThreadPanelData keeps direct replies flat", () => { + const root = message({ id: "root", createdAt: 1 }); + const reply = message({ + id: "reply", + createdAt: 2, + parentId: "root", + rootId: "root", + depth: 1, + tags: [["e", "root", "", "reply"]], + }); + + const panel = buildThreadPanelData([root, reply], "root", null, new Set()); + + assert.deepEqual( + panel.visibleReplies.map((entry) => ({ + id: entry.message.id, + depth: entry.message.depth, + })), + [{ id: "reply", depth: 0 }], + ); +}); + +test("buildThreadPanelData indents expanded subthread replies", () => { + const root = message({ id: "root", createdAt: 1 }); + const reply = message({ + id: "reply", + createdAt: 2, + parentId: "root", + rootId: "root", + depth: 1, + tags: [["e", "root", "", "reply"]], + }); + const nestedReply = message({ + id: "nested-reply", + createdAt: 3, + parentId: "reply", + rootId: "root", + depth: 2, + tags: [ + ["e", "root", "", "root"], + ["e", "reply", "", "reply"], + ], + }); + + const panel = buildThreadPanelData( + [root, reply, nestedReply], + "root", + null, + new Set(["reply"]), + ); + + assert.deepEqual( + panel.visibleReplies.map((entry) => ({ + id: entry.message.id, + depth: entry.message.depth, + })), + [ + { id: "reply", depth: 0 }, + { id: "nested-reply", depth: 1 }, + ], + ); +}); diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 71f67fc3e..ad704f24c 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -207,7 +207,7 @@ function buildVisibleThreadReplies(params: { appendExpandedReplies({ entries, parentId: openThreadHeadId, - depth: 1, + depth: 0, directChildrenByParentId, descendantStatsByMessageId, expandedReplyIds, diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 69a4a58aa..50ec9e332 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -255,7 +255,7 @@ export const MessageRow = React.memo(
-
{messageBodyNode}
+
+ {messageBodyNode} +
) : ( <> From 0fed686d435e011815fd112a43885893348fd174 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 11 May 2026 07:53:11 -0400 Subject: [PATCH 2/2] fix(desktop): show nested thread replies inline Co-authored-by: Cursor --- .../src/features/channels/ui/ChannelPane.tsx | 3 -- .../features/channels/ui/ChannelScreen.tsx | 10 +--- .../src/features/messages/lib/threadPanel.ts | 52 +++++++------------ .../messages/ui/MessageThreadPanel.tsx | 12 ----- desktop/tests/e2e/messaging.spec.ts | 13 +---- 5 files changed, 21 insertions(+), 69 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index c7b8b4bba..2d2171ea7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -81,7 +81,6 @@ type ChannelPaneProps = { onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onEditSave?: (content: string) => Promise; - onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; onOpenAgentSession: (pubkey: string) => void; onOpenDm?: (pubkeys: string[]) => void; @@ -143,7 +142,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete, onEdit, onEditSave, - onExpandThreadReplies, onJoinChannel, onOpenAgentSession, onOpenDm, @@ -418,7 +416,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete={onDelete} onEdit={onEdit} onEditSave={onEditSave} - onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} onScrollTargetResolved={onThreadScrollTargetResolved} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index b85a826ce..9b8c294fe 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -296,14 +296,8 @@ export function ChannelScreen({ timelineMessages, openThreadHeadId, threadReplyTargetId, - expandedThreadReplyIds, ), - [ - expandedThreadReplyIds, - openThreadHeadId, - threadReplyTargetId, - timelineMessages, - ], + [openThreadHeadId, threadReplyTargetId, timelineMessages], ); const openThreadHeadMessage = threadPanelData.threadHead; const threadMessages = threadPanelData.visibleReplies; @@ -322,7 +316,6 @@ export function ChannelScreen({ handleDelete, handleEdit, handleEditSave, - handleExpandThreadReplies, handleOpenThread, handleSendMessage, handleSendThreadReply, @@ -496,7 +489,6 @@ export function ChannelScreen({ onDelete={handleDelete} onEdit={handleEdit} onEditSave={handleEditSave} - onExpandThreadReplies={handleExpandThreadReplies} onOpenAgentSession={handleOpenAgentSession} onOpenDm={handleOpenDm} onCloseProfilePanel={handleCloseProfilePanel} diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index ad704f24c..c2bd7c542 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -150,67 +150,56 @@ function buildSummaryForDirectReplies( }; } -function appendExpandedReplies(params: { +function appendThreadPanelReplies(params: { entries: MainTimelineEntry[]; parentId: string; depth: number; directChildrenByParentId: Map; - descendantStatsByMessageId: Map; - expandedReplyIds: ReadonlySet; + visitedMessageIds: Set; }) { const { entries, parentId, depth, directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, + visitedMessageIds, } = params; const directReplies = directChildrenByParentId.get(parentId) ?? []; for (const reply of directReplies) { + if (visitedMessageIds.has(reply.id)) { + continue; + } + visitedMessageIds.add(reply.id); + entries.push({ message: normalizeInlineReplyMessage(reply, depth), - summary: buildSummaryForDirectReplies( - reply.id, - descendantStatsByMessageId, - ), + summary: null, }); - if (expandedReplyIds.has(reply.id)) { - appendExpandedReplies({ - entries, - parentId: reply.id, - depth: depth + 1, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - }); - } + appendThreadPanelReplies({ + entries, + parentId: reply.id, + depth: depth + 1, + directChildrenByParentId, + visitedMessageIds, + }); } } function buildVisibleThreadReplies(params: { openThreadHeadId: string; directChildrenByParentId: Map; - descendantStatsByMessageId: Map; - expandedReplyIds: ReadonlySet; }) { - const { - openThreadHeadId, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - } = params; + const { openThreadHeadId, directChildrenByParentId } = params; const entries: MainTimelineEntry[] = []; - appendExpandedReplies({ + appendThreadPanelReplies({ entries, parentId: openThreadHeadId, depth: 0, directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, + visitedMessageIds: new Set([openThreadHeadId]), }); return entries; @@ -238,7 +227,6 @@ export function buildThreadPanelData( messages: TimelineMessage[], openThreadHeadId: string | null, threadReplyTargetId: string | null, - expandedReplyIds: ReadonlySet, ): ThreadPanelData { if (!openThreadHeadId) { return { @@ -267,8 +255,6 @@ export function buildThreadPanelData( const visibleReplies = buildVisibleThreadReplies({ openThreadHeadId, directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, }); const replyTargetInBranch = diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index a12ad57ce..ea6ee338a 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -16,7 +16,6 @@ import { } from "@/shared/ui/OverlayPanelBackdrop"; import { MessageComposer } from "./MessageComposer"; import { MessageRow } from "./MessageRow"; -import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { useTimelineScrollManager } from "./useTimelineScrollManager"; @@ -35,7 +34,6 @@ type MessageThreadPanelProps = { onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onEditSave?: (content: string) => Promise; - onExpandReplies: (message: TimelineMessage) => void; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; onScrollTargetResolved: () => void; @@ -87,7 +85,6 @@ export function MessageThreadPanel({ onDelete, onEdit, onEditSave, - onExpandReplies, onResetWidth, onResizeStart, onScrollTargetResolved, @@ -244,15 +241,6 @@ export function MessageThreadPanel({ onToggleReaction={onToggleReaction} profiles={profiles} /> - {entry.summary ? ( - - ) : null} ); })} diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index b645edb39..48f309acd 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -424,8 +424,7 @@ test("opens a single-level thread panel with inline expansion", async ({ const firstReplySummaryRow = threadReplies.locator( `[data-thread-head-id="${firstReplyId}"]`, ); - await expect(firstReplySummaryRow).toHaveCount(1); - await expect(firstReplySummaryRow).toContainText("2 replies"); + await expect(firstReplySummaryRow).toHaveCount(0); await expect(rootSummaryRow).toContainText("18 replies"); await expect( @@ -448,16 +447,6 @@ test("opens a single-level thread panel with inline expansion", async ({ }); }) .toBeLessThanOrEqual(160); - - await firstReplySummaryRow.click(); - await expect( - threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }), - ).toHaveCount(0); - await expect( - threadReplies - .getByTestId("message-row") - .filter({ hasText: nestedReplyFromBob }), - ).toHaveCount(0); }); test("thread panel width uses session storage and reset handle", async ({