From a0c29efba031becb4dad7f26b458a40f3dff91f7 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 15:05:06 +0100
Subject: [PATCH 01/23] Add task link cards (#1325)
---
desktop/src/features/agents/agentConversations.test.mjs | 1 -
desktop/src/features/agents/agentConversations.ts | 5 -----
2 files changed, 6 deletions(-)
diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs
index 3facb2fcf..d78958877 100644
--- a/desktop/src/features/agents/agentConversations.test.mjs
+++ b/desktop/src/features/agents/agentConversations.test.mjs
@@ -7,7 +7,6 @@ import {
buildAgentConversationRecap,
buildAgentConversationMarkers,
deriveAgentConversationTitle,
- getAutoRoutedAgentConversationPubkeys,
getHiddenAgentConversationMessageIds,
parseAgentConversationMarker,
readPersistedAgentConversations,
diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts
index 7a10a31cf..0036412ad 100644
--- a/desktop/src/features/agents/agentConversations.ts
+++ b/desktop/src/features/agents/agentConversations.ts
@@ -82,11 +82,6 @@ export type AgentConversationRecapInput = {
messages: readonly TimelineMessage[];
};
-export type AgentConversationRouteableParticipant = {
- canMessage: boolean;
- pubkey: string;
-};
-
function normalizeAgentConversationStorageScope(
workspaceScope: string | null | undefined,
): string {
From b72824e4a8186a4600e04bc0c25800c1e33387b3 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Fri, 26 Jun 2026 19:13:25 +0100
Subject: [PATCH 02/23] Remove automatic agent reply routing
---
.../channels/ui/ChannelPane.helpers.test.mjs | 119 ------------------
.../channels/ui/ChannelPane.helpers.ts | 111 ----------------
.../src/features/channels/ui/ChannelPane.tsx | 23 +---
.../features/channels/ui/ChannelScreen.tsx | 32 +----
4 files changed, 2 insertions(+), 283 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
index 0f91d53a2..879c7351b 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
@@ -3,8 +3,6 @@ import test from "node:test";
import {
canOpenAgentConversationInChannel,
- getDmAutoRouteAgentPubkeys,
- getThreadAutoRouteAgentPubkeys,
mergeAutoRouteMentionPubkeys,
} from "./ChannelPane.helpers.ts";
@@ -50,7 +48,6 @@ test("new agent conversations require a writable channel", () => {
false,
);
});
-
test("existing agent conversation markers can open in read-only channels", () => {
assert.equal(
canOpenAgentConversationInChannel({
@@ -68,57 +65,6 @@ test("existing agent conversation markers can open in read-only channels", () =>
);
});
-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("auto-routed mentions merge with explicit mentions without duplicates", () => {
assert.deepEqual(
mergeAutoRouteMentionPubkeys({
@@ -128,68 +74,3 @@ test("auto-routed mentions merge with explicit mentions without duplicates", ()
["AGENT-ONE", "agent-two"],
);
});
-
-test("thread composer auto-routes exactly one current human and one known agent", () => {
- const knownAgentPubkeys = new Set(["agent-one", "agent-two"]);
-
- assert.deepEqual(
- getThreadAutoRouteAgentPubkeys({
- currentPubkey: "human",
- knownAgentPubkeys,
- messages: [
- { id: "root", pubkey: "human", tags: [["p", "agent-one"]] },
- { id: "reply", pubkey: "agent-one", tags: [] },
- ],
- }),
- ["agent-one"],
- );
-
- assert.deepEqual(
- getThreadAutoRouteAgentPubkeys({
- currentPubkey: "human",
- knownAgentPubkeys,
- messages: [
- { id: "root", pubkey: "human", tags: [["p", "agent-one"]] },
- { id: "reply", pubkey: "other-human", tags: [] },
- ],
- }),
- [],
- );
-
- assert.deepEqual(
- getThreadAutoRouteAgentPubkeys({
- currentPubkey: "human-one",
- knownAgentPubkeys,
- messages: [
- {
- id: "root",
- pubkey: "human-one",
- tags: [
- ["p", "human-two"],
- ["p", "agent-one"],
- ],
- },
- { id: "reply", pubkey: "agent-one", tags: [] },
- ],
- }),
- [],
- );
-
- assert.deepEqual(
- getThreadAutoRouteAgentPubkeys({
- currentPubkey: "human",
- knownAgentPubkeys,
- messages: [
- {
- id: "root",
- pubkey: "human",
- tags: [
- ["p", "agent-one"],
- ["p", "agent-two"],
- ],
- },
- ],
- }),
- [],
- );
-});
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
index d3669df32..125674632 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
@@ -1,5 +1,4 @@
import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel";
-import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages";
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
@@ -72,116 +71,6 @@ export function mentionsKnownAgent(
);
}
-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({
- currentPubkey,
- knownAgentPubkeys,
- messages,
-}: {
- currentPubkey?: string;
- knownAgentPubkeys: ReadonlySet;
- messages: readonly TimelineMessage[];
-}) {
- const agentPubkeys = new Map();
- const humanPubkeys = new Set();
- const normalizedCurrentPubkey = currentPubkey
- ? normalizePubkey(currentPubkey)
- : null;
-
- const addAuthor = (pubkey?: string | null) => {
- 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) {
- addAuthor(message.pubkey);
- }
-
- for (const pubkey of collectMessageMentionPubkeys([...messages])) {
- const normalized = normalizePubkey(pubkey);
- if (!normalized) {
- continue;
- }
-
- if (knownAgentPubkeys.has(normalized)) {
- agentPubkeys.set(normalized, pubkey);
- continue;
- }
-
- humanPubkeys.add(normalized);
- }
-
- if (agentPubkeys.size !== 1 || humanPubkeys.size !== 1) {
- return [];
- }
- if (normalizedCurrentPubkey && !humanPubkeys.has(normalizedCurrentPubkey)) {
- return [];
- }
-
- return [...agentPubkeys.values()];
-}
-
export function mergeAutoRouteMentionPubkeys({
autoRouteAgentPubkeys,
mentionPubkeys,
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index afbf7cb58..c928b0755 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -43,9 +43,7 @@ import {
canOpenAgentConversationInChannel,
getChannelIntroDescription,
getChannelIntroKind,
- getThreadAutoRouteAgentPubkeys,
isWelcomeSetupSystemMessage,
- mergeAutoRouteMentionPubkeys,
mentionsKnownAgent,
} from "@/features/channels/ui/ChannelPane.helpers";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
@@ -574,25 +572,6 @@ export const ChannelPane = React.memo(function ChannelPane({
...threadMessages.map((entry) => entry.message),
];
}, [threadHeadMessage, threadMessages]);
- const threadAutoRouteAgentPubkeys = React.useMemo(
- () =>
- getThreadAutoRouteAgentPubkeys({
- currentPubkey,
- knownAgentPubkeys,
- messages: threadSourceMessages,
- }),
- [currentPubkey, knownAgentPubkeys, threadSourceMessages],
- );
- const handleSendThreadReply = React.useCallback(
- (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => {
- const sendMentionPubkeys = mergeAutoRouteMentionPubkeys({
- autoRouteAgentPubkeys: threadAutoRouteAgentPubkeys,
- mentionPubkeys,
- });
- return onSendThreadReply(content, sendMentionPubkeys, mediaTags);
- },
- [onSendThreadReply, threadAutoRouteAgentPubkeys],
- );
const hiddenAgentConversationMessageIds = React.useMemo(() => {
const hiddenIds = getHiddenAgentConversationMessageIds(
baseVisibleMessages,
@@ -1008,7 +987,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onExpandReplies={onExpandThreadReplies}
onOpenAgentConversation={handleOpenAgentConversation}
onSelectReplyTarget={onSelectThreadReplyTarget}
- onSend={handleSendThreadReply}
+ onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 07bd395df..90562e6ac 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -12,10 +12,6 @@ import {
MSG_PREFIX,
THREAD_PREFIX,
} from "@/features/channels/readState/readStateFormat";
-import {
- getDmAutoRouteAgentPubkeys,
- mergeAutoRouteMentionPubkeys,
-} from "@/features/channels/ui/ChannelPane.helpers";
import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState";
import {
ChannelScreenHeader,
@@ -435,15 +431,6 @@ export function ChannelScreen({
}
return pubkeys;
}, [agentPubkeys, messageProfiles]);
- const dmAutoRouteAgentPubkeys = React.useMemo(
- () =>
- getDmAutoRouteAgentPubkeys({
- channel: activeChannel,
- currentPubkey,
- knownAgentPubkeys: routingAgentPubkeys,
- }),
- [activeChannel, currentPubkey, routingAgentPubkeys],
- );
const personasQuery = usePersonasQuery();
const { personaLookup, respondToLookup } = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];
@@ -564,23 +551,6 @@ export function ChannelScreen({
threadReplyTargetId,
toggleReactionMutation,
});
- const handleSendMessageWithDmAutoRoute = React.useCallback(
- async (
- content: string,
- mentionPubkeys: string[],
- mediaTags?: string[][],
- ) => {
- await handleSendMessage(
- content,
- mergeAutoRouteMentionPubkeys({
- autoRouteAgentPubkeys: dmAutoRouteAgentPubkeys,
- mentionPubkeys,
- }),
- mediaTags,
- );
- },
- [dmAutoRouteAgentPubkeys, handleSendMessage],
- );
const effectiveToggleReaction = React.useMemo(
() =>
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
@@ -1063,7 +1033,7 @@ export function ChannelScreen({
onCloseProfilePanel={handleCloseProfilePanel}
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
- onSendMessage={handleSendMessageWithDmAutoRoute}
+ onSendMessage={handleSendMessage}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetChange={setThreadScrollTargetId}
From 0c22bf4899fee99e4a471c4ad239f8a0b6a58c1c Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 13:30:25 +0100
Subject: [PATCH 03/23] Preserve task link context routing
---
desktop/src/features/channels/ui/ChannelPane.helpers.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
index 125674632..89e472a51 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
@@ -1,4 +1,5 @@
import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel";
+import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages";
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
From 38dd806bc33c456ce4d2b473a45e636eaeb4b085 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sun, 28 Jun 2026 09:10:13 +0100
Subject: [PATCH 04/23] Restore task link markdown handler
---
desktop/src/shared/ui/markdown.tsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 2235d14d1..ecbc9ce88 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -2102,6 +2102,14 @@ function MarkdownInner({
},
[goChannel],
);
+ const onOpenAgentConversationLink = React.useCallback(
+ (link: ParsedAgentConversationLink) => {
+ void goChannel(link.channelId, {
+ taskReplyId: link.agentReplyId,
+ });
+ },
+ [goChannel],
+ );
const onOpenMessageLink = React.useCallback(
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
From 7b5c0e4884ee9b1fc9d35e9fd00c1188f0fc3b61 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 08:30:25 +0100
Subject: [PATCH 05/23] Gate channel tasks experiment
---
desktop/scripts/check-file-sizes.mjs | 2 +-
desktop/src/app/AppShell.tsx | 17 +++-
desktop/src/app/routes/ChannelRouteScreen.tsx | 17 ++--
.../agents/ui/AgentConversationScreen.tsx | 1 +
.../src/features/channels/ui/ChannelPane.tsx | 38 +++++--
.../features/channels/ui/ChannelPane.types.ts | 1 +
.../features/channels/ui/ChannelScreen.tsx | 26 ++++-
.../ui/filterAgentConversationMessages.ts | 8 +-
.../lib/agentConversationLinkNode.tsx | 20 ++++
.../messages/lib/composerPasteHandler.ts | 6 +-
.../messages/lib/useRichTextEditor.ts | 6 +-
.../features/messages/ui/MessageComposer.tsx | 11 ++-
.../messages/ui/MessageThreadPanel.tsx | 3 +
.../messages/ui/TimelineMessageList.tsx | 2 -
desktop/src/shared/features/featureIds.ts | 1 +
desktop/src/shared/features/index.ts | 1 +
desktop/src/shared/ui/markdown.test.mjs | 22 +++--
desktop/src/shared/ui/markdown.tsx | 99 +++++++++++--------
desktop/src/shared/ui/markdown/utils.ts | 12 ++-
desktop/src/shared/useMessageDeepLinks.ts | 21 ++--
preview-features.json | 8 ++
21 files changed, 235 insertions(+), 87 deletions(-)
create mode 100644 desktop/src/shared/features/featureIds.ts
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 8e6dd3e50..387ca3ac8 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -154,7 +154,7 @@ const overrides = new Map([
["src/features/messages/ui/MessageComposer.tsx", 1010],
// continued-agent-conversations: channel sidebar children and active
// conversation unread suppression. Queued to split with sidebar sections.
- ["src/features/sidebar/ui/AppSidebar.tsx", 1081],
+ ["src/features/sidebar/ui/AppSidebar.tsx", 1087],
// PersistBackend enum + marker-on-keyring-success plumbing and its three
// fail-closed regression tests (silent identity rotation on keyring outage).
// A small overage from load-bearing security plumbing on a file already at
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 0dcff4095..35344b520 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -69,6 +69,7 @@ import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
+import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { joinChannel } from "@/shared/api/tauri";
import type { SearchHit } from "@/shared/api/types";
@@ -89,6 +90,7 @@ const LazySettingsScreen = React.lazy(async () => {
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
+ const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
const workspacesHook = useWorkspaces();
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
@@ -212,6 +214,7 @@ export function AppShell() {
} = useAgentConversationShellState({
channels,
currentPubkey,
+ enabled: isChannelTasksEnabled,
goAgents,
goChannel,
selectedView,
@@ -715,7 +718,11 @@ export function AppShell() {
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
- onHideAgentConversation={handleHideAgentConversation}
+ onHideAgentConversation={
+ isChannelTasksEnabled
+ ? handleHideAgentConversation
+ : undefined
+ }
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
@@ -790,7 +797,9 @@ export function AppShell() {
await goChannel(directMessage.id);
}}
onSelectAgentConversation={
- handleSelectAgentConversation
+ isChannelTasksEnabled
+ ? handleSelectAgentConversation
+ : undefined
}
onSelectAgents={() => {
clearSelectedAgentConversation();
@@ -839,7 +848,9 @@ export function AppShell() {
}
selectedChannelId={selectedChannelId}
selectedAgentConversationId={
- selectedAgentConversationId
+ isChannelTasksEnabled
+ ? selectedAgentConversationId
+ : null
}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx
index 4141c6984..2855e44af 100644
--- a/desktop/src/app/routes/ChannelRouteScreen.tsx
+++ b/desktop/src/app/routes/ChannelRouteScreen.tsx
@@ -20,6 +20,7 @@ import {
CHANNEL_TIMELINE_CONTENT_KINDS,
CHANNEL_TIMELINE_STATE_KINDS,
} from "@/shared/constants/kinds";
+import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
@@ -150,6 +151,7 @@ export function ChannelRouteScreen({
targetThreadRootId,
}: ChannelRouteScreenProps) {
const queryClient = useQueryClient();
+ const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
@@ -163,6 +165,9 @@ export function ChannelRouteScreen({
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
});
+ const effectiveAgentConversationReplyId = isChannelTasksEnabled
+ ? targetAgentConversationReplyId
+ : null;
// Reset spliced target events when the channel context changes (channel
// switch or entering/leaving a forum post). Tied to channel identity rather
@@ -191,7 +196,7 @@ export function ChannelRouteScreen({
// param-clear blanks the timeline. Resetting on channel / forum-post change
// is handled by the effect below; here we only fetch when there's a target.
if (
- (!targetAgentConversationReplyId &&
+ (!effectiveAgentConversationReplyId &&
!targetMessageId &&
!targetThreadRootId) ||
selectedPostId
@@ -215,7 +220,7 @@ export function ChannelRouteScreen({
}
const eventIds = [
- targetAgentConversationReplyId,
+ effectiveAgentConversationReplyId,
targetMessageId,
targetThreadRootId && targetThreadRootId !== targetMessageId
? targetThreadRootId
@@ -225,8 +230,8 @@ export function ChannelRouteScreen({
void fetchRouteTargetEvents(
channelId,
eventIds,
- targetAgentConversationReplyId ?? targetMessageId,
- targetAgentConversationReplyId,
+ effectiveAgentConversationReplyId ?? targetMessageId,
+ effectiveAgentConversationReplyId,
targetThreadRootId,
).then((events) => {
if (!isCancelled) {
@@ -251,7 +256,7 @@ export function ChannelRouteScreen({
selectedPostId,
channelId,
queryClient,
- targetAgentConversationReplyId,
+ effectiveAgentConversationReplyId,
targetMessageId,
targetThreadRootId,
]);
@@ -277,7 +282,7 @@ export function ChannelRouteScreen({
void goForumPost(channelId, postId);
}}
selectedForumPostId={selectedPostId}
- targetAgentConversationReplyId={targetAgentConversationReplyId}
+ targetAgentConversationReplyId={effectiveAgentConversationReplyId}
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx
index 78d797f64..cac2f84f8 100644
--- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx
+++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx
@@ -863,6 +863,7 @@ export function AgentConversationScreen({
containerClassName="px-5"
disabled={isComposerDisabled}
draftKey={`agent-conversation:${conversation.id}`}
+ enableAgentConversationLinks
isSending={sendMessageMutation.isPending}
mediaController={media}
onSend={handleSend}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index c928b0755..3417701dd 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -71,6 +71,7 @@ export const ChannelPane = React.memo(function ChannelPane({
channelManagementOpen = false,
currentPubkey,
editTarget = null,
+ enableAgentConversations = true,
fetchOlder,
header,
hasOlderMessages,
@@ -164,7 +165,7 @@ export const ChannelPane = React.memo(function ChannelPane({
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt;
- const isTasksSurface = surfaceTab === "tasks";
+ const isTasksSurface = enableAgentConversations && surfaceTab === "tasks";
const hasMainComposerOverlay = !isNonMemberView && !isTasksSurface;
const activeChannelId = activeChannel?.id ?? null;
const huddleMemberPubkeys = React.useMemo(
@@ -172,6 +173,9 @@ export const ChannelPane = React.memo(function ChannelPane({
[activeChannel, agentPubkeys, currentPubkey],
);
const huddleMemberPubkeysPending = agentPubkeysPending;
+ const activeAgentConversationMarkers = enableAgentConversations
+ ? agentConversationMarkers
+ : undefined;
const isActiveWelcomeChannel =
activeChannel !== null && isWelcomeChannel(activeChannel);
React.useEffect(() => {
@@ -335,6 +339,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const handleOpenAgentConversation = React.useCallback(
(message: TimelineMessage, options?: { publishMarker?: boolean }) => {
if (
+ !enableAgentConversations ||
!activeChannel ||
!message.pubkey ||
!canOpenAgentConversationInChannel({
@@ -372,7 +377,7 @@ export const ChannelPane = React.memo(function ChannelPane({
options,
);
},
- [activeChannel, messages, openAgentConversation],
+ [activeChannel, enableAgentConversations, messages, openAgentConversation],
);
const handleGoToTaskMessage = React.useCallback(
(
@@ -461,7 +466,8 @@ export const ChannelPane = React.memo(function ChannelPane({
const threadActivityAgents = React.useMemo(() => {
if (
threadComposerBotTypingPubkeys.length === 0 ||
- (openThreadHeadId &&
+ (enableAgentConversations &&
+ openThreadHeadId &&
agentConversationMarkers?.some(
(marker) => marker.threadRootId === openThreadHeadId,
))
@@ -478,6 +484,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}, [
activityAgents,
agentConversationMarkers,
+ enableAgentConversations,
openThreadHeadId,
threadComposerBotTypingPubkeys,
]);
@@ -573,6 +580,10 @@ export const ChannelPane = React.memo(function ChannelPane({
];
}, [threadHeadMessage, threadMessages]);
const hiddenAgentConversationMessageIds = React.useMemo(() => {
+ if (!enableAgentConversations) {
+ return new Set();
+ }
+
const hiddenIds = getHiddenAgentConversationMessageIds(
baseVisibleMessages,
agentConversationMarkers,
@@ -598,6 +609,7 @@ export const ChannelPane = React.memo(function ChannelPane({
agentConversationMarkers,
baseVisibleMessages,
channelFind.activeMatch?.messageId,
+ enableAgentConversations,
targetMessageId,
threadScrollTargetId,
threadSourceMessages,
@@ -760,7 +772,7 @@ export const ChannelPane = React.memo(function ChannelPane({
{isTasksSurface ? (
{
const panel = (
Promise;
header?: React.ReactNode;
hasOlderMessages?: boolean;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 90562e6ac..7851d4ac8 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -57,6 +57,7 @@ import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
+import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
@@ -92,6 +93,7 @@ export function ChannelScreen({
targetMessageEvents,
targetMessageId,
}: ChannelScreenProps) {
+ const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
const { goChannel, goHome } = useAppNavigation();
const [activeSurfaceTab, setActiveSurfaceTab] =
React.useState("messages");
@@ -159,7 +161,8 @@ export function ChannelScreen({
const mainInsetRef = useMainInsetRef();
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
- const canShowTasksSurface = activeChannel?.channelType === "stream";
+ const canShowTasksSurface =
+ isChannelTasksEnabled && activeChannel?.channelType === "stream";
const effectiveSurfaceTab = canShowTasksSurface
? activeSurfaceTab
: "messages";
@@ -479,7 +482,11 @@ export function ChannelScreen({
);
}, []);
const { agentConversationMarkers, unreadTimelineMessages } =
- useAgentConversationTimelineState(resolvedMessages, timelineMessages);
+ useAgentConversationTimelineState(
+ resolvedMessages,
+ timelineMessages,
+ isChannelTasksEnabled,
+ );
const channelFind = useChannelFind({
channelId: activeChannelId,
messages: timelineMessages,
@@ -694,6 +701,10 @@ export function ChannelScreen({
}, [activeChannelId, resetComposerTargets]);
const handleSurfaceTabChange = React.useCallback(
(tab: ChannelSurfaceTab) => {
+ if (tab === "tasks" && !isChannelTasksEnabled) {
+ return;
+ }
+
setActiveSurfaceTab(tab);
if (tab !== "tasks") {
@@ -712,6 +723,7 @@ export function ChannelScreen({
[
clearOptimisticThreadOverride,
handleCloseAgentSession,
+ isChannelTasksEnabled,
setChannelManagementOpen,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -723,7 +735,9 @@ export function ChannelScreen({
goChannel,
messageProfilesReady,
openAgentConversation,
- targetAgentConversationReplyId,
+ targetAgentConversationReplyId: isChannelTasksEnabled
+ ? targetAgentConversationReplyId
+ : null,
timelineMessages,
});
const { mainTimelineTargetMessageId, rootThreadHeadTargetId } =
@@ -905,7 +919,9 @@ export function ChannelScreen({
onAddBotOpenChange={setIsAddBotOpen}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={handleManageChannel}
- onSurfaceTabChange={handleSurfaceTabChange}
+ onSurfaceTabChange={
+ isChannelTasksEnabled ? handleSurfaceTabChange : undefined
+ }
onToggleMembers={handleToggleMembers}
showHeaderContent={!isSinglePanelView}
transparentChrome={activeChannel?.channelType !== "forum"}
@@ -924,6 +940,7 @@ export function ChannelScreen({
effectiveSurfaceTab,
handleSurfaceTabChange,
isAddBotOpen,
+ isChannelTasksEnabled,
joinChannelMutation.isPending,
joinChannelMutation.mutateAsync,
handleManageChannel,
@@ -969,6 +986,7 @@ export function ChannelScreen({
channelFind={channelFind}
channelManagementOpen={channelManagementOpen}
currentPubkey={currentPubkey}
+ enableAgentConversations={isChannelTasksEnabled}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
header={channelHeader}
diff --git a/desktop/src/features/channels/ui/filterAgentConversationMessages.ts b/desktop/src/features/channels/ui/filterAgentConversationMessages.ts
index 65153ee2a..a5739feb1 100644
--- a/desktop/src/features/channels/ui/filterAgentConversationMessages.ts
+++ b/desktop/src/features/channels/ui/filterAgentConversationMessages.ts
@@ -34,18 +34,20 @@ export function useUnreadTimelineMessages(
export function useAgentConversationMarkers(
messages: RelayEvent[],
+ enabled = true,
): AgentConversationMarker[] {
return React.useMemo(
- () => buildAgentConversationMarkers(messages),
- [messages],
+ () => (enabled ? buildAgentConversationMarkers(messages) : []),
+ [enabled, messages],
);
}
export function useAgentConversationTimelineState(
events: RelayEvent[],
messages: TimelineMessage[],
+ enabled = true,
) {
- const agentConversationMarkers = useAgentConversationMarkers(events);
+ const agentConversationMarkers = useAgentConversationMarkers(events, enabled);
const unreadTimelineMessages = useUnreadTimelineMessages(
messages,
agentConversationMarkers,
diff --git a/desktop/src/features/messages/lib/agentConversationLinkNode.tsx b/desktop/src/features/messages/lib/agentConversationLinkNode.tsx
index 3a261252e..539cc398a 100644
--- a/desktop/src/features/messages/lib/agentConversationLinkNode.tsx
+++ b/desktop/src/features/messages/lib/agentConversationLinkNode.tsx
@@ -14,6 +14,7 @@ import {
import { AGENT_CONVERSATION_LINK_NODE_NAME } from "./agentConversationLinkNodeName";
export type AgentConversationLinkNodeOptions = {
+ enabled?: boolean;
titleForHref?: (href: string) => string | undefined;
};
@@ -48,6 +49,20 @@ function getDisplayTitle(
function ComposerAgentConversationLinkView({ extension, node }: NodeViewProps) {
const href = String(node.attrs.href ?? "");
+ if (
+ (extension.options as AgentConversationLinkNodeOptions).enabled === false
+ ) {
+ return (
+
+ {href}
+
+ );
+ }
+
const title = getDisplayTitle(
href,
String(node.attrs.title ?? ""),
@@ -146,6 +161,7 @@ export const AgentConversationLinkNode =
addOptions() {
return {
+ enabled: true,
titleForHref: undefined,
};
},
@@ -230,6 +246,10 @@ export const AgentConversationLinkNode =
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
md: any,
) {
+ if (this.options.enabled === false) {
+ return;
+ }
+
registerAgentConversationLinkMarkdownIt(md, this.options);
},
},
diff --git a/desktop/src/features/messages/lib/composerPasteHandler.ts b/desktop/src/features/messages/lib/composerPasteHandler.ts
index d4c159ff7..3a648ea75 100644
--- a/desktop/src/features/messages/lib/composerPasteHandler.ts
+++ b/desktop/src/features/messages/lib/composerPasteHandler.ts
@@ -13,6 +13,7 @@ type PasteView = {
type ComposerPasteHandlerOptions = {
agentConversationTitleForHref?: (href: string) => string | undefined;
+ enableAgentConversationLinks?: boolean;
editor: NonNullable;
scrollComposerToBottom: () => void;
uploadFile: MediaUploadController["uploadFile"];
@@ -20,6 +21,7 @@ type ComposerPasteHandlerOptions = {
export function createMessageComposerPasteHandler({
agentConversationTitleForHref,
+ enableAgentConversationLinks = true,
editor,
scrollComposerToBottom,
uploadFile,
@@ -66,7 +68,9 @@ export function createMessageComposerPasteHandler({
const plainText = event.clipboardData?.getData("text/plain") ?? "";
const taskLinkPasteContent =
- plainText.includes("\n") || plainText.trim().length === 0
+ !enableAgentConversationLinks ||
+ plainText.includes("\n") ||
+ plainText.trim().length === 0
? null
: buildTaskLinkPasteContent(plainText, agentConversationTitleForHref);
if (taskLinkPasteContent) {
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
index 7074f2837..07191e982 100644
--- a/desktop/src/features/messages/lib/useRichTextEditor.ts
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -63,6 +63,8 @@ export type RichTextEditorOptions = {
customEmoji?: CustomEmoji[];
/** Resolve task-link titles for composer task cards. */
agentConversationTitleForHref?: (href: string) => string | undefined;
+ /** Enables task-link cards and task-link markdown parsing in the composer. */
+ enableAgentConversationLinks?: boolean;
/** Called on plain Enter (submit). Handled inside Tiptap's extension system
* so it fires *before* ProseMirror's default splitBlock behaviour. */
onSubmit?: () => void;
@@ -172,6 +174,7 @@ export function useRichTextEditor({
channelNames,
customEmoji,
agentConversationTitleForHref,
+ enableAgentConversationLinks = true,
onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen,
@@ -206,10 +209,11 @@ export function useRichTextEditor({
const agentConversationLinkExtension = React.useMemo(
() =>
AgentConversationLinkNode.configure({
+ enabled: enableAgentConversationLinks,
titleForHref: (href) =>
agentConversationTitleForHrefRef.current?.(href),
}),
- [],
+ [enableAgentConversationLinks],
);
const editor = useEditor(
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 5c77770ee..bc05210ae 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -90,6 +90,7 @@ type MessageComposerProps = {
mediaTags?: string[][],
) => Promise;
agentConversationTitleForHref?: (href: string) => string | undefined;
+ enableAgentConversationLinks?: boolean;
placeholder?: string;
profiles?: UserProfileLookup;
replyTarget?: {
@@ -119,6 +120,7 @@ function MessageComposerImpl({
onEditSave,
onSend,
agentConversationTitleForHref,
+ enableAgentConversationLinks = false,
placeholder,
profiles,
replyTarget = null,
@@ -233,6 +235,7 @@ function MessageComposerImpl({
channelNames: channelLinks.knownChannelNames,
customEmoji,
agentConversationTitleForHref,
+ enableAgentConversationLinks,
onSubmit: () => submitMessageRef.current(),
onEditLastOwnMessage: () => {
// Never re-enter edit from an empty edit (e.g. image-only edit whose
@@ -675,13 +678,19 @@ function MessageComposerImpl({
...richText.editor.options.editorProps,
handlePaste: createMessageComposerPasteHandler({
agentConversationTitleForHref,
+ enableAgentConversationLinks,
editor: richText.editor,
scrollComposerToBottom,
uploadFile: uploadFileRef.current,
}),
},
});
- }, [richText.editor, scrollComposerToBottom, agentConversationTitleForHref]);
+ }, [
+ richText.editor,
+ scrollComposerToBottom,
+ agentConversationTitleForHref,
+ enableAgentConversationLinks,
+ ]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index 0545263be..a72961501 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -45,6 +45,7 @@ type MessageThreadPanelProps = {
channelName: string;
currentPubkey?: string;
disabled?: boolean;
+ enableAgentConversationLinks?: boolean;
firstUnreadReplyId?: string | null;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
@@ -356,6 +357,7 @@ export function MessageThreadPanel({
channelName,
currentPubkey,
disabled = false,
+ enableAgentConversationLinks = false,
firstUnreadReplyId,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
@@ -680,6 +682,7 @@ export function MessageThreadPanel({
containerClassName={THREAD_PANEL_COMPOSER_GUTTER_CLASS}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
+ enableAgentConversationLinks={enableAgentConversationLinks}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 699dab149..6415b0eed 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -400,7 +400,6 @@ function MessageRowItem({
)}
>
+ messageLinkUrlTransform(value, key, taskLinksEnabled),
+ },
content,
),
);
@@ -478,6 +481,11 @@ test("messageLinkUrlTransform: preserves buzz://task href", () => {
assert.match(html, /href="buzz:\/\/task\?channel=c1&(?:amp;)?reply=m1"/);
});
+test("messageLinkUrlTransform: strips buzz://task href when disabled", () => {
+ const html = renderMarkdown("[task](buzz://task?channel=c1&reply=r1)", false);
+ assert.doesNotMatch(html, /href="buzz:\/\/task/);
+});
+
test("messageLinkUrlTransform: still strips javascript: scheme", () => {
const html = renderMarkdown("[xss](javascript:alert(1))");
// defaultUrlTransform replaces unsafe schemes with the empty string.
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index ecbc9ce88..a87755213 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -30,6 +30,7 @@ import {
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
+import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import {
extractSupportedLinkPreviews,
@@ -1600,6 +1601,7 @@ function AgentConversationLinkCard({
function createMarkdownComponents(
runtimeRef: React.RefObject,
interactive = true,
+ agentConversationLinksEnabled = true,
): Components {
const paragraphClassName = "leading-[inherit]";
const listItemClassName = "my-1 [&_p]:inline";
@@ -1694,41 +1696,42 @@ function createMarkdownComponents(
);
}
+ if (agentConversationLinksEnabled) {
+ const agentConversationLinkTarget =
+ resolveAgentConversationLinkRenderTarget({
+ href,
+ label,
+ });
+ if (agentConversationLinkTarget.kind !== "none") {
+ if (agentConversationLinkTarget.kind === "card") {
+ return (
+
+ );
+ }
- const agentConversationLinkTarget =
- resolveAgentConversationLinkRenderTarget({
- href,
- label,
- });
- if (agentConversationLinkTarget.kind !== "none") {
- if (agentConversationLinkTarget.kind === "card") {
return (
-
+ onClick={(event) => {
+ event.preventDefault();
+ onOpenAgentConversationLink(agentConversationLinkTarget.link);
+ }}
+ >
+ {children}
+
);
}
-
- return (
- {
- event.preventDefault();
- onOpenAgentConversationLink(agentConversationLinkTarget.link);
- }}
- >
- {children}
-
- );
}
// Malformed message deep link — fall through to the default
// anchor (renders as a normal external link).
@@ -2058,6 +2061,9 @@ function createMarkdownComponents(
const { agentConversationMarkers, onOpenAgentConversationLink } =
runtimeRef.current;
const href = getReactNodeText(children);
+ if (!agentConversationLinksEnabled) {
+ return {href};
+ }
const parsed = parseAgentConversationLink(href);
if (!parsed.ok) {
return {href};
@@ -2093,6 +2099,9 @@ function MarkdownInner({
searchQuery,
videoReviewContext,
}: MarkdownProps) {
+ const agentConversationLinksEnabled = useFeatureEnabled(
+ CHANNEL_TASKS_FEATURE_ID,
+ );
const { channels: rawChannels } = useChannelNavigation();
const channels = useStableArray(rawChannels);
const { goChannel } = useAppNavigation();
@@ -2155,24 +2164,34 @@ function MarkdownInner({
});
const components = React.useMemo(
- () => createMarkdownComponents(runtimeRef, interactive),
- [runtimeRef, interactive],
+ () =>
+ createMarkdownComponents(
+ runtimeRef,
+ interactive,
+ agentConversationLinksEnabled,
+ ),
+ [runtimeRef, interactive, agentConversationLinksEnabled],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
- const remarkPlugins = React.useMemo(
- () => [
+ const remarkPlugins = React.useMemo(() => {
+ // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
+ const plugins: any[] = [
remarkGfm,
remarkBreaks,
remarkSpoilers,
remarkMessageLinks,
- remarkAgentConversationLinks,
+ ];
+ if (agentConversationLinksEnabled) {
+ plugins.push(remarkAgentConversationLinks);
+ }
+ plugins.push(
[remarkMentions, { mentionNames }],
[remarkChannelLinks, { channelNames }],
[remarkCustomEmoji, { customEmoji }],
- ],
- [mentionNames, channelNames, customEmoji],
- );
+ );
+ return plugins;
+ }, [agentConversationLinksEnabled, mentionNames, channelNames, customEmoji]);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const rehypePlugins = React.useMemo(() => {
@@ -2201,7 +2220,9 @@ function MarkdownInner({
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
- urlTransform={messageLinkUrlTransform}
+ urlTransform={(value, key) =>
+ messageLinkUrlTransform(value, key, agentConversationLinksEnabled)
+ }
>
{processedContent}
diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts
index 9e1f02c9d..3e3c5987b 100644
--- a/desktop/src/shared/ui/markdown/utils.ts
+++ b/desktop/src/shared/ui/markdown/utils.ts
@@ -66,10 +66,18 @@ export function isInsideHiddenSpoiler(element: Element): boolean {
* component override can see them, which would break copy → paste → click
* end-to-end. Everything else delegates to `defaultUrlTransform`.
*/
-export function messageLinkUrlTransform(value: string, key: string): string {
+export function messageLinkUrlTransform(
+ value: string,
+ key: string,
+ agentConversationLinksEnabled = true,
+): string {
+ if (key === "href" && isMessageLink(value)) {
+ return value;
+ }
if (
key === "href" &&
- (isMessageLink(value) || isAgentConversationLink(value))
+ agentConversationLinksEnabled &&
+ isAgentConversationLink(value)
) {
return value;
}
diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts
index dcf38d55e..08de0fa67 100644
--- a/desktop/src/shared/useMessageDeepLinks.ts
+++ b/desktop/src/shared/useMessageDeepLinks.ts
@@ -1,6 +1,7 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
+import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import {
listenForAgentConversationDeepLinks,
listenForMessageDeepLinks,
@@ -22,6 +23,7 @@ import {
*/
export function useMessageDeepLinks() {
const { goChannel } = useAppNavigation();
+ const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
React.useEffect(() => {
let cancelled = false;
@@ -32,18 +34,19 @@ export function useMessageDeepLinks() {
threadRootId: payload.threadRootId,
});
});
- const agentConversationUnlistenPromise =
- listenForAgentConversationDeepLinks((payload) => {
- if (cancelled) return;
- void goChannel(payload.channelId, {
- taskReplyId: payload.agentReplyId,
- });
- });
+ const agentConversationUnlistenPromise = isChannelTasksEnabled
+ ? listenForAgentConversationDeepLinks((payload) => {
+ if (cancelled) return;
+ void goChannel(payload.channelId, {
+ taskReplyId: payload.agentReplyId,
+ });
+ })
+ : null;
return () => {
cancelled = true;
void messageUnlistenPromise.then((fn) => fn());
- void agentConversationUnlistenPromise.then((fn) => fn());
+ void agentConversationUnlistenPromise?.then((fn) => fn());
};
- }, [goChannel]);
+ }, [goChannel, isChannelTasksEnabled]);
}
diff --git a/preview-features.json b/preview-features.json
index 38ea181bb..ede6e2fdd 100644
--- a/preview-features.json
+++ b/preview-features.json
@@ -32,6 +32,14 @@
"platforms": [
"desktop"
]
+ },
+ {
+ "id": "channel-tasks",
+ "name": "Channel Tasks",
+ "description": "Dedicated agent task conversations inside channels",
+ "platforms": [
+ "desktop"
+ ]
}
]
}
From 81887699c8f1aa442e8e28e9c885cc37d9d13e5d Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 09:33:19 +0100
Subject: [PATCH 06/23] 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}
From 2b4afb8f1dbc23f77eed75c5e60f44ee42bd7970 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 10:23:25 +0100
Subject: [PATCH 07/23] Fix task review regressions
---
desktop/src/features/messages/ui/TimelineMessageList.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 6415b0eed..699dab149 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -400,6 +400,7 @@ function MessageRowItem({
)}
>
Date: Sat, 27 Jun 2026 10:48:54 +0100
Subject: [PATCH 08/23] Keep source task thread replies visible
---
.../agents/agentConversations.test.mjs | 50 ++++++++++++++++++-
.../src/features/agents/agentConversations.ts | 6 +++
2 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs
index ccb653bdb..0041407af 100644
--- a/desktop/src/features/agents/agentConversations.test.mjs
+++ b/desktop/src/features/agents/agentConversations.test.mjs
@@ -7,6 +7,7 @@ import {
buildAgentConversationRecap,
buildAgentConversationMarkers,
deriveAgentConversationTitle,
+ getAutoRoutedAgentConversationPubkeys,
getHiddenAgentConversationMessageIds,
parseAgentConversationMarker,
readPersistedAgentConversations,
@@ -282,6 +283,7 @@ test("continued conversations persist across app restarts", () => {
test("message-anchored tasks persist without a primary agent", () => {
withMockLocalStorage(() => {
+ const workspaceScope = "wss://relay.example.com";
const root = message({
body: "Can someone turn this into a task?",
createdAt: 1,
@@ -297,8 +299,8 @@ test("message-anchored tasks persist without a primary agent", () => {
threadRootMessage: root,
});
- writePersistedAgentConversations("human", [conversation]);
- const persisted = readPersistedAgentConversations("human");
+ writePersistedAgentConversations("human", workspaceScope, [conversation]);
+ const persisted = readPersistedAgentConversations("human", workspaceScope);
assert.equal(persisted.length, 1);
assert.equal(persisted[0].id, conversation.id);
@@ -558,6 +560,50 @@ test("continued conversation marker hides loaded task replies when anchor is out
assert.deepEqual([...hiddenIds], ["task-reply"]);
});
+test("source-message task marker does not hide later thread replies", () => {
+ const root = message({
+ body: "Can you look into the data model?",
+ createdAt: 1,
+ id: "root",
+ });
+ const humanAnchor = message({
+ body: "Let's make this a task.",
+ createdAt: 2,
+ id: "human-anchor",
+ });
+ const laterReply = message({
+ body: "This normal thread reply should stay visible.",
+ createdAt: 3,
+ id: "later",
+ });
+ const marker = parseAgentConversationMarker({
+ ...markerEvent({
+ content: {
+ agentName: "",
+ agentPubkey: "",
+ agentReplyId: "human-anchor",
+ startedAt: 2,
+ },
+ createdAt: 2,
+ id: "source-marker",
+ includeAgent: false,
+ }),
+ tags: [
+ ["h", "channel"],
+ ["e", "root", "", "root"],
+ ["e", "human-anchor", "", "agent-reply"],
+ ["title", "Source task"],
+ ],
+ });
+
+ const hiddenIds = getHiddenAgentConversationMessageIds(
+ [root, humanAnchor, laterReply],
+ marker ? [marker] : [],
+ );
+
+ assert.deepEqual([...hiddenIds], []);
+});
+
test("continued conversation markers keep later task anchors visible", () => {
const root = message({
body: "Can you look into the data model?",
diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts
index 744f94d87..9d1e1643b 100644
--- a/desktop/src/features/agents/agentConversations.ts
+++ b/desktop/src/features/agents/agentConversations.ts
@@ -678,6 +678,9 @@ export function getHiddenAgentConversationMessageIds(
for (const marker of markers) {
const anchorMessage = messageById.get(marker.agentReplyId);
const anchorIndex = messageIndexById.get(marker.agentReplyId);
+ if (!marker.agentPubkey) {
+ continue;
+ }
if (!anchorMessage || anchorIndex === undefined) {
const hasLoadedThreadContext = orderedMessages.some(({ message }) => {
const messageThreadRootId = message.rootId ?? message.parentId ?? null;
@@ -695,6 +698,9 @@ export function getHiddenAgentConversationMessageIds(
if (anchorThreadRootId !== marker.threadRootId) {
continue;
}
+ if (anchorMessage.pubkey !== marker.agentPubkey) {
+ continue;
+ }
const anchorMessageIds =
anchorMessageIdsByThreadRootId.get(marker.threadRootId) ?? new Set();
From 295c7a07aa42bfd72547c96d60ec9442fe224e5f Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 10:54:05 +0100
Subject: [PATCH 09/23] Infer agents from task source mentions
---
.../channels/ui/useAgentConversationRouteTarget.ts | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
index 05d0409e7..31e641539 100644
--- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
+++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
@@ -4,6 +4,7 @@ import type {
AgentConversationMarker,
OpenAgentConversationInput,
} from "@/features/agents/agentConversations";
+import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages";
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -80,11 +81,18 @@ export function useAgentConversationRouteTarget({
const sourceAuthorIsAgent = sourceMessage.pubkey
? agentPubkeys.has(normalizePubkey(sourceMessage.pubkey))
: false;
+ const mentionedAgentPubkey =
+ collectMessageMentionPubkeys([sourceMessage]).find((pubkey) =>
+ agentPubkeys.has(normalizePubkey(pubkey)),
+ ) ?? "";
const taskAgentPubkey =
marker?.agentPubkey ||
- (sourceAuthorIsAgent ? (sourceMessage.pubkey ?? "") : "");
+ (sourceAuthorIsAgent ? (sourceMessage.pubkey ?? "") : "") ||
+ mentionedAgentPubkey;
const taskAgentName =
- marker?.agentName || (taskAgentPubkey ? sourceMessage.author : "");
+ marker?.agentName ||
+ (sourceAuthorIsAgent && taskAgentPubkey ? sourceMessage.author : "") ||
+ taskAgentPubkey;
const rootId =
sourceMessage.rootId ?? sourceMessage.parentId ?? sourceMessage.id;
const contextMessages = timelineMessages.filter(
From 75940450bedc802e2252eeae16334b1112a94979 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 11:21:33 +0100
Subject: [PATCH 10/23] Fix task link feature gate regressions
---
desktop/src/features/channels/ui/ChannelScreen.tsx | 5 +++++
.../features/channels/ui/useAgentConversationRouteTarget.ts | 6 ++++++
desktop/src/features/messages/lib/composerPasteHandler.ts | 2 +-
desktop/src/features/messages/lib/useRichTextEditor.ts | 2 +-
4 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index cfb2f77c4..2668f3852 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -341,6 +341,10 @@ export function ChannelScreen({
const managedAgents = managedAgentsQuery.data ?? [];
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? [];
+ const agentLookupReady =
+ !channelMembersQuery.isLoading &&
+ !managedAgentsQuery.isLoading &&
+ !relayAgentsQuery.isLoading;
const agentPubkeys = React.useMemo(() => {
const pubkeys = new Set();
for (const member of channelMembers ?? []) {
@@ -732,6 +736,7 @@ export function ChannelScreen({
useAgentConversationRouteTarget({
activeChannel,
agentConversationMarkers,
+ agentLookupReady,
agentPubkeys,
enabled: isChannelTasksEnabled,
goChannel,
diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
index 31e641539..6d4b57fa3 100644
--- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
+++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
@@ -23,6 +23,7 @@ type UseAgentConversationRouteTargetInput = {
activeChannel: Channel | null;
agentConversationMarkers: readonly AgentConversationMarker[];
agentPubkeys: ReadonlySet;
+ agentLookupReady: boolean;
enabled: boolean;
goChannel: GoChannel;
messageProfilesReady: boolean;
@@ -37,6 +38,7 @@ type UseAgentConversationRouteTargetInput = {
export function useAgentConversationRouteTarget({
activeChannel,
agentConversationMarkers,
+ agentLookupReady,
agentPubkeys,
enabled,
goChannel,
@@ -77,6 +79,9 @@ export function useAgentConversationRouteTarget({
if (!sourceMessage) {
return;
}
+ if (!marker && !agentLookupReady) {
+ return;
+ }
const sourceAuthorIsAgent = sourceMessage.pubkey
? agentPubkeys.has(normalizePubkey(sourceMessage.pubkey))
@@ -128,6 +133,7 @@ export function useAgentConversationRouteTarget({
}, [
activeChannel,
agentConversationMarkers,
+ agentLookupReady,
agentPubkeys,
enabled,
goChannel,
diff --git a/desktop/src/features/messages/lib/composerPasteHandler.ts b/desktop/src/features/messages/lib/composerPasteHandler.ts
index 3a648ea75..5b69688cd 100644
--- a/desktop/src/features/messages/lib/composerPasteHandler.ts
+++ b/desktop/src/features/messages/lib/composerPasteHandler.ts
@@ -21,7 +21,7 @@ type ComposerPasteHandlerOptions = {
export function createMessageComposerPasteHandler({
agentConversationTitleForHref,
- enableAgentConversationLinks = true,
+ enableAgentConversationLinks = false,
editor,
scrollComposerToBottom,
uploadFile,
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
index 07191e982..9e4831169 100644
--- a/desktop/src/features/messages/lib/useRichTextEditor.ts
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -174,7 +174,7 @@ export function useRichTextEditor({
channelNames,
customEmoji,
agentConversationTitleForHref,
- enableAgentConversationLinks = true,
+ enableAgentConversationLinks = false,
onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen,
From 0d94a359a434aece9112fe9d470ce962409d76ec Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 12:11:20 +0100
Subject: [PATCH 11/23] Defer task opens until agents resolve
---
.../src/features/channels/ui/ChannelPane.tsx | 109 +++++++++++++++++-
.../features/channels/ui/ChannelPane.types.ts | 1 +
.../features/channels/ui/ChannelScreen.tsx | 2 +
3 files changed, 106 insertions(+), 6 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 03d8be6f5..3c8165281 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -64,6 +64,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
agentConversationMarkers,
+ agentLookupReady = true,
agentPubkeys,
agentPubkeysPending = false,
agentSessionAgents,
@@ -155,6 +156,12 @@ export const ChannelPane = React.memo(function ChannelPane({
const [taskFocusMessageId, setTaskFocusMessageId] = React.useState<
string | null
>(null);
+ const [pendingAgentConversationOpen, setPendingAgentConversationOpen] =
+ React.useState<{
+ channelId: string;
+ messageId: string;
+ publishMarker?: boolean;
+ } | null>(null);
const previousTaskFocusChannelIdRef = React.useRef(null);
const completedWelcomeBannerChannelIdsRef = React.useRef(new Set());
const welcomeComposerDismissTimerRef = React.useRef(null);
@@ -316,6 +323,19 @@ export const ChannelPane = React.memo(function ChannelPane({
}, [activityAgents, agentPubkeys, agentSessionAgents, profiles]);
const resolveTaskAgentForMessage = React.useCallback(
(message: TimelineMessage) => {
+ const markerAgent = activeAgentConversationMarkers?.find(
+ (marker) =>
+ marker.channelId === activeChannelId &&
+ marker.agentReplyId === message.id &&
+ marker.agentPubkey,
+ );
+ if (markerAgent) {
+ return {
+ name: markerAgent.agentName || markerAgent.agentPubkey,
+ pubkey: markerAgent.agentPubkey,
+ };
+ }
+
if (message.pubkey) {
const directAgent = knownAgentByPubkey.get(
normalizePubkey(message.pubkey),
@@ -326,6 +346,15 @@ export const ChannelPane = React.memo(function ChannelPane({
pubkey: directAgent.pubkey,
};
}
+ if (message.role === "bot") {
+ return {
+ name:
+ message.author?.trim() ||
+ message.personaDisplayName?.trim() ||
+ message.pubkey,
+ pubkey: message.pubkey,
+ };
+ }
}
for (const pubkey of collectMessageMentionPubkeys([message])) {
@@ -337,7 +366,7 @@ export const ChannelPane = React.memo(function ChannelPane({
return null;
},
- [knownAgentByPubkey],
+ [activeAgentConversationMarkers, activeChannelId, knownAgentByPubkey],
);
const completeWelcomeComposerBanner = React.useCallback(() => {
if (!activeChannelId || !isActiveWelcomeChannel) {
@@ -395,8 +424,12 @@ export const ChannelPane = React.memo(function ChannelPane({
},
[onOpenAgentSession],
);
- const handleOpenAgentConversation = React.useCallback(
- (message: TimelineMessage, options?: { publishMarker?: boolean }) => {
+ const openResolvedAgentConversation = React.useCallback(
+ (
+ message: TimelineMessage,
+ taskAgent: { name: string; pubkey: string } | null,
+ options?: { publishMarker?: boolean },
+ ) => {
if (
!enableAgentConversations ||
!activeChannel ||
@@ -409,7 +442,6 @@ 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) =>
@@ -437,14 +469,79 @@ export const ChannelPane = React.memo(function ChannelPane({
options,
);
},
+ [activeChannel, enableAgentConversations, messages, openAgentConversation],
+ );
+ const handleOpenAgentConversation = React.useCallback(
+ (message: TimelineMessage, options?: { publishMarker?: boolean }) => {
+ if (
+ !enableAgentConversations ||
+ !activeChannel ||
+ message.pending ||
+ !canOpenAgentConversationInChannel({
+ channel: activeChannel,
+ publishMarker: options?.publishMarker,
+ })
+ ) {
+ return;
+ }
+
+ const taskAgent = resolveTaskAgentForMessage(message);
+ if (!taskAgent && !agentLookupReady) {
+ setPendingAgentConversationOpen({
+ channelId: activeChannel.id,
+ messageId: message.id,
+ publishMarker: options?.publishMarker,
+ });
+ return;
+ }
+
+ openResolvedAgentConversation(message, taskAgent, options);
+ },
[
activeChannel,
+ agentLookupReady,
enableAgentConversations,
- messages,
- openAgentConversation,
+ openResolvedAgentConversation,
resolveTaskAgentForMessage,
],
);
+ React.useEffect(() => {
+ if (!pendingAgentConversationOpen) {
+ return;
+ }
+ if (
+ !activeChannel ||
+ activeChannel.id !== pendingAgentConversationOpen.channelId
+ ) {
+ setPendingAgentConversationOpen(null);
+ return;
+ }
+ if (!agentLookupReady) {
+ return;
+ }
+
+ const pendingMessage = messages.find(
+ (message) => message.id === pendingAgentConversationOpen.messageId,
+ );
+ if (!pendingMessage || pendingMessage.pending) {
+ setPendingAgentConversationOpen(null);
+ return;
+ }
+
+ setPendingAgentConversationOpen(null);
+ openResolvedAgentConversation(
+ pendingMessage,
+ resolveTaskAgentForMessage(pendingMessage),
+ { publishMarker: pendingAgentConversationOpen.publishMarker },
+ );
+ }, [
+ activeChannel,
+ agentLookupReady,
+ messages,
+ openResolvedAgentConversation,
+ pendingAgentConversationOpen,
+ resolveTaskAgentForMessage,
+ ]);
const handleGoToTaskMessage = React.useCallback(
(
marker: AgentConversationMarker,
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index 9ce6ff96a..133232f4b 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -16,6 +16,7 @@ import type { Channel } from "@/shared/api/types";
export type ChannelPaneProps = {
activeChannel: Channel | null;
agentConversationMarkers?: readonly AgentConversationMarker[];
+ agentLookupReady?: boolean;
activityAgents?: BotActivityAgent[];
agentPubkeys?: ReadonlySet;
agentPubkeysPending?: boolean;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 2668f3852..e53909fbe 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -344,6 +344,7 @@ export function ChannelScreen({
const agentLookupReady =
!channelMembersQuery.isLoading &&
!managedAgentsQuery.isLoading &&
+ !messageProfilesQuery.isLoading &&
!relayAgentsQuery.isLoading;
const agentPubkeys = React.useMemo(() => {
const pubkeys = new Set();
@@ -986,6 +987,7 @@ export function ChannelScreen({
activeChannel={activeChannel}
activityAgents={channelAgentSessionAgents}
agentConversationMarkers={agentConversationMarkers}
+ agentLookupReady={agentLookupReady}
agentPubkeys={routingAgentPubkeys}
agentPubkeysPending={agentPubkeysPending}
agentSessionAgents={agentSessionAgents}
From 14f5a40b2b60af01dc2d188e376f987748c75990 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 12:49:55 +0100
Subject: [PATCH 12/23] Infer DM agents for task starts
---
.../src/features/channels/ui/ChannelPane.tsx | 24 ++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 3c8165281..e3f95d4a2 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -41,6 +41,7 @@ import {
} from "@/features/channels/ui/WelcomeComposerBanner";
import {
canOpenAgentConversationInChannel,
+ getDmAutoRouteAgentPubkeys,
getChannelIntroDescription,
getChannelIntroKind,
isWelcomeSetupSystemMessage,
@@ -289,6 +290,15 @@ export const ChannelPane = React.memo(function ChannelPane({
return pubkeys;
}, [activityAgents, agentPubkeys, agentSessionAgents]);
+ const dmAutoRouteAgentPubkeys = React.useMemo(
+ () =>
+ getDmAutoRouteAgentPubkeys({
+ channel: activeChannel,
+ currentPubkey,
+ knownAgentPubkeys,
+ }),
+ [activeChannel, currentPubkey, knownAgentPubkeys],
+ );
const knownAgentByPubkey = React.useMemo(() => {
const agents = new Map();
const addAgent = (pubkey: string, name?: string | null) => {
@@ -364,9 +374,21 @@ export const ChannelPane = React.memo(function ChannelPane({
}
}
+ for (const pubkey of dmAutoRouteAgentPubkeys) {
+ const dmAgent = knownAgentByPubkey.get(normalizePubkey(pubkey));
+ if (dmAgent) {
+ return dmAgent;
+ }
+ }
+
return null;
},
- [activeAgentConversationMarkers, activeChannelId, knownAgentByPubkey],
+ [
+ activeAgentConversationMarkers,
+ activeChannelId,
+ dmAutoRouteAgentPubkeys,
+ knownAgentByPubkey,
+ ],
);
const completeWelcomeComposerBanner = React.useCallback(() => {
if (!activeChannelId || !isActiveWelcomeChannel) {
From e4c069a41eddbe6757326ce1c7d6bd2ced9f5525 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 13:32:05 +0100
Subject: [PATCH 13/23] Infer DM task agent from participants
---
.../ui/useAgentConversationRouteTarget.ts | 23 ++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
index 6d4b57fa3..42eb0d348 100644
--- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
+++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
@@ -35,6 +35,25 @@ type UseAgentConversationRouteTargetInput = {
timelineMessages: readonly TimelineMessage[];
};
+function getSingleDmAgentPubkey(
+ channel: Channel,
+ agentPubkeys: ReadonlySet,
+) {
+ if (channel.channelType !== "dm") {
+ return "";
+ }
+
+ const dmAgentPubkeys = new Map();
+ for (const pubkey of channel.participantPubkeys) {
+ const normalized = normalizePubkey(pubkey);
+ if (agentPubkeys.has(normalized)) {
+ dmAgentPubkeys.set(normalized, pubkey);
+ }
+ }
+
+ return dmAgentPubkeys.size === 1 ? [...dmAgentPubkeys.values()][0] : "";
+}
+
export function useAgentConversationRouteTarget({
activeChannel,
agentConversationMarkers,
@@ -90,10 +109,12 @@ export function useAgentConversationRouteTarget({
collectMessageMentionPubkeys([sourceMessage]).find((pubkey) =>
agentPubkeys.has(normalizePubkey(pubkey)),
) ?? "";
+ const dmAgentPubkey = getSingleDmAgentPubkey(activeChannel, agentPubkeys);
const taskAgentPubkey =
marker?.agentPubkey ||
(sourceAuthorIsAgent ? (sourceMessage.pubkey ?? "") : "") ||
- mentionedAgentPubkey;
+ mentionedAgentPubkey ||
+ dmAgentPubkey;
const taskAgentName =
marker?.agentName ||
(sourceAuthorIsAgent && taskAgentPubkey ? sourceMessage.author : "") ||
From eec16a3c251745c269b0fe1cd5bb5ba5928f685e Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sat, 27 Jun 2026 14:24:39 +0100
Subject: [PATCH 14/23] Wait for task link agent inference
---
desktop/src/features/channels/ui/ChannelScreen.tsx | 2 +-
.../src/features/channels/ui/useAgentConversationRouteTarget.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index e53909fbe..834fb4e77 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -738,7 +738,7 @@ export function ChannelScreen({
activeChannel,
agentConversationMarkers,
agentLookupReady,
- agentPubkeys,
+ agentPubkeys: routingAgentPubkeys,
enabled: isChannelTasksEnabled,
goChannel,
messageProfilesReady,
diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
index 42eb0d348..b4e7029d6 100644
--- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
+++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
@@ -98,7 +98,7 @@ export function useAgentConversationRouteTarget({
if (!sourceMessage) {
return;
}
- if (!marker && !agentLookupReady) {
+ if (!marker?.agentPubkey && !agentLookupReady) {
return;
}
From 38777fb9133e7596f37bb452d50a36d5fdfcccf6 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Sun, 28 Jun 2026 13:46:28 +0100
Subject: [PATCH 15/23] Stabilize settings e2e helper
---
desktop/tests/helpers/settings.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts
index 0c2659b19..b016c7f4a 100644
--- a/desktop/tests/helpers/settings.ts
+++ b/desktop/tests/helpers/settings.ts
@@ -21,7 +21,9 @@ export async function openProfileMenu(page: Page) {
export async function openSettings(page: Page, section?: SettingsSection) {
await openProfileMenu(page);
- await page.getByTestId("profile-popover-settings").click();
+ const settingsItem = page.getByTestId("profile-popover-settings");
+ await expect(settingsItem).toBeVisible();
+ await settingsItem.click({ force: true });
await expect(page.getByTestId("settings-view")).toBeVisible();
if (section) {
From f915433df34788fdb08ced81c9e0b609dbc5bb27 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 16:30:15 +0100
Subject: [PATCH 16/23] Fix task route readiness and backfill
---
.../channels/ui/ChannelPane.helpers.test.mjs | 40 +++++++++++++++
.../channels/ui/ChannelPane.helpers.ts | 50 ++++++++++++++++++-
.../src/features/channels/ui/ChannelPane.tsx | 10 ++--
.../features/channels/ui/ChannelScreen.tsx | 2 +-
4 files changed, 95 insertions(+), 7 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
index 879c7351b..b94a8974b 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
@@ -3,6 +3,7 @@ import test from "node:test";
import {
canOpenAgentConversationInChannel,
+ getDmTaskAgentPubkeys,
mergeAutoRouteMentionPubkeys,
} from "./ChannelPane.helpers.ts";
@@ -74,3 +75,42 @@ test("auto-routed mentions merge with explicit mentions without duplicates", ()
["AGENT-ONE", "agent-two"],
);
});
+
+test("DM task agent inference requires exactly one other known agent", () => {
+ const knownAgentPubkeys = new Set(["agent-one", "agent-two"]);
+
+ assert.deepEqual(
+ getDmTaskAgentPubkeys({
+ channel: channel({
+ channelType: "dm",
+ participantPubkeys: ["human", "agent-one"],
+ }),
+ currentPubkey: "human",
+ knownAgentPubkeys,
+ }),
+ ["agent-one"],
+ );
+
+ assert.deepEqual(
+ getDmTaskAgentPubkeys({
+ channel: channel({
+ channelType: "dm",
+ participantPubkeys: ["human", "agent-one", "agent-two"],
+ }),
+ currentPubkey: "human",
+ knownAgentPubkeys,
+ }),
+ [],
+ );
+
+ assert.deepEqual(
+ getDmTaskAgentPubkeys({
+ channel: channel({
+ participantPubkeys: ["human", "agent-one"],
+ }),
+ currentPubkey: "human",
+ knownAgentPubkeys,
+ }),
+ [],
+ );
+});
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
index 89e472a51..8643525ce 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
@@ -1,5 +1,4 @@
import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel";
-import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages";
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
@@ -72,6 +71,55 @@ export function mentionsKnownAgent(
);
}
+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 getDmTaskAgentPubkeys({
+ channel,
+ currentPubkey,
+ knownAgentPubkeys,
+}: {
+ channel: Channel | null;
+ currentPubkey?: string;
+ knownAgentPubkeys: ReadonlySet;
+}) {
+ if (channel?.channelType !== "dm") {
+ return [];
+ }
+
+ const normalizedCurrentPubkey = currentPubkey
+ ? normalizePubkey(currentPubkey)
+ : null;
+
+ return singleKnownAgentPubkey(
+ channel.participantPubkeys.filter(
+ (pubkey) =>
+ !normalizedCurrentPubkey ||
+ normalizePubkey(pubkey) !== normalizedCurrentPubkey,
+ ),
+ knownAgentPubkeys,
+ );
+}
+
export function mergeAutoRouteMentionPubkeys({
autoRouteAgentPubkeys,
mentionPubkeys,
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index e3f95d4a2..50c020b79 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -41,9 +41,9 @@ import {
} from "@/features/channels/ui/WelcomeComposerBanner";
import {
canOpenAgentConversationInChannel,
- getDmAutoRouteAgentPubkeys,
getChannelIntroDescription,
getChannelIntroKind,
+ getDmTaskAgentPubkeys,
isWelcomeSetupSystemMessage,
mentionsKnownAgent,
} from "@/features/channels/ui/ChannelPane.helpers";
@@ -290,9 +290,9 @@ export const ChannelPane = React.memo(function ChannelPane({
return pubkeys;
}, [activityAgents, agentPubkeys, agentSessionAgents]);
- const dmAutoRouteAgentPubkeys = React.useMemo(
+ const dmTaskAgentPubkeys = React.useMemo(
() =>
- getDmAutoRouteAgentPubkeys({
+ getDmTaskAgentPubkeys({
channel: activeChannel,
currentPubkey,
knownAgentPubkeys,
@@ -374,7 +374,7 @@ export const ChannelPane = React.memo(function ChannelPane({
}
}
- for (const pubkey of dmAutoRouteAgentPubkeys) {
+ for (const pubkey of dmTaskAgentPubkeys) {
const dmAgent = knownAgentByPubkey.get(normalizePubkey(pubkey));
if (dmAgent) {
return dmAgent;
@@ -386,7 +386,7 @@ export const ChannelPane = React.memo(function ChannelPane({
[
activeAgentConversationMarkers,
activeChannelId,
- dmAutoRouteAgentPubkeys,
+ dmTaskAgentPubkeys,
knownAgentByPubkey,
],
);
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 834fb4e77..3d7ddc6af 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -344,7 +344,7 @@ export function ChannelScreen({
const agentLookupReady =
!channelMembersQuery.isLoading &&
!managedAgentsQuery.isLoading &&
- !messageProfilesQuery.isLoading &&
+ messageProfilesReady &&
!relayAgentsQuery.isLoading;
const agentPubkeys = React.useMemo(() => {
const pubkeys = new Set();
From a82c36dab24326280ab7cf2c8baa23a68c9472ab Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 17:08:58 +0100
Subject: [PATCH 17/23] Preserve agent p-tags on focused sends
---
.../channels/ui/ChannelPane.helpers.test.mjs | 59 +++++++++++++++++
.../channels/ui/ChannelPane.helpers.ts | 65 +++++++++++++++++++
.../src/features/channels/ui/ChannelPane.tsx | 29 ++++++++-
3 files changed, 150 insertions(+), 3 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
index b94a8974b..ab79b6d49 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
@@ -4,6 +4,7 @@ import test from "node:test";
import {
canOpenAgentConversationInChannel,
getDmTaskAgentPubkeys,
+ getThreadTaskAgentPubkeys,
mergeAutoRouteMentionPubkeys,
} from "./ChannelPane.helpers.ts";
@@ -114,3 +115,61 @@ test("DM task agent inference requires exactly one other known agent", () => {
[],
);
});
+
+test("thread task agent inference requires exactly one known agent and one human", () => {
+ const knownAgentPubkeys = new Set(["agent-one", "agent-two"]);
+
+ assert.deepEqual(
+ getThreadTaskAgentPubkeys({
+ currentPubkey: "human",
+ knownAgentPubkeys,
+ messages: [
+ {
+ pubkey: "human",
+ tags: [["p", "agent-one"]],
+ },
+ {
+ pubkey: "agent-one",
+ tags: [["p", "human"]],
+ },
+ ],
+ }),
+ ["agent-one"],
+ );
+
+ assert.deepEqual(
+ getThreadTaskAgentPubkeys({
+ currentPubkey: "human",
+ knownAgentPubkeys,
+ messages: [
+ {
+ pubkey: "human",
+ tags: [["p", "agent-one"]],
+ },
+ {
+ pubkey: "other-human",
+ tags: [["p", "human"]],
+ },
+ ],
+ }),
+ [],
+ );
+
+ assert.deepEqual(
+ getThreadTaskAgentPubkeys({
+ currentPubkey: "human",
+ knownAgentPubkeys,
+ messages: [
+ {
+ pubkey: "human",
+ tags: [["p", "agent-one"]],
+ },
+ {
+ pubkey: "agent-two",
+ tags: [["p", "human"]],
+ },
+ ],
+ }),
+ [],
+ );
+});
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
index 8643525ce..bcb47c02b 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
@@ -3,6 +3,7 @@ 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";
@@ -120,6 +121,53 @@ export function getDmTaskAgentPubkeys({
);
}
+export function getThreadTaskAgentPubkeys({
+ currentPubkey,
+ knownAgentPubkeys,
+ messages,
+}: {
+ currentPubkey?: string;
+ knownAgentPubkeys: ReadonlySet;
+ messages: readonly TimelineMessage[];
+}) {
+ const normalizedCurrentPubkey = currentPubkey
+ ? normalizePubkey(currentPubkey)
+ : null;
+ const participants = new Map();
+
+ const addParticipant = (pubkey: string | null | undefined) => {
+ if (!pubkey) {
+ return;
+ }
+ const normalized = normalizePubkey(pubkey);
+ if (!normalized || participants.has(normalized)) {
+ return;
+ }
+ participants.set(normalized, pubkey);
+ };
+
+ for (const message of messages) {
+ addParticipant(message.pubkey);
+ for (const tag of message.tags ?? []) {
+ addParticipant(getMentionTagPubkey(tag));
+ }
+ }
+
+ const agentPubkeys = new Map();
+
+ for (const [normalized, pubkey] of participants) {
+ if (normalizedCurrentPubkey && normalized === normalizedCurrentPubkey) {
+ continue;
+ }
+ if (!knownAgentPubkeys.has(normalized)) {
+ return [];
+ }
+ agentPubkeys.set(normalized, pubkey);
+ }
+
+ return agentPubkeys.size === 1 ? [...agentPubkeys.values()] : [];
+}
+
export function mergeAutoRouteMentionPubkeys({
autoRouteAgentPubkeys,
mentionPubkeys,
@@ -148,3 +196,20 @@ export function mergeAutoRouteMentionPubkeys({
return merged;
}
+
+export function mergeTaskAgentMentionPubkeys({
+ agentPubkeys,
+ mentionPubkeys,
+}: {
+ agentPubkeys: readonly string[];
+ mentionPubkeys: string[];
+}) {
+ if (agentPubkeys.length === 0) {
+ return mentionPubkeys;
+ }
+
+ return mergeAutoRouteMentionPubkeys({
+ autoRouteAgentPubkeys: agentPubkeys,
+ mentionPubkeys,
+ });
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 50c020b79..520f6e916 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -44,7 +44,9 @@ import {
getChannelIntroDescription,
getChannelIntroKind,
getDmTaskAgentPubkeys,
+ getThreadTaskAgentPubkeys,
isWelcomeSetupSystemMessage,
+ mergeTaskAgentMentionPubkeys,
mentionsKnownAgent,
} from "@/features/channels/ui/ChannelPane.helpers";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
@@ -421,13 +423,17 @@ export const ChannelPane = React.memo(function ChannelPane({
mentionPubkeys: string[],
mediaTags?: string[][],
) => {
+ const sendMentionPubkeys = mergeTaskAgentMentionPubkeys({
+ agentPubkeys: dmTaskAgentPubkeys,
+ mentionPubkeys,
+ });
const shouldCompleteWelcomeBanner =
isActiveWelcomeChannel &&
(containsWelcomePersonaMention(content) ||
- mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys));
+ mentionsKnownAgent(sendMentionPubkeys, knownAgentPubkeys));
messageTimelineRef.current?.scrollToBottomOnNextUpdate();
- await onSendMessage(content, mentionPubkeys, mediaTags);
+ await onSendMessage(content, sendMentionPubkeys, mediaTags);
if (shouldCompleteWelcomeBanner) {
completeWelcomeComposerBanner();
@@ -435,6 +441,7 @@ export const ChannelPane = React.memo(function ChannelPane({
},
[
completeWelcomeComposerBanner,
+ dmTaskAgentPubkeys,
isActiveWelcomeChannel,
knownAgentPubkeys,
onSendMessage,
@@ -764,6 +771,22 @@ export const ChannelPane = React.memo(function ChannelPane({
...threadMessages.map((entry) => entry.message),
];
}, [threadHeadMessage, threadMessages]);
+ const threadTaskAgentPubkeys = getThreadTaskAgentPubkeys({
+ currentPubkey,
+ knownAgentPubkeys,
+ messages: threadSourceMessages,
+ });
+ const handleSendThreadReply = React.useCallback(
+ (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => {
+ const sendMentionPubkeys = mergeTaskAgentMentionPubkeys({
+ agentPubkeys: threadTaskAgentPubkeys,
+ mentionPubkeys,
+ });
+
+ return onSendThreadReply(content, sendMentionPubkeys, mediaTags);
+ },
+ [onSendThreadReply, threadTaskAgentPubkeys],
+ );
const hiddenAgentConversationMessageIds = React.useMemo(() => {
if (!enableAgentConversations) {
return new Set();
@@ -1194,7 +1217,7 @@ export const ChannelPane = React.memo(function ChannelPane({
: undefined
}
onSelectReplyTarget={onSelectThreadReplyTarget}
- onSend={onSendThreadReply}
+ onSend={handleSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
From b89fd209ce5b75522a4e300d258f62dc695146b7 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 18:11:10 +0100
Subject: [PATCH 18/23] Wait for task marker backfill before opening links
---
desktop/src/app/routes/ChannelRouteScreen.tsx | 61 ++++++++++++++-----
.../features/channels/ui/ChannelScreen.tsx | 2 +
.../channels/ui/ChannelScreen.types.ts | 1 +
.../ui/useAgentConversationRouteTarget.ts | 6 ++
4 files changed, 55 insertions(+), 15 deletions(-)
diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx
index 2855e44af..1e1b3ff43 100644
--- a/desktop/src/app/routes/ChannelRouteScreen.tsx
+++ b/desktop/src/app/routes/ChannelRouteScreen.tsx
@@ -168,6 +168,24 @@ export function ChannelRouteScreen({
const effectiveAgentConversationReplyId = isChannelTasksEnabled
? targetAgentConversationReplyId
: null;
+ const targetAgentConversationBackfillKey =
+ effectiveAgentConversationReplyId && !selectedPostId
+ ? [
+ channelId,
+ effectiveAgentConversationReplyId,
+ targetMessageId ?? "",
+ targetThreadRootId ?? "",
+ ].join(":")
+ : null;
+ const [
+ completedTargetAgentConversationBackfillKey,
+ setCompletedTargetAgentConversationBackfillKey,
+ ] = React.useState(null);
+ const targetAgentConversationBackfillPending = Boolean(
+ targetAgentConversationBackfillKey &&
+ completedTargetAgentConversationBackfillKey !==
+ targetAgentConversationBackfillKey,
+ );
// Reset spliced target events when the channel context changes (channel
// switch or entering/leaving a forum post). Tied to channel identity rather
@@ -201,6 +219,7 @@ export function ChannelRouteScreen({
!targetThreadRootId) ||
selectedPostId
) {
+ setCompletedTargetAgentConversationBackfillKey(null);
return () => {
isCancelled = true;
};
@@ -233,21 +252,29 @@ export function ChannelRouteScreen({
effectiveAgentConversationReplyId ?? targetMessageId,
effectiveAgentConversationReplyId,
targetThreadRootId,
- ).then((events) => {
- if (!isCancelled) {
- queryClient.setQueryData(
- channelMessagesKey(channelId),
- (currentEvents) => mergeRouteEvents(currentEvents, events),
- );
- setTargetMessageEvents((currentEvents) => {
- const eventsById = new Map();
- for (const event of [...currentEvents, ...events]) {
- eventsById.set(event.id, event);
- }
- return Array.from(eventsById.values());
- });
- }
- });
+ )
+ .then((events) => {
+ if (!isCancelled) {
+ queryClient.setQueryData(
+ channelMessagesKey(channelId),
+ (currentEvents) => mergeRouteEvents(currentEvents, events),
+ );
+ setTargetMessageEvents((currentEvents) => {
+ const eventsById = new Map();
+ for (const event of [...currentEvents, ...events]) {
+ eventsById.set(event.id, event);
+ }
+ return Array.from(eventsById.values());
+ });
+ }
+ })
+ .finally(() => {
+ if (!isCancelled && targetAgentConversationBackfillKey) {
+ setCompletedTargetAgentConversationBackfillKey(
+ targetAgentConversationBackfillKey,
+ );
+ }
+ });
return () => {
isCancelled = true;
@@ -257,6 +284,7 @@ export function ChannelRouteScreen({
channelId,
queryClient,
effectiveAgentConversationReplyId,
+ targetAgentConversationBackfillKey,
targetMessageId,
targetThreadRootId,
]);
@@ -283,6 +311,9 @@ export function ChannelRouteScreen({
}}
selectedForumPostId={selectedPostId}
targetAgentConversationReplyId={effectiveAgentConversationReplyId}
+ targetAgentConversationBackfillPending={
+ targetAgentConversationBackfillPending
+ }
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 3d7ddc6af..ac79b84d4 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -88,6 +88,7 @@ export function ChannelScreen({
onCloseForumPost,
onSelectForumPost,
selectedForumPostId,
+ targetAgentConversationBackfillPending = false,
targetAgentConversationReplyId,
targetForumReplyId,
targetMessageEvents,
@@ -743,6 +744,7 @@ export function ChannelScreen({
goChannel,
messageProfilesReady,
openAgentConversation,
+ targetBackfillPending: targetAgentConversationBackfillPending,
targetAgentConversationReplyId: isChannelTasksEnabled
? targetAgentConversationReplyId
: null,
diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts
index 5401c44b1..24c23745a 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.types.ts
+++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts
@@ -12,6 +12,7 @@ export type ChannelScreenProps = {
onCloseForumPost: () => void;
onSelectForumPost: (postId: string) => void;
selectedForumPostId: string | null;
+ targetAgentConversationBackfillPending?: boolean;
targetAgentConversationReplyId: string | null;
targetForumReplyId: string | null;
targetMessageEvents: RelayEvent[];
diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
index b4e7029d6..09f10bbd2 100644
--- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
+++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
@@ -31,6 +31,7 @@ type UseAgentConversationRouteTargetInput = {
input: OpenAgentConversationInput,
options?: { publishMarker?: boolean },
) => void;
+ targetBackfillPending: boolean;
targetAgentConversationReplyId: string | null;
timelineMessages: readonly TimelineMessage[];
};
@@ -63,6 +64,7 @@ export function useAgentConversationRouteTarget({
goChannel,
messageProfilesReady,
openAgentConversation,
+ targetBackfillPending,
targetAgentConversationReplyId,
timelineMessages,
}: UseAgentConversationRouteTargetInput) {
@@ -98,6 +100,9 @@ export function useAgentConversationRouteTarget({
if (!sourceMessage) {
return;
}
+ if (!marker && targetBackfillPending) {
+ return;
+ }
if (!marker?.agentPubkey && !agentLookupReady) {
return;
}
@@ -160,6 +165,7 @@ export function useAgentConversationRouteTarget({
goChannel,
messageProfilesReady,
openAgentConversation,
+ targetBackfillPending,
targetAgentConversationReplyId,
timelineMessages,
]);
From ab74fcfe49d10a6de55231f17aa32a83b4f2f8a2 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 20:23:17 +0100
Subject: [PATCH 19/23] Restore task auto-route exports after rebase
---
desktop/src/features/agents/agentConversations.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts
index 9d1e1643b..cb930fcf9 100644
--- a/desktop/src/features/agents/agentConversations.ts
+++ b/desktop/src/features/agents/agentConversations.ts
@@ -83,6 +83,11 @@ export type AgentConversationRecapInput = {
messages: readonly TimelineMessage[];
};
+export type AgentConversationRouteableParticipant = {
+ canMessage: boolean;
+ pubkey: string;
+};
+
function normalizeAgentConversationStorageScope(
workspaceScope: string | null | undefined,
): string {
From db57069135eaf1dc55ce8ef170e5c14c6245cde5 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 21:05:34 +0100
Subject: [PATCH 20/23] Limit DM task inference to one agent participant
---
.../channels/ui/ChannelPane.helpers.test.mjs | 12 ++++++++++
.../channels/ui/ChannelPane.helpers.ts | 23 ++++++++++++-------
2 files changed, 27 insertions(+), 8 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
index ab79b6d49..517dec71b 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
@@ -104,6 +104,18 @@ test("DM task agent inference requires exactly one other known agent", () => {
[],
);
+ assert.deepEqual(
+ getDmTaskAgentPubkeys({
+ channel: channel({
+ channelType: "dm",
+ participantPubkeys: ["human", "agent-one", "human-two"],
+ }),
+ currentPubkey: "human",
+ knownAgentPubkeys,
+ }),
+ [],
+ );
+
assert.deepEqual(
getDmTaskAgentPubkeys({
channel: channel({
diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
index bcb47c02b..29b8bc837 100644
--- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts
@@ -110,15 +110,22 @@ export function getDmTaskAgentPubkeys({
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
+ const otherParticipants = new Map();
- return singleKnownAgentPubkey(
- channel.participantPubkeys.filter(
- (pubkey) =>
- !normalizedCurrentPubkey ||
- normalizePubkey(pubkey) !== normalizedCurrentPubkey,
- ),
- knownAgentPubkeys,
- );
+ for (const pubkey of channel.participantPubkeys) {
+ const normalized = normalizePubkey(pubkey);
+ if (normalizedCurrentPubkey && normalized === normalizedCurrentPubkey) {
+ continue;
+ }
+
+ otherParticipants.set(normalized, pubkey);
+ }
+
+ if (otherParticipants.size !== 1) {
+ return [];
+ }
+
+ return singleKnownAgentPubkey(otherParticipants.values(), knownAgentPubkeys);
}
export function getThreadTaskAgentPubkeys({
From 7353a2e7a39975d8c9018b655a710fba7efb678a Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 21:31:33 +0100
Subject: [PATCH 21/23] Fix task markdown rebase cleanup
---
desktop/scripts/check-file-sizes.mjs | 5 +++--
desktop/src/shared/ui/markdown.tsx | 8 --------
2 files changed, 3 insertions(+), 10 deletions(-)
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 387ca3ac8..4b5e0c207 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -170,8 +170,9 @@ const overrides = new Map([
// Shared UI was added to this guard after splitting globals/markdown so
// large shared renderers cannot grow further while follow-up splits land.
// continued-agent-conversations: task-link card renderer and marker lookup
- // are temporarily housed here until markdown renderers are split further.
- ["src/shared/ui/markdown.tsx", 2258],
+ // plus experiment-gate wiring are temporarily housed here until markdown
+ // renderers are split further.
+ ["src/shared/ui/markdown.tsx", 2279],
["src/shared/ui/VideoPlayer.tsx", 2199],
["src/shared/ui/sidebar.tsx", 1042],
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index a87755213..c507943ad 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -2111,14 +2111,6 @@ function MarkdownInner({
},
[goChannel],
);
- const onOpenAgentConversationLink = React.useCallback(
- (link: ParsedAgentConversationLink) => {
- void goChannel(link.channelId, {
- taskReplyId: link.agentReplyId,
- });
- },
- [goChannel],
- );
const onOpenMessageLink = React.useCallback(
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
From e05076f14bda87f489baf6714a5d8be2f29a11b4 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Mon, 29 Jun 2026 21:56:32 +0100
Subject: [PATCH 22/23] Fix task link DM agent inference
---
.../features/channels/ui/ChannelScreen.tsx | 1 +
.../ui/useAgentConversationRouteTarget.ts | 29 ++++++-------------
2 files changed, 10 insertions(+), 20 deletions(-)
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index ac79b84d4..9ef61e234 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -740,6 +740,7 @@ export function ChannelScreen({
agentConversationMarkers,
agentLookupReady,
agentPubkeys: routingAgentPubkeys,
+ currentPubkey,
enabled: isChannelTasksEnabled,
goChannel,
messageProfilesReady,
diff --git a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
index 09f10bbd2..4a66cceb0 100644
--- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
+++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts
@@ -8,6 +8,7 @@ import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTime
import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
+import { getDmTaskAgentPubkeys } from "./ChannelPane.helpers";
type GoChannel = (
channelId: string,
@@ -24,6 +25,7 @@ type UseAgentConversationRouteTargetInput = {
agentConversationMarkers: readonly AgentConversationMarker[];
agentPubkeys: ReadonlySet;
agentLookupReady: boolean;
+ currentPubkey?: string;
enabled: boolean;
goChannel: GoChannel;
messageProfilesReady: boolean;
@@ -36,30 +38,12 @@ type UseAgentConversationRouteTargetInput = {
timelineMessages: readonly TimelineMessage[];
};
-function getSingleDmAgentPubkey(
- channel: Channel,
- agentPubkeys: ReadonlySet,
-) {
- if (channel.channelType !== "dm") {
- return "";
- }
-
- const dmAgentPubkeys = new Map();
- for (const pubkey of channel.participantPubkeys) {
- const normalized = normalizePubkey(pubkey);
- if (agentPubkeys.has(normalized)) {
- dmAgentPubkeys.set(normalized, pubkey);
- }
- }
-
- return dmAgentPubkeys.size === 1 ? [...dmAgentPubkeys.values()][0] : "";
-}
-
export function useAgentConversationRouteTarget({
activeChannel,
agentConversationMarkers,
agentLookupReady,
agentPubkeys,
+ currentPubkey,
enabled,
goChannel,
messageProfilesReady,
@@ -114,7 +98,11 @@ export function useAgentConversationRouteTarget({
collectMessageMentionPubkeys([sourceMessage]).find((pubkey) =>
agentPubkeys.has(normalizePubkey(pubkey)),
) ?? "";
- const dmAgentPubkey = getSingleDmAgentPubkey(activeChannel, agentPubkeys);
+ const [dmAgentPubkey = ""] = getDmTaskAgentPubkeys({
+ channel: activeChannel,
+ currentPubkey,
+ knownAgentPubkeys: agentPubkeys,
+ });
const taskAgentPubkey =
marker?.agentPubkey ||
(sourceAuthorIsAgent ? (sourceMessage.pubkey ?? "") : "") ||
@@ -161,6 +149,7 @@ export function useAgentConversationRouteTarget({
agentConversationMarkers,
agentLookupReady,
agentPubkeys,
+ currentPubkey,
enabled,
goChannel,
messageProfilesReady,
From 5e28bde92786683af15f2390bab2e5ce7caa45ad Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Tue, 30 Jun 2026 07:42:04 +0100
Subject: [PATCH 23/23] Hide new task action in read-only channels
---
desktop/src/features/channels/ui/ChannelPane.tsx | 8 ++++++++
desktop/src/features/messages/ui/MessageRow.tsx | 7 ++++++-
desktop/src/features/messages/ui/MessageThreadPanel.tsx | 4 ++++
desktop/src/features/messages/ui/MessageTimeline.tsx | 3 +++
desktop/src/features/messages/ui/TimelineMessageList.tsx | 8 ++++++++
5 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 520f6e916..1153d9ee6 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -534,6 +534,12 @@ export const ChannelPane = React.memo(function ChannelPane({
resolveTaskAgentForMessage,
],
);
+ const canCreateAgentConversation = React.useMemo(
+ () =>
+ enableAgentConversations &&
+ canOpenAgentConversationInChannel({ channel: activeChannel }),
+ [activeChannel, enableAgentConversations],
+ );
React.useEffect(() => {
if (!pendingAgentConversationOpen) {
return;
@@ -1003,6 +1009,7 @@ export const ChannelPane = React.memo(function ChannelPane({
directMessageIntro={directMessageIntro}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
+ canCreateAgentConversation={canCreateAgentConversation}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
@@ -1216,6 +1223,7 @@ export const ChannelPane = React.memo(function ChannelPane({
? handleOpenAgentConversation
: undefined
}
+ canCreateAgentConversation={canCreateAgentConversation}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={handleSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index af3c2c2e3..dfc862562 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -118,6 +118,7 @@ export const MessageRow = React.memo(
function MessageRow({
channelId = null,
collapseDepthGuideActions,
+ canCreateAgentConversation = true,
connectDescendants = false,
depthGuideDepths,
highlighted = false,
@@ -155,6 +156,7 @@ export const MessageRow = React.memo(
}: {
agentConversationMarkers?: readonly AgentConversationMarker[];
agentPubkeys?: ReadonlySet;
+ canCreateAgentConversation?: boolean;
channelId?: string | null;
collapseDepthGuideActions?: ReadonlyArray;
connectDescendants?: boolean;
@@ -471,7 +473,9 @@ export const MessageRow = React.memo(
isUnread={isUnread}
message={message}
onContinueConversation={
- message.pending ? undefined : onOpenAgentConversation
+ message.pending || !canCreateAgentConversation
+ ? undefined
+ : onOpenAgentConversation
}
onDelete={onDelete}
onEdit={onEdit}
@@ -867,6 +871,7 @@ export const MessageRow = React.memo(
prev.message.personaDisplayName === next.message.personaDisplayName &&
prev.agentConversationMarkers === next.agentConversationMarkers &&
prev.agentPubkeys === next.agentPubkeys &&
+ prev.canCreateAgentConversation === next.canCreateAgentConversation &&
prev.collapseDepthGuideActions === next.collapseDepthGuideActions &&
prev.collapseDescendantsLabel === next.collapseDescendantsLabel &&
prev.connectDescendants === next.connectDescendants &&
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index a72961501..33096039f 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -44,6 +44,7 @@ type MessageThreadPanelProps = {
channelId: string | null;
channelName: string;
currentPubkey?: string;
+ canCreateAgentConversation?: boolean;
disabled?: boolean;
enableAgentConversationLinks?: boolean;
firstUnreadReplyId?: string | null;
@@ -356,6 +357,7 @@ export function MessageThreadPanel({
channelId,
channelName,
currentPubkey,
+ canCreateAgentConversation = true,
disabled = false,
enableAgentConversationLinks = false,
firstUnreadReplyId,
@@ -518,6 +520,7 @@ export function MessageThreadPanel({
isFollowingThread={isFollowingThread}
isUnread={isMessageUnreadById?.(threadHead.id)}
message={threadHead}
+ canCreateAgentConversation={canCreateAgentConversation}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
@@ -584,6 +587,7 @@ export function MessageThreadPanel({
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isUnread={isMessageUnreadById?.(entry.message.id)}
message={entry.message}
+ canCreateAgentConversation={canCreateAgentConversation}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 5a2901693..1f3e335b2 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -47,6 +47,7 @@ type MessageTimelineProps = {
emptyTitle?: string;
emptyDescription?: string;
currentPubkey?: string;
+ canCreateAgentConversation?: boolean;
fetchOlder?: () => Promise;
hasOlderMessages?: boolean;
/** Optional external ref to the scroll container — used by the parent to
@@ -159,6 +160,7 @@ const MessageTimelineBase = React.forwardRef<
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
currentPubkey,
+ canCreateAgentConversation = true,
fetchOlder,
hasComposerOverlay = true,
contentTopPadding = "chrome",
@@ -619,6 +621,7 @@ const MessageTimelineBase = React.forwardRef<
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
+ canCreateAgentConversation={canCreateAgentConversation}
firstUnreadMessageId={firstUnreadMessageId}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 699dab149..fb766e89f 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -33,6 +33,7 @@ type TimelineMessageListProps = {
channelName?: string;
channelType?: ChannelType | null;
currentPubkey?: string;
+ canCreateAgentConversation?: boolean;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
/** Event id of the oldest unread top-level message; renders a "New" divider above it. */
@@ -90,6 +91,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
channelName,
channelType,
currentPubkey,
+ canCreateAgentConversation = true,
firstUnreadMessageId = null,
followThreadById,
highlightedMessageId = null,
@@ -214,6 +216,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
agentConversationMarker={agentConversationMarkerByMessageId.get(
item.entry.message.id,
)}
+ canCreateAgentConversation={canCreateAgentConversation}
channelId={channelId}
currentPubkey={currentPubkey}
entry={item.entry}
@@ -248,6 +251,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
agentConversationMarkers,
agentPubkeys,
agentConversationMarkerByMessageId,
+ canCreateAgentConversation,
channelId,
currentPubkey,
followThreadById,
@@ -315,6 +319,7 @@ type MessageRowItemProps = Pick<
TimelineMessageListProps,
| "agentPubkeys"
| "agentConversationMarkers"
+ | "canCreateAgentConversation"
| "channelId"
| "currentPubkey"
| "followThreadById"
@@ -347,6 +352,7 @@ function MessageRowItem({
agentPubkeys,
agentConversationMarkers,
agentConversationMarker,
+ canCreateAgentConversation,
channelId,
currentPubkey,
entry,
@@ -402,6 +408,7 @@ function MessageRowItem({