From 155ac1c5878ff005fb065ac1abf4a2d234c69afe Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 16 Aug 2026 12:09:47 +0800 Subject: [PATCH 01/13] fix(webview): render expanded task header text as markdown The collapsed task title still shows raw text, but the expanded view rendered the prompt verbatim via , so markdown syntax (bold, code, lists) appeared as literal characters. Render it through MarkdownBlock like other chat messages and drop the now-redundant whitespace-pre-wrap class. --- webview-ui/src/components/chat/TaskHeader.tsx | 6 +++-- .../chat/__tests__/TaskHeader.spec.tsx | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..5140e45254 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -29,6 +29,8 @@ import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" import { LucideIconButton } from "./LucideIconButton" +import MarkdownBlock from "../common/MarkdownBlock" + export interface TaskHeaderProps { task: ClineMessage tokensIn: number @@ -324,13 +326,13 @@ const TaskHeader = ({ className="text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
- +
{task.images && task.images.length > 0 && } diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 2a302e6b18..76b9ecedbb 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -329,4 +329,27 @@ describe("TaskHeader", () => { expect(screen.getByText("25%")).toBeInTheDocument() }) }) + + describe("Expanded task text markdown rendering", () => { + it("shows raw source while collapsed and formatted markdown when expanded", async () => { + const { container } = renderTaskHeader({ + task: { type: "say", ts: Date.now(), text: "**bold** and `code`", images: [] }, + }) + + // Collapsed state renders the raw task text (no markdown formatting yet). + expect(screen.getByText("**bold** and `code`")).toBeInTheDocument() + expect(container.querySelector("strong")).toBeNull() + + // Expand the header by clicking the collapsed title. + fireEvent.click(screen.getByText("**bold** and `code`")) + + // Expanded state applies markdown: **bold** becomes , `code` becomes . + const bold = await screen.findByText("bold") + expect(bold.tagName).toBe("STRONG") + expect(container.querySelector("code")?.textContent).toBe("code") + + // The raw markdown source must not be displayed verbatim in the expanded view. + expect(screen.queryByText("**bold** and `code`")).not.toBeInTheDocument() + }) + }) }) From c90f28d2094fcf4529054971074a36ed5e3a044c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 16 Aug 2026 12:33:01 +0800 Subject: [PATCH 02/13] fix(webview): use VS Code-style scrollbar for expanded task prompt box The expanded prompt box used a default always-visible Chromium scrollbar while the message list uses the hover-reveal .scrollable style, so two differently-styled scrollbars stacked in the same column. Add the shared .scrollable class so both behave consistently. --- webview-ui/src/components/chat/TaskHeader.tsx | 2 +- .../components/chat/__tests__/TaskHeader.spec.tsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 5140e45254..5ff0e801d3 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -326,7 +326,7 @@ const TaskHeader = ({ className="text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
{ // The raw markdown source must not be displayed verbatim in the expanded view. expect(screen.queryByText("**bold** and `code`")).not.toBeInTheDocument() }) + + it("uses the shared scrollable style for the expanded prompt box", () => { + const { container } = renderTaskHeader({ + task: { type: "say", ts: Date.now(), text: "prompt", images: [] }, + }) + + // Expand the header. + fireEvent.click(screen.getByText("prompt")) + + // The prompt box must use the VS Code-style .scrollable scrollbar (hover-reveal), + // not a default always-visible Chromium scrollbar, so it matches the message list. + const scrollBox = container.querySelector(".scrollable") + expect(scrollBox).not.toBeNull() + expect(scrollBox?.className).toContain("max-h-80") + }) }) }) From 0f4debadaf6d10e25c9f6c013e49d6b240c013f9 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 16 Aug 2026 12:50:47 +0800 Subject: [PATCH 03/13] fix(webview): keep task header expanded when clicking rendered markdown links MarkdownBlock renders prompt links as elements, which the header click handler did not guard against (only buttons/role=button/img), so clicking a link inside the expanded prompt toggled isTaskExpanded and collapsed the panel. Ignore anchor targets in the toggle handler; add regression tests for link clicks, headings/lists rendering, and empty prompts. --- webview-ui/src/components/chat/TaskHeader.tsx | 4 +- .../chat/__tests__/TaskHeader.spec.tsx | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 5ff0e801d3..b614dbbb92 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -165,7 +165,9 @@ const TaskHeader = ({ e.target.closest('[role="button"]') || e.target.closest("[data-radix-popper-content-wrapper]") || e.target.closest("img") || - e.target.tagName === "IMG") + e.target.tagName === "IMG" || + e.target.closest("a") || + e.target.tagName === "A") ) { return } diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 633aa783d5..4d9bd40656 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -366,5 +366,56 @@ describe("TaskHeader", () => { expect(scrollBox).not.toBeNull() expect(scrollBox?.className).toContain("max-h-80") }) + + it("renders headings and lists in the expanded view", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "# Heading\n- item one\n- item two", + images: [], + }, + }) + + // Expand via the header container (the raw multi-line title is not a stable text target). + fireEvent.click(container.querySelector(".cursor-pointer")!) + + const heading = await screen.findByRole("heading") + expect(heading.textContent).toBe("Heading") + expect(container.querySelector("ul li")).not.toBeNull() + }) + + it("does not collapse the panel when a rendered markdown link is clicked", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "**bold** [example](https://example.com)", + images: [], + }, + }) + + // Expand the header. + fireEvent.click(screen.getByText("**bold** [example](https://example.com)")) + const link = await screen.findByRole("link", { name: "example" }) + + // Clicking a rendered link must not toggle isTaskExpanded (the header click + // handler ignores anchor targets), so the expanded content stays visible. + fireEvent.click(link) + expect(container.querySelector("strong")).not.toBeNull() + }) + + it("renders an empty prompt without crashing", () => { + const { container } = renderTaskHeader({ + task: { type: "say", ts: Date.now(), text: undefined as any, images: [] }, + }) + + // No title text to click, so expand via the header container itself. + fireEvent.click(container.querySelector(".cursor-pointer")!) + + // The empty prompt renders nothing but must not crash; the rest of the + // expanded header (cost row) is still present. + expect(screen.getByText("$0.05")).toBeInTheDocument() + }) }) }) From 73ed937e68f9137508fc167b51484a73c8765c4d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 16 Aug 2026 12:57:43 +0800 Subject: [PATCH 04/13] test(webview): drop as-any cast from empty-prompt TaskHeader fixture ClineMessage.text is optional (z.string().optional()), so the empty-prompt case can omit the property instead of casting undefined through any. --- webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 4d9bd40656..926762e93c 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -407,7 +407,8 @@ describe("TaskHeader", () => { it("renders an empty prompt without crashing", () => { const { container } = renderTaskHeader({ - task: { type: "say", ts: Date.now(), text: undefined as any, images: [] }, + // `text` is optional on ClineMessage; omit it to exercise the empty-prompt path. + task: { type: "say", ts: Date.now(), images: [] }, }) // No title text to click, so expand via the header container itself. From 1d13c74a247919371373e057116d392c392bcefe Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 17 Aug 2026 22:12:02 +0800 Subject: [PATCH 05/13] fix(chat): preserve mentions in expanded task markdown --- .../chat/__tests__/TaskHeader.spec.tsx | 24 ++++ .../src/components/common/MarkdownBlock.tsx | 56 ++++++++- .../common/__tests__/MarkdownBlock.spec.tsx | 106 +++++++++++++++++- 3 files changed, 183 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 926762e93c..5a5f6055b4 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -405,6 +405,30 @@ describe("TaskHeader", () => { expect(container.querySelector("strong")).not.toBeNull() }) + it("keeps context mentions clickable in the expanded markdown view", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "Inspect @/src/file.ts, @problems, and @terminal.", + images: [], + }, + }) + + // Expand via the header container because the collapsed title contains split mention spans. + fireEvent.click(container.querySelector(".cursor-pointer")!) + await screen.findByText(/Inspect/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions).toHaveLength(3) + expect(mentions[0].textContent).toBe("@/src/file.ts") + expect(mentions[1].textContent).toBe("@problems") + expect(mentions[2].textContent).toBe("@terminal") + + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/file.ts" }) + }) + it("renders an empty prompt without crashing", () => { const { container } = renderTaskHeader({ // `text` is optional on ClineMessage; omit it to exercise the empty-prompt path. diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 02f696553f..200e197fbf 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -6,12 +6,66 @@ import rehypeKatex from "rehype-katex" import remarkMath from "remark-math" import remarkGfm from "remark-gfm" +import { mentionRegexGlobal } from "@roo/context-mentions" + import { vscode } from "@src/utils/vscode" import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown" import CodeBlock from "./CodeBlock" import MermaidBlock from "./MermaidBlock" +/** + * Rehype plugin that wraps context mentions (@/path, @problems, @terminal, etc.) + * in clickable spans matching the styling used by the collapsed Mention component. + */ +function rehypeMentions() { + return (tree: any) => { + visit(tree, "text", (node: any, index, parent) => { + if (parent?.tagName === "span" && parent.properties?.className?.includes("mention-context-highlight")) { + return + } + + const originalValue = String(node.value) + const matches = Array.from(originalValue.matchAll(mentionRegexGlobal)) + + if (matches.length === 0) { + return + } + + const children: any[] = [] + let lastIndex = 0 + + for (const match of matches) { + const mentionText = match[0] + const mentionValue = match[1] ?? mentionText.slice(1) // capture group or full mention without @ + const mentionStart = match.index! + + if (mentionStart > lastIndex) { + children.push({ type: "text", value: originalValue.slice(lastIndex, mentionStart) }) + } + + children.push({ + type: "element", + tagName: "span", + properties: { + className: ["mention-context-highlight", "text-[0.9em]", "cursor-pointer"], + onClick: () => vscode.postMessage({ type: "openMention", text: mentionValue }), + }, + children: [{ type: "text", value: mentionText }], + }) + + lastIndex = mentionStart + mentionText.length + } + + if (lastIndex < originalValue.length) { + children.push({ type: "text", value: originalValue.slice(lastIndex) }) + } + + parent.children.splice(index, 1, ...children) + }) + } +} + // Codicon glyphs used as the leading icon for each GitHub-style alert type. const ALERT_ICONS: Record = { note: "codicon-info", @@ -415,7 +469,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { } }, ]} - rehypePlugins={[rehypeKatex as any]} + rehypePlugins={[rehypeMentions, rehypeKatex as any]} components={components}> {markdown || ""} diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index 2c56fc418a..bdd476ae08 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -1,13 +1,21 @@ -import { render, screen } from "@/utils/test-utils" +import { render, screen, fireEvent } from "@/utils/test-utils" import MarkdownBlock from "../MarkdownBlock" +const { mockPostMessage } = vi.hoisted(() => ({ + mockPostMessage: vi.fn(), +})) + vi.mock("@src/utils/vscode", () => ({ vscode: { - postMessage: vi.fn(), + postMessage: mockPostMessage, }, })) +beforeEach(() => { + mockPostMessage.mockClear() +}) + vi.mock("@src/context/ExtensionStateContext", () => ({ useExtensionState: () => ({ theme: "dark", @@ -217,4 +225,98 @@ describe("MarkdownBlock", () => { expect(screen.getByText("Third level ordered")).toBeInTheDocument() expect(screen.getByText("Back to first level")).toBeInTheDocument() }) + + describe("Context mentions (#559)", () => { + it("renders @/path/file.ts as a clickable mention span", async () => { + const markdown = "Check out @/src/components/chat/TaskHeader.tsx for details." + const { container } = render() + + await screen.findByText(/Check out/, { exact: false }) + + // The mention should be wrapped in a span with the mention-context-highlight class. + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@/src/components/chat/TaskHeader.tsx") + + // The trailing period must remain outside the mention span. + expect(container.querySelector("p")?.textContent).toBe( + "Check out @/src/components/chat/TaskHeader.tsx for details.", + ) + }) + + it("renders @problems as a clickable mention span", async () => { + const markdown = "Review the issues listed in @problems before proceeding." + const { container } = render() + + await screen.findByText(/Review/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + }) + + it("renders @terminal as a clickable mention span", async () => { + const markdown = "See the output captured in @terminal." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@terminal") + }) + + it("renders multiple mentions in the same paragraph", async () => { + const markdown = "Check @/src/file.ts and @problems, then review @terminal." + const { container } = render() + + await screen.findByText(/Check/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(3) + expect(mentions[0].textContent).toBe("@/src/file.ts") + expect(mentions[1].textContent).toBe("@problems") + expect(mentions[2].textContent).toBe("@terminal") + }) + + it("posts openMention message when a mention span is clicked", async () => { + const markdown = "See @/src/components/chat/TaskHeader.tsx." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + const mentionSpan = container.querySelector("span.mention-context-highlight")! + fireEvent.click(mentionSpan) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openMention", + text: "/src/components/chat/TaskHeader.tsx", + }) + }) + + it("does not match @ in the middle of a word or log entry", async () => { + const markdown = "Error: Failed@localhost/status code 404." + const { container } = render() + + await screen.findByText(/Error/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(0) + }) + + it("preserves regular text around mentions", async () => { + const markdown = "Before @problems middle after" + const { container } = render() + + await screen.findByText(/Before/, { exact: false }) + + const paragraph = container.querySelector("p") + expect(paragraph?.textContent).toBe("Before @problems middle after") + + // The mention span should only contain the mention itself. + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + }) + }) }) From 99986b3471e6eeee53d4fec6c94db60183d43611 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 19 Aug 2026 20:15:27 +0800 Subject: [PATCH 06/13] fix(webview): keep expanded task panel open after mention click The mention span handler in MarkdownBlock now stops propagation before posting openMention, so a mention click inside the expanded task header no longer bubbles to the TaskHeader toggle and collapses the panel. Adds a regression assertion that the expanded markdown stays rendered after clicking a mention. Addresses CodeRabbit review comment on PR #1257. --- .../src/components/chat/__tests__/TaskHeader.spec.tsx | 6 ++++++ webview-ui/src/components/common/MarkdownBlock.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 5a5f6055b4..178a5fc4db 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -427,6 +427,12 @@ describe("TaskHeader", () => { fireEvent.click(mentions[0]) expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/file.ts" }) + + // The mention click must not bubble to the header toggle (the mention handler + // stops propagation), so the expanded markdown stays rendered after the + // mention is opened instead of the panel collapsing. + expect(screen.getByText(/Inspect/, { exact: false })).toBeInTheDocument() + expect(container.querySelectorAll("span.mention-context-highlight")).toHaveLength(3) }) it("renders an empty prompt without crashing", () => { diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 200e197fbf..7cbb0818f8 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -49,7 +49,12 @@ function rehypeMentions() { tagName: "span", properties: { className: ["mention-context-highlight", "text-[0.9em]", "cursor-pointer"], - onClick: () => vscode.postMessage({ type: "openMention", text: mentionValue }), + onClick: (event: React.MouseEvent) => { + // Keep mention clicks from bubbling to the TaskHeader toggle, which + // would collapse the expanded panel right after opening the mention. + event.stopPropagation() + vscode.postMessage({ type: "openMention", text: mentionValue }) + }, }, children: [{ type: "text", value: mentionText }], }) From 03e98abae298d83a2ceed5d3bd4e3cdcae931752 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 19 Aug 2026 20:46:21 +0800 Subject: [PATCH 07/13] fix(webview): keep mentions literal in code and keyboard accessible Addresses the updated CodeRabbit merge-risk notes on PR #1257: rehypeMentions now skips text inside code elements (mention patterns in code blocks rendered verbatim and no longer vanished from CodeBlock text extraction), and mention spans are keyboard operable via role=button, tabIndex and Enter/Space key handling. Adds regression tests for both behaviors. --- .../src/components/common/MarkdownBlock.tsx | 18 ++++++++ .../common/__tests__/MarkdownBlock.spec.tsx | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 7cbb0818f8..cc6e0bd763 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -25,6 +25,13 @@ function rehypeMentions() { return } + // Code is literal content: never interactive-ify mention patterns inside it. + // Wrapping them would also corrupt the CodeBlock text extraction, which only + // keeps string children (the mention text would silently disappear). + if (parent?.tagName === "code") { + return + } + const originalValue = String(node.value) const matches = Array.from(originalValue.matchAll(mentionRegexGlobal)) @@ -49,12 +56,23 @@ function rehypeMentions() { tagName: "span", properties: { className: ["mention-context-highlight", "text-[0.9em]", "cursor-pointer"], + role: "button", + tabIndex: 0, onClick: (event: React.MouseEvent) => { // Keep mention clicks from bubbling to the TaskHeader toggle, which // would collapse the expanded panel right after opening the mention. event.stopPropagation() vscode.postMessage({ type: "openMention", text: mentionValue }) }, + // Keyboard parity with the click handler (a role=button span is not a + // native button, so Enter/Space must be handled explicitly). + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") { + return + } + event.stopPropagation() + vscode.postMessage({ type: "openMention", text: mentionValue }) + }, }, children: [{ type: "text", value: mentionText }], }) diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index bdd476ae08..ae4f05b748 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -304,6 +304,50 @@ describe("MarkdownBlock", () => { expect(mentions.length).toBe(0) }) + it("keeps mention patterns literal inside fenced code blocks", async () => { + const markdown = "```bash\necho hello @problems\n```" + const { container } = render() + + await screen.findByText(/echo/, { exact: false }) + + // Code is literal content: the mention must stay plain text, not become a + // clickable span (which would also make the text vanish from CodeBlock). + expect(container.querySelector("code")?.textContent).toBe("echo hello @problems\n") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + }) + + it("keeps mention patterns literal inside inline code", async () => { + const markdown = "Use `@problems` carefully." + const { container } = render() + + await screen.findByText(/Use/, { exact: false }) + + expect(container.querySelector("code")?.textContent).toBe("@problems") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + }) + + it("makes mentions keyboard operable (role=button, tabIndex, Enter/Space)", async () => { + const markdown = "See @terminal." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + const mention = container.querySelector("span.mention-context-highlight")! + expect(mention.getAttribute("role")).toBe("button") + expect(mention.getAttribute("tabindex")).toBe("0") + + fireEvent.keyDown(mention, { key: "Enter" }) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" }) + + mockPostMessage.mockClear() + fireEvent.keyDown(mention, { key: " " }) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" }) + + mockPostMessage.mockClear() + fireEvent.keyDown(mention, { key: "a" }) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + it("preserves regular text around mentions", async () => { const markdown = "Before @problems middle after" const { container } = render() From 279be66f6c9b369c520ee8f543c16dc4a8f1b2b1 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 19 Aug 2026 21:02:28 +0800 Subject: [PATCH 08/13] test(webview): cover remaining mention-splitter branches Adds a standalone-mention regression test that exercises the no-leading-text and no-trailing-text branches of the rehypeMentions splitter, and drops the unreachable match[1] ?? mentionText.slice(1) fallback (the mention regex has one mandatory capture group, so match[1] is always the non-empty value and identical to match[0].slice(1)). Lifts PR patch coverage of the changed MarkdownBlock lines from ~87.5% to full. --- .../src/components/common/MarkdownBlock.tsx | 4 +++- .../common/__tests__/MarkdownBlock.spec.tsx | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index cc6e0bd763..ad37dfc4ea 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -44,7 +44,9 @@ function rehypeMentions() { for (const match of matches) { const mentionText = match[0] - const mentionValue = match[1] ?? mentionText.slice(1) // capture group or full mention without @ + // mentionRegexGlobal has one mandatory capture group, so match[1] is always + // the non-empty value after "@" (match[0].slice(1) would be identical). + const mentionValue = match[1] const mentionStart = match.index! if (mentionStart > lastIndex) { diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index ae4f05b748..16bac5c88f 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -326,6 +326,22 @@ describe("MarkdownBlock", () => { expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) }) + it("renders a standalone mention with no surrounding text", async () => { + // A mention that both starts and ends the text node exercises the + // no-leading-text and no-trailing-text branches of the splitter. + const markdown = "@problems" + const { container } = render() + + await screen.findByText("@problems") + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + + // No leading/trailing text: the paragraph is exactly the mention. + expect(container.querySelector("p")?.textContent).toBe("@problems") + }) + it("makes mentions keyboard operable (role=button, tabIndex, Enter/Space)", async () => { const markdown = "See @terminal." const { container } = render() From 84b22e36c2bd7973d6b69a935f9b0ef87765c643 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 20 Aug 2026 13:07:30 +0800 Subject: [PATCH 09/13] fix(webview): gate clickable mentions to user-authored task text Addresses edelauna's review on PR #1257: gate the rehypeMentions plugin behind a new MarkdownBlock `mentions` prop (off by default) so assistant messages, reasoning, tool output, and todo lists keep mention patterns as inert text; only the expanded TaskHeader prompt (user-authored) passes it. Also extend the skip guard from `code` to `pre`/`a` so a mention inside link text no longer becomes a nested role=button span (invalid per WHATWG) that blocks the anchor's openFile handler. Adds regression tests for both behaviors. --- webview-ui/src/components/chat/TaskHeader.tsx | 2 +- .../src/components/common/MarkdownBlock.tsx | 23 +++++--- .../common/__tests__/MarkdownBlock.spec.tsx | 52 +++++++++++++++---- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index b614dbbb92..92dd3572bc 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -334,7 +334,7 @@ const TaskHeader = ({ WebkitLineClamp: "unset", WebkitBoxOrient: "vertical", }}> - +
{task.images && task.images.length > 0 && } diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index ad37dfc4ea..1e50279b47 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -25,10 +25,13 @@ function rehypeMentions() { return } - // Code is literal content: never interactive-ify mention patterns inside it. - // Wrapping them would also corrupt the CodeBlock text extraction, which only - // keeps string children (the mention text would silently disappear). - if (parent?.tagName === "code") { + // Code and links are literal or already-interactive content: never + // interactive-ify mention patterns inside them. Inside a role=button + // span would be invalid nested interactive content (WHATWG) and its + // stopPropagation would block the anchor's own openFile handler; inside + // code it would corrupt the CodeBlock text extraction, which only keeps + // string children (the mention text would silently disappear). + if (parent?.tagName === "code" || parent?.tagName === "pre" || parent?.tagName === "a") { return } @@ -111,6 +114,14 @@ const ALERT_LABELS: Record = { interface MarkdownBlockProps { markdown?: string + /** + * Render context mentions (@/path, @problems, @terminal, ...) as clickable + * spans that post `openMention`. Off by default: mentions are only + * actionable where the text is user-authored (the expanded task prompt). + * Assistant-generated content (messages, reasoning, tool output, todos) + * keeps mention patterns as inert text. + */ + mentions?: boolean } const StyledMarkdown = styled.div` @@ -352,7 +363,7 @@ const StyledMarkdown = styled.div` } ` -const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { +const MarkdownBlock = memo(({ markdown, mentions = false }: MarkdownBlockProps) => { const components = useMemo( () => ({ table: ({ children, ...props }: any) => { @@ -494,7 +505,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { } }, ]} - rehypePlugins={[rehypeMentions, rehypeKatex as any]} + rehypePlugins={[...(mentions ? [rehypeMentions] : []), rehypeKatex as any]} components={components}> {markdown || ""} diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index 16bac5c88f..5c3035f28c 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -227,9 +227,22 @@ describe("MarkdownBlock", () => { }) describe("Context mentions (#559)", () => { + it("keeps mention patterns inert when the mentions prop is not set", async () => { + // Mentions are only actionable where text is user-authored. Assistant + // content rendered through the default MarkdownBlock must keep mention + // patterns as plain, non-interactive text. + const markdown = "Check @/src/file.ts and @problems." + const { container } = render() + + await screen.findByText(/Check/, { exact: false }) + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.querySelector("p")?.textContent).toBe("Check @/src/file.ts and @problems.") + }) + it("renders @/path/file.ts as a clickable mention span", async () => { const markdown = "Check out @/src/components/chat/TaskHeader.tsx for details." - const { container } = render() + const { container } = render() await screen.findByText(/Check out/, { exact: false }) @@ -246,7 +259,7 @@ describe("MarkdownBlock", () => { it("renders @problems as a clickable mention span", async () => { const markdown = "Review the issues listed in @problems before proceeding." - const { container } = render() + const { container } = render() await screen.findByText(/Review/, { exact: false }) @@ -257,7 +270,7 @@ describe("MarkdownBlock", () => { it("renders @terminal as a clickable mention span", async () => { const markdown = "See the output captured in @terminal." - const { container } = render() + const { container } = render() await screen.findByText(/See/, { exact: false }) @@ -268,7 +281,7 @@ describe("MarkdownBlock", () => { it("renders multiple mentions in the same paragraph", async () => { const markdown = "Check @/src/file.ts and @problems, then review @terminal." - const { container } = render() + const { container } = render() await screen.findByText(/Check/, { exact: false }) @@ -281,7 +294,7 @@ describe("MarkdownBlock", () => { it("posts openMention message when a mention span is clicked", async () => { const markdown = "See @/src/components/chat/TaskHeader.tsx." - const { container } = render() + const { container } = render() await screen.findByText(/See/, { exact: false }) @@ -306,7 +319,7 @@ describe("MarkdownBlock", () => { it("keeps mention patterns literal inside fenced code blocks", async () => { const markdown = "```bash\necho hello @problems\n```" - const { container } = render() + const { container } = render() await screen.findByText(/echo/, { exact: false }) @@ -318,7 +331,7 @@ describe("MarkdownBlock", () => { it("keeps mention patterns literal inside inline code", async () => { const markdown = "Use `@problems` carefully." - const { container } = render() + const { container } = render() await screen.findByText(/Use/, { exact: false }) @@ -326,11 +339,30 @@ describe("MarkdownBlock", () => { expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) }) + it("keeps mention patterns literal inside link text even when enabled", async () => { + // A mention inside must not become a nested role=button span: that + // is invalid interactive content (WHATWG) and would block the anchor's + // own openFile handler via stopPropagation. + const markdown = "see [open @/src/main.ts](/src/main.ts) please" + const { container } = render() + + await screen.findByText(/please/, { exact: false }) + + const anchor = container.querySelector("a")! + expect(container.querySelectorAll("a span.mention-context-highlight").length).toBe(0) + expect(anchor.textContent).toBe("open @/src/main.ts") + + // The anchor's own handler still fires (nothing swallows the click). + fireEvent.click(anchor) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openFile", text: "/src/main.ts" }) + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "openMention" })) + }) + it("renders a standalone mention with no surrounding text", async () => { // A mention that both starts and ends the text node exercises the // no-leading-text and no-trailing-text branches of the splitter. const markdown = "@problems" - const { container } = render() + const { container } = render() await screen.findByText("@problems") @@ -344,7 +376,7 @@ describe("MarkdownBlock", () => { it("makes mentions keyboard operable (role=button, tabIndex, Enter/Space)", async () => { const markdown = "See @terminal." - const { container } = render() + const { container } = render() await screen.findByText(/See/, { exact: false }) @@ -366,7 +398,7 @@ describe("MarkdownBlock", () => { it("preserves regular text around mentions", async () => { const markdown = "Before @problems middle after" - const { container } = render() + const { container } = render() await screen.findByText(/Before/, { exact: false }) From bd93019c29ce4b049e4156b81cb5b43814bfc54d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 12:43:14 +0800 Subject: [PATCH 10/13] fix(webview): keep newlines and full mention paths in expanded task header Address review feedback on #1257: - Add a `breaks` prop to MarkdownBlock (remark-breaks) so single newlines in user-authored prompts render as a structural
instead of collapsing to spaces per CommonMark. TaskHeader (the expanded prompt) opts in; assistant content keeps the default soft-break behavior. A small rehype plugin drops the stray \n text node mdast-util-to-hast emits after each
, which would otherwise double-break under the webview's white-space: pre-wrap paragraphs. - Match context mentions on the raw markdown string before remark tokenizes it instead of on remark's split text nodes. This restores the collapsed behavior for paths containing markdown-active characters (e.g. @/src/__init__.py), which tokenization previously truncated to @/src/ and posted as the wrong openMention value. Literal/non-text regions (code, links, images, HTML, math) are masked first via a throwaway mdast parse so mentions inside them stay inert. Adds regression tests for both behaviors in MarkdownBlock and TaskHeader. --- pnpm-lock.yaml | 26 +++ webview-ui/package.json | 3 + webview-ui/src/components/chat/TaskHeader.tsx | 2 +- .../chat/__tests__/TaskHeader.spec.tsx | 49 ++++++ .../src/components/common/MarkdownBlock.tsx | 163 ++++++++++++++++-- .../common/__tests__/MarkdownBlock.spec.tsx | 108 ++++++++++++ 6 files changed, 334 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 393c6ac143..c2102d8924 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -848,12 +848,18 @@ importers: rehype-katex: specifier: ^7.0.1 version: 7.0.1 + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 remark-math: specifier: ^6.0.0 version: 6.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 remove-markdown: specifier: ^0.6.4 version: 0.6.4 @@ -881,6 +887,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.3.2) + unified: + specifier: ^11.0.5 + version: 11.0.5 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -6149,6 +6158,9 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -7152,6 +7164,9 @@ packages: rehype-react@6.2.1: resolution: {integrity: sha512-f9KIrjktvLvmbGc7si25HepocOg4z0MuNOtweigKzBcDjiGSTGhyz6VSgaV5K421Cq1O+z4/oxRJ5G9owo0KVg==} + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -14412,6 +14427,11 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -15684,6 +15704,12 @@ snapshots: '@mapbox/hast-util-table-cell-style': 0.2.1 hast-to-hyperscript: 9.0.1 + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 diff --git a/webview-ui/package.json b/webview-ui/package.json index 83777bcbf1..cebc26bc4d 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -68,8 +68,10 @@ "react-use": "^17.5.1", "react-virtuoso": "^4.7.13", "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", "remove-markdown": "^0.6.4", "shell-quote": "^1.8.2", "shiki": "^3.2.1", @@ -79,6 +81,7 @@ "tailwind-merge": "^3.0.0", "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", + "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "use-sound": "^5.0.0", "vscode-material-icons": "^0.1.1", diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 92dd3572bc..098e251981 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -334,7 +334,7 @@ const TaskHeader = ({ WebkitLineClamp: "unset", WebkitBoxOrient: "vertical", }}> - + {task.images && task.images.length > 0 && } diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 178a5fc4db..c3008e2976 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -435,6 +435,55 @@ describe("TaskHeader", () => { expect(container.querySelectorAll("span.mention-context-highlight")).toHaveLength(3) }) + it("keeps single newlines as line breaks in a plain-text prompt", async () => { + const { container } = renderTaskHeader({ + task: { type: "say", ts: Date.now(), text: "Fix the login bug\nIt crashes on startup", images: [] }, + }) + + // Expand via the header container (the raw multi-line title is not a stable text target). + fireEvent.click(container.querySelector(".cursor-pointer")!) + + // Inexact match: the soft break splits the paragraph into text
text, so no + // single element's full text equals the first line. + await screen.findByText(/Fix the login bug/, { exact: false }) + + // The previous expanded view rendered plain text with whitespace-pre-wrap, so a + // single newline was always a line break. The markdown pipeline collapses soft + // breaks to spaces per CommonMark unless remark-breaks is enabled, so the header + // must keep the newline structural (
) instead of reflowing the prompt into + // one paragraph. + const paragraph = container.querySelector(".scrollable p") + expect(paragraph).not.toBeNull() + expect(paragraph?.querySelector("br")).not.toBeNull() + expect(paragraph?.textContent).toBe("Fix the login bugIt crashes on startup") + }) + + it("still parses markdown headings and lists while keeping newlines inside them", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "# Heading\n- item one\n continued line\n- item two", + images: [], + }, + }) + + // Expand via the header container (the raw multi-line title is not a stable text target). + fireEvent.click(container.querySelector(".cursor-pointer")!) + + const heading = await screen.findByRole("heading") + expect(heading.textContent).toBe("Heading") + + // Markdown still parses (the # line is a heading, the - lines are list items)... + const items = container.querySelectorAll(".scrollable li") + expect(items).toHaveLength(2) + + // ...and the soft break inside the first item renders as a line break. + expect(items[0]?.querySelector("br")).not.toBeNull() + expect(items[0]?.textContent).toBe("item onecontinued line") + expect(items[1]?.textContent).toBe("item two") + }) + it("renders an empty prompt without crashing", () => { const { container } = renderTaskHeader({ // `text` is optional on ClineMessage; omit it to exercise the empty-prompt path. diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 1e50279b47..4108a6107f 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -3,8 +3,11 @@ import ReactMarkdown from "react-markdown" import styled from "styled-components" import { visit } from "unist-util-visit" import rehypeKatex from "rehype-katex" -import remarkMath from "remark-math" +import remarkBreaks from "remark-breaks" import remarkGfm from "remark-gfm" +import remarkMath from "remark-math" +import remarkParse from "remark-parse" +import { unified } from "unified" import { mentionRegexGlobal } from "@roo/context-mentions" @@ -14,19 +17,97 @@ import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown" import CodeBlock from "./CodeBlock" import MermaidBlock from "./MermaidBlock" +// Control character that wraps a mention index in the preprocessed markdown. +// It cannot be typed into a prompt and carries no markdown meaning, so remark +// always keeps a whole placeholder inside a single text node. Built via +// `new RegExp` from a string constant (a template literal) so the control +// character does not appear in a regex literal (no-control-regex). +const MENTION_PLACEHOLDER_CHAR = "\u0001" +const MENTION_PLACEHOLDER_REGEX = new RegExp(`${MENTION_PLACEHOLDER_CHAR}(\\d+)${MENTION_PLACEHOLDER_CHAR}`, "g") + +// mdast node types whose raw source regions must never be mention-rewritten: +// code blocks (fenced or indented), inline code, links, images, raw HTML, and +// math all render as literal or non-text content. +const MENTION_MASK_NODE_TYPES = new Set(["code", "inlineCode", "link", "image", "html", "inlineMath", "math"]) + +/** + * Rewrite mention patterns in the RAW markdown string before remark tokenizes + * it, replacing each match with an indexed placeholder. + * + * Matching on remark's tokenized text nodes truncates paths that contain + * markdown-active characters: `@/src/__init__.py` is parsed as + * `@/src/` + init + `.py`, so per-node matching would only + * see `@/src/` and post the wrong path to `openMention`. Raw-string matching + * is also the behavior of the collapsed component, so this restores + * it for the expanded view. + * + * Regions that render as literal content (code, links, images, HTML, math) are + * masked to spaces in a throwaway mdast parse first; using the exact positions + * remark sees guarantees a mention inside such a region is never rewritten. + */ +function prepareMentions(markdown: string): { preparedMarkdown: string; mentions: string[] } { + if (!markdown) { + return { preparedMarkdown: markdown, mentions: [] } + } + + // A throwaway parse with the same extensions as the render pipeline, so the + // reported positions match what remark will tokenize. Mask literal and + // non-text regions to spaces (mdast positions carry absolute source + // offsets): a mention inside any of them must stay inert, because code and + // links render as literal/interactive content, and images, raw HTML, and + // math keep their source text unchanged. + const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(markdown) + + const masked = markdown.split("") + visit(tree, (node: any) => { + if (!MENTION_MASK_NODE_TYPES.has(node.type)) { + return + } + const start = node.position?.start?.offset + const end = node.position?.end?.offset + if (typeof start !== "number" || typeof end !== "number") { + return + } + for (let i = start; i < end && i < masked.length; i++) { + masked[i] = " " + } + }) + + const mentions: string[] = [] + let preparedMarkdown = "" + let lastIndex = 0 + for (const match of masked.join("").matchAll(mentionRegexGlobal)) { + const start = match.index! + preparedMarkdown += markdown.slice(lastIndex, start) + mentions.push(markdown.slice(start, start + match[0].length)) + preparedMarkdown += `${MENTION_PLACEHOLDER_CHAR}${mentions.length - 1}${MENTION_PLACEHOLDER_CHAR}` + lastIndex = start + match[0].length + } + preparedMarkdown += markdown.slice(lastIndex) + + return { preparedMarkdown, mentions } +} + /** - * Rehype plugin that wraps context mentions (@/path, @problems, @terminal, etc.) - * in clickable spans matching the styling used by the collapsed Mention component. + * Rehype plugin that replaces the mention placeholders produced by + * prepareMentions with clickable spans matching the styling used by the + * collapsed Mention component. */ -function rehypeMentions() { +function rehypeMentions(mentions: string[]) { return (tree: any) => { - visit(tree, "text", (node: any, index, parent) => { + visit(tree, "text", (node: any, index: number | undefined, parent: any) => { + if (index === undefined || !parent) { + return + } + + // Skip text inside spans we already created (the visitor may revisit + // children inserted during the same pass). if (parent?.tagName === "span" && parent.properties?.className?.includes("mention-context-highlight")) { return } - // Code and links are literal or already-interactive content: never - // interactive-ify mention patterns inside them. Inside
a role=button + // prepareMentions already masks code and link regions, but keep these + // guards so the plugin stays safe on any tree: inside a role=button // span would be invalid nested interactive content (WHATWG) and its // stopPropagation would block the anchor's own openFile handler; inside // code it would corrupt the CodeBlock text extraction, which only keeps @@ -36,20 +117,26 @@ function rehypeMentions() { } const originalValue = String(node.value) - const matches = Array.from(originalValue.matchAll(mentionRegexGlobal)) + const matches = Array.from(originalValue.matchAll(MENTION_PLACEHOLDER_REGEX)) if (matches.length === 0) { return } + // If any placeholder fails to resolve (should not happen), leave the + // text untouched instead of rendering the control characters verbatim. + if (matches.some((match) => mentions[Number(match[1])] === undefined)) { + return + } + const children: any[] = [] let lastIndex = 0 for (const match of matches) { - const mentionText = match[0] - // mentionRegexGlobal has one mandatory capture group, so match[1] is always - // the non-empty value after "@" (match[0].slice(1) would be identical). - const mentionValue = match[1] + const mentionText = mentions[Number(match[1])] + // The raw mention includes the leading "@"; the posted value is the + // full path/word after it, matching the collapsed Mention component. + const mentionValue = mentionText.slice(1) const mentionStart = match.index! if (mentionStart > lastIndex) { @@ -82,7 +169,7 @@ function rehypeMentions() { children: [{ type: "text", value: mentionText }], }) - lastIndex = mentionStart + mentionText.length + lastIndex = mentionStart + match[0].length } if (lastIndex < originalValue.length) { @@ -94,6 +181,30 @@ function rehypeMentions() { } } +/** + * Rehype plugin that drops the lone "\n" text node mdast-util-to-hast emits + * right after every
(its hardBreak handler returns [
, "\n"]). + * + * The paragraph styling in this webview uses `white-space: pre-wrap`, where a + * literal newline is significant. Without this, every remark-breaks
would + * be followed by an extra pre-wrap line break, inserting a blank line between + * each soft-broken line. Removing the node leaves exactly one line break per + * soft break, independent of CSS white-space handling. + */ +function rehypeStripBreakNewlines() { + return (tree: any) => { + visit(tree, "element", (node: any, index: number | undefined, parent: any) => { + if (node.tagName !== "br" || index === undefined || !parent) { + return + } + const next = parent.children[index + 1] + if (next?.type === "text" && next.value === "\n") { + parent.children.splice(index + 1, 1) + } + }) + } +} + // Codicon glyphs used as the leading icon for each GitHub-style alert type. const ALERT_ICONS: Record = { note: "codicon-info", @@ -122,6 +233,14 @@ interface MarkdownBlockProps { * keeps mention patterns as inert text. */ mentions?: boolean + /** + * Render single newlines as
(remark-breaks) instead of collapsing them + * to spaces per CommonMark. Off by default so the shared pipeline keeps its + * CommonMark soft-break behavior for assistant-generated content. The + * expanded task prompt (user-authored text) enables it so plain multi-line + * prompts keep their line breaks while markdown still parses. + */ + breaks?: boolean } const StyledMarkdown = styled.div` @@ -363,7 +482,7 @@ const StyledMarkdown = styled.div` } ` -const MarkdownBlock = memo(({ markdown, mentions = false }: MarkdownBlockProps) => { +const MarkdownBlock = memo(({ markdown, mentions = false, breaks = false }: MarkdownBlockProps) => { const components = useMemo( () => ({ table: ({ children, ...props }: any) => { @@ -484,6 +603,13 @@ const MarkdownBlock = memo(({ markdown, mentions = false }: MarkdownBlockProps) [], ) + // When mentions are actionable, rewrite the raw markdown before parsing so + // mention matching runs on the untokenized string (see prepareMentions). + const { preparedMarkdown, mentions: mentionList } = useMemo( + () => (mentions ? prepareMentions(markdown || "") : { preparedMarkdown: markdown || "", mentions: [] }), + [markdown, mentions], + ) + return ( { return (tree: any) => { visit(tree, "code", (node: any) => { @@ -505,9 +632,13 @@ const MarkdownBlock = memo(({ markdown, mentions = false }: MarkdownBlockProps) } }, ]} - rehypePlugins={[...(mentions ? [rehypeMentions] : []), rehypeKatex as any]} + rehypePlugins={[ + ...(mentions ? [[rehypeMentions, mentionList] as const] : []), + ...(breaks ? [rehypeStripBreakNewlines] : []), + rehypeKatex as any, + ]} components={components}> - {markdown || ""} + {preparedMarkdown} ) diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index 5c3035f28c..6046e3ccf4 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -410,5 +410,113 @@ describe("MarkdownBlock", () => { expect(mentions.length).toBe(1) expect(mentions[0].textContent).toBe("@problems") }) + + it("matches the full mention path when it contains markdown-active characters", async () => { + // remark tokenizes `@/src/__init__.py` as `@/src/` + init + `.py`, + // so matching on tokenized text nodes would truncate the mention to `@/src/` and + // post the wrong path. Mention matching must run on the raw string instead. + const markdown = "Run the tests for @/src/__init__.py now." + const { container } = render() + + await screen.findByText(/Run the tests/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@/src/__init__.py") + + // No stray for the `__init__` part: the whole path is one mention. + expect(container.querySelector("p")?.querySelector("strong")).toBeNull() + + // Clicking must post the FULL path, not the truncated `@/src/` prefix. + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/__init__.py" }) + }) + + it("matches mentions containing asterisks on the raw string", async () => { + // `*files*` would tokenize as emphasis, splitting the path across text nodes. + const markdown = "Check @/src/*files* before shipping." + const { container } = render() + + await screen.findByText(/Check/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@/src/*files*") + + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/*files*" }) + }) + + it("resolves mentions and line breaks together in the same prompt", async () => { + // The raw-string mention preprocessing and remark-breaks both rewrite the + // paragraph; they must compose: the mention stays a single span and the + // soft break between the lines renders as one
. + const markdown = "Check @/src/file.ts\nthen review @problems" + const { container } = render() + + await screen.findByText(/then review/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions).toHaveLength(2) + expect(mentions[0].textContent).toBe("@/src/file.ts") + expect(mentions[1].textContent).toBe("@problems") + + const paragraph = container.querySelector("p") + expect(paragraph?.querySelectorAll("br")).toHaveLength(1) + expect(paragraph?.textContent).toBe("Check @/src/file.tsthen review @problems") + }) + + it("keeps placeholder-free output when the prompt contains no mentions", async () => { + // Preprocessing must not leak placeholder control characters into rendered + // text when the (raw) text happens to contain mention-like patterns that do + // not match (e.g. @ not preceded by whitespace). + const markdown = "Failed@localhost/status code 404." + const { container } = render() + + await screen.findByText(/Failed/, { exact: false }) + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.querySelector("p")?.textContent).toBe("Failed@localhost/status code 404.") + }) + }) + + describe("line breaks (breaks prop)", () => { + it("renders a soft line break as
when breaks is set", async () => { + const markdown = "line one\nline two" + const { container } = render() + + await screen.findByText(/line one/) + + const paragraph = container.querySelector("p") + expect(paragraph).not.toBeNull() + // remark-breaks turns the single newline into a real
so the line + // break is structural instead of relying on CSS white-space. + expect(paragraph?.querySelector("br")).not.toBeNull() + expect(paragraph?.textContent).toBe("line oneline two") + }) + + it("keeps soft line breaks as text by default", async () => { + const markdown = "line one\nline two" + const { container } = render() + + // The text matcher must be inexact: by default the newline stays inside + // the single text node, so "line one" is not a standalone node. + await screen.findByText(/line one/, { exact: false }) + + const paragraph = container.querySelector("p") + expect(paragraph?.querySelector("br")).toBeNull() + expect(paragraph?.textContent).toBe("line one\nline two") + }) + + it("keeps blank lines as paragraph breaks when breaks is set", async () => { + const markdown = "first paragraph\n\nsecond paragraph" + const { container } = render() + + await screen.findByText("first paragraph") + + // Two separate paragraphs (the blank line is a hard break, not a soft one). + expect(container.querySelectorAll("p")).toHaveLength(2) + expect(container.querySelector("p")?.querySelector("br")).toBeNull() + }) }) }) From 630309c18147becd7c4228860977cf4060b52633 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 14:21:19 +0800 Subject: [PATCH 11/13] fix(webview): respect mention boundaries and prevent Space scroll on activation Address CodeRabbit findings on #1257: - Match mentions on the raw markdown string and discard a match only when its range intersects a masked literal region, instead of matching a space-masked copy. Masking turned a preceding `)` or backtick into whitespace, which made a non-mention like `[file](/src/a.ts)`@problems` actionable even though the shared regex's start boundary rejects it in the raw text. Adding regression tests for a mention directly after a link/inline code (inert) and one separated by a space (actionable). - Call event.preventDefault() in the mention span's Enter/Space keydown handler so Space does not also scroll the expanded task panel while a mention has focus. The keyboard test now asserts both keys are default-prevented and an unrelated key is not. --- .../src/components/common/MarkdownBlock.tsx | 44 +++++++++++------ .../common/__tests__/MarkdownBlock.spec.tsx | 47 +++++++++++++++++-- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 4108a6107f..b728ac72ea 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -41,9 +41,13 @@ const MENTION_MASK_NODE_TYPES = new Set(["code", "inlineCode", "link", "image", * is also the behavior of the collapsed component, so this restores * it for the expanded view. * - * Regions that render as literal content (code, links, images, HTML, math) are - * masked to spaces in a throwaway mdast parse first; using the exact positions - * remark sees guarantees a mention inside such a region is never rewritten. + * Matching runs on the raw string so the shared regex's boundary rules apply + * unchanged (replacing literal regions with spaces would turn a preceding `)` + * or backtick into whitespace and make non-mentions actionable). Literal / non- + * text regions (code, links, images, HTML, math) are marked via a throwaway + * mdast parse with the exact positions remark sees, and a match whose range + * intersects one of them is discarded so mentions inside such regions stay + * inert. */ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions: string[] } { if (!markdown) { @@ -51,14 +55,14 @@ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions } // A throwaway parse with the same extensions as the render pipeline, so the - // reported positions match what remark will tokenize. Mask literal and - // non-text regions to spaces (mdast positions carry absolute source - // offsets): a mention inside any of them must stay inert, because code and - // links render as literal/interactive content, and images, raw HTML, and - // math keep their source text unchanged. + // reported positions match what remark will tokenize. Mark literal and + // non-text regions (mdast positions carry absolute source offsets): a + // mention inside any of them must stay inert, because code and links render + // as literal/interactive content, and images, raw HTML, and math keep their + // source text unchanged. const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(markdown) - const masked = markdown.split("") + const isMasked = new Array(markdown.length).fill(false) visit(tree, (node: any) => { if (!MENTION_MASK_NODE_TYPES.has(node.type)) { return @@ -68,20 +72,28 @@ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions if (typeof start !== "number" || typeof end !== "number") { return } - for (let i = start; i < end && i < masked.length; i++) { - masked[i] = " " + for (let i = start; i < end && i < isMasked.length; i++) { + isMasked[i] = true } }) const mentions: string[] = [] let preparedMarkdown = "" let lastIndex = 0 - for (const match of masked.join("").matchAll(mentionRegexGlobal)) { + for (const match of markdown.matchAll(mentionRegexGlobal)) { const start = match.index! + const end = start + match[0].length + // The raw string (not a masked copy) is what the shared regex's boundary + // rules must see: masking would turn a preceding `)` or backtick into a + // space and make a non-mention actionable (e.g. `[file](/src/a.ts)`@problems``). + // Discard a match only when its range lands inside a masked literal region. + if (isMasked.slice(start, end).some(Boolean)) { + continue + } preparedMarkdown += markdown.slice(lastIndex, start) - mentions.push(markdown.slice(start, start + match[0].length)) + mentions.push(markdown.slice(start, end)) preparedMarkdown += `${MENTION_PLACEHOLDER_CHAR}${mentions.length - 1}${MENTION_PLACEHOLDER_CHAR}` - lastIndex = start + match[0].length + lastIndex = end } preparedMarkdown += markdown.slice(lastIndex) @@ -158,10 +170,14 @@ function rehypeMentions(mentions: string[]) { }, // Keyboard parity with the click handler (a role=button span is not a // native button, so Enter/Space must be handled explicitly). + // preventDefault keeps Space from also scrolling the expanded task panel, + // which otherwise receives the key's default action when a mention has + // focus. onKeyDown: (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") { return } + event.preventDefault() event.stopPropagation() vscode.postMessage({ type: "openMention", text: mentionValue }) }, diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index 6046e3ccf4..d887db3037 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -358,6 +358,42 @@ describe("MarkdownBlock", () => { expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "openMention" })) }) + it("keeps the shared regex boundary rules when a mention directly follows a link or inline code", async () => { + // Matching must run on the raw string so the shared regex's start boundary + // sees the real characters: with no whitespace after a closing `)` or a + // backtick, `@problems` is not a mention (the collapsed + // component rejects it too). Replacing the literal regions with spaces + // before matching would make them actionable. + const markdown = "[file](/src/a.ts)@problems and `x`@problems" + const { container } = render() + + // The anchor text is a stable, unique wait target. + await screen.findByText("file") + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + // The text still renders verbatim (link + plain text, no spans). + expect(container.querySelector("p")?.textContent).toBe("file@problems and x@problems") + }) + + it("keeps a whitespace-separated mention after a link or inline code actionable", async () => { + // The space is a legitimate boundary for the shared regex, so these + // mentions stay clickable: the masked regions end before the spaces and + // the match ranges do not intersect them. + const markdown = "[file](/src/a.ts) @problems and `x` @problems" + const { container } = render() + + // The anchor text is a stable, unique wait target. + await screen.findByText("file") + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions).toHaveLength(2) + expect(mentions[0].textContent).toBe("@problems") + expect(mentions[1].textContent).toBe("@problems") + + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "problems" }) + }) + it("renders a standalone mention with no surrounding text", async () => { // A mention that both starts and ends the text node exercises the // no-leading-text and no-trailing-text branches of the splitter. @@ -384,15 +420,20 @@ describe("MarkdownBlock", () => { expect(mention.getAttribute("role")).toBe("button") expect(mention.getAttribute("tabindex")).toBe("0") - fireEvent.keyDown(mention, { key: "Enter" }) + // Enter/Space must both post and be default-prevented: dispatching a + // cancelable event returns false once preventDefault has run, and Space's + // default action would otherwise scroll the expanded task panel while a + // mention has focus. + expect(fireEvent.keyDown(mention, { key: "Enter" })).toBe(false) expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" }) mockPostMessage.mockClear() - fireEvent.keyDown(mention, { key: " " }) + expect(fireEvent.keyDown(mention, { key: " " })).toBe(false) expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" }) mockPostMessage.mockClear() - fireEvent.keyDown(mention, { key: "a" }) + // An unrelated key neither posts nor prevents the default action. + expect(fireEvent.keyDown(mention, { key: "a" })).toBe(true) expect(mockPostMessage).not.toHaveBeenCalled() }) From d9bc16d9bb6fa1294ced5dffe86a234dbb9a7ddb Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 17:22:55 +0800 Subject: [PATCH 12/13] fix(webview): mask reference link definitions before mention rewriting Address CodeRabbit finding on #1257: - Add definition to MENTION_MASK_NODE_TYPES so a reference link destination (e.g. [docs]: @/docs/readme.md) is masked before prepareMentions() runs. Without it, the destination was rewritten to a mention placeholder and that placeholder became the reference link's href, corrupting the href with control characters instead of rendering a mention span. - Regression test: the reference link keeps its original href, the destination never becomes a mention span, a real mention in the body stays actionable, and no placeholder control characters leak into the output. --- .../src/components/common/MarkdownBlock.tsx | 30 +++++++++++++------ .../common/__tests__/MarkdownBlock.spec.tsx | 27 +++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index b728ac72ea..78a57958b0 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -26,9 +26,20 @@ const MENTION_PLACEHOLDER_CHAR = "\u0001" const MENTION_PLACEHOLDER_REGEX = new RegExp(`${MENTION_PLACEHOLDER_CHAR}(\\d+)${MENTION_PLACEHOLDER_CHAR}`, "g") // mdast node types whose raw source regions must never be mention-rewritten: -// code blocks (fenced or indented), inline code, links, images, raw HTML, and -// math all render as literal or non-text content. -const MENTION_MASK_NODE_TYPES = new Set(["code", "inlineCode", "link", "image", "html", "inlineMath", "math"]) +// code blocks (fenced or indented), inline code, links, images, raw HTML, math, +// and reference link definitions all render as literal or non-text content +// (rewriting a definition's destination would corrupt the reference link's +// href instead of producing a mention span). +const MENTION_MASK_NODE_TYPES = new Set([ + "code", + "inlineCode", + "link", + "image", + "html", + "inlineMath", + "math", + "definition", +]) /** * Rewrite mention patterns in the RAW markdown string before remark tokenizes @@ -44,10 +55,10 @@ const MENTION_MASK_NODE_TYPES = new Set(["code", "inlineCode", "link", "image", * Matching runs on the raw string so the shared regex's boundary rules apply * unchanged (replacing literal regions with spaces would turn a preceding `)` * or backtick into whitespace and make non-mentions actionable). Literal / non- - * text regions (code, links, images, HTML, math) are marked via a throwaway - * mdast parse with the exact positions remark sees, and a match whose range - * intersects one of them is discarded so mentions inside such regions stay - * inert. + * text regions (code, links, images, HTML, math, reference link definitions) + * are marked via a throwaway mdast parse with the exact positions remark sees, + * and a match whose range intersects one of them is discarded so mentions + * inside such regions stay inert. */ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions: string[] } { if (!markdown) { @@ -58,8 +69,9 @@ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions // reported positions match what remark will tokenize. Mark literal and // non-text regions (mdast positions carry absolute source offsets): a // mention inside any of them must stay inert, because code and links render - // as literal/interactive content, and images, raw HTML, and math keep their - // source text unchanged. + // as literal/interactive content, images, raw HTML, and math keep their + // source text unchanged, and a reference link definition's destination + // becomes the link's href (rewriting it would corrupt the href). const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(markdown) const isMasked = new Array(markdown.length).fill(false) diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index d887db3037..ee17150567 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -358,6 +358,33 @@ describe("MarkdownBlock", () => { expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "openMention" })) }) + it("keeps reference link destinations inert and preserves the original href", async () => { + // A reference definition's destination renders as the reference link's + // href, so rewriting it to a mention placeholder would corrupt the href + // (control characters instead of the original path) rather than produce + // a mention span. The whole definition region must stay masked. + const markdown = "[docs]: @/docs/readme.md\n\nSee [docs] and @problems." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + // The reference link keeps its original href, untouched by mention + // preprocessing, and the destination never becomes a mention span. + const anchor = container.querySelector("a")! + expect(anchor).toHaveAttribute("href", "@/docs/readme.md") + expect(anchor.textContent).toBe("docs") + expect(container.querySelectorAll("a span.mention-context-highlight").length).toBe(0) + + // Masking the definition must not affect a real mention in the body. + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + + // No placeholder control characters leak into the rendered output. + expect(container.textContent).not.toContain("\u0001") + expect(anchor.getAttribute("href")).not.toContain("\u0001") + }) + it("keeps the shared regex boundary rules when a mention directly follows a link or inline code", async () => { // Matching must run on the raw string so the shared regex's start boundary // sees the real characters: with no whitespace after a closing `)` or a From 203f76b2a1fd752f396b7c47598c152668387085 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 18:39:38 +0800 Subject: [PATCH 13/13] fix(webview): mask reference links and image references before mention rewriting Address CodeRabbit finding on #1257: - Add linkReference and imageReference to MENTION_MASK_NODE_TYPES so a reference link's label and an image reference's alt are masked before prepareMentions() runs. Without it, a mention pattern inside such a label (e.g. [the @problems summary][docs]) was rewritten to an indexed placeholder that remark kept inside the label; since rehypeMentions() skips anchors, the raw placeholder (control character plus index) rendered verbatim inside the link text. Image references corrupted the alt attribute the same way. - Regression tests: a mention inside a reference link label and inside an image reference alt both stay inert, the label text and alt attribute are preserved verbatim, no mention span is rendered, and no placeholder control characters leak into the output. --- .../src/components/common/MarkdownBlock.tsx | 24 +++++++----- .../common/__tests__/MarkdownBlock.spec.tsx | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 78a57958b0..76c93422ba 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -27,9 +27,11 @@ const MENTION_PLACEHOLDER_REGEX = new RegExp(`${MENTION_PLACEHOLDER_CHAR}(\\d+)$ // mdast node types whose raw source regions must never be mention-rewritten: // code blocks (fenced or indented), inline code, links, images, raw HTML, math, -// and reference link definitions all render as literal or non-text content -// (rewriting a definition's destination would corrupt the reference link's -// href instead of producing a mention span). +// reference link definitions, and reference links/images all render as literal +// or non-text content. Rewriting a definition's destination would corrupt the +// reference link's href; rewriting a reference label or alt would leak the raw +// placeholder into the anchor text or img alt (rehypeMentions skips anchors), +// instead of producing a mention span. const MENTION_MASK_NODE_TYPES = new Set([ "code", "inlineCode", @@ -39,6 +41,8 @@ const MENTION_MASK_NODE_TYPES = new Set([ "inlineMath", "math", "definition", + "linkReference", + "imageReference", ]) /** @@ -55,10 +59,10 @@ const MENTION_MASK_NODE_TYPES = new Set([ * Matching runs on the raw string so the shared regex's boundary rules apply * unchanged (replacing literal regions with spaces would turn a preceding `)` * or backtick into whitespace and make non-mentions actionable). Literal / non- - * text regions (code, links, images, HTML, math, reference link definitions) - * are marked via a throwaway mdast parse with the exact positions remark sees, - * and a match whose range intersects one of them is discarded so mentions - * inside such regions stay inert. + * text regions (code, links, images, HTML, math, reference link definitions, + * and reference links/images) are marked via a throwaway mdast parse with the + * exact positions remark sees, and a match whose range intersects one of them + * is discarded so mentions inside such regions stay inert. */ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions: string[] } { if (!markdown) { @@ -70,8 +74,10 @@ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions // non-text regions (mdast positions carry absolute source offsets): a // mention inside any of them must stay inert, because code and links render // as literal/interactive content, images, raw HTML, and math keep their - // source text unchanged, and a reference link definition's destination - // becomes the link's href (rewriting it would corrupt the href). + // source text unchanged, a reference link definition's destination becomes + // the link's href, and a reference link/image label or alt renders as the + // anchor text or img alt (rewriting any of them would corrupt the href/alt + // or leak the raw placeholder into the rendered output). const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(markdown) const isMasked = new Array(markdown.length).fill(false) diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index ee17150567..5091f20d6d 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -385,6 +385,43 @@ describe("MarkdownBlock", () => { expect(anchor.getAttribute("href")).not.toContain("\u0001") }) + it("keeps reference link labels inert and preserves the label text", async () => { + // A mention inside a reference link's label renders as the anchor's + // text. Rewriting it to a mention placeholder would leak the raw + // placeholder (rehypeMentions skips anchors, so the control characters + // would render verbatim inside the link) and a role=button span inside + //
would be invalid nested interactive content. The whole reference + // region must stay masked. + const markdown = "See [the @problems summary][docs] now.\n\n[docs]: https://example.com/problems" + const { container } = render() + + await screen.findByText(/now/, { exact: false }) + + const anchor = container.querySelector("a")! + expect(anchor).toHaveAttribute("href", "https://example.com/problems") + expect(anchor.textContent).toBe("the @problems summary") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + + // No placeholder control characters leak into the rendered output. + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps image reference alt text inert and preserves the alt attribute", async () => { + // An image reference's alt renders as the img's alt attribute. Rewriting + // it to a mention placeholder would corrupt the alt instead of producing + // a mention span. The whole reference region must stay masked. + const markdown = "See ![a @problems screenshot][docs] now.\n\n[docs]: https://example.com/problems.png" + const { container } = render() + + await screen.findByText(/now/, { exact: false }) + + const img = container.querySelector("img")! + expect(img).toHaveAttribute("src", "https://example.com/problems.png") + expect(img).toHaveAttribute("alt", "a @problems screenshot") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.textContent).not.toContain("\u0001") + }) + it("keeps the shared regex boundary rules when a mention directly follows a link or inline code", async () => { // Matching must run on the raw string so the shared regex's start boundary // sees the real characters: with no whitespace after a closing `)` or a