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 }],