diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8e6dd3e50..4b5e0c207 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 @@ -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/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..1e1b3ff43 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,27 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); return cachedTarget ? [cachedTarget] : []; }); + 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 @@ -191,11 +214,12 @@ 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 ) { + setCompletedTargetAgentConversationBackfillKey(null); return () => { isCancelled = true; }; @@ -215,7 +239,7 @@ export function ChannelRouteScreen({ } const eventIds = [ - targetAgentConversationReplyId, + effectiveAgentConversationReplyId, targetMessageId, targetThreadRootId && targetThreadRootId !== targetMessageId ? targetThreadRootId @@ -225,24 +249,32 @@ export function ChannelRouteScreen({ void fetchRouteTargetEvents( channelId, eventIds, - targetAgentConversationReplyId ?? targetMessageId, - targetAgentConversationReplyId, + 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; @@ -251,7 +283,8 @@ export function ChannelRouteScreen({ selectedPostId, channelId, queryClient, - targetAgentConversationReplyId, + effectiveAgentConversationReplyId, + targetAgentConversationBackfillKey, targetMessageId, targetThreadRootId, ]); @@ -277,7 +310,10 @@ export function ChannelRouteScreen({ void goForumPost(channelId, postId); }} selectedForumPostId={selectedPostId} - targetAgentConversationReplyId={targetAgentConversationReplyId} + targetAgentConversationReplyId={effectiveAgentConversationReplyId} + targetAgentConversationBackfillPending={ + targetAgentConversationBackfillPending + } targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index 3facb2fcf..0041407af 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -156,7 +156,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", @@ -166,15 +171,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", @@ -227,6 +232,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"; @@ -266,6 +281,34 @@ 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, + id: "root", + }); + const conversation = buildAgentConversation({ + agentName: "", + agentPubkey: "", + agentReply: root, + channel: { id: "channel", name: "general" }, + contextMessages: [root], + parentMessage: null, + threadRootMessage: root, + }); + + writePersistedAgentConversations("human", workspaceScope, [conversation]); + const persisted = readPersistedAgentConversations("human", workspaceScope); + + 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({ @@ -517,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 7a10a31cf..cb930fcf9 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[]; @@ -263,8 +264,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); @@ -294,8 +295,6 @@ function parseStoredAgentConversation( if ( !id || - !agentName || - !agentPubkey || !agentReply || !channelId || !channelName || @@ -474,7 +473,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 @@ -483,7 +482,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") ?? @@ -501,7 +500,7 @@ export function parseAgentConversationMarker( ? content.startedAt : event.created_at; - if (!channelId || !threadRootId || !agentReplyId || !agentPubkey) { + if (!channelId || !threadRootId || !agentReplyId) { return null; } @@ -629,16 +628,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( @@ -680,6 +683,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; @@ -697,6 +703,9 @@ export function getHiddenAgentConversationMessageIds( if (anchorThreadRootId !== marker.threadRootId) { continue; } + if (anchorMessage.pubkey !== marker.agentPubkey) { + continue; + } const anchorMessageIds = anchorMessageIdsByThreadRootId.get(marker.threadRootId) ?? new Set(); 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 78d797f64..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); @@ -863,6 +867,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.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index 0f91d53a2..517dec71b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -3,8 +3,8 @@ import test from "node:test"; import { canOpenAgentConversationInChannel, - getDmAutoRouteAgentPubkeys, - getThreadAutoRouteAgentPubkeys, + getDmTaskAgentPubkeys, + getThreadTaskAgentPubkeys, mergeAutoRouteMentionPubkeys, } from "./ChannelPane.helpers.ts"; @@ -50,7 +50,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,11 +67,21 @@ 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", () => { +test("auto-routed mentions merge with explicit mentions without duplicates", () => { + assert.deepEqual( + mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: ["AGENT-ONE"], + mentionPubkeys: ["agent-one", "agent-two"], + }), + ["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( - getDmAutoRouteAgentPubkeys({ + getDmTaskAgentPubkeys({ channel: channel({ channelType: "dm", participantPubkeys: ["human", "agent-one"], @@ -84,7 +93,7 @@ test("DM composer auto-routes only when exactly one other participant is an agen ); assert.deepEqual( - getDmAutoRouteAgentPubkeys({ + getDmTaskAgentPubkeys({ channel: channel({ channelType: "dm", participantPubkeys: ["human", "agent-one", "agent-two"], @@ -96,7 +105,7 @@ test("DM composer auto-routes only when exactly one other participant is an agen ); assert.deepEqual( - getDmAutoRouteAgentPubkeys({ + getDmTaskAgentPubkeys({ channel: channel({ channelType: "dm", participantPubkeys: ["human", "agent-one", "human-two"], @@ -108,7 +117,7 @@ test("DM composer auto-routes only when exactly one other participant is an agen ); assert.deepEqual( - getDmAutoRouteAgentPubkeys({ + getDmTaskAgentPubkeys({ channel: channel({ participantPubkeys: ["human", "agent-one"], }), @@ -119,74 +128,57 @@ test("DM composer auto-routes only when exactly one other participant is an agen ); }); -test("auto-routed mentions merge with explicit mentions without duplicates", () => { - assert.deepEqual( - mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: ["AGENT-ONE"], - mentionPubkeys: ["agent-one", "agent-two"], - }), - ["AGENT-ONE", "agent-two"], - ); -}); - -test("thread composer auto-routes exactly one current human and one 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( - getThreadAutoRouteAgentPubkeys({ + getThreadTaskAgentPubkeys({ currentPubkey: "human", knownAgentPubkeys, messages: [ - { id: "root", pubkey: "human", tags: [["p", "agent-one"]] }, - { id: "reply", pubkey: "agent-one", tags: [] }, + { + pubkey: "human", + tags: [["p", "agent-one"]], + }, + { + pubkey: "agent-one", + tags: [["p", "human"]], + }, ], }), ["agent-one"], ); assert.deepEqual( - getThreadAutoRouteAgentPubkeys({ + getThreadTaskAgentPubkeys({ 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"], - ], + pubkey: "human", + tags: [["p", "agent-one"]], + }, + { + pubkey: "other-human", + tags: [["p", "human"]], }, - { id: "reply", pubkey: "agent-one", tags: [] }, ], }), [], ); assert.deepEqual( - getThreadAutoRouteAgentPubkeys({ + getThreadTaskAgentPubkeys({ currentPubkey: "human", knownAgentPubkeys, messages: [ { - id: "root", pubkey: "human", - tags: [ - ["p", "agent-one"], - ["p", "agent-two"], - ], + 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 d3669df32..29b8bc837 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -1,9 +1,9 @@ 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"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; export function getChannelIntroKind(channel: Channel): string { const isPrivate = channel.visibility === "private"; @@ -94,7 +94,7 @@ function singleKnownAgentPubkey( return agentPubkeys.size === 1 ? [...agentPubkeys.values()] : []; } -export function getDmAutoRouteAgentPubkeys({ +export function getDmTaskAgentPubkeys({ channel, currentPubkey, knownAgentPubkeys, @@ -110,11 +110,11 @@ export function getDmAutoRouteAgentPubkeys({ const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) : null; - const otherParticipants = new Map(); + for (const pubkey of channel.participantPubkeys) { const normalized = normalizePubkey(pubkey); - if (!normalized || normalized === normalizedCurrentPubkey) { + if (normalizedCurrentPubkey && normalized === normalizedCurrentPubkey) { continue; } @@ -128,7 +128,7 @@ export function getDmAutoRouteAgentPubkeys({ return singleKnownAgentPubkey(otherParticipants.values(), knownAgentPubkeys); } -export function getThreadAutoRouteAgentPubkeys({ +export function getThreadTaskAgentPubkeys({ currentPubkey, knownAgentPubkeys, messages, @@ -137,49 +137,42 @@ export function getThreadAutoRouteAgentPubkeys({ knownAgentPubkeys: ReadonlySet; messages: readonly TimelineMessage[]; }) { - const agentPubkeys = new Map(); - const humanPubkeys = new Set(); const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) : null; + const participants = new Map(); - const addAuthor = (pubkey?: string | null) => { - if (!pubkey) return; + const addParticipant = (pubkey: string | null | undefined) => { + if (!pubkey) { + return; + } const normalized = normalizePubkey(pubkey); - if (!normalized) return; - if (knownAgentPubkeys.has(normalized)) { - agentPubkeys.set(normalized, pubkey); + if (!normalized || participants.has(normalized)) { return; } - humanPubkeys.add(normalized); + participants.set(normalized, pubkey); }; for (const message of messages) { - addAuthor(message.pubkey); + addParticipant(message.pubkey); + for (const tag of message.tags ?? []) { + addParticipant(getMentionTagPubkey(tag)); + } } - for (const pubkey of collectMessageMentionPubkeys([...messages])) { - const normalized = normalizePubkey(pubkey); - if (!normalized) { - continue; - } + const agentPubkeys = new Map(); - if (knownAgentPubkeys.has(normalized)) { - agentPubkeys.set(normalized, pubkey); + for (const [normalized, pubkey] of participants) { + if (normalizedCurrentPubkey && normalized === normalizedCurrentPubkey) { continue; } - - humanPubkeys.add(normalized); - } - - if (agentPubkeys.size !== 1 || humanPubkeys.size !== 1) { - return []; - } - if (normalizedCurrentPubkey && !humanPubkeys.has(normalizedCurrentPubkey)) { - return []; + if (!knownAgentPubkeys.has(normalized)) { + return []; + } + agentPubkeys.set(normalized, pubkey); } - return [...agentPubkeys.values()]; + return agentPubkeys.size === 1 ? [...agentPubkeys.values()] : []; } export function mergeAutoRouteMentionPubkeys({ @@ -210,3 +203,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 afbf7cb58..1153d9ee6 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -43,9 +43,10 @@ import { canOpenAgentConversationInChannel, getChannelIntroDescription, getChannelIntroKind, - getThreadAutoRouteAgentPubkeys, + getDmTaskAgentPubkeys, + getThreadTaskAgentPubkeys, isWelcomeSetupSystemMessage, - mergeAutoRouteMentionPubkeys, + mergeTaskAgentMentionPubkeys, mentionsKnownAgent, } from "@/features/channels/ui/ChannelPane.helpers"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; @@ -53,6 +54,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"; @@ -61,9 +63,11 @@ 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, + agentLookupReady = true, agentPubkeys, agentPubkeysPending = false, agentSessionAgents, @@ -73,6 +77,7 @@ export const ChannelPane = React.memo(function ChannelPane({ channelManagementOpen = false, currentPubkey, editTarget = null, + enableAgentConversations = true, fetchOlder, header, hasOlderMessages, @@ -154,6 +159,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); @@ -166,7 +177,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( @@ -174,6 +185,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(() => { @@ -278,6 +292,106 @@ export const ChannelPane = React.memo(function ChannelPane({ return pubkeys; }, [activityAgents, agentPubkeys, agentSessionAgents]); + const dmTaskAgentPubkeys = React.useMemo( + () => + getDmTaskAgentPubkeys({ + channel: activeChannel, + currentPubkey, + knownAgentPubkeys, + }), + [activeChannel, currentPubkey, knownAgentPubkeys], + ); + 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) => { + 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), + ); + if (directAgent) { + return { + name: message.author?.trim() || directAgent.name, + 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])) { + const mentionedAgent = knownAgentByPubkey.get(normalizePubkey(pubkey)); + if (mentionedAgent) { + return mentionedAgent; + } + } + + for (const pubkey of dmTaskAgentPubkeys) { + const dmAgent = knownAgentByPubkey.get(normalizePubkey(pubkey)); + if (dmAgent) { + return dmAgent; + } + } + + return null; + }, + [ + activeAgentConversationMarkers, + activeChannelId, + dmTaskAgentPubkeys, + knownAgentByPubkey, + ], + ); const completeWelcomeComposerBanner = React.useCallback(() => { if (!activeChannelId || !isActiveWelcomeChannel) { return; @@ -309,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(); @@ -323,6 +441,7 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [ completeWelcomeComposerBanner, + dmTaskAgentPubkeys, isActiveWelcomeChannel, knownAgentPubkeys, onSendMessage, @@ -334,11 +453,16 @@ 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 || - !message.pubkey || + message.pending || !canOpenAgentConversationInChannel({ channel: activeChannel, publishMarker: options?.publishMarker, @@ -357,8 +481,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, @@ -374,8 +498,85 @@ export const ChannelPane = React.memo(function ChannelPane({ options, ); }, - [activeChannel, messages, openAgentConversation], + [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, + openResolvedAgentConversation, + resolveTaskAgentForMessage, + ], + ); + const canCreateAgentConversation = React.useMemo( + () => + enableAgentConversations && + canOpenAgentConversationInChannel({ channel: activeChannel }), + [activeChannel, enableAgentConversations], + ); + 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, @@ -463,7 +664,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, )) @@ -480,6 +682,7 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [ activityAgents, agentConversationMarkers, + enableAgentConversations, openThreadHeadId, threadComposerBotTypingPubkeys, ]); @@ -574,26 +777,27 @@ 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 threadTaskAgentPubkeys = getThreadTaskAgentPubkeys({ + currentPubkey, + knownAgentPubkeys, + messages: threadSourceMessages, + }); const handleSendThreadReply = React.useCallback( (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => { - const sendMentionPubkeys = mergeAutoRouteMentionPubkeys({ - autoRouteAgentPubkeys: threadAutoRouteAgentPubkeys, + const sendMentionPubkeys = mergeTaskAgentMentionPubkeys({ + agentPubkeys: threadTaskAgentPubkeys, mentionPubkeys, }); + return onSendThreadReply(content, sendMentionPubkeys, mediaTags); }, - [onSendThreadReply, threadAutoRouteAgentPubkeys], + [onSendThreadReply, threadTaskAgentPubkeys], ); const hiddenAgentConversationMessageIds = React.useMemo(() => { + if (!enableAgentConversations) { + return new Set(); + } + const hiddenIds = getHiddenAgentConversationMessageIds( baseVisibleMessages, agentConversationMarkers, @@ -619,6 +823,7 @@ export const ChannelPane = React.memo(function ChannelPane({ agentConversationMarkers, baseVisibleMessages, channelFind.activeMatch?.messageId, + enableAgentConversations, targetMessageId, threadScrollTargetId, threadSourceMessages, @@ -781,7 +986,7 @@ export const ChannelPane = React.memo(function ChannelPane({ {isTasksSurface ? ( { const panel = ( ; agentPubkeysPending?: boolean; @@ -30,6 +31,7 @@ export type ChannelPaneProps = { id: string; imetaMedia?: ImetaMedia[]; } | null; + enableAgentConversations?: boolean; fetchOlder?: () => 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 07bd395df..9ef61e234 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, @@ -61,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"; @@ -91,11 +88,13 @@ export function ChannelScreen({ onCloseForumPost, onSelectForumPost, selectedForumPostId, + targetAgentConversationBackfillPending = false, targetAgentConversationReplyId, targetForumReplyId, targetMessageEvents, targetMessageId, }: ChannelScreenProps) { + const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID); const { goChannel, goHome } = useAppNavigation(); const [activeSurfaceTab, setActiveSurfaceTab] = React.useState("messages"); @@ -163,7 +162,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"; @@ -342,6 +342,11 @@ export function ChannelScreen({ const managedAgents = managedAgentsQuery.data ?? []; const relayAgentsQuery = useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; + const agentLookupReady = + !channelMembersQuery.isLoading && + !managedAgentsQuery.isLoading && + messageProfilesReady && + !relayAgentsQuery.isLoading; const agentPubkeys = React.useMemo(() => { const pubkeys = new Set(); for (const member of channelMembers ?? []) { @@ -435,15 +440,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 ?? []; @@ -492,7 +488,11 @@ export function ChannelScreen({ ); }, []); const { agentConversationMarkers, unreadTimelineMessages } = - useAgentConversationTimelineState(resolvedMessages, timelineMessages); + useAgentConversationTimelineState( + resolvedMessages, + timelineMessages, + isChannelTasksEnabled, + ); const channelFind = useChannelFind({ channelId: activeChannelId, messages: timelineMessages, @@ -564,23 +564,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 @@ -724,6 +707,10 @@ export function ChannelScreen({ }, [activeChannelId, resetComposerTargets]); const handleSurfaceTabChange = React.useCallback( (tab: ChannelSurfaceTab) => { + if (tab === "tasks" && !isChannelTasksEnabled) { + return; + } + setActiveSurfaceTab(tab); if (tab !== "tasks") { @@ -742,6 +729,7 @@ export function ChannelScreen({ [ clearOptimisticThreadOverride, handleCloseAgentSession, + isChannelTasksEnabled, setChannelManagementOpen, setOpenThreadHeadId, setProfilePanelPubkey, @@ -749,11 +737,18 @@ export function ChannelScreen({ ); useAgentConversationRouteTarget({ activeChannel, - activeChannelId, + agentConversationMarkers, + agentLookupReady, + agentPubkeys: routingAgentPubkeys, + currentPubkey, + enabled: isChannelTasksEnabled, goChannel, messageProfilesReady, openAgentConversation, - targetAgentConversationReplyId, + targetBackfillPending: targetAgentConversationBackfillPending, + targetAgentConversationReplyId: isChannelTasksEnabled + ? targetAgentConversationReplyId + : null, timelineMessages, }); const { mainTimelineTargetMessageId, rootThreadHeadTargetId } = @@ -935,7 +930,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"} @@ -954,6 +951,7 @@ export function ChannelScreen({ effectiveSurfaceTab, handleSurfaceTabChange, isAddBotOpen, + isChannelTasksEnabled, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, handleManageChannel, @@ -992,6 +990,7 @@ export function ChannelScreen({ activeChannel={activeChannel} activityAgents={channelAgentSessionAgents} agentConversationMarkers={agentConversationMarkers} + agentLookupReady={agentLookupReady} agentPubkeys={routingAgentPubkeys} agentPubkeysPending={agentPubkeysPending} agentSessionAgents={agentSessionAgents} @@ -999,6 +998,7 @@ export function ChannelScreen({ channelFind={channelFind} channelManagementOpen={channelManagementOpen} currentPubkey={currentPubkey} + enableAgentConversations={isChannelTasksEnabled} canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} header={channelHeader} @@ -1063,7 +1063,7 @@ export function ChannelScreen({ onCloseProfilePanel={handleCloseProfilePanel} onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessageWithDmAutoRoute} + onSendMessage={handleSendMessage} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} onThreadScrollTargetChange={setThreadScrollTargetId} 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/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/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/channels/ui/useAgentConversationRouteTarget.ts b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts index 5637d3968..4a66cceb0 100644 --- a/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts +++ b/desktop/src/features/channels/ui/useAgentConversationRouteTarget.ts @@ -1,44 +1,66 @@ 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 { 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"; +import { getDmTaskAgentPubkeys } from "./ChannelPane.helpers"; -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; + agentLookupReady: boolean; + currentPubkey?: string; + enabled: boolean; goChannel: GoChannel; messageProfilesReady: boolean; - openAgentConversation: OpenAgentConversation; + openAgentConversation: ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, + ) => void; + targetBackfillPending: boolean; targetAgentConversationReplyId: string | null; timelineMessages: readonly TimelineMessage[]; }; export function useAgentConversationRouteTarget({ activeChannel, - activeChannelId, + agentConversationMarkers, + agentLookupReady, + agentPubkeys, + currentPubkey, + enabled, goChannel, messageProfilesReady, openAgentConversation, + targetBackfillPending, 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 +71,59 @@ 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; + } + if (!marker && targetBackfillPending) { + return; + } + if (!marker?.agentPubkey && !agentLookupReady) { return; } - const rootId = agentReply.rootId ?? agentReply.parentId ?? agentReply.id; + const sourceAuthorIsAgent = sourceMessage.pubkey + ? agentPubkeys.has(normalizePubkey(sourceMessage.pubkey)) + : false; + const mentionedAgentPubkey = + collectMessageMentionPubkeys([sourceMessage]).find((pubkey) => + agentPubkeys.has(normalizePubkey(pubkey)), + ) ?? ""; + const [dmAgentPubkey = ""] = getDmTaskAgentPubkeys({ + channel: activeChannel, + currentPubkey, + knownAgentPubkeys: agentPubkeys, + }); + const taskAgentPubkey = + marker?.agentPubkey || + (sourceAuthorIsAgent ? (sourceMessage.pubkey ?? "") : "") || + mentionedAgentPubkey || + dmAgentPubkey; + const taskAgentName = + marker?.agentName || + (sourceAuthorIsAgent && taskAgentPubkey ? sourceMessage.author : "") || + taskAgentPubkey; + 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 +133,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,10 +146,15 @@ export function useAgentConversationRouteTarget({ }); }, [ activeChannel, - activeChannelId, + agentConversationMarkers, + agentLookupReady, + agentPubkeys, + currentPubkey, + enabled, goChannel, messageProfilesReady, openAgentConversation, + targetBackfillPending, targetAgentConversationReplyId, timelineMessages, ]); 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..5b69688cd 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 = false, 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..9e4831169 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 = false, 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/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 7ba31d80f..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; @@ -272,10 +274,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 +473,9 @@ export const MessageRow = React.memo( isUnread={isUnread} message={message} onContinueConversation={ - isAgentMessage ? onOpenAgentConversation : undefined + message.pending || !canCreateAgentConversation + ? undefined + : onOpenAgentConversation } onDelete={onDelete} onEdit={onEdit} @@ -871,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 0545263be..33096039f 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -44,7 +44,9 @@ type MessageThreadPanelProps = { channelId: string | null; channelName: string; currentPubkey?: string; + canCreateAgentConversation?: boolean; disabled?: boolean; + enableAgentConversationLinks?: boolean; firstUnreadReplyId?: string | null; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; @@ -355,7 +357,9 @@ export function MessageThreadPanel({ channelId, channelName, currentPubkey, + canCreateAgentConversation = true, disabled = false, + enableAgentConversationLinks = false, firstUnreadReplyId, huddleMemberPubkeys, huddleMemberPubkeysPending = false, @@ -516,6 +520,7 @@ export function MessageThreadPanel({ isFollowingThread={isFollowingThread} isUnread={isMessageUnreadById?.(threadHead.id)} message={threadHead} + canCreateAgentConversation={canCreateAgentConversation} onDelete={ onDelete && canManageMessage(threadHead, currentPubkey) ? onDelete @@ -582,6 +587,7 @@ export function MessageThreadPanel({ huddleMemberPubkeysPending={huddleMemberPubkeysPending} isUnread={isMessageUnreadById?.(entry.message.id)} message={entry.message} + canCreateAgentConversation={canCreateAgentConversation} onDelete={ onDelete && canManageMessage(entry.message, currentPubkey) @@ -680,6 +686,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/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({ + 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 2235d14d1..c507943ad 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(); @@ -2147,24 +2156,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(() => { @@ -2193,7 +2212,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/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) { 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" + ] } ] }