From da7ded54257f3665e902d6aabd693b7b6025e1c1 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 7 Apr 2026 16:31:53 +0200 Subject: [PATCH] desktop: thread UX, main-feed filtering, and channel header - Anchor thread sidebar on the opened message; fetch by Nostr root; list messages after the focus chronologically. - Thread composer: parent chain uses focus id before root so nested replies do not post as depth-1 to the channel root; keep reply target while thread is open. - Main channel list: show only roots and direct replies to the thread root (parentId === rootId); nested replies stay out of main. - Thread hints: aggregate by main-timeline anchor (prefer agent direct reply); show hints on depth-1 rows; highlight focused thread row. - ChatHeader: optional description; channel view drops subtitle line. - E2E: adjust integration tests for header without channel description. - Mentions/timeline types: relay agents in mentions; displayDepth and related. Made-with: Cursor --- .../src/features/channels/ui/ChannelPane.tsx | 12 +- .../features/channels/ui/ChannelScreen.tsx | 96 ++++++++----- desktop/src/features/chat/ui/ChatHeader.tsx | 17 ++- .../messages/lib/formatTimelineMessages.ts | 11 +- .../features/messages/lib/threadHintAnchor.ts | 38 +++++ .../src/features/messages/lib/useMentions.ts | 66 ++++++++- desktop/src/features/messages/types.ts | 2 + .../messages/ui/ChannelThreadPanel.tsx | 134 +++++++++++++++--- .../src/features/messages/ui/MessageRow.tsx | 12 +- .../features/messages/ui/MessageTimeline.tsx | 6 +- .../messages/ui/TimelineMessageList.tsx | 6 +- desktop/tests/e2e/integration.spec.ts | 11 +- 12 files changed, 318 insertions(+), 93 deletions(-) create mode 100644 desktop/src/features/messages/lib/threadHintAnchor.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 5d3d6b296e..01b67cec05 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -52,7 +52,9 @@ type ChannelPaneProps = { replyTargetId: string | null; replyTargetMessage: TimelineMessage | null; targetMessageId: string | null; - threadHintsByRootId: Map; + threadHintsByAnchorId: Map; + /** Message id to anchor the thread panel header on (the row that opened the thread). */ + threadFocusEventId: string | null; threadRootId: string | null; typingPubkeys: string[]; }; @@ -82,7 +84,8 @@ export const ChannelPane = React.memo(function ChannelPane({ replyTargetId, replyTargetMessage, targetMessageId, - threadHintsByRootId, + threadHintsByAnchorId, + threadFocusEventId, threadRootId, typingPubkeys, }: ChannelPaneProps) { @@ -107,7 +110,7 @@ export const ChannelPane = React.memo(function ChannelPane({
(null); const [editTargetId, setEditTargetId] = React.useState(null); const [threadRootId, setThreadRootId] = React.useState(null); + /** Event id of the message row used to open the thread (Reply / thread hint). */ + const [threadFocusEventId, setThreadFocusEventId] = React.useState< + string | null + >(null); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; @@ -188,7 +193,11 @@ export function ChannelScreen({ ], ); - /** Main channel timeline: top-level messages only; thread replies live in the thread sidebar. */ + /** + * Main channel: top-level posts plus **direct** replies to the thread root only + * (`parentId === rootId` in NIP-10 e-tags). Nested replies (`parentId !== rootId`) + * stay sidebar-only — they must not appear in the main stream even if depth is wrong. + */ const mainTimelineMessages = React.useMemo( () => timelineMessages.filter((message) => { @@ -196,13 +205,30 @@ export function ChannelScreen({ return true; } - return message.depth === 0; + const { parentId, rootId } = message; + if (!parentId) { + return true; + } + + if (rootId && parentId === rootId) { + return true; + } + + return false; }), [timelineMessages], ); - /** Reply counts + participant labels for roots that have sidebar thread activity. */ - const threadHintsByRootId = React.useMemo(() => { + /** + * Reply counts keyed by **main-timeline anchor** (prefer the direct reply to + * the thread root — e.g. agent message — over the user's root post). + */ + const threadHintsByAnchorId = React.useMemo(() => { + const messageById = new Map( + timelineMessages.map((m) => [m.id, m]), + ); + const mainIds = new Set(mainTimelineMessages.map((m) => m.id)); + const aggregates = new Map< string, { replyCount: number; pubkeys: string[] } @@ -212,15 +238,17 @@ export function ChannelScreen({ if (m.kind === KIND_SYSTEM_MESSAGE) { continue; } - if (m.depth === 0) { - continue; - } - const rootId = m.rootId; - if (!rootId) { + const { parentId, rootId } = m; + if (!parentId || !rootId || parentId === rootId) { continue; } - const agg = aggregates.get(rootId) ?? { replyCount: 0, pubkeys: [] }; + const anchorId = threadHintAnchorIdForNestedMessage( + m, + messageById, + mainIds, + ); + const agg = aggregates.get(anchorId) ?? { replyCount: 0, pubkeys: [] }; agg.replyCount += 1; if (m.pubkey) { const pk = m.pubkey.toLowerCase(); @@ -228,11 +256,11 @@ export function ChannelScreen({ agg.pubkeys.push(m.pubkey); } } - aggregates.set(rootId, agg); + aggregates.set(anchorId, agg); } const out = new Map(); - for (const [rootId, agg] of aggregates) { + for (const [anchorId, agg] of aggregates) { const participantPubkeys = agg.pubkeys.slice(0, 5); const participantLabels = participantPubkeys.map((pk) => resolveUserLabel({ @@ -242,14 +270,14 @@ export function ChannelScreen({ preferResolvedSelfLabel: true, }), ); - out.set(rootId, { + out.set(anchorId, { replyCount: agg.replyCount, participantPubkeys, participantLabels, }); } return out; - }, [currentPubkey, messageProfiles, timelineMessages]); + }, [currentPubkey, mainTimelineMessages, messageProfiles, timelineMessages]); const replyTargetMessage = React.useMemo( () => timelineMessages.find((message) => message.id === replyTargetId) ?? null, @@ -283,6 +311,7 @@ export function ChannelScreen({ const handleReplyOpenThread = React.useCallback( (message: TimelineMessage) => { setThreadRootId(message.rootId ?? message.id); + setThreadFocusEventId(message.id); handleReply(message); }, [handleReply], @@ -290,6 +319,7 @@ export function ChannelScreen({ const handleCloseThread = React.useCallback(() => { setThreadRootId(null); + setThreadFocusEventId(null); setReplyTargetId(null); }, []); @@ -327,7 +357,10 @@ export function ChannelScreen({ return; } - const parentEventId = replyTargetId ?? threadRootId; + // Prefer explicit reply chain, then the message that opened the thread (e.g. agent). + // Never fall back to thread root alone when focus exists — that would create a depth-1 + // reply to the root and duplicate the message in the main channel. + const parentEventId = replyTargetId ?? threadFocusEventId ?? threadRootId; const result = await sendMessageMutation.mutateAsync({ content, @@ -348,6 +381,7 @@ export function ChannelScreen({ queryClient, replyTargetId, sendMessageMutation, + threadFocusEventId, threadRootId, ], ); @@ -358,20 +392,6 @@ export function ChannelScreen({ [canReact, handleToggleReaction], ); - const channelDescription = activeChannel - ? [ - activeChannel.archivedAt ? "Archived." : null, - !activeChannel.isMember - ? "Read-only until you join this open channel." - : null, - activeChannel.topic, - activeChannel.description, - activeChannel.purpose, - null, - ] - .filter((value) => value && value.trim().length > 0) - .join(" ") || "Channel details and activity." - : "Connect to the relay to browse channels and read messages."; const shouldLoadTimeline = activeChannel !== null && activeChannel.channelType !== "forum"; const isTimelineLoading = @@ -384,6 +404,7 @@ export function ChannelScreen({ setReplyTargetId(null); setEditTargetId(null); setThreadRootId(null); + setThreadFocusEventId(null); }, [], ); @@ -399,13 +420,22 @@ export function ChannelScreen({ }, [activeChannelId, resetComposerTargets]); React.useEffect(() => { - if (replyTargetId && !replyTargetMessage) { + // While the thread panel is open, keep replyTargetId even if the target is not in the + // current timeline slice momentarily — clearing it would make thread sends fall back to + // the thread root and surface replies in the main channel. + if (replyTargetId && !replyTargetMessage && !threadRootId) { setReplyTargetId(null); } if (editTargetId && !editTargetMessage) { setEditTargetId(null); } - }, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]); + }, [ + editTargetId, + editTargetMessage, + replyTargetId, + replyTargetMessage, + threadRootId, + ]); React.useEffect(() => { resetRequestedAncestors(activeChannelId); @@ -504,7 +534,6 @@ export function ChannelScreen({ } channelType={activeChannel?.channelType} visibility={activeChannel?.visibility} - description={channelDescription} statusBadge={ activeChannel?.channelType === "dm" && activeDmPresenceStatus ? ( {statusBadge ?
{statusBadge}
: null}
-

- {description} -

+ {description?.trim() ? ( +

+ {description} +

+ ) : null}
{actions ?
{actions}
: null} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 723edc7d0f..db8d604759 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -284,6 +284,14 @@ export function formatTimelineMessages( }); const thread = getThreadReference(event.tags); const edit = editsByTargetId.get(event.id); + const depth = getDepth(event); + let displayDepth = depth; + if (thread.parentId) { + const parentEvent = eventsById.get(thread.parentId); + if (parentEvent && depth === 1 && getDepth(parentEvent) === 0) { + displayDepth = 0; + } + } return { id: event.id, createdAt: event.created_at, @@ -299,7 +307,8 @@ export function formatTimelineMessages( body: edit ? edit.content : event.content, parentId: thread.parentId, rootId: thread.rootId, - depth: getDepth(event), + depth, + displayDepth, accent: currentPubkey === authorPubkey, pending: event.pending, edited: edit !== undefined, diff --git a/desktop/src/features/messages/lib/threadHintAnchor.ts b/desktop/src/features/messages/lib/threadHintAnchor.ts new file mode 100644 index 0000000000..ab4fa93f33 --- /dev/null +++ b/desktop/src/features/messages/lib/threadHintAnchor.ts @@ -0,0 +1,38 @@ +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Where to show "N replies" for nested thread activity: walk up from the nested + * message's parent until we hit a **main-timeline** message. Prefer a **direct + * reply to the thread root** (e.g. the agent's first response) over the root + * post itself, so hints attach to the Q&A anchor users reply under. + */ +export function threadHintAnchorIdForNestedMessage( + nested: TimelineMessage, + messageById: Map, + mainMessageIds: Set, +): string { + let pid: string | null | undefined = nested.parentId; + let rootCandidate: string | null = null; + + while (pid) { + const msg = messageById.get(pid); + if (!msg) { + break; + } + + if (mainMessageIds.has(pid)) { + const isDirectReplyToThreadRoot = Boolean( + msg.parentId && msg.rootId && msg.parentId === msg.rootId, + ); + if (isDirectReplyToThreadRoot) { + return pid; + } + if (!msg.parentId) { + rootCandidate = pid; + } + } + pid = msg.parentId; + } + + return rootCandidate ?? nested.rootId ?? nested.id; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 66d1f48677..56f8fc4d09 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; @@ -17,6 +20,7 @@ export function useMentions(channelId: string | null) { const membersQuery = useChannelMembersQuery(channelId); const members = membersQuery.data; const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentNamesByPubkey = React.useMemo( () => new Map( @@ -27,6 +31,16 @@ export function useMentions(channelId: string | null) { ), [managedAgentsQuery.data], ); + const relayAgentNamesByPubkey = React.useMemo( + () => + new Map( + (relayAgentsQuery.data ?? []).map((agent) => [ + agent.pubkey.toLowerCase(), + agent.name, + ]), + ), + [relayAgentsQuery.data], + ); const knownNames = React.useMemo(() => { if (!members) return []; @@ -34,13 +48,30 @@ export function useMentions(channelId: string | null) { for (const member of members) { const name = member.displayName ?? - managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()); + managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ?? + relayAgentNamesByPubkey.get(member.pubkey.toLowerCase()); if (name) { names.push(name); } } + if (channelId) { + for (const agent of relayAgentsQuery.data ?? []) { + if (!agent.channelIds.includes(channelId)) { + continue; + } + if (!names.includes(agent.name)) { + names.push(agent.name); + } + } + } return names; - }, [members, managedAgentNamesByPubkey]); + }, [ + channelId, + members, + managedAgentNamesByPubkey, + relayAgentNamesByPubkey, + relayAgentsQuery.data, + ]); /** Lower-cased version of knownNames, used for case-insensitive prefix matching. */ const knownNamesLower = React.useMemo( @@ -80,6 +111,7 @@ export function useMentions(channelId: string | null) { .map((member) => { const fallbackName = managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ?? + relayAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ?? member.pubkey.slice(0, 8); return { @@ -98,7 +130,12 @@ export function useMentions(channelId: string | null) { displayName: label, role: member.role === "admin" ? "admin" : null, })); - }, [managedAgentNamesByPubkey, members, mentionQuery]); + }, [ + managedAgentNamesByPubkey, + members, + mentionQuery, + relayAgentNamesByPubkey, + ]); const isMentionOpen = mentionQuery !== null && suggestions.length > 0; @@ -188,15 +225,32 @@ export function useMentions(channelId: string | null) { } const name = member.displayName ?? - managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()); + managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ?? + relayAgentNamesByPubkey.get(member.pubkey.toLowerCase()); if (name && hasMention(name)) { pubkeys.push(member.pubkey); } } + for (const agent of relayAgentsQuery.data ?? []) { + if ( + pubkeys.some((pk) => pk.toLowerCase() === agent.pubkey.toLowerCase()) + ) { + continue; + } + if (hasMention(agent.name)) { + pubkeys.push(agent.pubkey); + } + } + return [...new Set(pubkeys)]; }, - [members, managedAgentNamesByPubkey], + [ + members, + managedAgentNamesByPubkey, + relayAgentNamesByPubkey, + relayAgentsQuery.data, + ], ); const clearMentions = React.useCallback(() => { diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index da2e6e2a37..d0bb0fc59e 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -26,6 +26,8 @@ export type TimelineMessage = { parentId?: string | null; rootId?: string | null; depth: number; + /** Layout indent (0 = full-width row). First reply under a root is 0 so Q&A reads inline in main. */ + displayDepth: number; accent?: boolean; pending?: boolean; edited?: boolean; diff --git a/desktop/src/features/messages/ui/ChannelThreadPanel.tsx b/desktop/src/features/messages/ui/ChannelThreadPanel.tsx index 3b447c08e3..a3b469669f 100644 --- a/desktop/src/features/messages/ui/ChannelThreadPanel.tsx +++ b/desktop/src/features/messages/ui/ChannelThreadPanel.tsx @@ -9,7 +9,12 @@ import { import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { channelThreadKey } from "@/features/messages/lib/messageQueryKeys"; import { getForumThread } from "@/shared/api/forum"; -import type { Channel } from "@/shared/api/types"; +import type { + Channel, + ForumPost, + ForumThreadResponse, + ThreadReply, +} from "@/shared/api/types"; import { resolveUserLabel, type UserProfileLookup, @@ -23,11 +28,88 @@ import { Skeleton } from "@/shared/ui/skeleton"; import { formatRelativeTime } from "@/features/forum/lib/time"; +type ThreadHeaderSlice = + | { type: "post"; data: ForumPost } + | { type: "reply"; data: ThreadReply }; + +type ThreadTailItem = + | { type: "post"; data: ForumPost } + | { type: "reply"; data: ThreadReply }; + +/** Anchor header on the opened message; list messages strictly after it in time. */ +function sliceThreadForFocus( + thread: ForumThreadResponse, + focusEventId: string | null, +): { header: ThreadHeaderSlice; tail: ThreadTailItem[] } { + const post = thread.post; + const entries = [ + { + kind: "post" as const, + eventId: post.eventId, + createdAt: post.createdAt, + post, + }, + ...thread.replies.map((reply) => ({ + kind: "reply" as const, + eventId: reply.eventId, + createdAt: reply.createdAt, + reply, + })), + ].sort((a, b) => a.createdAt - b.createdAt); + + const focusId = + focusEventId && focusEventId.length > 0 ? focusEventId : post.eventId; + const focusIdx = entries.findIndex((e) => e.eventId === focusId); + + if (focusIdx < 0) { + return { + header: { type: "post", data: post }, + tail: entries + .filter((e) => e.eventId !== post.eventId) + .map((e) => + e.kind === "post" + ? { type: "post" as const, data: e.post } + : { type: "reply" as const, data: e.reply }, + ), + }; + } + + const focusEntry = entries[focusIdx]; + if (!focusEntry) { + return { + header: { type: "post", data: post }, + tail: entries + .filter((e) => e.eventId !== post.eventId) + .map((e) => + e.kind === "post" + ? { type: "post" as const, data: e.post } + : { type: "reply" as const, data: e.reply }, + ), + }; + } + const header: ThreadHeaderSlice = + focusEntry.kind === "post" + ? { type: "post", data: focusEntry.post } + : { type: "reply", data: focusEntry.reply }; + + const tail = entries + .slice(focusIdx + 1) + .map((e) => + e.kind === "post" + ? { type: "post" as const, data: e.post } + : { type: "reply" as const, data: e.reply }, + ); + + return { header, tail }; +} + type ChannelThreadPanelProps = { channel: Channel; currentPubkey?: string; profiles?: UserProfileLookup; rootEventId: string; + /** Message id of the timeline row that opened this thread (header anchor). */ + focusEventId: string | null; onClose: () => void; onCancelReply: () => void; onSend: ( @@ -107,6 +189,7 @@ export function ChannelThreadPanel({ currentPubkey, profiles, rootEventId, + focusEventId, onClose, onCancelReply, onSend, @@ -134,13 +217,21 @@ export function ChannelThreadPanel({ const thread = threadQuery.data; const isLoading = threadQuery.isPending; + const { headerSlice, tailItems } = React.useMemo(() => { + if (!thread) { + return { headerSlice: null as ThreadHeaderSlice | null, tailItems: [] }; + } + const { header, tail } = sliceThreadForFocus(thread, focusEventId); + return { headerSlice: header, tailItems: tail }; + }, [focusEventId, thread]); + const implicitThreadAgentMention = React.useMemo(() => { - const rootPk = thread?.post.pubkey; - if (!rootPk) { + const headerPk = headerSlice?.data.pubkey ?? thread?.post.pubkey; + if (!headerPk) { return null; } - const lower = rootPk.toLowerCase(); + const lower = headerPk.toLowerCase(); const managed = (managedAgentsQuery.data ?? []).find( (a) => a.pubkey.toLowerCase() === lower, ); @@ -156,7 +247,12 @@ export function ChannelThreadPanel({ } return null; - }, [managedAgentsQuery.data, relayAgentsQuery.data, thread?.post.pubkey]); + }, [ + headerSlice, + managedAgentsQuery.data, + relayAgentsQuery.data, + thread?.post.pubkey, + ]); return (