diff --git a/packages/views/editor/extensions/mention-extension.test.ts b/packages/views/editor/extensions/mention-extension.test.ts index 13fcadc6a..478d09424 100644 --- a/packages/views/editor/extensions/mention-extension.test.ts +++ b/packages/views/editor/extensions/mention-extension.test.ts @@ -45,6 +45,23 @@ describe("mention tokenizer", () => { expect(token!.attributes.type).toBe("agent"); }); + it.each(["A\\", "ends\\", "a\\]b", "f(x)", "back\\slash"])( + "round-trips a label containing backslash/parens: %j", + (label) => { + // The linear tokenizer treats "\" as an escape lead, so renderMarkdown + // must escape "\" too — otherwise a trailing "\" swallows the closing "]" + // and the mention fails to parse back (regression guard for the + // de-ambiguation fix). + const md = renderMarkdown({ + attrs: { id: "aaa-bbb", label, type: "member" }, + }); + const token = tokenize(md); + expect(token).toBeDefined(); + expect(token!.attributes.label).toBe(label); + expect(token!.attributes.id).toBe("aaa-bbb"); + }, + ); + it("does not match an ordinary Markdown link before a mention", () => { const src = "Check [docs](https://example.com) - [@User](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)"; diff --git a/packages/views/editor/extensions/mention-extension.ts b/packages/views/editor/extensions/mention-extension.ts index 2f3b68e07..40f7519ae 100644 --- a/packages/views/editor/extensions/mention-extension.ts +++ b/packages/views/editor/extensions/mention-extension.ts @@ -2,6 +2,7 @@ import Mention from "@tiptap/extension-mention"; import { mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { MentionView } from "./mention-view"; +import { escapeMarkdownLabel } from "../utils/escape-markdown-label"; const MENTION_LINK_MARKER = "](mention://"; @@ -90,8 +91,10 @@ export const BaseMentionExtension = Mention.extend({ /^\[@?((?:\\.|[^\]\\])+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, ); if (!match) return undefined; - // Unescape backslash-escaped brackets that renderMarkdown may produce. - const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); + // Reverse escapeMarkdownLabel: unescape \[ \] \\ \( \) that + // renderMarkdown produced. Must mirror the escaped set exactly, or a + // label containing "\" fails to round-trip through the linear tokenizer. + const rawLabel = match[1]?.replace(/\\([[\]\\()])/g, "$1"); return { type: "mention", raw: match[0], @@ -105,9 +108,11 @@ export const BaseMentionExtension = Mention.extend({ renderMarkdown: (node: any) => { const { id, label, type = "member" } = node.attrs || {}; const prefix = type === "issue" || type === "project" ? "" : "@"; - // Escape square brackets in the label so the markdown link syntax - // is not broken when the name contains [ or ] (e.g. "David[TF]"). - const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]"); + // Escape [ ] \ ( ) in the label so the markdown link syntax is not broken + // and the label survives the linear tokenizer (which now treats "\" as an + // escape lead, not an ordinary char). Must stay in sync with the unescape + // in tokenize() above. Shared with file-card/slash via escapeMarkdownLabel. + const safeLabel = escapeMarkdownLabel(label ?? id); return `[${prefix}${safeLabel}](mention://${type}/${id})`; }, }); diff --git a/packages/views/editor/extensions/slash-command-extension.test.ts b/packages/views/editor/extensions/slash-command-extension.test.ts index 43e17e6d0..a83c5b5d0 100644 --- a/packages/views/editor/extensions/slash-command-extension.test.ts +++ b/packages/views/editor/extensions/slash-command-extension.test.ts @@ -79,6 +79,16 @@ describe("slash command tokenizer", () => { expect(tokenize(md)?.attributes.label).toBe("deploy[prod]"); }); + it.each(["A\\", "ends\\", "a\\]b", "f(x)", "back\\slash"])( + "round-trips a label containing backslash/parens: %j", + (label) => { + // renderMarkdown must escape "\" so a trailing "\" does not swallow the + // closing "]" under the linear tokenizer (regression guard). + const md = renderMarkdown({ attrs: { id: "skill-1", label } }); + expect(tokenize(md)?.attributes.label).toBe(label); + }, + ); + it("does not match ordinary markdown links", () => { expect(tokenize("[docs](https://example.com)")).toBeUndefined(); }); diff --git a/packages/views/editor/extensions/slash-command-extension.ts b/packages/views/editor/extensions/slash-command-extension.ts index b742bfb98..b232d1892 100644 --- a/packages/views/editor/extensions/slash-command-extension.ts +++ b/packages/views/editor/extensions/slash-command-extension.ts @@ -3,6 +3,7 @@ import { mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { SlashCommandView } from "./slash-command-view"; import { formatSlashCommandLabel } from "./slash-command-utils"; +import { escapeMarkdownLabel } from "../utils/escape-markdown-label"; export const SlashCommandExtension = Mention.extend({ name: "slashCommand", @@ -45,7 +46,10 @@ export const SlashCommandExtension = Mention.extend({ /^\[\/((?:\\.|[^\]\\])+)\]\(slash:\/\/skill\/([^)]+)\)/, ); if (!match) return undefined; - const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); + // Reverse escapeMarkdownLabel: unescape \[ \] \\ \( \). Must mirror the + // escaped set exactly, or a label containing "\" fails to round-trip + // through the linear tokenizer. + const rawLabel = match[1]?.replace(/\\([[\]\\()])/g, "$1"); return { type: "slashCommand", raw: match[0], @@ -60,9 +64,9 @@ export const SlashCommandExtension = Mention.extend({ renderMarkdown: (node: any) => { const { id, label } = node.attrs || {}; - const safeLabel = formatSlashCommandLabel(label) - .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]"); + // Escape [ ] \ ( ) so the label survives the linear tokenizer; must stay in + // sync with the unescape in tokenize() above. + const safeLabel = escapeMarkdownLabel(formatSlashCommandLabel(label)); return `[/${safeLabel}](slash://skill/${id})`; }, });