From 98e0471e370245366fb52498b16939df0351eb22 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 30 Jun 2026 13:43:01 +0100 Subject: [PATCH] Add marker-aware thread behavior --- desktop/src/app/AppShell.tsx | 3 + desktop/src/app/AppShellContext.tsx | 18 + .../channels/ui/ChannelPane.helpers.test.mjs | 206 ++++++ .../channels/ui/ChannelPane.helpers.ts | 145 ++++ .../src/features/channels/ui/ChannelPane.tsx | 185 ++++- .../features/channels/ui/ChannelPane.types.ts | 2 + desktop/src/features/messages/hooks.ts | 25 +- .../messages/lib/auxBackfill.test.mjs | 40 ++ .../src/features/messages/lib/auxBackfill.ts | 69 +- .../messages/lib/threadPanel.test.mjs | 33 +- .../src/features/messages/lib/threadPanel.ts | 107 ++- .../messages/lib/timelineItems.test.mjs | 18 + .../features/messages/lib/timelineItems.ts | 7 +- .../ui/AgentConversationMarkerRow.tsx | 351 ++++++++++ .../features/messages/ui/MessageActionBar.tsx | 32 +- .../src/features/messages/ui/MessageRow.tsx | 118 +++- .../messages/ui/MessageThreadPanel.tsx | 635 +++++++----------- .../messages/ui/MessageThreadSummaryRow.tsx | 4 +- .../features/messages/ui/MessageTimeline.tsx | 51 +- .../messages/ui/TimelineMessageList.tsx | 59 +- .../features/messages/ui/useAnchoredScroll.ts | 17 +- .../messages/ui/useComposerHeightPadding.ts | 9 +- .../e2e/thread-reply-anchor-roleplay.spec.ts | 30 +- desktop/tests/e2e/thread-unread.spec.ts | 251 +++---- 24 files changed, 1676 insertions(+), 739 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs create mode 100644 desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 8c1a21a32..5c7c8959e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -569,9 +569,12 @@ export function AppShell() { {}, + updateAgentConversationTitle: () => {}, openCreateChannel: handleOpenCreateChannel, openChannelManagement: (channelId?: string) => { setManagedChannelId( diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 3266210fd..de87f8dc0 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -1,4 +1,9 @@ import * as React from "react"; +import type { + AgentConversation, + AgentConversationTitleStatus, + OpenAgentConversationInput, +} from "@/features/agents/agentConversations"; import type { ContextParentResolver } from "@/features/channels/readState/readStateManager"; import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; import type { FeedItemState } from "@/features/home/useFeedItemState"; @@ -7,6 +12,7 @@ import type { FeedItem } from "@/shared/api/types"; const EMPTY_SET = new Set(); type AppShellContextValue = { + agentConversations: readonly AgentConversation[]; markAllChannelsRead: () => void; markChannelRead: ( channelId: string, @@ -14,6 +20,15 @@ type AppShellContextValue = { options?: { topLevelOnly?: boolean }, ) => void; markChannelUnread: (channelId: string) => void; + openAgentConversation: ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, + ) => void; + updateAgentConversationTitle: ( + conversationId: string, + title: string, + titleStatus: AgentConversationTitleStatus, + ) => void; openCreateChannel: () => void; openChannelManagement: (channelId?: string) => void; // NIP-RS read marker for a channel as a unix-seconds timestamp, or null @@ -48,9 +63,12 @@ type AppShellContextValue = { }; const AppShellContext = React.createContext({ + agentConversations: [], markAllChannelsRead: () => {}, markChannelRead: () => {}, markChannelUnread: () => {}, + openAgentConversation: () => {}, + updateAgentConversationTitle: () => {}, openCreateChannel: () => {}, openChannelManagement: () => {}, getChannelReadAt: () => null, diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 000000000..284b77e85 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canOpenAgentConversationInChannel, + getDmAutoRouteAgentPubkeys, + getThreadAutoRouteAgentPubkeys, + mergeAutoRouteMentionPubkeys, +} from "./ChannelPane.helpers.ts"; + +function channel(overrides = {}) { + return { + id: "channel", + name: "Channel", + channelType: "stream", + visibility: "open", + description: "", + topic: null, + purpose: null, + memberCount: 2, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + ...overrides, + }; +} + +function message(overrides = {}) { + return { + id: "message", + createdAt: 1, + pubkey: "human", + author: "Human", + avatarUrl: null, + role: undefined, + personaDisplayName: undefined, + time: "1:00 PM", + body: "Body", + parentId: null, + rootId: null, + depth: 0, + accent: false, + pending: undefined, + edited: false, + kind: 9, + tags: [], + reactions: undefined, + ...overrides, + }; +} + +test("DM composer auto-routes only when exactly one other participant is an agent", () => { + const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); + + assert.deepEqual( + getDmAutoRouteAgentPubkeys({ + channel: channel({ + channelType: "dm", + participantPubkeys: ["human", "agent-one"], + }), + currentPubkey: "human", + knownAgentPubkeys, + }), + ["agent-one"], + ); + + assert.deepEqual( + getDmAutoRouteAgentPubkeys({ + channel: channel({ + channelType: "dm", + participantPubkeys: ["human", "agent-one", "agent-two"], + }), + currentPubkey: "human", + knownAgentPubkeys, + }), + [], + ); + + assert.deepEqual( + getDmAutoRouteAgentPubkeys({ + channel: channel({ + channelType: "dm", + participantPubkeys: ["human", "agent-one", "human-two"], + }), + currentPubkey: "human", + knownAgentPubkeys, + }), + [], + ); + + assert.deepEqual( + getDmAutoRouteAgentPubkeys({ + channel: channel({ + participantPubkeys: ["human", "agent-one"], + }), + currentPubkey: "human", + knownAgentPubkeys, + }), + [], + ); +}); + +test("thread composer auto-routes only for one human and one known agent", () => { + const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages: [ + message({ id: "root", tags: [["p", "agent-one"]] }), + message({ id: "agent-reply", pubkey: "agent-one" }), + ], + }), + ["agent-one"], + ); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages: [ + message({ + id: "root", + pubkey: "human-one", + tags: [ + ["p", "human-one"], + ["p", "agent-one"], + ], + }), + message({ + id: "human-two-reply", + pubkey: "human-two", + tags: [ + ["p", "human-two"], + ["p", "agent-one"], + ], + }), + message({ id: "agent-reply", pubkey: "agent-one" }), + ], + }), + [], + ); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages: [ + message({ id: "agent-one-reply", pubkey: "agent-one" }), + message({ id: "agent-two-reply", pubkey: "agent-two" }), + ], + }), + [], + ); +}); + +test("auto-routed mentions merge with explicit mentions without duplicates", () => { + assert.deepEqual( + mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: ["AGENT-ONE"], + mentionPubkeys: ["agent-one", "agent-two"], + }), + ["AGENT-ONE", "agent-two"], + ); +}); + +test("new agent conversations require a writable channel", () => { + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel(), + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + }), + false, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + }), + false, + ); +}); + +test("existing agent conversation markers can open in read-only channels", () => { + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + publishMarker: false, + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + publishMarker: false, + }), + true, + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index a30ee2411..f56754435 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -2,6 +2,8 @@ import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; export function getChannelIntroKind(channel: Channel): string { const isPrivate = channel.visibility === "private"; @@ -43,6 +45,24 @@ export function isWelcomeSetupSystemMessage(message: TimelineMessage) { } } +export function canOpenAgentConversationInChannel({ + channel, + publishMarker, +}: { + channel: Channel | null; + publishMarker?: boolean; +}) { + if (!channel) { + return false; + } + + if (publishMarker === false) { + return true; + } + + return channel.archivedAt === null && channel.isMember; +} + export function mentionsKnownAgent( mentionPubkeys: string[], knownAgentPubkeys: ReadonlySet, @@ -51,3 +71,128 @@ export function mentionsKnownAgent( knownAgentPubkeys.has(pubkey.toLowerCase()), ); } + +function singleKnownAgentPubkey( + pubkeys: Iterable, + knownAgentPubkeys: ReadonlySet, +) { + const agentPubkeys = new Map(); + + for (const pubkey of pubkeys) { + if (!pubkey) { + continue; + } + + const normalized = normalizePubkey(pubkey); + if (!knownAgentPubkeys.has(normalized)) { + continue; + } + + agentPubkeys.set(normalized, pubkey); + } + + return agentPubkeys.size === 1 ? [...agentPubkeys.values()] : []; +} + +export function getDmAutoRouteAgentPubkeys({ + channel, + currentPubkey, + knownAgentPubkeys, +}: { + channel: Channel | null; + currentPubkey?: string; + knownAgentPubkeys: ReadonlySet; +}) { + if (channel?.channelType !== "dm") { + return []; + } + + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + const otherParticipants = new Map(); + for (const pubkey of channel.participantPubkeys) { + const normalized = normalizePubkey(pubkey); + if (!normalized || normalized === normalizedCurrentPubkey) { + continue; + } + + otherParticipants.set(normalized, pubkey); + } + + if (otherParticipants.size !== 1) { + return []; + } + + return singleKnownAgentPubkey(otherParticipants.values(), knownAgentPubkeys); +} + +export function getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages, +}: { + knownAgentPubkeys: ReadonlySet; + messages: readonly TimelineMessage[]; +}) { + const agentPubkeys = new Map(); + const humanPubkeys = new Set(); + const addParticipant = (pubkey: string | null | undefined) => { + if (!pubkey) { + return; + } + + const normalized = normalizePubkey(pubkey); + if (!normalized) { + return; + } + + if (knownAgentPubkeys.has(normalized)) { + agentPubkeys.set(normalized, pubkey); + return; + } + + humanPubkeys.add(normalized); + }; + + for (const message of messages) { + addParticipant(message.pubkey); + + for (const tag of message.tags ?? []) { + addParticipant(getMentionTagPubkey(tag)); + } + } + + return agentPubkeys.size === 1 && humanPubkeys.size === 1 + ? [...agentPubkeys.values()] + : []; +} + +export function mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys, + mentionPubkeys, +}: { + autoRouteAgentPubkeys: readonly string[]; + mentionPubkeys: readonly string[]; +}) { + const seenPubkeys = new Set(); + const merged: string[] = []; + const add = (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!normalized || seenPubkeys.has(normalized)) { + return; + } + + seenPubkeys.add(normalized); + merged.push(pubkey); + }; + + for (const pubkey of autoRouteAgentPubkeys) { + add(pubkey); + } + for (const pubkey of mentionPubkeys) { + add(pubkey); + } + + return merged; +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 556180830..1faa3e4cf 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -11,6 +11,7 @@ import { MessageTimeline, type MessageTimelineHandle, } from "@/features/messages/ui/MessageTimeline"; +import { getHiddenAgentConversationMessageIds } from "@/features/agents/agentConversations"; import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; import { getDmHuddleMemberPubkeys, @@ -38,6 +39,7 @@ import { type WelcomeComposerBannerState, } from "@/features/channels/ui/WelcomeComposerBanner"; import { + canOpenAgentConversationInChannel, getChannelIntroDescription, getChannelIntroKind, isWelcomeSetupSystemMessage, @@ -51,11 +53,13 @@ import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRen import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { useAppShell } from "@/app/AppShellContext"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, + agentConversationMarkers, agentPubkeys, agentPubkeysPending = false, agentSessionAgents, @@ -139,6 +143,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const timelineScrollRef = React.useRef(null); const messageTimelineRef = React.useRef(null); const composerWrapperRef = React.useRef(null); + const { openAgentConversation } = useAppShell(); const completedWelcomeBannerChannelIdsRef = React.useRef(new Set()); const welcomeComposerDismissTimerRef = React.useRef(null); const welcomeComposerHideTimerRef = React.useRef(null); @@ -236,17 +241,6 @@ export const ChannelPane = React.memo(function ChannelPane({ return true; }, [findLastOwnEditable, messages, onEdit]); - const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { - if (!onEdit) return false; - const scope: TimelineMessage[] = []; - if (threadHeadMessage) scope.push(threadHeadMessage); - for (const entry of threadMessages) scope.push(entry.message); - const target = findLastOwnEditable(scope); - if (!target) return false; - onEdit(target); - return true; - }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); - const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || @@ -317,6 +311,54 @@ export const ChannelPane = React.memo(function ChannelPane({ onSendMessage, ], ); + const handleOpenAgentSession = React.useCallback( + (pubkey: string) => { + onOpenAgentSession(pubkey); + }, + [onOpenAgentSession], + ); + const handleOpenAgentConversation = React.useCallback( + (message: TimelineMessage, options?: { publishMarker?: boolean }) => { + if ( + !activeChannel || + !message.pubkey || + !canOpenAgentConversationInChannel({ + channel: activeChannel, + publishMarker: options?.publishMarker, + }) + ) { + return; + } + + const rootId = message.rootId ?? message.parentId ?? message.id; + const contextMessages = messages.filter( + (candidate) => + candidate.id === rootId || + candidate.id === message.id || + candidate.rootId === rootId || + candidate.parentId === rootId, + ); + openAgentConversation( + { + agentName: message.author, + agentPubkey: message.pubkey, + agentReply: message, + channel: activeChannel, + contextMessages, + parentMessage: message.parentId + ? (messages.find( + (candidate) => candidate.id === message.parentId, + ) ?? null) + : null, + threadRootMessage: rootId + ? (messages.find((candidate) => candidate.id === rootId) ?? null) + : null, + }, + options, + ); + }, + [activeChannel, messages, openAgentConversation], + ); const canDropInMainColumn = hasMainComposerOverlay && !isComposerDisabled && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; @@ -359,8 +401,29 @@ export const ChannelPane = React.memo(function ChannelPane({ } return pubkeys; }, [botTypingEntries, openThreadHeadId]); - const hasThreadComposerBotActivity = - threadComposerBotTypingPubkeys.length > 0; + const threadActivityAgents = React.useMemo(() => { + if ( + threadComposerBotTypingPubkeys.length === 0 || + (openThreadHeadId && + agentConversationMarkers?.some( + (marker) => marker.threadRootId === openThreadHeadId, + )) + ) { + return []; + } + + const threadTypingSet = new Set( + threadComposerBotTypingPubkeys.map((pubkey) => pubkey.toLowerCase()), + ); + return activityAgents.filter((agent) => + threadTypingSet.has(agent.pubkey.toLowerCase()), + ); + }, [ + activityAgents, + agentConversationMarkers, + openThreadHeadId, + threadComposerBotTypingPubkeys, + ]); const directMessageIntro = React.useMemo( () => buildDirectMessageIntro({ @@ -435,22 +498,92 @@ export const ChannelPane = React.memo(function ChannelPane({ }; }, [activeChannel, onAddAgent, onCreateChannel, onOpenMembers]); - const visibleMessages = React.useMemo(() => { + const baseVisibleMessages = React.useMemo(() => { if (!isWelcomeChannel(activeChannel)) { return messages; } return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); + const threadSourceMessages = React.useMemo(() => { + if (!threadHeadMessage && threadMessages.length === 0) { + return []; + } + + return [ + ...(threadHeadMessage ? [threadHeadMessage] : []), + ...threadMessages.map((entry) => entry.message), + ]; + }, [threadHeadMessage, threadMessages]); + const hiddenAgentConversationMessageIds = React.useMemo(() => { + const hiddenIds = getHiddenAgentConversationMessageIds( + baseVisibleMessages, + agentConversationMarkers, + ); + const threadHiddenIds = getHiddenAgentConversationMessageIds( + threadSourceMessages, + agentConversationMarkers, + ); + for (const id of threadHiddenIds) { + hiddenIds.add(id); + } + if (targetMessageId) { + hiddenIds.delete(targetMessageId); + } + if (threadScrollTargetId) { + hiddenIds.delete(threadScrollTargetId); + } + if (channelFind.activeMatch?.messageId) { + hiddenIds.delete(channelFind.activeMatch.messageId); + } + return hiddenIds; + }, [ + agentConversationMarkers, + baseVisibleMessages, + channelFind.activeMatch?.messageId, + targetMessageId, + threadScrollTargetId, + threadSourceMessages, + ]); + const visibleMessages = React.useMemo(() => { + if (hiddenAgentConversationMessageIds.size === 0) { + return baseVisibleMessages; + } + + return baseVisibleMessages.filter( + (message) => !hiddenAgentConversationMessageIds.has(message.id), + ); + }, [baseVisibleMessages, hiddenAgentConversationMessageIds]); + const visibleThreadMessages = React.useMemo(() => { + if (hiddenAgentConversationMessageIds.size === 0) { + return threadMessages; + } + + return threadMessages.filter( + (entry) => !hiddenAgentConversationMessageIds.has(entry.message.id), + ); + }, [hiddenAgentConversationMessageIds, threadMessages]); const mainTimelineEntries = React.useMemo( () => buildMainTimelineEntries(visibleMessages), [visibleMessages], ); + const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { + if (!onEdit) return false; + // Thread scope = the open thread head plus its visible replies, in + // chronological order. The head is oldest, so append it first. + const scope: TimelineMessage[] = []; + if (threadHeadMessage) scope.push(threadHeadMessage); + for (const entry of visibleThreadMessages) scope.push(entry.message); + const target = findLastOwnEditable(scope); + if (!target) return false; + onEdit(target); + return true; + }, [findLastOwnEditable, onEdit, threadHeadMessage, visibleThreadMessages]); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, threadHeadMessage, - threadMessages, + threadMessages: visibleThreadMessages, }); const videoReviewCommentsByRootId = React.useMemo( () => buildVideoReviewCommentsByRootId(messages), @@ -569,6 +702,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} { const panel = ( - ) : null - } /> ); return wrapAux(panel, "message-thread-panel"); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 02b441ff6..ac2cdc0ca 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -1,4 +1,5 @@ import type * as React from "react"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar"; import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; @@ -14,6 +15,7 @@ import type { import type { Channel } from "@/shared/api/types"; export type ChannelPaneProps = { activeChannel: Channel | null; + agentConversationMarkers?: readonly AgentConversationMarker[]; activityAgents?: BotActivityAgent[]; agentPubkeys?: ReadonlySet; agentPubkeysPending?: boolean; diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8ddfe38e8..2957528ca 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -126,6 +126,7 @@ export function createOptimisticMessage( mentionPubkeys: string[] = [], parentEventId: string | null = null, mediaTags: string[][] = [], + clientTags: string[][] = [], ): RelayEvent { const localKey = `optimistic-${crypto.randomUUID()}`; const tags: string[][] = []; @@ -154,6 +155,9 @@ export function createOptimisticMessage( for (const tag of mediaTags) { tags.push(tag); } + for (const tag of clientTags) { + tags.push(tag); + } return { id: localKey, @@ -248,6 +252,7 @@ export function useChannelSubscription(channel: Channel | null) { channelMessagesKey(channelId), (current = []) => mergeTimelineCacheMessages(current, event), ); + void backfillAuxForMessages(queryClient, channelId, [event]); if (event.kind === KIND_SYSTEM_MESSAGE) { try { @@ -341,10 +346,12 @@ export function useSendMessageMutation( mentionPubkeys?: string[]; parentEventId?: string | null; mediaTags?: string[][]; + clientTags?: string[][]; }, MessageQueryContext | undefined >({ mutationFn: async ({ + clientTags, content, mentionPubkeys, parentEventId, @@ -371,7 +378,12 @@ export function useSendMessageMutation( // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + (clientTags?.length ?? 0) > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(channel.id), @@ -385,6 +397,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + clientTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -423,6 +436,7 @@ export function useSendMessageMutation( ...imetaTags, ...emojiTags, ...mentionTags, + ...(clientTags ?? []), ], content: content.trim(), sig: "", @@ -436,7 +450,13 @@ export function useSendMessageMutation( mentionTags, ); }, - onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => { + onMutate: async ({ + clientTags, + content, + mentionPubkeys, + parentEventId, + mediaTags, + }) => { if (!channel || !identity || channel.channelType === "forum") { return undefined; } @@ -454,6 +474,7 @@ export function useSendMessageMutation( mentionPubkeys ?? [], parentEventId ?? null, mediaTags ?? [], + clientTags ?? [], ); queryClient.setQueryData( diff --git a/desktop/src/features/messages/lib/auxBackfill.test.mjs b/desktop/src/features/messages/lib/auxBackfill.test.mjs index 18cfc5538..cfa990a30 100644 --- a/desktop/src/features/messages/lib/auxBackfill.test.mjs +++ b/desktop/src/features/messages/lib/auxBackfill.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + collectAgentConversationMarkerReferenceIds, collectAuxEventIdsForDeletionBackfill, collectMessageIdsForAuxBackfill, mergeAuxEventsWithDeletionBackfill, @@ -59,6 +60,45 @@ test("returns empty for a window of only auxiliary events", () => { assert.deepEqual(collectMessageIdsForAuxBackfill(events), []); }); +test("collects task marker references from loaded timeline replies", () => { + const rootId = hex("1"); + const parentId = hex("2"); + const replyId = hex("3"); + const events = [ + event(replyId, 9, { + tags: [ + ["h", CHANNEL_ID], + ["e", rootId, "", "root"], + ["e", parentId, "", "reply"], + ], + }), + ]; + + assert.deepEqual(collectAgentConversationMarkerReferenceIds(events), [ + replyId, + rootId, + parentId, + ]); +}); + +test("skips non-content events when collecting task marker references", () => { + const messageId = hex("1"); + const markerId = hex("2"); + const events = [ + event(messageId, 9), + event(markerId, 40004, { + tags: [ + ["h", CHANNEL_ID], + ["e", messageId, "", "root"], + ], + }), + ]; + + assert.deepEqual(collectAgentConversationMarkerReferenceIds(events), [ + messageId, + ]); +}); + test("collects reaction and edit ids for deletion-marker backfill", () => { const events = [ event(hex("1"), 9), diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts index df0272d73..c436b99c2 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -1,11 +1,15 @@ import type { QueryClient } from "@tanstack/react-query"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { channelMessagesKey, sortMessages, } from "@/features/messages/lib/messageQueryKeys"; import { relayClient } from "@/shared/api/relayClient"; -import { buildChannelStructuralAuxFilter } from "@/shared/api/relayChannelFilters"; +import { + buildChannelAgentConversationMarkerFilter, + buildChannelStructuralAuxFilter, +} from "@/shared/api/relayChannelFilters"; import type { RelayEvent } from "@/shared/api/types"; import { CHANNEL_TIMELINE_CONTENT_KINDS, @@ -17,6 +21,10 @@ const TIMELINE_CONTENT_KINDS: ReadonlySet = new Set( CHANNEL_TIMELINE_CONTENT_KINDS, ); +function isTimelineContentEvent(event: RelayEvent) { + return TIMELINE_CONTENT_KINDS.has(event.kind); +} + /** * Extract the ids of the visible content messages from a freshly-fetched * history window. Auxiliary events (reactions, edits, deletions) are then @@ -26,9 +34,30 @@ const TIMELINE_CONTENT_KINDS: ReadonlySet = new Set( export function collectMessageIdsForAuxBackfill( historyEvents: RelayEvent[], ): string[] { - return historyEvents - .filter((event) => TIMELINE_CONTENT_KINDS.has(event.kind)) - .map((event) => event.id); + return historyEvents.filter(isTimelineContentEvent).map((event) => event.id); +} + +export function collectAgentConversationMarkerReferenceIds( + historyEvents: RelayEvent[], +): string[] { + const referenceIds = new Set(); + + for (const event of historyEvents) { + if (!isTimelineContentEvent(event)) { + continue; + } + + referenceIds.add(event.id); + const { parentId, rootId } = getThreadReference(event.tags); + if (rootId) { + referenceIds.add(rootId); + } + if (parentId) { + referenceIds.add(parentId); + } + } + + return [...referenceIds]; } export function collectAuxEventIdsForDeletionBackfill( @@ -86,18 +115,31 @@ export async function backfillAuxForMessages( historyEvents: RelayEvent[], ): Promise { const messageIds = collectMessageIdsForAuxBackfill(historyEvents); - if (messageIds.length === 0) { + const markerReferenceIds = + collectAgentConversationMarkerReferenceIds(historyEvents); + if (messageIds.length === 0 && markerReferenceIds.length === 0) { return; } try { const cacheKey = channelMessagesKey(channelId); const cachedEvents = queryClient.getQueryData(cacheKey) ?? []; - const auxEvents = await relayClient.fetchAuxEventsByReference( - channelId, - messageIds, - buildChannelStructuralAuxFilter, - ); + const [auxEvents, markerEvents] = await Promise.all([ + messageIds.length > 0 + ? relayClient.fetchAuxEventsByReference( + channelId, + messageIds, + buildChannelStructuralAuxFilter, + ) + : Promise.resolve([]), + markerReferenceIds.length > 0 + ? relayClient.fetchAuxEventsByReference( + channelId, + markerReferenceIds, + buildChannelAgentConversationMarkerFilter, + ) + : Promise.resolve([]), + ]); const mergedAuxEvents = await mergeAuxEventsWithDeletionBackfill({ channelId, cachedEvents, @@ -105,16 +147,17 @@ export async function backfillAuxForMessages( fetchAuxEventsForMessages: (...args) => relayClient.fetchAuxDeletionEventsForAuxEvents(...args), }); - if (mergedAuxEvents.length === 0) { + const eventsToMerge = [...markerEvents, ...mergedAuxEvents]; + if (eventsToMerge.length === 0) { return; } queryClient.setQueryData(cacheKey, (current = []) => - sortMessages([...current, ...mergedAuxEvents]), + sortMessages([...current, ...eventsToMerge]), ); } catch (error) { console.error( - "Failed to backfill auxiliary events for channel", + "Failed to backfill timeline reference events for channel", channelId, error, ); diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 5bf870c2b..15049919c 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -107,7 +107,7 @@ test("buildMainTimelineEntries keeps huddle thread replies out of the parent tim ); }); -test("buildThreadPanelData connects direct comments to the thread head", () => { +test("buildThreadPanelData flattens every descendant into the thread panel", () => { const root = message({ id: "root", createdAt: 1 }); const directComment = message({ id: "direct-comment", @@ -140,15 +140,16 @@ test("buildThreadPanelData connects direct comments to the thread head", () => { panelData.visibleReplies.map((entry) => ({ id: entry.message.id, depth: entry.message.depth, + summary: entry.summary, })), [ - { id: "direct-comment", depth: 1 }, - { id: "nested-reply", depth: 2 }, + { id: "direct-comment", depth: 1, summary: null }, + { id: "nested-reply", depth: 1, summary: null }, ], ); }); -test("buildThreadPanelData hides collapsed summaries for expanded replies", () => { +test("buildThreadPanelData does not hide nested replies behind collapsed summaries", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -183,11 +184,21 @@ test("buildThreadPanelData hides collapsed summaries for expanded replies", () = new Set(["branch"]), ); - assert.equal(collapsed.visibleReplies[0].summary?.replyCount, 1); - assert.equal(expanded.visibleReplies[0].summary, null); + assert.deepEqual( + collapsed.visibleReplies.map((entry) => ({ + id: entry.message.id, + depth: entry.message.depth, + summary: entry.summary, + })), + [ + { id: "branch", depth: 1, summary: null }, + { id: "child", depth: 1, summary: null }, + ], + ); + assert.deepEqual(expanded.visibleReplies, collapsed.visibleReplies); }); -test("buildThreadSummaryFromVisibleEntries counts visible rows and hidden descendants", () => { +test("buildThreadSummaryFromVisibleEntries counts flattened visible rows", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -280,7 +291,7 @@ test("hasNestedThreadBranches returns false for flat direct replies", () => { assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false); }); -test("hasNestedThreadBranches returns true for visible nested replies", () => { +test("hasNestedThreadBranches returns false for flattened nested replies", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -304,10 +315,10 @@ test("hasNestedThreadBranches returns true for visible nested replies", () => { new Set(["branch"]), ); - assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true); + assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false); }); -test("hasNestedThreadBranches returns true for collapsed nested replies", () => { +test("hasNestedThreadBranches returns false for previously collapsed nested replies", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -331,7 +342,7 @@ test("hasNestedThreadBranches returns true for collapsed nested replies", () => new Set(), ); - assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true); + assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false); }); test("shouldRenderUnreadDivider_firstUnreadIsFirstRendered_suppressesDivider", () => { diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 300b5213a..eb553161a 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -315,70 +315,58 @@ export function hasNestedThreadBranches(entries: readonly MainTimelineEntry[]) { ); } -function appendExpandedReplies(params: { - entries: MainTimelineEntry[]; - parentId: string; - depth: number; - directChildrenByParentId: Map; - descendantStatsByMessageId: Map; - expandedReplyIds: ReadonlySet; -}) { - const { - entries, - parentId, - depth, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - } = params; - const directReplies = directChildrenByParentId.get(parentId) ?? []; - - for (const reply of directReplies) { - const isExpanded = expandedReplyIds.has(reply.id); - entries.push({ - message: normalizeInlineReplyMessage(reply, depth), - summary: isExpanded - ? null - : buildSummaryForDirectReplies(reply.id, descendantStatsByMessageId), - }); +function isDescendantOfMessage( + message: TimelineMessage, + ancestorId: string, + messageById: Map, +) { + if (message.id === ancestorId) { + return false; + } - if (isExpanded) { - appendExpandedReplies({ - entries, - parentId: reply.id, - depth: depth + 1, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - }); + if (message.rootId === ancestorId || message.parentId === ancestorId) { + return true; + } + + let parentId = message.parentId ?? null; + let hops = 0; + const maxHops = messageById.size + 1; + while (parentId && hops < maxHops) { + if (parentId === ancestorId) { + return true; } + + parentId = messageById.get(parentId)?.parentId ?? null; + hops += 1; } + + return false; } function buildVisibleThreadReplies(params: { openThreadHeadId: string; - directChildrenByParentId: Map; - descendantStatsByMessageId: Map; - expandedReplyIds: ReadonlySet; + messageById: Map; }) { - const { - openThreadHeadId, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - } = params; - const entries: MainTimelineEntry[] = []; - - appendExpandedReplies({ - entries, - parentId: openThreadHeadId, - depth: 1, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - }); + const { openThreadHeadId, messageById } = params; - return entries; + return [...messageById.values()] + .map((message, index) => ({ index, message })) + .filter(({ message }) => + isDescendantOfMessage(message, openThreadHeadId, messageById), + ) + .sort((left, right) => { + if (left.message.createdAt !== right.message.createdAt) { + return left.message.createdAt - right.message.createdAt; + } + + return left.index - right.index; + }) + .map( + ({ message }): MainTimelineEntry => ({ + message: normalizeInlineReplyMessage(message, 1), + summary: null, + }), + ); } export function buildMainTimelineEntries( @@ -428,7 +416,7 @@ export function buildThreadPanelDataFromIndex( index: ThreadPanelIndex, openThreadHeadId: string | null, threadReplyTargetId: string | null, - expandedReplyIds: ReadonlySet, + _expandedReplyIds: ReadonlySet, ): ThreadPanelData { if (!openThreadHeadId) { return { @@ -439,8 +427,7 @@ export function buildThreadPanelDataFromIndex( }; } - const { directChildrenByParentId, descendantStatsByMessageId, messageById } = - index; + const { descendantStatsByMessageId, messageById } = index; const threadHead = messageById.get(openThreadHeadId) ?? null; if (!threadHead) { @@ -455,9 +442,7 @@ export function buildThreadPanelDataFromIndex( const normalizedThreadHead = normalizeHeadMessage(threadHead); const visibleReplies = buildVisibleThreadReplies({ openThreadHeadId, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, + messageById, }); const replyTargetInBranch = diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 58ea21722..c8566382d 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -86,6 +86,24 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => { assert.deepEqual(kinds(items), ["day-divider", "message", "system"]); }); +test("buildTimelineItems: can omit only the initial day divider", () => { + const entries = [ + entry({ id: "d1a", createdAt: dayAt(2026, 6, 13) }), + entry({ id: "d1b", createdAt: dayAt(2026, 6, 13, 13) }), + entry({ id: "d2a", createdAt: dayAt(2026, 6, 14) }), + ]; + const { items } = buildTimelineItems(entries, null, { + showInitialDayDivider: false, + }); + + assert.deepEqual(kinds(items), [ + "message", + "message", + "day-divider", + "message", + ]); +}); + test("buildTimelineItems: empty entries produce no items", () => { const { items } = buildTimelineItems([], null); assert.equal(items.length, 0); diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index bb20ac216..0bdd92f75 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -30,6 +30,10 @@ export type TimelineItemsResult = { items: TimelineItem[]; }; +type BuildTimelineItemsOptions = { + showInitialDayDivider?: boolean; +}; + /** Stable per-item key, unique across the flattened stream. */ export function getTimelineItemKey(item: TimelineItem): string { return item.key; @@ -47,6 +51,7 @@ function entryRenderKey(entry: MainTimelineEntry): string { export function buildTimelineItems( entries: MainTimelineEntry[], firstUnreadMessageId: string | null, + { showInitialDayDivider = true }: BuildTimelineItemsOptions = {}, ): TimelineItemsResult { const items: TimelineItem[] = []; @@ -66,7 +71,7 @@ export function buildTimelineItems( const renderKey = entryRenderKey(entry); const dayBoundary = dayBoundariesByStartIndex.get(i); - if (dayBoundary) { + if (dayBoundary && (showInitialDayDivider || i !== 0)) { items.push({ kind: "day-divider", key: dayBoundary.key, diff --git a/desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx b/desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx new file mode 100644 index 000000000..79795504c --- /dev/null +++ b/desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx @@ -0,0 +1,351 @@ +import { MessagesSquare } from "lucide-react"; + +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; +import type { TimelineMessage } from "@/features/messages/types"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type AgentConversationMarkerRowProps = { + className?: string; + currentPubkey?: string; + marker: AgentConversationMarker; + message: TimelineMessage; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; + profiles?: UserProfileLookup; +}; + +const RECAP_SECTION_PATTERN = + /\*\*(Original request|Findings|Outcome|Next steps):\*\*/g; +const RECAP_DETAIL_TAIL_PATTERN = + /\b(?:Nothing else needed|Nice work|Kenny asked|The user asked|Button system\s*(?:\(|[—-])|Composer primitives\s*(?:\(|[—-])|Sidebar navigation\s*(?:\(|[—-])|Key gotcha\s*:|Decisions?\s*:|Team agreed\b)[\s\S]*$/i; + +function parseRecapSections(value: string): Map { + const matches = [...value.matchAll(RECAP_SECTION_PATTERN)]; + const sections = new Map(); + if (matches.length === 0) { + return sections; + } + + matches.forEach((match, index) => { + const label = match[1]; + const start = (match.index ?? 0) + match[0].length; + const end = + index + 1 < matches.length + ? (matches[index + 1].index ?? value.length) + : value.length; + const content = value.slice(start, end).trim(); + if (content) { + sections.set(label, content); + } + }); + + return sections; +} + +function stripRecapMarkdown(value: string): string { + return value + .replace(RECAP_SECTION_PATTERN, "") + .replace(/\bConversation recap:\s*/gi, "") + .replace(/^\s*[\w .'-]{1,40}:\s+(?=\S)/gm, "") + .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") + .replace(/[`*_~>#]/g, "") + .replace(/^\s*[-*]\s+/gm, "") + .replace(/(?:^|\s)\d+\.\s+/g, " ") + .replace(/\n+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function sentenceCase(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + return `${trimmed.charAt(0).toLocaleUpperCase()}${trimmed.slice(1)}`; +} + +function ensureSentence(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; +} + +function formatJoinedList(items: readonly string[]): string { + if (items.length <= 1) { + return items[0] ?? ""; + } + if (items.length === 2) { + return `${items[0]} and ${items[1]}`; + } + + return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; +} + +function cleanPreviewTopic(title: string): string | null { + const topic = title.trim(); + if ( + !topic || + topic.toLocaleLowerCase() === "new conversation" || + /^conversation(?:\s+(?:in|about|with)\b.*)?$/i.test(topic) + ) { + return null; + } + + return topic; +} + +function extractCoveredAreas(value: string): string[] { + const areas: string[] = []; + const seen = new Set(); + const areaPattern = + /(?:^|[.!?]\s+)([A-Z][A-Za-z0-9 /&+-]{2,70})(?:\s*\([^)]*\))?\s+[—-]\s+/g; + + for (const match of value.matchAll(areaPattern)) { + const area = match[1]?.replace(/\s+/g, " ").trim(); + if (!area) { + continue; + } + + const normalized = area.toLocaleLowerCase(); + if ( + seen.has(normalized) || + /^(key gotcha|decisions?|next steps?|conversation recap)$/i.test(area) + ) { + continue; + } + + seen.add(normalized); + areas.push(area); + if (areas.length >= 4) { + break; + } + } + + return areas; +} + +function extractLabeledText( + value: string, + labelPattern: string, +): string | null { + const labelRegex = new RegExp( + `(?:^|[.\\n]\\s*)${labelPattern}\\s*:\\s*([\\s\\S]*?)(?=(?:^|[.\\n]\\s*)(?:Key gotcha|Decisions?|Next steps(?:\\s*\\([^)]*\\))?|Original request|Findings|Outcome)\\s*:|$)`, + "i", + ); + const match = value.match(labelRegex); + const text = stripRecapMarkdown(match?.[1] ?? ""); + + return text || null; +} + +function stripRecapDetailTail(value: string): string { + return value.replace(RECAP_DETAIL_TAIL_PATTERN, "").trim(); +} + +function cleanNextStepsPreviewText(value: string | null): string | null { + const cleaned = stripRecapDetailTail(value ?? "") + .replace(/^pending [^:]+:\s*/i, "") + .replace(/^,\s*/, "") + .replace(/\s+/g, " ") + .trim(); + + return cleaned || null; +} + +function firstUsefulSentence(value: string): string | null { + const sentences = value + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); + + return ( + sentences.find( + (sentence) => + !/^(kenny asked|the user asked|conversation recap)\b/i.test(sentence), + ) ?? + sentences[0] ?? + null + ); +} + +function lowerFirst(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + return `${trimmed.charAt(0).toLocaleLowerCase()}${trimmed.slice(1)}`; +} + +function formatNextStepsText(value: string): string { + const normalized = value.replace(/^to\s+/i, "").trim(); + const items = normalized + .split( + /\s+(?=(?:Publish|Link|Cross-link|Follow-up|Follow up|Add|Create|Update|Review|Share|Ship|Document)\b)/, + ) + .map((item) => item.trim()) + .filter(Boolean); + const formatted = + items.length <= 1 + ? lowerFirst(normalized) + : formatJoinedList(items.map(lowerFirst)); + + if (/^(?:to|until|once)\b/i.test(formatted)) { + return formatted; + } + + return `to ${formatted}`; +} + +function buildRecapPreview(summary: string, title: string): string { + const sections = parseRecapSections(summary); + const outcome = stripRecapMarkdown(sections.get("Outcome") ?? ""); + const findings = stripRecapMarkdown(sections.get("Findings") ?? ""); + const nextSteps = stripRecapMarkdown(sections.get("Next steps") ?? ""); + const source = [outcome, findings].filter(Boolean).join(" ").trim(); + const fullText = stripRecapMarkdown(summary); + const topic = cleanPreviewTopic(title); + const areas = extractCoveredAreas(source || fullText); + const decision = extractLabeledText(summary, "Decisions?"); + const rawNextSteps = + nextSteps || extractLabeledText(summary, "Next steps(?:\\s*\\([^)]*\\))?"); + const nextStepText = cleanNextStepsPreviewText(rawNextSteps); + const fallbackSentence = firstUsefulSentence( + stripRecapDetailTail(source || fullText), + ); + const sentences: string[] = []; + + if (topic) { + sentences.push(`This conversation focused on ${topic}.`); + } + + if (areas.length > 0) { + const areaText = formatJoinedList(topic ? areas : areas.map(lowerFirst)); + sentences.push( + topic + ? `The main takeaways covered ${areaText}.` + : ensureSentence(sentenceCase(areaText)), + ); + } else if (fallbackSentence) { + sentences.push(ensureSentence(sentenceCase(fallbackSentence))); + } + + if (decision) { + sentences.push(ensureSentence(sentenceCase(decision))); + } + + if (nextStepText) { + sentences.push( + `Next steps are ${ensureSentence(formatNextStepsText(nextStepText))}`, + ); + } + + const preview = sentences.join(" ").replace(/\s+/g, " ").trim(); + + return ( + preview || + (fallbackSentence + ? ensureSentence(sentenceCase(fallbackSentence)) + : fullText) + ); +} + +export function AgentConversationMarkerRow({ + className, + currentPubkey, + marker, + message, + onOpenAgentConversation, + profiles, +}: AgentConversationMarkerRowProps) { + const starterProfile = profiles?.[normalizePubkey(marker.starterPubkey)]; + const starterName = resolveUserLabel({ + currentPubkey, + profiles, + pubkey: marker.starterPubkey, + }); + const recapPreview = marker.summary + ? buildRecapPreview(marker.summary, marker.title) + : null; + + return ( +
+ +
+
+
+
+ +
+
+

+ Dedicated conversation +

+

+ {marker.title} +

+
+ +
+ {marker.summary ? ( +
+

+ Conversation recap +

+ {recapPreview ? ( +

+ {recapPreview} +

+ ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index baf9ca066..9fa6557ed 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -8,6 +8,7 @@ import { Link2, MailCheck, MailOpen, + MessagesSquare, Pencil, SmilePlus, Trash2, @@ -344,6 +345,7 @@ export function MessageActionBar({ onMarkRead, onReactionBadgeBurstRequest, onReactionSelect, + onContinueConversation, onRemindLater, onReply, onUnfollowThread, @@ -363,6 +365,7 @@ export function MessageActionBar({ onMarkRead?: (message: TimelineMessage) => void; onReactionBadgeBurstRequest?: (emoji: string) => void; onReactionSelect?: (emoji: string) => Promise; + onContinueConversation?: (message: TimelineMessage) => void; onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; @@ -391,6 +394,7 @@ export function MessageActionBar({ ); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); + const hasContinueConversationAction = Boolean(onContinueConversation); const hasMoreMenuActions = Boolean(onEdit) || @@ -433,7 +437,12 @@ export function MessageActionBar({ [onReactionBadgeBurstRequest, onReactionSelect, wouldAddReaction], ); - if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) { + if ( + !hasReplyAction && + !hasReactionAction && + !hasContinueConversationAction && + !hasMoreMenuActions + ) { return null; } @@ -515,6 +524,27 @@ export function MessageActionBar({ ) : null} + {hasContinueConversationAction ? ( + + + + + Continue conversation + + ) : null} + {hasReplyAction ? ( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index fff5d049d..b61f384a8 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,7 +1,10 @@ import * as React from "react"; -import type { TimelineMessage } from "@/features/messages/types"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; +import type { + TimelineMessage, + TimelineReaction, +} from "@/features/messages/types"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -41,6 +44,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); +const AGENT_STATUS_REACTION_EMOJIS = new Set(["👀", "💬"]); export type ThreadDepthGuideAction = { active?: boolean; @@ -49,6 +53,66 @@ export type ThreadDepthGuideAction = { message: TimelineMessage; }; +function stripAgentStatusReactionUsers( + reaction: TimelineReaction, + agentPubkeys: ReadonlySet, +): TimelineReaction | null { + if (!AGENT_STATUS_REACTION_EMOJIS.has(reaction.emoji)) { + return reaction; + } + + const remainingUsers = reaction.users.filter( + (user) => !agentPubkeys.has(normalizePubkey(user.pubkey)), + ); + const removedCount = reaction.users.length - remainingUsers.length; + if (removedCount <= 0) { + return reaction; + } + + const nextCount = Math.max(0, reaction.count - removedCount); + if (nextCount === 0) { + return null; + } + + return { + ...reaction, + count: nextCount, + users: remainingUsers, + }; +} + +function stripAgentStatusReactions( + message: TimelineMessage, + agentPubkeys: ReadonlySet, +) { + if (!message.reactions?.length || agentPubkeys.size === 0) { + return message; + } + + let didChange = false; + const reactions = message.reactions + .map((reaction) => { + const nextReaction = stripAgentStatusReactionUsers( + reaction, + agentPubkeys, + ); + if (nextReaction !== reaction) { + didChange = true; + } + return nextReaction; + }) + .filter((reaction): reaction is TimelineReaction => reaction !== null); + + if (!didChange) { + return message; + } + + return { + ...message, + reactions: reactions.length > 0 ? reactions : undefined, + }; +} + export const MessageRow = React.memo( function MessageRow({ channelId = null, @@ -77,6 +141,7 @@ export const MessageRow = React.memo( onFollowThread, onMarkUnread, onMarkRead, + onOpenAgentConversation, onToggleReaction, onReply, onUnfollowThread, @@ -119,6 +184,10 @@ export const MessageRow = React.memo( onFollowThread?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -137,23 +206,6 @@ export const MessageRow = React.memo( const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); - const { - reactions, - canToggle: canToggleReactions, - pending: reactionPending, - errorMessage: reactionErrorMessage, - select: handleReactionSelect, - } = useReactionHandler(message, onToggleReaction); - const { openReminder, activeReminderEventIds } = useRemindLater(); - const hasActiveReminder = activeReminderEventIds.has(message.id); - const mentionNames = React.useMemo( - () => resolveMentionNames(message.tags, profiles), - [profiles, message.tags], - ); - const mentionPubkeysByName = React.useMemo( - () => resolveMentionPubkeysByName(message.tags, profiles), - [profiles, message.tags], - ); const resolvedAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(agentPubkeys ?? []); @@ -171,6 +223,27 @@ export const MessageRow = React.memo( resolvedAgentPubkeys.has(normalizePubkey(message.pubkey))) ? "bot" : message.role; + const messageForReactions = React.useMemo( + () => stripAgentStatusReactions(message, resolvedAgentPubkeys), + [message, resolvedAgentPubkeys], + ); + const { + reactions, + canToggle: canToggleReactions, + pending: reactionPending, + errorMessage: reactionErrorMessage, + select: handleReactionSelect, + } = useReactionHandler(messageForReactions, onToggleReaction); + const { openReminder, activeReminderEventIds } = useRemindLater(); + const hasActiveReminder = activeReminderEventIds.has(message.id); + const mentionNames = React.useMemo( + () => resolveMentionNames(message.tags, profiles), + [profiles, message.tags], + ); + const mentionPubkeysByName = React.useMemo( + () => resolveMentionPubkeysByName(message.tags, profiles), + [profiles, message.tags], + ); const agentMentionPubkeysByName = React.useMemo(() => { if (!mentionPubkeysByName) { return undefined; @@ -196,7 +269,10 @@ export const MessageRow = React.memo( message.tags, ); const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5"; - + const isAgentMessage = + message.pubkey != null && + !message.pending && + resolvedAgentPubkeys.has(normalizePubkey(message.pubkey)); const { channels } = useChannelNavigation(); const channelNames = React.useMemo( () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), @@ -391,6 +467,9 @@ export const MessageRow = React.memo( isFollowingThread={isFollowingThread} isUnread={isUnread} message={message} + onContinueConversation={ + isAgentMessage ? onOpenAgentConversation : undefined + } onDelete={onDelete} onEdit={onEdit} onFollowThread={onFollowThread} @@ -804,6 +883,7 @@ export const MessageRow = React.memo( prev.onCollapseDescendants === next.onCollapseDescendants && prev.onCollapseDescendantsHoverChange === next.onCollapseDescendantsHoverChange && + prev.onOpenAgentConversation === next.onOpenAgentConversation && prev.profiles === next.profiles && prev.searchQuery === next.searchQuery && prev.videoReviewContext === next.videoReviewContext, diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 7060694ea..86c83a1ef 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -1,11 +1,10 @@ import * as React from "react"; import { ArrowDown } from "lucide-react"; -import { - buildThreadSummaryFromVisibleEntries, - hasNestedThreadBranches, - type MainTimelineEntry, -} from "@/features/messages/lib/threadPanel"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -21,11 +20,14 @@ import { AuxiliaryPanelTitle, } from "@/shared/layout/AuxiliaryPanel"; import { Button } from "@/shared/ui/button"; +import { Shimmer } from "@/shared/ui/Shimmer"; import { Skeleton } from "@/shared/ui/skeleton"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; +import { AgentConversationMarkerRow } from "./AgentConversationMarkerRow"; +import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageComposer } from "./MessageComposer"; -import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow"; -import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { MessageRow } from "./MessageRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; import { useComposerHeightPadding } from "./useComposerHeightPadding"; @@ -33,6 +35,7 @@ import { useAnchoredScroll } from "./useAnchoredScroll"; import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot"; type MessageThreadPanelProps = { + agentConversationMarkers?: readonly AgentConversationMarker[]; agentPubkeys?: ReadonlySet; channel: Channel | null; channelId: string | null; @@ -60,6 +63,10 @@ type MessageThreadPanelProps = { onEditSave?: (content: string, mediaTags?: string[][]) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onExpandReplies: (message: TimelineMessage) => void; onScrollTargetResolved: () => void; onSelectReplyTarget: (message: TimelineMessage) => void; @@ -80,9 +87,9 @@ type MessageThreadPanelProps = { threadReplies: MainTimelineEntry[]; threadUnreadCount?: number; threadReplyUnreadCounts?: ReadonlyMap; + threadActivityAgents?: readonly ThreadActivityAgent[]; threadTypingPubkeys: string[]; threadHeadVideoReviewContext?: VideoReviewContext; - toolbarExtraActions?: React.ReactNode; widthPx: number; transparentChrome?: boolean; isFollowingThread?: boolean; @@ -91,10 +98,16 @@ type MessageThreadPanelProps = { onUnfollowThread?: () => void; }; +type ThreadActivityAgent = { + name: string; + pubkey: string; +}; + +/** Stable `useDeferredValue` initial value; mirrors `EMPTY_MESSAGES`. */ const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = []; const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2"; const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5"; -const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = -0.125; + type MessageThreadPanelSkeletonProps = { isSinglePanelView?: boolean; layout?: "standalone" | "split"; @@ -114,55 +127,101 @@ function canManageMessage( ); } -function hasLaterVisibleSibling( - entries: readonly MainTimelineEntry[], - entryIndex: number, -): boolean { - const depth = entries[entryIndex]?.message.depth; - if (depth == null) { - return false; +function normalizeActivityText(value: string) { + return value.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); +} + +function getActivityLabel(item: TranscriptItem): string { + if (item.type === "message") { + return item.role === "assistant" ? "Responding..." : "Thinking..."; + } + + if (item.type !== "tool") { + return "Thinking..."; + } + + const activityText = normalizeActivityText( + [item.buzzToolName, item.toolName, item.title] + .filter((value): value is string => Boolean(value)) + .join(" "), + ); + + if (/\b(send message|send)\b/.test(activityText)) { + return "Responding..."; + } + + if ( + /\b(review|diff|compare|pull request|pr|changes?|patch)\b/.test( + activityText, + ) + ) { + return "Reviewing..."; } - for (let index = entryIndex + 1; index < entries.length; index += 1) { - const nextDepth = entries[index].message.depth; - if (nextDepth <= depth) { - return nextDepth === depth; - } + if ( + /\b(edit|write|update|create|delete|set|add|remove|join|leave|archive|unarchive|publish|trigger|approve|vote)\b/.test( + activityText, + ) + ) { + return "Editing..."; } - return false; + if ( + /\b(search|find|lookup|query|fetch|get|list|read|retrieve|history|thread|channel|user|feed|canvas|presence)\b/.test( + activityText, + ) + ) { + return "Searching..."; + } + + return "Thinking..."; } -function getActiveContinuationDepths({ - ancestors, - entries, - index, - message, +function ThreadAgentActivityRow({ + agent, + channelId, + profiles, }: { - ancestors: readonly { index: number; message: TimelineMessage }[]; - entries: readonly MainTimelineEntry[]; - index: number; - message: TimelineMessage; -}): number[] { - const depths: number[] = []; - - for (const ancestor of ancestors) { - if (ancestor.message.depth === 0) { - continue; - } - - const childDepth = ancestor.message.depth + 1; - const pathChild = - message.depth === childDepth - ? { index, message } - : ancestors.find((candidate) => candidate.message.depth === childDepth); - - if (pathChild && hasLaterVisibleSibling(entries, pathChild.index)) { - depths.push(ancestor.message.depth); - } - } + agent: ThreadActivityAgent; + channelId: string | null; + profiles?: UserProfileLookup; +}) { + const transcript = useAgentTranscript(true, agent.pubkey); + const activityLabel = React.useMemo(() => { + const scopedTranscript = channelId + ? transcript.filter((item) => item.channelId === channelId) + : transcript; + + const latestActivity = scopedTranscript[scopedTranscript.length - 1]; + return latestActivity ? getActivityLabel(latestActivity) : "Thinking..."; + }, [channelId, transcript]); + const profile = profiles?.[agent.pubkey.toLowerCase()]; - return depths; + return ( +
+ +
+ + + {profile?.displayName || agent.name} + + +

+ {activityLabel} +

+
+
+ ); } function ThreadMessageSkeleton({ isHead = false }: { isHead?: boolean }) { @@ -241,7 +300,7 @@ export function MessageThreadPanelSkeleton({ const threadBody = (
(null); const threadContentRef = React.useRef(null); const threadComposerWrapperRef = React.useRef(null); - const [hoveredCollapseBranchId, setHoveredCollapseBranchId] = React.useState< - string | null - >(null); - const [collapsedThreadHeadId, setCollapsedThreadHeadId] = React.useState< - string | null - >(null); const isOverlay = useIsThreadPanelOverlay(); const threadHeadId = threadHead?.id ?? null; useEscapeKey(onClose, isOverlay || isSinglePanelView); @@ -349,42 +401,6 @@ export function MessageThreadPanel({ isSinglePanelView, ); - const collapseThreadHeadReplies = React.useCallback(() => { - if (!threadHeadId) { - return; - } - - setHoveredCollapseBranchId(null); - setCollapsedThreadHeadId(threadHeadId); - }, [threadHeadId]); - const expandThreadHeadReplies = React.useCallback(() => { - setHoveredCollapseBranchId(null); - setCollapsedThreadHeadId(null); - }, []); - const handleCollapseBranchHoverChange = React.useCallback( - (message: TimelineMessage, hovered: boolean) => { - setHoveredCollapseBranchId((current) => { - if (hovered) { - return message.id; - } - - return current === message.id ? null : current; - }); - }, - [], - ); - const handleCollapseDepthGuide = React.useCallback( - (message: TimelineMessage) => { - if (message.id === threadHeadId) { - collapseThreadHeadReplies(); - return; - } - - onExpandReplies(message); - }, - [collapseThreadHeadReplies, onExpandReplies, threadHeadId], - ); - const composerReplyTarget = replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id ? { @@ -399,23 +415,6 @@ export function MessageThreadPanel({ EMPTY_THREAD_REPLIES, ); const isRepliesPending = deferredThreadReplies !== threadReplies; - const scrollTargetIsVisibleReply = React.useMemo( - () => - scrollTargetId !== null && - scrollTargetId !== threadHeadId && - deferredThreadReplies.some( - (entry) => entry.message.id === scrollTargetId, - ), - [deferredThreadReplies, scrollTargetId, threadHeadId], - ); - const isThreadHeadRepliesCollapsed = - collapsedThreadHeadId === threadHeadId && !scrollTargetIsVisibleReply; - - React.useLayoutEffect(() => { - if (scrollTargetIsVisibleReply && collapsedThreadHeadId === threadHeadId) { - setCollapsedThreadHeadId(null); - } - }, [collapsedThreadHeadId, scrollTargetIsVisibleReply, threadHeadId]); // Which of the three states the reply region paints this frame. Delegated to // a pure helper so the "don't flash empty over an incoming list" rule is @@ -424,143 +423,76 @@ export function MessageThreadPanel({ deferredThreadReplies.length, threadReplies.length, ); - const threadHeadSummary = React.useMemo(() => { - if (!threadHeadId) { - return null; - } - - return buildThreadSummaryFromVisibleEntries( - threadHeadId, - deferredThreadReplies, - ); - }, [deferredThreadReplies, threadHeadId]); - const visibleThreadHeadSummary = isThreadHeadRepliesCollapsed - ? threadHeadSummary - : null; - const threadMessages = React.useMemo( () => deferredThreadReplies.map((entry) => entry.message), [deferredThreadReplies], ); - const shouldShowThreadBranchGuides = React.useMemo( - () => hasNestedThreadBranches(deferredThreadReplies), + const flatThreadReplyEntries = React.useMemo( + () => + deferredThreadReplies.map((entry) => ({ + ...entry, + message: + entry.message.depth === 0 + ? entry.message + : { ...entry.message, depth: 0 }, + })), [deferredThreadReplies], ); - const highlightedBranch = React.useMemo(() => { - if (!hoveredCollapseBranchId) { - return null; - } - - if (hoveredCollapseBranchId === threadHeadId) { - return { - depth: 0, - endIndex: deferredThreadReplies.length - 1, - id: hoveredCollapseBranchId, - startIndex: -1, - }; - } - - const startIndex = deferredThreadReplies.findIndex( - (entry) => entry.message.id === hoveredCollapseBranchId, - ); - if (startIndex < 0) { - return null; - } - - const depth = deferredThreadReplies[startIndex].message.depth; - let endIndex = startIndex; - while ( - endIndex + 1 < deferredThreadReplies.length && - deferredThreadReplies[endIndex + 1].message.depth > depth - ) { - endIndex += 1; - } - - return { - depth, - endIndex, - id: hoveredCollapseBranchId, - startIndex, - }; - }, [deferredThreadReplies, hoveredCollapseBranchId, threadHeadId]); - const threadReplyRenderItems = React.useMemo(() => { - if (!threadHead) { - return []; - } - - const ancestorStack: { index: number; message: TimelineMessage }[] = [ - { index: -1, message: threadHead }, - ]; - - return deferredThreadReplies.map((entry, index) => { - while ( - ancestorStack.length > 0 && - ancestorStack[ancestorStack.length - 1].message.depth >= - entry.message.depth - ) { - ancestorStack.pop(); - } - - const ancestors = [...ancestorStack]; - const continuationDepths = getActiveContinuationDepths({ - ancestors, - entries: deferredThreadReplies, - index, - message: entry.message, - }); - const collapseDepthGuideAncestors = ancestors.filter((ancestor) => - continuationDepths.includes(ancestor.message.depth), - ); - const collapseDepthGuideActions: ThreadDepthGuideAction[] | undefined = - collapseDepthGuideAncestors.length > 0 - ? collapseDepthGuideAncestors.map((ancestor) => ({ - active: - hoveredCollapseBranchId === ancestor.message.id && - entry.message.depth === ancestor.message.depth + 1, - depth: ancestor.message.depth, - label: - ancestor.message.id === threadHead.id - ? "Collapse thread" - : "Collapse replies", - message: ancestor.message, - })) - : undefined; - const nextEntry = deferredThreadReplies[index + 1]; - const connectsToVisibleChild = - nextEntry != null && nextEntry.message.depth > entry.message.depth; - - if (connectsToVisibleChild && !entry.summary) { - ancestorStack.push({ index, message: entry.message }); - } + const agentConversationMarkerByMessageId = React.useMemo( + () => + new Map( + (agentConversationMarkers ?? []).map((marker) => [ + marker.agentReplyId, + marker, + ]), + ), + [agentConversationMarkers], + ); - return { - collapseDepthGuideActions, - connectsToVisibleChild, - continuationDepths, - entry, - index, - }; - }); - }, [deferredThreadReplies, hoveredCollapseBranchId, threadHead]); - - const { isAtBottom, newMessageCount, onScroll, scrollToBottom } = - useAnchoredScroll({ - channelId: threadHeadId, - contentRef: threadContentRef, - isLoading: repliesRenderState === "pending", - messages: threadMessages, - onTargetReached: onScrollTargetResolved, - scrollContainerRef: threadBodyRef, - targetMessageId: scrollTargetId, - }); + const { + isAtBottom, + newMessageCount, + onScroll, + scrollToBottom, + scrollToBottomOnNextUpdate, + } = useAnchoredScroll({ + channelId: threadHeadId, + contentRef: threadContentRef, + isLoading: repliesRenderState === "pending", + messages: threadMessages, + onTargetReached: onScrollTargetResolved, + scrollContainerRef: threadBodyRef, + targetMessageId: scrollTargetId, + }); + const handleSendReply = React.useCallback( + (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => { + scrollToBottomOnNextUpdate(); + return onSend(content, mentionPubkeys, mediaTags); + }, + [onSend, scrollToBottomOnNextUpdate], + ); if (!threadHead) { return null; } + const threadHeadAgentConversationMarker = + agentConversationMarkerByMessageId.get(threadHead.id) ?? null; + const threadActivityRows = + threadActivityAgents.length > 0 + ? threadActivityAgents.map((agent) => ( + + )) + : null; + const threadScrollRegion = ( onUnfollowThread() : undefined } profiles={profiles} - showDepthGuides={shouldShowThreadBranchGuides} + showDepthGuides={false} videoReviewContext={threadHeadVideoReviewContext} /> + {threadHeadAgentConversationMarker ? ( + + ) : null}
@@ -612,159 +553,68 @@ export function MessageThreadPanel({ data-testid="message-thread-replies" > {repliesRenderState === "list" ? ( - visibleThreadHeadSummary ? ( -
- -
- ) : ( -
- {threadReplyRenderItems.map((item) => { - const { - collapseDepthGuideActions, - connectsToVisibleChild, - continuationDepths, - entry, - index, - } = item; - const showUnreadDivider = - index > 0 && entry.message.id === firstUnreadReplyId; - const isHighlightedBranchOwner = - highlightedBranch?.id === entry.message.id; - const isInsideHighlightedBranch = - highlightedBranch != null && - index > highlightedBranch.startIndex && - index <= highlightedBranch.endIndex; - const isDirectChildOfHighlightedBranch = - isInsideHighlightedBranch && - highlightedBranch != null && - index > highlightedBranch.startIndex && - index <= highlightedBranch.endIndex && - entry.message.depth === highlightedBranch.depth + 1; - const highlightedLineDepths = - shouldShowThreadBranchGuides && - isInsideHighlightedBranch && - highlightedBranch - ? [highlightedBranch.depth] - : undefined; - return ( -
- {showUnreadDivider ? : null} - + {flatThreadReplyEntries.map((entry, index) => { + const showUnreadDivider = + index > 0 && entry.message.id === firstUnreadReplyId; + const agentConversationMarker = + agentConversationMarkerByMessageId.get(entry.message.id) ?? + null; + + return ( +
+ {showUnreadDivider ? : null} + onSelectReplyTarget(entry.message) + : undefined + } + onToggleReaction={onToggleReaction} + profiles={profiles} + showDepthGuides={false} + /> + {agentConversationMarker ? ( + - {entry.summary ? ( - - ) : null} -
- ); - })} -
- ) - ) : repliesRenderState === "empty" ? ( + ) : null} +
+ ); + })} + {threadActivityRows} + + ) : repliesRenderState === "empty" && !threadActivityRows ? ( // Only show the empty state when the thread is GENUINELY empty. // Keying off `deferredThreadReplies` would flash "No replies" for a // frame while a non-empty list streams in on the deferred commit. @@ -776,6 +626,8 @@ export function MessageThreadPanel({ Reply in the thread to continue this branch.

+ ) : repliesRenderState === "empty" ? ( +
{threadActivityRows}
) : // "pending": deferred list is empty but the live list has content — // rows are streaming in on the deferred commit. Paint nothing rather // than flashing the empty state. @@ -823,7 +675,7 @@ export function MessageThreadPanel({ onCancelReply={composerReplyTarget ? onCancelReply : undefined} onEditLastOwnMessage={onEditLastOwnMessage} onEditSave={onEditSave} - onSend={onSend} + onSend={handleSendReply} placeholder={`Reply in thread to ${threadHead.author}`} profiles={profiles} replyTarget={composerReplyTarget} @@ -837,9 +689,6 @@ export function MessageThreadPanel({ )} >
- {toolbarExtraActions ? ( -
{toolbarExtraActions}
- ) : null} {threadTypingPubkeys.length > 0 ? ( void; - onOpenThread: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; showDepthGuides?: boolean; summary: TimelineThreadSummary; summaryIndentOffsetRem?: number; @@ -201,7 +201,7 @@ export function MessageThreadSummaryRow({ className="group relative isolate inline-flex h-8 w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full text-left text-xs font-medium text-muted-foreground transition-[color,opacity] before:pointer-events-none before:absolute before:-bottom-0.5 before:-left-0.5 before:-right-2 before:-top-0.5 before:-z-10 before:rounded-full before:content-[''] before:transition-[background-color,box-shadow] hover:text-foreground hover:opacity-90 hover:before:bg-background/95 hover:before:ring-1 hover:before:ring-border/70 focus-visible:outline-hidden focus-visible:before:bg-background/95 focus-visible:before:ring-1 focus-visible:before:ring-ring" data-thread-head-id={message.id} data-testid="message-thread-summary" - onClick={() => onOpenThread(message)} + onClick={() => onOpenThread?.(message)} style={{ marginLeft: threadReplyLength(marginLeftRem) }} type="button" > diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 8d8756275..94f246c0a 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -7,6 +7,7 @@ import { selectTimelineBodySurface, selectTimelineIntroSurface, } from "@/features/messages/lib/timelineSnapshot"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; import type { TimelineMessage } from "@/features/messages/types"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; @@ -28,6 +29,7 @@ export type MessageTimelineHandle = { }; type MessageTimelineProps = { + agentConversationMarkers?: readonly AgentConversationMarker[]; agentPubkeys?: ReadonlySet; channelId?: string | null; channelIntro?: ChannelIntro | null; @@ -52,7 +54,10 @@ type MessageTimelineProps = { scrollContainerRef?: React.RefObject; /** True when the timeline has the composer overlay below it. */ hasComposerOverlay?: boolean; + contentTopPadding?: "chrome" | "compact"; isFetchingOlder?: boolean; + layoutShiftKey?: string | number | null; + messageListPlacement?: "bottom" | "top"; messageFooters?: Record; /** Map from lowercase pubkey → persona display name for bot members. */ personaLookup?: Map; @@ -64,6 +69,10 @@ type MessageTimelineProps = { onEdit?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onReply?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( @@ -85,6 +94,7 @@ type MessageTimelineProps = { searchMatchingMessageIds?: Set; /** The current find-in-channel query string. */ searchQuery?: string; + showInitialDayDivider?: boolean; targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; /** Event id of the oldest unread top-level message at channel open, or null. */ @@ -93,6 +103,7 @@ type MessageTimelineProps = { unreadCount?: number; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + trailingContent?: React.ReactNode; }; type ChannelIntroAction = { @@ -137,6 +148,7 @@ const MessageTimelineBase = React.forwardRef< MessageTimelineProps >(function MessageTimeline( { + agentConversationMarkers, agentPubkeys, channelId, channelIntro = null, @@ -149,8 +161,11 @@ const MessageTimelineBase = React.forwardRef< currentPubkey, fetchOlder, hasComposerOverlay = true, + contentTopPadding = "chrome", hasOlderMessages = true, isFetchingOlder = false, + layoutShiftKey = null, + messageListPlacement = "bottom", followThreadById, huddleMemberPubkeys, huddleMemberPubkeysPending = false, @@ -163,6 +178,7 @@ const MessageTimelineBase = React.forwardRef< onEdit, onMarkUnread, onMarkRead, + onOpenAgentConversation, onReply, channelName, channelType, @@ -174,11 +190,13 @@ const MessageTimelineBase = React.forwardRef< searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + showInitialDayDivider = true, targetMessageId = null, onTargetReached, firstUnreadMessageId = null, unreadCount = 0, threadUnreadCounts, + trailingContent, }: MessageTimelineProps, ref, ) { @@ -215,15 +233,16 @@ const MessageTimelineBase = React.forwardRef< liveSnapshot, }); const isRenderPending = deferredSnapshot !== liveSnapshot; + const scrollRouteKey = `${channelId ?? "none"}:${layoutShiftKey ?? "none"}`; const scrollRestorationId = targetMessageId - ? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}` - : `message-timeline:${channelId ?? "none"}`; + ? `message-timeline:${scrollRouteKey}:target:${targetMessageId}` + : `message-timeline:${scrollRouteKey}`; // Keep the scroll node's DOM lifetime scoped to a channel. TanStack Router's // scroll-restoration listener runs outside React and may write a saved // scrollTop into the current scroll element during navigation; reusing the // same node across channel routes can leave the newly-loaded message list // painted at a stale offset until the user's next scroll event forces layout. - const scrollContainerDomKey = channelId ?? "none"; + const scrollContainerDomKey = scrollRouteKey; const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, @@ -246,6 +265,7 @@ const MessageTimelineBase = React.forwardRef< isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, + resetKey: scrollRouteKey, scrollContainerRef, targetMessageId, }); @@ -404,7 +424,7 @@ const MessageTimelineBase = React.forwardRef<
{showTimelineSkeleton ? ( @@ -570,10 +596,16 @@ const MessageTimelineBase = React.forwardRef< {showMessageList ? (
+ {trailingContent}
) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 22ebe8cb4..baf0dc0e3 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -6,6 +6,7 @@ import { getTimelineItemKey, type TimelineItem, } from "@/features/messages/lib/timelineItems"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { @@ -18,6 +19,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; +import { AgentConversationMarkerRow } from "./AgentConversationMarkerRow"; import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; @@ -25,6 +27,7 @@ import { SystemMessageRow } from "./SystemMessageRow"; import { UnreadDivider } from "./UnreadDivider"; type TimelineMessageListProps = { + agentConversationMarkers?: readonly AgentConversationMarker[]; agentPubkeys?: ReadonlySet; channelId?: string | null; channelName?: string; @@ -47,6 +50,10 @@ type TimelineMessageListProps = { onEdit?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onReply?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( @@ -71,11 +78,13 @@ type TimelineMessageListProps = { searchMatchingMessageIds?: Set; /** The current find-in-channel query string. */ searchQuery?: string; + showInitialDayDivider?: boolean; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ + agentConversationMarkers, agentPubkeys, channelId, channelName, @@ -95,6 +104,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onEdit, onMarkUnread, onMarkRead, + onOpenAgentConversation, onReply, isSendingVideoReviewComment = false, onSendVideoReviewComment, @@ -103,6 +113,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + showInitialDayDivider = true, threadUnreadCounts, unfollowThreadById, }: TimelineMessageListProps) { @@ -159,8 +170,21 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ // The flattened item stream, memoized on the entries and the unread boundary // (the unread divider is its own item, so it shifts subsequent rows). const itemsResult = React.useMemo( - () => buildTimelineItems(entries, firstUnreadMessageId), - [entries, firstUnreadMessageId], + () => + buildTimelineItems(entries, firstUnreadMessageId, { + showInitialDayDivider, + }), + [entries, firstUnreadMessageId, showInitialDayDivider], + ); + const agentConversationMarkerByMessageId = React.useMemo( + () => + new Map( + (agentConversationMarkers ?? []).map((marker) => [ + marker.agentReplyId, + marker, + ]), + ), + [agentConversationMarkers], ); const renderItem = React.useCallback( @@ -186,6 +210,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ return ( & { + agentConversationMarker?: AgentConversationMarker; entry: MainTimelineEntry; footer: React.ReactNode; isUnread?: boolean; @@ -310,6 +342,7 @@ type MessageRowItemProps = Pick< function MessageRowItem({ agentPubkeys, + agentConversationMarker, channelId, currentPubkey, entry, @@ -324,6 +357,7 @@ function MessageRowItem({ onEdit, onMarkUnread, onMarkRead, + onOpenAgentConversation, onReply, onToggleReaction, profiles, @@ -382,6 +416,7 @@ function MessageRowItem({ } onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} + onOpenAgentConversation={onOpenAgentConversation} onToggleReaction={onToggleReaction} onReply={onReply} onUnfollowThread={ @@ -401,6 +436,16 @@ function MessageRowItem({ summary={summary} unreadCount={threadUnreadCounts?.get(message.id)} /> + {agentConversationMarker ? ( + + ) : null} {footer}
); @@ -423,6 +468,7 @@ function MessageRowItem({ onEdit={canEdit} onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} + onOpenAgentConversation={onOpenAgentConversation} onToggleReaction={onToggleReaction} onReply={onReply} profiles={profiles} @@ -430,6 +476,15 @@ function MessageRowItem({ showDepthGuides={false} videoReviewContext={videoReviewContext} /> + {agentConversationMarker ? ( + + ) : null} {footer} ); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 6a18a9684..6d8e89ecc 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -40,6 +40,8 @@ type UseAnchoredScrollOptions = { contentRef: React.RefObject; /** Resets when changed; lets us drop anchor + scroll state across channels. */ channelId?: string | null; + /** Resets when changed; includes channel plus route-specific layout state. */ + resetKey?: string | null; /** Suppresses initial scroll-to-bottom while a skeleton is showing. */ isLoading: boolean; /** Source of truth for the rendered list. Used to detect new-at-bottom @@ -145,6 +147,7 @@ export function useAnchoredScroll({ scrollContainerRef, contentRef, channelId, + resetKey = channelId ?? null, isLoading, messages, @@ -181,10 +184,10 @@ export function useAnchoredScroll({ // guard runs on a native scroll event, outside React's render cycle. const settlingRef = React.useRef(false); - // Reset everything when the channel changes — the layout effect that runs - // immediately after this reset is responsible for either jumping to bottom - // or to the target message for the new channel. - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we want this effect to fire exactly when the channel changes (and on mount). + // Reset everything when the route's scroll identity changes — the layout + // effect that runs immediately after this reset is responsible for either + // jumping to bottom or to the target message for the new view. + // biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is intentionally the sole trigger — it includes channel identity plus route-specific layout state. React.useLayoutEffect(() => { anchorRef.current = { kind: "at-bottom" }; setIsAtBottom(true); @@ -205,7 +208,7 @@ export function useAnchoredScroll({ cancelAnimationFrame(mountPinRafIdRef.current); mountPinRafIdRef.current = null; } - }, [channelId]); + }, [resetKey]); const scrollToBottomImperative = React.useCallback( (behavior: ScrollBehavior = "auto") => { @@ -454,7 +457,7 @@ export function useAnchoredScroll({ // mid-history, native scroll anchoring (overflow-anchor) holds the reading // row across the reflow, so there's nothing to do. // --------------------------------------------------------------------------- - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one. + // biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on route identity changes the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous route's detached node and re-observe the live one. React.useEffect(() => { const content = contentRef.current; if (!content || typeof ResizeObserver === "undefined") return; @@ -467,7 +470,7 @@ export function useAnchoredScroll({ }); observer.observe(content); return () => observer.disconnect(); - }, [channelId, contentRef, scrollContainerRef]); + }, [resetKey, contentRef, scrollContainerRef]); // --------------------------------------------------------------------------- // Target message handling (deep link, jump-to-reply, etc.). Distinct from diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts index fe805ada4..b75798176 100644 --- a/desktop/src/features/messages/ui/useComposerHeightPadding.ts +++ b/desktop/src/features/messages/ui/useComposerHeightPadding.ts @@ -4,8 +4,8 @@ import { observeElementBlockSize } from "@/shared/layout/observeElementBlockSize /** * Observes the height of the composer overlay and sets the scroll - * container's `paddingBottom` to match, so content is never hidden - * behind the absolutely-positioned composer. + * container's `paddingBottom` to match, plus optional extra breathing room, so + * content is never hidden behind the absolutely-positioned composer. * * If the user is already scrolled to the bottom when padding increases, * auto-scrolls to keep them at the bottom (no visible gap). @@ -14,6 +14,7 @@ export function useComposerHeightPadding( scrollContainerRef: React.RefObject, composerRef: React.RefObject, resetKey?: unknown, + extraPaddingPx = 0, ) { React.useEffect(() => { void resetKey; @@ -35,7 +36,7 @@ export function useComposerHeightPadding( let lastPadding: number | null = null; const applyPadding = (height: number) => { - const padding = Math.ceil(height); + const padding = Math.ceil(height + extraPaddingPx); if (lastPadding !== null && Math.abs(padding - lastPadding) <= 1) { return; } @@ -60,5 +61,5 @@ export function useComposerHeightPadding( disconnect(); scrollEl.style.paddingBottom = ""; }; - }, [scrollContainerRef, composerRef, resetKey]); + }, [scrollContainerRef, composerRef, resetKey, extraPaddingPx]); } diff --git a/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts b/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts index b4a61c5da..5855bfe4c 100644 --- a/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts +++ b/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts @@ -136,18 +136,6 @@ async function openThread(page: import("@playwright/test").Page) { await expect(page.getByTestId("message-thread-panel")).toBeVisible(); } -async function expandReply( - page: import("@playwright/test").Page, - replyId: string, -) { - const replies = page - .getByTestId("message-thread-replies") - .getByTestId("message-row"); - const before = await replies.count(); - await page.locator(`[data-thread-head-id="${replyId}"]`).click(); - await expect.poll(() => replies.count()).toBeGreaterThan(before); -} - async function screenshotThreadPanel( page: import("@playwright/test").Page, path: string, @@ -160,7 +148,7 @@ async function screenshotThreadPanel( } test.describe("thread reply anchor A/B roleplay screenshots", () => { - test("01-baseline-human-reply-nests-agent-at-depth-2", async ({ page }) => { + test("01-baseline-human-reply-flattens-agent-in-panel", async ({ page }) => { await setupRoleplayChannel(page); const now = Math.floor(Date.now() / 1000); @@ -186,8 +174,8 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { }, ); - // Baseline queue.rs anchored the agent response to the triggering human - // reply, producing depth 2 under Nora's message. + // Even when an older agent response is anchored to the triggering human + // reply, the thread panel now renders the whole thread as a flat list. await emitMockMessage( page, CHANNEL, @@ -201,15 +189,14 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { ); await openThread(page); - await expandReply(page, humanReply.id); await expect(page.getByText("Nora: adding context")).toBeVisible(); await expect(page.getByText("Pinky: Got it")).toBeVisible(); await expect( page.getByTestId("message-thread-replies").getByTestId("message-row"), ).toHaveCount(2); - await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); - await screenshotThreadPanel(page, `${SHOTS}/01-baseline-depth-2.png`); + await screenshotThreadPanel(page, `${SHOTS}/01-baseline-flat.png`); }); test("02-patched-human-reply-flattens-agent-at-root", async ({ page }) => { @@ -300,7 +287,7 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { await screenshotThreadPanel(page, `${SHOTS}/03-top-level-human-root.png`); }); - test("04-agent-only-branch-keeps-deeper-nesting", async ({ page }) => { + test("04-agent-only-branch-flattens-in-panel", async ({ page }) => { await setupRoleplayChannel(page); const now = Math.floor(Date.now() / 1000); @@ -338,14 +325,13 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { ); await openThread(page); - await expandReply(page, brainReply.id); await expect(page.getByText("Brain: Check the anchor")).toBeVisible(); await expect(page.getByText("Pinky: Good catch")).toBeVisible(); await expect( page.getByTestId("message-thread-replies").getByTestId("message-row"), ).toHaveCount(2); - await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); - await screenshotThreadPanel(page, `${SHOTS}/04-agent-only-nested.png`); + await screenshotThreadPanel(page, `${SHOTS}/04-agent-only-flat.png`); }); }); diff --git a/desktop/tests/e2e/thread-unread.spec.ts b/desktop/tests/e2e/thread-unread.spec.ts index 586ca3dd1..61fdb62be 100644 --- a/desktop/tests/e2e/thread-unread.spec.ts +++ b/desktop/tests/e2e/thread-unread.spec.ts @@ -91,24 +91,6 @@ function unreadTimestamp() { // dot without the user having to participate in the thread first. const SELF_PUBKEY = "deadbeef".repeat(8); -// Nested replies are collapsed behind a summary row that carries the parent's -// id (data-thread-head-id). Expanding one level renders that reply's direct -// children, so the rendered count MUST grow after the click — asserting that -// ties the test to genuine rendered depth: a no-op expansion fails here rather -// than passing silently. A level can reveal several children at once (a -// branch), so the check is "grew", not "grew by one". -async function expandReply( - page: import("@playwright/test").Page, - replyId: string, -) { - const replies = page - .getByTestId("message-thread-replies") - .getByTestId("message-row"); - const before = await replies.count(); - await page.locator(`[data-thread-head-id="${replyId}"]`).click(); - await expect.poll(() => replies.count()).toBeGreaterThan(before); -} - test.describe("thread unread indicator", () => { test("01-thread-unread-badge", async ({ page }) => { await installMockBridge(page); @@ -277,9 +259,8 @@ test.describe("thread unread indicator", () => { await waitForMockLiveSubscription(page, "general"); // Build a genuinely nested branch by chaining parentEventId: each reply's - // id becomes the next reply's parent, so threadPanel increments depth per - // level and renders progressive indentation. The first three levels are - // dated in the past — they are the "already read" structure. + // id becomes the next reply's parent. The panel now presents that whole + // thread as a flat list, while unread counting still walks the subtree. const past = Math.floor(Date.now() / 1000) - 60; const r1 = await emitMockMessage( page, @@ -313,15 +294,15 @@ test.describe("thread unread indicator", () => { createdAt: past + 3, }); - // Open the thread on the welcome root, expand the read structure - // (r1 → r2; r3 is a leaf until r4/r5 arrive), then close. This sets the - // read frontier over everything that currently exists. + // Open the thread on the welcome root, then close. The flat panel marks the + // currently visible descendants read. const summary = page.getByTestId("message-thread-summary").first(); await expect(summary).toBeVisible(); await summary.click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - await expandReply(page, r1.id); - await expandReply(page, r2.id); + await expect( + page.getByTestId("message-thread-replies").getByTestId("message-row"), + ).toHaveCount(4); await page.getByTestId("auxiliary-panel-close").click(); await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); @@ -342,63 +323,30 @@ test.describe("thread unread indicator", () => { createdAt: base + 1, }); - // Switch back, open the thread, and expand every level down to the - // unread tail. Each expandReply asserts a row appeared, so green here - // means the nesting genuinely rendered — not just that a divider exists. + // Switch back and open the thread. All descendants, including the unread + // tail, should be visible without expanding nested branches. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - await expandReply(page, r1.id); - await expandReply(page, r2.id); - await expandReply(page, r3.id); - await expandReply(page, r4.id); // Fully expanded: r1, r2, sibling, r3, r4, r5 — six rendered replies. const replies = page .getByTestId("message-thread-replies") .getByTestId("message-row"); await expect(replies).toHaveCount(6); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); + await expect(page.getByTestId("thread-collapse-guide")).toHaveCount(0); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("message-thread-summary"), + ).toHaveCount(0); const divider = page.getByTestId("message-unread-divider"); await expect(divider).toBeVisible(); await divider.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); - - const panel = page.getByTestId("message-thread-panel"); - await page.getByTestId("message-thread-head").scrollIntoViewIfNeeded(); - await expect( - panel.locator( - `[data-testid="thread-collapse-rail"][data-thread-head-id="mock-general-welcome"]`, - ), - ).toHaveCount(0); - await expect( - panel.locator( - `[data-testid="thread-collapse-guide"][data-thread-head-id="mock-general-welcome"]`, - ), - ).toHaveCount(0); - - await page - .locator( - `[data-testid="thread-collapse-guide"][data-thread-head-id="${r1.id}"]`, - ) - .first() - .click(); - await expect(replies).toHaveCount(1); - await expect( - page - .getByTestId("message-thread-replies") - .locator( - `[data-testid="message-thread-summary"][data-thread-head-id="${r1.id}"]`, - ), - ).toBeVisible(); - await expect( - page - .getByTestId("message-thread-replies") - .locator( - `[data-testid="thread-collapse-rail"][data-thread-head-id="${r1.id}"]`, - ), - ).toHaveCount(0); }); test("05-thread-in-panel-subtree-badge", async ({ page }) => { @@ -410,9 +358,7 @@ test.describe("thread unread indicator", () => { await waitForMockLiveSubscription(page, "general"); // A branch p (with a child c) plus a leaf sibling of p, all dated in the - // past so they form the "already read" structure. p keeps a child, so its - // in-panel row renders as a collapsible summary that can carry a subtree - // badge; the leaf sibling proves the panel shows other rows too. + // past so they form the "already read" structure. const past = Math.floor(Date.now() / 1000) - 60; const p = await emitMockMessage(page, "general", "Branch parent", { parentEventId: "mock-general-welcome", @@ -431,8 +377,7 @@ test.describe("thread unread indicator", () => { }); // Open the thread to snapshot the read frontier over the existing - // structure, then close. p stays collapsed — its summary row must remain a - // collapsed branch for the subtree badge to render. + // structure, then close. const summary = page.getByTestId("message-thread-summary").first(); await expect(summary).toBeVisible(); await summary.click(); @@ -440,8 +385,7 @@ test.describe("thread unread indicator", () => { await page.getByTestId("auxiliary-panel-close").click(); await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); - // Switch away, then emit two unread replies deep under p (children of c) — - // p's subtree gains unread descendants while p itself stays collapsed. + // Switch away, then emit two unread replies deep under p (children of c). await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -462,44 +406,39 @@ test.describe("thread unread indicator", () => { createdAt: base + 1, }); - // Switch back and open the panel WITHOUT expanding p. The collapsed p row - // must show its subtree unread count (the two unread descendants). + // Switch back. The root summary still counts unread descendants even + // though the panel will render them flat. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + const rootBadge = page + .getByTestId("message-thread-summary") + .first() + .getByTestId("thread-unread-badge"); + await expect(rootBadge).toContainText("2"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - // p renders as a collapsed summary row (it has a child); the sibling is a - // leaf and renders as a plain row, not a summary. Gate on p's summary row - // first — green here means the branch genuinely rendered, so the badge - // assertion below is read off a real collapsed row, not an empty panel. - const inPanelSummaries = page - .getByTestId("message-thread-replies") - .getByTestId("message-thread-summary"); - await expect(inPanelSummaries).toHaveCount(1); - - // Scope to message-thread-replies: this is the in-panel per-branch badge, - // NOT the depth-0 channel-timeline badge that lives outside the container. - // Against pre-2.5 code the in-panel badge was hard-0, so this fails there. - const inPanelBadge = page + const replies = page .getByTestId("message-thread-replies") - .getByTestId("thread-unread-badge"); - await expect(inPanelBadge).toBeVisible(); - await expect(inPanelBadge).toContainText("2"); - - // v3 contract: expanding a branch marks only its REVEALED direct children - // read, never the whole subtree. The unread replies sit two levels under p - // (p -> c -> c2 -> c2-child), so a single expand of p only reveals c — the - // deeper unread stays collapsed and the badge survives. The badge clears - // only as each level is individually revealed: expand p (reveals c, badge - // still counts c2 + c2-child), expand c (reveals c2, read), expand c2 - // (reveals c2-child, read) -> badge clears to 0. - await expandReply(page, p.id); - await expect(inPanelBadge).toBeVisible(); - - await expandReply(page, c.id); - await expandReply(page, c2.id); - await expect(inPanelBadge).toHaveCount(0); + .getByTestId("message-row"); + await expect(replies).toHaveCount(5); + await expect( + page.getByText("Unread under the branch", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Another unread under the branch"), + ).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("message-thread-summary"), + ).toHaveCount(0); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); + await expect(page.getByTestId("message-unread-divider")).toBeVisible(); }); test("06-in-panel-badge-bumps-on-live-reply", async ({ page }) => { @@ -510,8 +449,7 @@ test.describe("thread unread indicator", () => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - // Collapsed branch p with one read child, plus an unread descendant so the - // in-panel subtree badge starts at a known count. + // Branch p with one read child, plus an unread descendant. const past = Math.floor(Date.now() / 1000) - 60; const p = await emitMockMessage(page, "general", "Branch parent", { parentEventId: "mock-general-welcome", @@ -541,28 +479,32 @@ test.describe("thread unread indicator", () => { createdAt: base, }); - // Reopen WITHOUT expanding p: badge shows the single unread descendant. + // Reopen. The unread descendant is visible directly in the flat panel. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - const inPanelBadge = page - .getByTestId("message-thread-replies") - .getByTestId("thread-unread-badge"); - await expect(inPanelBadge).toBeVisible(); - await expect(inPanelBadge).toContainText("1"); + await expect(page.getByText("First unread under branch")).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); - // A live reply from another author lands under the open, collapsed branch. - // The live root marker did NOT advance (panel open ≠ branch expanded), so - // the badge must bump to 2 on the same tick — readStateVersion-driven - // recompute is what makes this fire live rather than on a later re-render. + // A live reply from another author lands under the open thread and appears + // as another flat reply instead of bumping an in-panel branch badge. await emitMockMessage(page, "general", "Second unread under branch", { parentEventId: c.id, pubkey: TEST_IDENTITIES.bob.pubkey, createdAt: base + 1, }); - await expect(inPanelBadge).toContainText("2"); + await expect(page.getByText("Second unread under branch")).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); }); test("07-expand-clears-own-branch-badge-sibling-survives", async ({ @@ -575,7 +517,7 @@ test.describe("thread unread indicator", () => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - // Two collapsed sibling branches, each with one read child. branchOld will + // Two sibling branches, each with one read child. branchOld will // gain a chronologically EARLIER unread reply; branchNew a LATER one. const past = Math.floor(Date.now() / 1000) - 120; const branchOld = await emitMockMessage(page, "general", "Older branch", { @@ -611,11 +553,7 @@ test.describe("thread unread indicator", () => { // Each branch gains its own unread reply, nested one level under the // branch's child (branchNew -> newChild -> unread; branchOld -> oldChild -> - // unread). Under the v3 per-message contract, expanding a branch marks only - // its REVEALED direct children read — so revealing newChild does NOT reach - // the unread reply beneath it. Clearing a branch's badge requires expanding - // down to the level the unread actually sits at; the sibling branch is - // never touched, so its badge survives independently. + // unread). const base = unreadTimestamp(); await emitMockMessage(page, "general", "Unread in older branch", { parentEventId: oldChild.id, @@ -630,29 +568,26 @@ test.describe("thread unread indicator", () => { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + const rootBadge = page + .getByTestId("message-thread-summary") + .first() + .getByTestId("thread-unread-badge"); + await expect(rootBadge).toContainText("2"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - // Both collapsed branches carry an unread badge before any expand. - const inPanelBadges = page + const replies = page .getByTestId("message-thread-replies") - .getByTestId("thread-unread-badge"); - await expect(inPanelBadges).toHaveCount(2); - - // Expand the LATER branch down to where its unread sits: revealing - // branchNew shows newChild (still collapsed over the unread reply, so the - // badge survives), then revealing newChild marks the unread reply read and - // clears branchNew's badge. The older sibling is never expanded, so its - // badge survives — per-message markers isolate each branch. - await expandReply(page, branchNew.id); - await expect(inPanelBadges).toHaveCount(2); - await expandReply(page, newChild.id); - await expect(inPanelBadges).toHaveCount(1); - - // Expanding the older branch to its unread depth clears the last badge. - await expandReply(page, branchOld.id); - await expandReply(page, oldChild.id); - await expect(inPanelBadges).toHaveCount(0); + .getByTestId("message-row"); + await expect(replies).toHaveCount(6); + await expect(page.getByText("Unread in older branch")).toBeVisible(); + await expect(page.getByText("Unread in newer branch")).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); }); // Regression guard for the Option-1 channel-marker fix: viewing a channel @@ -858,15 +793,9 @@ test.describe("thread unread indicator", () => { // Regression guard for the mention-gate + subtree-count fixes. The viewer is // a pure MENTION RECIPIENT of a nested reply in a thread they never authored, // participated in, or followed: root `mock-general-alice` (Alice-authored) -> - // reply A (Alice) -> reply B (Alice, @-mentions self). This fails pre-fix on - // TWO independent defects: - // 1. The badge gate `isNotifiedForThread` had no mention term, so a - // recipient who never participated/authored/followed gated false and the - // badge never appeared at all. - // 2. `computeThreadBadgeCounts` counted only the root's DIRECT children, so - // the nested mention reply B (under A) was never tallied toward the root. - // After the gate fix the badge appears but undercounts (1, missing B); only - // after the subtree-count fix does it reach 2. Asserting `2` gates both. + // reply A (Alice) -> reply B (Alice, @-mentions self). The root badge must + // count the whole unread subtree, while opening the flat thread panel should + // reveal and mark both replies read without branch expansion. test("14-mention-only-nested-thread-badge", async ({ page }) => { await installMockBridge(page); await page.goto("/"); @@ -911,17 +840,13 @@ test.describe("thread unread indicator", () => { await expect(badge).toBeVisible(); await expect(badge).toContainText("2"); - // v3 contract: opening a thread marks only its REVEALED direct children - // read, never the whole subtree. Opening Alice's thread reveals direct - // child A (read), but nested mention B stays collapsed under A — so the - // root badge drops to 1, not 0. Expanding A reveals B, marks it read, and - // clears the badge. The badge predicate reads the live per-message marker, - // not a subtree-max open ceiling. await aliceSummary.click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - await expect(badge).toContainText("1"); - - await expandReply(page, replyA?.id ?? ""); + await expect(page.getByText("Reply A (depth 1)")).toBeVisible(); + await expect( + page.getByText("Reply B mentioning you (depth 2)"), + ).toBeVisible(); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); await expect(badge).toHaveCount(0); await page.getByTestId("auxiliary-panel-close").click();