Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions webview-ui/src/components/chat/TaskHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -324,13 +328,13 @@ const TaskHeader = ({
className="text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
<div
ref={textRef}
className="overflow-auto max-h-80 whitespace-pre-wrap break-words break-anywhere cursor-text py-0.5"
className="scrollable overflow-auto max-h-80 break-words break-anywhere cursor-text py-0.5"
style={{
display: "-webkit-box",
WebkitLineClamp: "unset",
WebkitBoxOrient: "vertical",
}}>
<Mention text={task.text} />
<MarkdownBlock markdown={task.text ?? ""} />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
</div>
</div>
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
Expand Down
114 changes: 114 additions & 0 deletions webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>, `code` becomes <code>.
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()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
56 changes: 55 additions & 1 deletion webview-ui/src/components/common/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlertType, string> = {
note: "codicon-info",
Expand Down Expand Up @@ -415,7 +469,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
}
},
]}
rehypePlugins={[rehypeKatex as any]}
rehypePlugins={[rehypeMentions, rehypeKatex as any]}
components={components}>
{markdown || ""}
</ReactMarkdown>
Expand Down
106 changes: 104 additions & 2 deletions webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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(<MarkdownBlock markdown={markdown} />)

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(<MarkdownBlock markdown={markdown} />)

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(<MarkdownBlock markdown={markdown} />)

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(<MarkdownBlock markdown={markdown} />)

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(<MarkdownBlock markdown={markdown} />)

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(<MarkdownBlock markdown={markdown} />)

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(<MarkdownBlock markdown={markdown} />)

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")
})
})
})
Loading