diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index f8103e4e03..8443ce477a 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -564,3 +564,96 @@ test("dedicated conversation view stops at the next task anchor", () => { assert.deepEqual(visibleIds, ["root", "agent-reply", "first-task-reply"]); }); + +test("dedicated conversation view keeps older task descendant replies", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const firstAnchor = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const firstTaskReply = message({ + body: "This belongs in the first task.", + createdAt: 3, + id: "first-task-reply", + }); + const secondAnchor = message({ + body: "Let's split this into another task.", + createdAt: 4, + id: "second-anchor", + pubkey: "agent", + }); + const secondTaskReply = message({ + body: "This belongs in the second task.", + createdAt: 5, + id: "second-task-reply", + }); + const firstTaskFollowup = { + ...message({ + body: "Continuing the first task after task two started.", + createdAt: 6, + id: "first-task-followup", + }), + parentId: "first-task-reply", + rootId: "root", + }; + const plainRootReply = message({ + body: "A plain root reply after task two should not join task one.", + createdAt: 7, + id: "plain-root-reply", + }); + const messages = [ + root, + firstAnchor, + firstTaskReply, + secondAnchor, + secondTaskReply, + firstTaskFollowup, + plainRootReply, + ]; + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply: firstAnchor, + channel: { id: "channel", name: "general" }, + contextMessages: messages, + parentMessage: root, + threadRootMessage: root, + }); + const firstMarker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 2 }, createdAt: 2 }), + ); + const secondMarker = parseAgentConversationMarker({ + ...markerEvent({ + content: { agentReplyId: "second-anchor", startedAt: 4 }, + createdAt: 4, + id: "second-marker", + }), + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "second-anchor", "", "agent-reply"], + ["p", "agent"], + ["title", "Second task"], + ], + }); + const markers = [firstMarker, secondMarker].filter(Boolean); + + const visibleIds = messages + .filter((entry) => + isConversationMessage(entry, conversation, markers, messages), + ) + .map((entry) => entry.id); + + assert.deepEqual(visibleIds, [ + "root", + "agent-reply", + "first-task-reply", + "first-task-followup", + ]); +}); diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts index 9c933bc602..42d2eee85d 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts +++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts @@ -93,6 +93,16 @@ export function isConversationMessage( return false; } + const markerAnchorIds = new Set( + markers + .filter( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId !== conversation.agentReply.id, + ) + .map((marker) => marker.agentReplyId), + ); const orderedThreadMessages = messages.length > 0 ? messages.filter( @@ -133,7 +143,44 @@ export function isConversationMessage( } } - return messageIndex < nextAnchorIndex; + if (messageIndex < nextAnchorIndex) { + return true; + } + + const selectedTaskMessageIds = new Set(); + for (const candidate of orderedThreadMessages) { + const candidateIndex = messageIndexById.get(candidate.id); + if ( + candidateIndex !== undefined && + candidateIndex >= anchorIndex && + candidateIndex < nextAnchorIndex + ) { + selectedTaskMessageIds.add(candidate.id); + } + } + selectedTaskMessageIds.delete(conversation.threadRootId); + + const messageById = new Map( + orderedThreadMessages.map((candidate) => [candidate.id, candidate]), + ); + let parentId = message.parentId; + const visited = new Set([message.id]); + while (parentId && !visited.has(parentId)) { + if (selectedTaskMessageIds.has(parentId)) { + return true; + } + if ( + parentId === conversation.threadRootId || + markerAnchorIds.has(parentId) + ) { + return false; + } + + visited.add(parentId); + parentId = messageById.get(parentId)?.parentId ?? null; + } + + return false; } const currentMarker = diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index 1f77560a5b..9d4294a557 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -135,7 +135,6 @@ export function AgentConversationScreen({ const { getMessageReadAt, isThreadMuted, - markThreadRead, markMessageRead, updateAgentConversationTitle, } = useAppShell(); @@ -420,11 +419,6 @@ export function AgentConversationScreen({ }); }, [conversation, conversationSourceMessages, updateAgentConversationTitle]); React.useEffect(() => { - const latestMessage = timelineMessages[timelineMessages.length - 1] ?? null; - if (latestMessage) { - markThreadRead(conversation.threadRootId, latestMessage.createdAt); - } - if (isThreadMuted(conversation.threadRootId)) { return; } @@ -440,9 +434,19 @@ export function AgentConversationScreen({ getMessageReadAt, isThreadMuted, markMessageRead, - markThreadRead, timelineMessages, ]); + const replyParentEventId = React.useMemo(() => { + const latestTaskMessage = [...timelineMessages] + .reverse() + .find((message) => message.id !== conversation.threadRootId); + + return ( + latestTaskMessage?.id ?? + conversation.agentReply.id ?? + conversation.threadRootId + ); + }, [conversation.agentReply.id, conversation.threadRootId, timelineMessages]); const routeableAgentPubkeys = React.useMemo( () => agentParticipants @@ -539,10 +543,10 @@ export function AgentConversationScreen({ autoRouteAgentPubkeys: autoRoutedAgentPubkeys, mentionPubkeys, }), - parentEventId: conversation.threadRootId, + parentEventId: replyParentEventId, }); }, - [autoRoutedAgentPubkeys, conversation.threadRootId, sendMessageMutation], + [autoRoutedAgentPubkeys, replyParentEventId, sendMessageMutation], ); const isComposerDisabled = @@ -734,6 +738,7 @@ export function AgentConversationScreen({ emptyTitle="No conversation messages yet" hasComposerOverlay isLoading={messagesQuery.isLoading && timelineMessages.length === 0} + layoutShiftKey={conversation.id} messageListPlacement="top" messages={timelineMessages} profiles={profiles}