Autolink bare task URLs

This commit is contained in:
klopez4212
2026-06-30 13:55:16 +01:00
parent 7e7f20cddf
commit 891e97af87
4 changed files with 118 additions and 0 deletions
@@ -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) };
}
@@ -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,
};
},
);
}
+36
View File
@@ -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);
});
+33
View File
@@ -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 <span>{href}</span>;
}
if (!interactive) {
return <span className="font-medium text-current">Open task</span>;
}
return (
<button
type="button"
data-agent-conversation-link=""
aria-label="Open task"
title={href}
className="cursor-pointer font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
onClick={() => {
onOpenAgentConversationLink(parsed.value);
}}
>
Open task
</button>
);
},
} as Components;
}
@@ -2021,6 +2053,7 @@ function MarkdownInner({
remarkBreaks,
remarkSpoilers,
remarkMessageLinks,
remarkAgentConversationLinks,
[remarkMentions, { mentionNames }],
[remarkChannelLinks, { channelNames }],
[remarkCustomEmoji, { customEmoji }],