diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index d78958877..ccb653bdb 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -155,7 +155,12 @@ test("continued conversation mention routing preserves explicit multi-agent ment ); }); -function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) { +function markerEvent({ + content = {}, + createdAt = 1, + id = "marker", + includeAgent = true, +} = {}) { return { id, pubkey: "starter", @@ -165,15 +170,15 @@ function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) { ["h", "channel"], ["e", "root", "", "root"], ["e", "agent-reply", "", "agent-reply"], - ["p", "agent"], + ...(includeAgent ? [["p", "agent"]] : []), ["title", "Data in Buzz app"], ], content: JSON.stringify({ version: 1, title: "Data in Buzz app", titleStatus: "resolved", - agentName: "Fizz", - agentPubkey: "agent", + agentName: includeAgent ? "Fizz" : "", + agentPubkey: includeAgent ? "agent" : "", threadRootId: "root", threadRootMessageId: "root", parentMessageId: "root", @@ -226,6 +231,16 @@ test("continued conversation marker parses summary metadata", () => { assert.equal(marker?.summaryCreatedAt, 12); }); +test("continued conversation marker can anchor a task without a primary agent", () => { + const marker = parseAgentConversationMarker( + markerEvent({ includeAgent: false }), + ); + + assert.equal(marker?.agentName, "Task"); + assert.equal(marker?.agentPubkey, ""); + assert.equal(marker?.agentReplyId, "agent-reply"); +}); + test("continued conversations persist across app restarts", () => { withMockLocalStorage(() => { const workspaceScope = "wss://relay.example.com"; @@ -265,6 +280,33 @@ test("continued conversations persist across app restarts", () => { }); }); +test("message-anchored tasks persist without a primary agent", () => { + withMockLocalStorage(() => { + const root = message({ + body: "Can someone turn this into a task?", + createdAt: 1, + id: "root", + }); + const conversation = buildAgentConversation({ + agentName: "", + agentPubkey: "", + agentReply: root, + channel: { id: "channel", name: "general" }, + contextMessages: [root], + parentMessage: null, + threadRootMessage: root, + }); + + writePersistedAgentConversations("human", [conversation]); + const persisted = readPersistedAgentConversations("human"); + + assert.equal(persisted.length, 1); + assert.equal(persisted[0].id, conversation.id); + assert.equal(persisted[0].agentPubkey, ""); + assert.equal(persisted[0].agentReply.id, "root"); + }); +}); + test("continued conversation marker summary update replaces earlier marker", () => { const markers = buildAgentConversationMarkers([ markerEvent({ diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts index 0036412ad..744f94d87 100644 --- a/desktop/src/features/agents/agentConversations.ts +++ b/desktop/src/features/agents/agentConversations.ts @@ -41,6 +41,7 @@ export type AgentConversation = { export type OpenAgentConversationInput = { agentName: string; agentPubkey: string; + /** Source message the task was started from. Kept as `agentReply` for link compatibility. */ agentReply: TimelineMessage; channel: Pick; contextMessages?: TimelineMessage[]; @@ -258,8 +259,8 @@ function parseStoredAgentConversation( } const id = maybeString(value.id); - const agentName = maybeString(value.agentName); - const agentPubkey = maybeString(value.agentPubkey); + const agentName = maybeString(value.agentName) ?? "Task"; + const agentPubkey = maybeString(value.agentPubkey) ?? ""; const channelId = maybeString(value.channelId); const channelName = maybeString(value.channelName); const threadRootId = maybeString(value.threadRootId); @@ -289,8 +290,6 @@ function parseStoredAgentConversation( if ( !id || - !agentName || - !agentPubkey || !agentReply || !channelId || !channelName || @@ -469,7 +468,7 @@ export function parseAgentConversationMarker( (typeof content.agentReplyId === "string" ? content.agentReplyId : null); const agentPubkey = getTagValue(event.tags, "p") ?? - (typeof content.agentPubkey === "string" ? content.agentPubkey : null); + (typeof content.agentPubkey === "string" ? content.agentPubkey : ""); const parentMessageId = typeof content.parentMessageId === "string" ? content.parentMessageId @@ -478,7 +477,7 @@ export function parseAgentConversationMarker( typeof content.threadRootMessageId === "string" ? content.threadRootMessageId : null; - const agentName = trimmedString(content.agentName) || agentPubkey || "Agent"; + const agentName = trimmedString(content.agentName) || agentPubkey || "Task"; const title = trimmedString(content.title) ?? getTagValue(event.tags, "title") ?? @@ -496,7 +495,7 @@ export function parseAgentConversationMarker( ? content.startedAt : event.created_at; - if (!channelId || !threadRootId || !agentReplyId || !agentPubkey) { + if (!channelId || !threadRootId || !agentReplyId) { return null; } @@ -624,16 +623,20 @@ export async function publishAgentConversationMarker( } : {}), }); + const tags = [ + ["h", conversation.channelId], + ["e", conversation.threadRootId, "", "root"], + ["e", conversation.agentReply.id, "", "agent-reply"], + ["title", conversation.title], + ]; + if (conversation.agentPubkey) { + tags.splice(3, 0, ["p", conversation.agentPubkey]); + } + const event = await signRelayEvent({ kind: KIND_AGENT_CONVERSATION_COMPAT, content, - tags: [ - ["h", conversation.channelId], - ["e", conversation.threadRootId, "", "root"], - ["e", conversation.agentReply.id, "", "agent-reply"], - ["p", conversation.agentPubkey], - ["title", conversation.title], - ], + tags, }); return relayClient.publishEvent( diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts index 087c458f1..ba84833fa 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts +++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts @@ -370,7 +370,8 @@ export function buildKnownAgentParticipants({ }); } - if (!participants.has(normalizePubkey(conversation.agentPubkey))) { + const primaryAgentKey = normalizePubkey(conversation.agentPubkey); + if (primaryAgentKey && !participants.has(primaryAgentKey)) { add({ canMessage: true, displayName: conversation.agentName, diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index cac2f84f8..43b034eaf 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -275,11 +275,10 @@ export function AgentConversationScreen({ conversationSourceMessages, knownAgentParticipants, ); + const primaryAgentKey = normalizePubkey(conversation.agentPubkey); if ( - !pubkeys.some( - (pubkey) => - normalizePubkey(pubkey) === normalizePubkey(conversation.agentPubkey), - ) + primaryAgentKey && + !pubkeys.some((pubkey) => normalizePubkey(pubkey) === primaryAgentKey) ) { pubkeys.unshift(conversation.agentPubkey); } @@ -517,6 +516,9 @@ export function AgentConversationScreen({ [restrictedAgentNames], ); const composerPlaceholder = React.useMemo(() => { + if (agentParticipants.length === 0) { + return "Message task"; + } if (!canMessageAnyAgent) { return "Reply to conversation"; } @@ -527,9 +529,11 @@ export function AgentConversationScreen({ return "Message conversation"; }, [agentParticipants, canMessageAnyAgent]); const emptyDescription = - agentParticipants.length === 1 - ? "Send a message below to keep working with this agent on the topic." - : "Send a message below to keep working with these agents on the topic."; + agentParticipants.length === 0 + ? "Send a message below to start working on this task." + : agentParticipants.length === 1 + ? "Send a message below to keep working with this agent on the topic." + : "Send a message below to keep working with these agents on the topic."; const [isPublishingThreadSummary, setIsPublishingThreadSummary] = React.useState(false); const lastPublishedThreadRecapRef = React.useRef(null); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 3417701dd..03d8be6f5 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -51,6 +51,7 @@ import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelec import { Button } from "@/shared/ui/button"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { isBroadcastReply } from "@/features/messages/lib/threading"; +import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; @@ -59,6 +60,7 @@ import { useAppShell } from "@/app/AppShellContext"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentConversationMarkers, @@ -280,6 +282,63 @@ export const ChannelPane = React.memo(function ChannelPane({ return pubkeys; }, [activityAgents, agentPubkeys, agentSessionAgents]); + const knownAgentByPubkey = React.useMemo(() => { + const agents = new Map(); + const addAgent = (pubkey: string, name?: string | null) => { + const key = normalizePubkey(pubkey); + if (!key) { + return; + } + + const profileName = profiles?.[key]?.displayName?.trim(); + const fallbackName = name?.trim() || profileName || pubkey; + const current = agents.get(key); + agents.set(key, { + name: + current?.name && current.name !== current.pubkey + ? current.name + : fallbackName, + pubkey: current?.pubkey ?? pubkey, + }); + }; + + for (const agent of agentSessionAgents) { + addAgent(agent.pubkey, agent.name); + } + for (const agent of activityAgents) { + addAgent(agent.pubkey, agent.name); + } + for (const pubkey of agentPubkeys ?? []) { + addAgent(pubkey); + } + + return agents; + }, [activityAgents, agentPubkeys, agentSessionAgents, profiles]); + const resolveTaskAgentForMessage = React.useCallback( + (message: TimelineMessage) => { + if (message.pubkey) { + const directAgent = knownAgentByPubkey.get( + normalizePubkey(message.pubkey), + ); + if (directAgent) { + return { + name: message.author?.trim() || directAgent.name, + pubkey: directAgent.pubkey, + }; + } + } + + for (const pubkey of collectMessageMentionPubkeys([message])) { + const mentionedAgent = knownAgentByPubkey.get(normalizePubkey(pubkey)); + if (mentionedAgent) { + return mentionedAgent; + } + } + + return null; + }, + [knownAgentByPubkey], + ); const completeWelcomeComposerBanner = React.useCallback(() => { if (!activeChannelId || !isActiveWelcomeChannel) { return; @@ -341,7 +400,7 @@ export const ChannelPane = React.memo(function ChannelPane({ if ( !enableAgentConversations || !activeChannel || - !message.pubkey || + message.pending || !canOpenAgentConversationInChannel({ channel: activeChannel, publishMarker: options?.publishMarker, @@ -350,6 +409,7 @@ export const ChannelPane = React.memo(function ChannelPane({ return; } + const taskAgent = resolveTaskAgentForMessage(message); const rootId = message.rootId ?? message.parentId ?? message.id; const contextMessages = messages.filter( (candidate) => @@ -360,8 +420,8 @@ export const ChannelPane = React.memo(function ChannelPane({ ); openAgentConversation( { - agentName: message.author, - agentPubkey: message.pubkey, + agentName: taskAgent?.name ?? "", + agentPubkey: taskAgent?.pubkey ?? "", agentReply: message, channel: activeChannel, contextMessages, @@ -377,7 +437,13 @@ export const ChannelPane = React.memo(function ChannelPane({ options, ); }, - [activeChannel, enableAgentConversations, messages, openAgentConversation], + [ + activeChannel, + enableAgentConversations, + messages, + openAgentConversation, + resolveTaskAgentForMessage, + ], ); const handleGoToTaskMessage = React.useCallback( ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 7851d4ac8..cfb2f77c4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -731,7 +731,9 @@ export function ChannelScreen({ ); useAgentConversationRouteTarget({ activeChannel, - activeChannelId, + agentConversationMarkers, + agentPubkeys, + enabled: isChannelTasksEnabled, goChannel, messageProfilesReady, openAgentConversation, diff --git a/desktop/src/features/channels/ui/ChannelTasksView.tsx b/desktop/src/features/channels/ui/ChannelTasksView.tsx index b5d61a1d9..8cb26334e 100644 --- a/desktop/src/features/channels/ui/ChannelTasksView.tsx +++ b/desktop/src/features/channels/ui/ChannelTasksView.tsx @@ -281,8 +281,8 @@ export function ChannelTasksView({ No tasks yet

- New tasks will appear here when an agent conversation is - opened from this channel. + New tasks will appear here when one is started from a message + in this channel.

{olderTasksLoader} diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts index 5637d3968..05d0409e7 100644 --- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts +++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts @@ -1,44 +1,58 @@ import * as React from "react"; -import type { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import type { OpenAgentConversationInput } from "@/features/agents/agentConversations"; +import type { + AgentConversationMarker, + OpenAgentConversationInput, +} from "@/features/agents/agentConversations"; import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; -type GoChannel = ReturnType["goChannel"]; -type OpenAgentConversation = ( - input: OpenAgentConversationInput, - options?: { publishMarker?: boolean }, -) => void; +type GoChannel = ( + channelId: string, + options?: { + messageId?: string; + replace?: boolean; + taskReplyId?: string; + threadRootId?: string | null; + }, +) => Promise; -type UseAgentConversationRouteTargetOptions = { +type UseAgentConversationRouteTargetInput = { activeChannel: Channel | null; - activeChannelId: string | null; + agentConversationMarkers: readonly AgentConversationMarker[]; + agentPubkeys: ReadonlySet; + enabled: boolean; goChannel: GoChannel; messageProfilesReady: boolean; - openAgentConversation: OpenAgentConversation; + openAgentConversation: ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, + ) => void; targetAgentConversationReplyId: string | null; timelineMessages: readonly TimelineMessage[]; }; export function useAgentConversationRouteTarget({ activeChannel, - activeChannelId, + agentConversationMarkers, + agentPubkeys, + enabled, goChannel, messageProfilesReady, openAgentConversation, targetAgentConversationReplyId, timelineMessages, -}: UseAgentConversationRouteTargetOptions) { +}: UseAgentConversationRouteTargetInput) { const handledRouteTargetRef = React.useRef(null); React.useEffect(() => { - if (!targetAgentConversationReplyId) { + if (!enabled || !targetAgentConversationReplyId) { handledRouteTargetRef.current = null; return; } - const targetKey = `${activeChannelId ?? "none"}:${targetAgentConversationReplyId}`; + const targetKey = `${activeChannel?.id ?? "none"}:${targetAgentConversationReplyId}`; if (handledRouteTargetRef.current === targetKey) { return; } @@ -49,26 +63,40 @@ export function useAgentConversationRouteTarget({ return; } - const agentReply = + const marker = + agentConversationMarkers.find( + (candidate) => + candidate.channelId === activeChannel.id && + candidate.agentReplyId === targetAgentConversationReplyId, + ) ?? null; + const sourceMessage = timelineMessages.find( (message) => message.id === targetAgentConversationReplyId, ) ?? null; - const agentReplyPubkey = agentReply?.pubkey; - if (!agentReply || !agentReplyPubkey) { + if (!sourceMessage) { return; } - const rootId = agentReply.rootId ?? agentReply.parentId ?? agentReply.id; + const sourceAuthorIsAgent = sourceMessage.pubkey + ? agentPubkeys.has(normalizePubkey(sourceMessage.pubkey)) + : false; + const taskAgentPubkey = + marker?.agentPubkey || + (sourceAuthorIsAgent ? (sourceMessage.pubkey ?? "") : ""); + const taskAgentName = + marker?.agentName || (taskAgentPubkey ? sourceMessage.author : ""); + const rootId = + sourceMessage.rootId ?? sourceMessage.parentId ?? sourceMessage.id; const contextMessages = timelineMessages.filter( (candidate) => candidate.id === rootId || - candidate.id === agentReply.id || + candidate.id === sourceMessage.id || candidate.rootId === rootId || candidate.parentId === rootId, ); - const parentMessage = agentReply.parentId + const parentMessage = sourceMessage.parentId ? (timelineMessages.find( - (candidate) => candidate.id === agentReply.parentId, + (candidate) => candidate.id === sourceMessage.parentId, ) ?? null) : null; const threadRootMessage = @@ -78,9 +106,9 @@ export function useAgentConversationRouteTarget({ void goChannel(activeChannel.id, { replace: true }).then(() => { openAgentConversation( { - agentName: agentReply.author, - agentPubkey: agentReplyPubkey, - agentReply, + agentName: taskAgentName, + agentPubkey: taskAgentPubkey, + agentReply: sourceMessage, channel: activeChannel, contextMessages, parentMessage, @@ -91,7 +119,9 @@ export function useAgentConversationRouteTarget({ }); }, [ activeChannel, - activeChannelId, + agentConversationMarkers, + agentPubkeys, + enabled, goChannel, messageProfilesReady, openAgentConversation, diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 7ba31d80f..af3c2c2e3 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -272,10 +272,6 @@ 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), @@ -475,7 +471,7 @@ export const MessageRow = React.memo( isUnread={isUnread} message={message} onContinueConversation={ - isAgentMessage ? onOpenAgentConversation : undefined + message.pending ? undefined : onOpenAgentConversation } onDelete={onDelete} onEdit={onEdit}