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 0941a22e2b..098e251981 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 @@ -163,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 } @@ -324,13 +328,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..c3008e2976 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -329,4 +329,173 @@ 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() + }) + + 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") + }) + + 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("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" }) + + // 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("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. + task: { type: "say", ts: Date.now(), 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() + }) + }) }) diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 02f696553f..76c93422ba 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -3,8 +3,13 @@ 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" import { vscode } from "@src/utils/vscode" import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown" @@ -12,6 +17,228 @@ 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, math, +// 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", + "link", + "image", + "html", + "inlineMath", + "math", + "definition", + "linkReference", + "imageReference", +]) + +/** + * 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. + * + * 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, + * 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) { + return { preparedMarkdown: markdown, mentions: [] } + } + + // A throwaway parse with the same extensions as the render pipeline, so the + // 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, images, raw HTML, and math keep their + // 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) + 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 < isMasked.length; i++) { + isMasked[i] = true + } + }) + + const mentions: string[] = [] + let preparedMarkdown = "" + let lastIndex = 0 + 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, end)) + preparedMarkdown += `${MENTION_PLACEHOLDER_CHAR}${mentions.length - 1}${MENTION_PLACEHOLDER_CHAR}` + lastIndex = end + } + preparedMarkdown += markdown.slice(lastIndex) + + return { preparedMarkdown, mentions } +} + +/** + * Rehype plugin that replaces the mention placeholders produced by + * prepareMentions with clickable spans matching the styling used by the + * collapsed Mention component. + */ +function rehypeMentions(mentions: string[]) { + return (tree: any) => { + 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 + } + + // 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 + // string children (the mention text would silently disappear). + if (parent?.tagName === "code" || parent?.tagName === "pre" || parent?.tagName === "a") { + return + } + + const originalValue = String(node.value) + 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 = 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) { + 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"], + 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). + // 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 }) + }, + }, + children: [{ type: "text", value: mentionText }], + }) + + lastIndex = mentionStart + match[0].length + } + + if (lastIndex < originalValue.length) { + children.push({ type: "text", value: originalValue.slice(lastIndex) }) + } + + parent.children.splice(index, 1, ...children) + }) + } +} + +/** + * 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", @@ -32,6 +259,22 @@ 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 + /** + * 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` @@ -273,7 +516,7 @@ const StyledMarkdown = styled.div` } ` -const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { +const MarkdownBlock = memo(({ markdown, mentions = false, breaks = false }: MarkdownBlockProps) => { const components = useMemo( () => ({ table: ({ children, ...props }: any) => { @@ -394,6 +637,13 @@ const MarkdownBlock = memo(({ markdown }: 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 ( { [remarkGfm, { singleTilde: false }], remarkMath, remarkGithubAlerts, + ...(breaks ? [remarkBreaks] : []), () => { return (tree: any) => { visit(tree, "code", (node: any) => { @@ -415,9 +666,13 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { } }, ]} - rehypePlugins={[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 2c56fc418a..5091f20d6d 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,403 @@ describe("MarkdownBlock", () => { expect(screen.getByText("Third level ordered")).toBeInTheDocument() expect(screen.getByText("Back to first level")).toBeInTheDocument() }) + + 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() + + 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("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("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("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 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 + // 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. + 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() + + 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") + + // 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() + expect(fireEvent.keyDown(mention, { key: " " })).toBe(false) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" }) + + mockPostMessage.mockClear() + // An unrelated key neither posts nor prevents the default action. + expect(fireEvent.keyDown(mention, { key: "a" })).toBe(true) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + 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") + }) + + 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() + }) + }) })