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"
+ ]
}
]
}