diff --git a/desktop/src/features/agents/agentConversationLink.ts b/desktop/src/features/agents/agentConversationLink.ts
index 9aceee197..f22f0e0c9 100644
--- a/desktop/src/features/agents/agentConversationLink.ts
+++ b/desktop/src/features/agents/agentConversationLink.ts
@@ -1,5 +1,8 @@
const AGENT_CONVERSATION_LINK_SCHEME = "buzz:";
const AGENT_CONVERSATION_LINK_HOST = "task";
+export const AGENT_CONVERSATION_LINK_URL_PATTERN =
+ /buzz:\/\/task\?[^\s<>"')\]]+/g;
+const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
export type AgentConversationLinkInput = {
agentReplyId: string;
@@ -79,3 +82,17 @@ export function isAgentConversationLink(
`${AGENT_CONVERSATION_LINK_SCHEME}//${AGENT_CONVERSATION_LINK_HOST}`
);
}
+
+function isUnmatchedClosing(value: string): boolean {
+ const closing = value[value.length - 1];
+ const opening = closing === ")" ? "(" : "[";
+ return value.split(closing).length > value.split(opening).length;
+}
+
+export function trimAgentConversationLinkMatch(matchText: string) {
+ let value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, "");
+ while (/[)\]]$/.test(value) && isUnmatchedClosing(value)) {
+ value = value.slice(0, -1).replace(TRAILING_PUNCTUATION_PATTERN, "");
+ }
+ return { value, trailing: matchText.slice(value.length) };
+}
diff --git a/desktop/src/features/agents/remarkAgentConversationLinks.ts b/desktop/src/features/agents/remarkAgentConversationLinks.ts
new file mode 100644
index 000000000..e767f994d
--- /dev/null
+++ b/desktop/src/features/agents/remarkAgentConversationLinks.ts
@@ -0,0 +1,32 @@
+/**
+ * Remark plugin that detects bare `buzz://task?…` URLs and replaces each with
+ * a custom task-link element. The renderer turns that element into an in-app
+ * task link instead of leaving a raw custom-scheme URL as inert text.
+ */
+
+import { createRemarkPrefixPlugin } from "../../shared/lib/createRemarkPrefixPlugin.ts";
+import {
+ AGENT_CONVERSATION_LINK_URL_PATTERN,
+ trimAgentConversationLinkMatch,
+} from "./agentConversationLink.ts";
+
+export default function remarkAgentConversationLinks() {
+ return createRemarkPrefixPlugin(
+ AGENT_CONVERSATION_LINK_URL_PATTERN,
+ (matchText) => {
+ const { value, trailing } = trimAgentConversationLinkMatch(matchText);
+
+ return {
+ node: {
+ type: "agent-conversation-link",
+ value,
+ data: {
+ hName: "agent-conversation-link",
+ hChildren: [{ type: "text", value }],
+ },
+ },
+ trailing,
+ };
+ },
+ );
+}
diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs
index 0f6c0e75c..656edbbdf 100644
--- a/desktop/src/shared/ui/markdown.test.mjs
+++ b/desktop/src/shared/ui/markdown.test.mjs
@@ -535,12 +535,18 @@ test("remarkSpoilers: block delimiter spoilers expose a block prop to React", ()
// the rendering side is a plain React component covered by app-level use.
import remarkMessageLinks from "../../features/messages/lib/remarkMessageLinks.ts";
+import remarkAgentConversationLinks from "../../features/agents/remarkAgentConversationLinks.ts";
function runPlugin(tree) {
remarkMessageLinks()(tree);
return tree;
}
+function runTaskPlugin(tree) {
+ remarkAgentConversationLinks()(tree);
+ return tree;
+}
+
function paragraph(...children) {
return { type: "root", children: [{ type: "paragraph", children }] };
}
@@ -665,3 +671,33 @@ test("remarkMessageLinks: text inside inlineCode is left alone", () => {
assert.equal(kids[0].type, "inlineCode");
assert.equal(kids[0].value, "buzz://message?channel=c&id=m");
});
+
+test("remarkAgentConversationLinks: bare buzz://task URL is replaced", () => {
+ const tree = runTaskPlugin(paragraph(text("buzz://task?channel=c&reply=r")));
+ const para = tree.children[0];
+ assert.equal(para.children.length, 1);
+ assert.equal(para.children[0].type, "agent-conversation-link");
+ assert.equal(para.children[0].value, "buzz://task?channel=c&reply=r");
+ assert.equal(para.children[0].data.hName, "agent-conversation-link");
+});
+
+test("remarkAgentConversationLinks: trailing punctuation stays outside URL", () => {
+ const tree = runTaskPlugin(
+ paragraph(text("see buzz://task?channel=c&reply=r.")),
+ );
+ const kids = tree.children[0].children;
+ assert.equal(kids.length, 3);
+ assert.equal(kids[0].value, "see ");
+ assert.equal(kids[1].type, "agent-conversation-link");
+ assert.equal(kids[1].value, "buzz://task?channel=c&reply=r");
+ assert.equal(kids[2].value, ".");
+});
+
+test("remarkAgentConversationLinks: non-task buzz:// URLs are not matched", () => {
+ const original = "buzz://message?channel=c&id=m";
+ const tree = runTaskPlugin(paragraph(text(original)));
+ const kids = tree.children[0].children;
+ assert.equal(kids.length, 1);
+ assert.equal(kids[0].type, "text");
+ assert.equal(kids[0].value, original);
+});
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 6a31c93dc..f6b653424 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -19,6 +19,7 @@ import {
parseAgentConversationLink,
type ParsedAgentConversationLink,
} from "@/features/agents/agentConversationLink";
+import remarkAgentConversationLinks from "@/features/agents/remarkAgentConversationLinks";
import {
parseMessageLink,
resolveMessageLinkRenderTarget,
@@ -1941,6 +1942,37 @@ function createMarkdownComponents(
/>
);
},
+ "agent-conversation-link": ({
+ children,
+ }: {
+ children?: React.ReactNode;
+ }) => {
+ const { onOpenAgentConversationLink } = runtimeRef.current;
+ const href = String(children ?? "");
+ const parsed = parseAgentConversationLink(href);
+ if (!parsed.ok) {
+ return {href};
+ }
+
+ if (!interactive) {
+ return Open task;
+ }
+
+ return (
+
+ );
+ },
} as Components;
}
@@ -2021,6 +2053,7 @@ function MarkdownInner({
remarkBreaks,
remarkSpoilers,
remarkMessageLinks,
+ remarkAgentConversationLinks,
[remarkMentions, { mentionNames }],
[remarkChannelLinks, { channelNames }],
[remarkCustomEmoji, { customEmoji }],