diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..b614dbbb92 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..5a5f6055b4 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -329,4 +329,118 @@ 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" }) + }) + + 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..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") + }) + }) })