From 81887699c8f1aa442e8e28e9c885cc37d9d13e5d Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 09:33:19 +0100
Subject: [PATCH] Allow tasks from any message
---
.../agents/agentConversations.test.mjs | 50 +++++++++++-
.../src/features/agents/agentConversations.ts | 31 +++----
.../ui/AgentConversationScreen.helpers.ts | 3 +-
.../agents/ui/AgentConversationScreen.tsx | 18 +++--
.../src/features/channels/ui/ChannelPane.tsx | 74 ++++++++++++++++-
.../features/channels/ui/ChannelScreen.tsx | 4 +-
.../features/channels/ui/ChannelTasksView.tsx | 4 +-
.../ui/useAgentConversationRouteTarget.ts | 80 +++++++++++++------
.../src/features/messages/ui/MessageRow.tsx | 6 +-
9 files changed, 207 insertions(+), 63 deletions(-)
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}