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({