Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
155ac1c
fix(webview): render expanded task header text as markdown
easonliang28 Aug 16, 2026
c90f28d
fix(webview): use VS Code-style scrollbar for expanded task prompt box
easonliang28 Aug 16, 2026
0f4deba
fix(webview): keep task header expanded when clicking rendered markdo…
easonliang28 Aug 16, 2026
73ed937
test(webview): drop as-any cast from empty-prompt TaskHeader fixture
easonliang28 Aug 16, 2026
0842e9b
Merge branch 'main' into fix/task-header-markdown
easonLiangWorldedtech Aug 17, 2026
1d13c74
fix(chat): preserve mentions in expanded task markdown
easonliang28 Aug 17, 2026
3811f0b
Merge branch 'main' into fix/task-header-markdown
easonLiangWorldedtech Aug 19, 2026
99986b3
fix(webview): keep expanded task panel open after mention click
easonliang28 Aug 19, 2026
03e98ab
fix(webview): keep mentions literal in code and keyboard accessible
easonliang28 Aug 19, 2026
279be66
test(webview): cover remaining mention-splitter branches
easonliang28 Aug 19, 2026
84b22e3
fix(webview): gate clickable mentions to user-authored task text
easonliang28 Aug 20, 2026
1206a95
Merge branch 'main' into fix/task-header-markdown
easonLiangWorldedtech Aug 20, 2026
bd93019
fix(webview): keep newlines and full mention paths in expanded task h…
easonliang28 Aug 22, 2026
630309c
fix(webview): respect mention boundaries and prevent Space scroll on …
easonliang28 Aug 22, 2026
d9bc16d
fix(webview): mask reference link definitions before mention rewriting
easonliang28 Aug 23, 2026
203f76b
fix(webview): mask reference links and image references before mentio…
easonliang28 Aug 23, 2026
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
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions webview-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
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"
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
style={{
display: "-webkit-box",
WebkitLineClamp: "unset",
WebkitBoxOrient: "vertical",
}}>
<Mention text={task.text} />
<MarkdownBlock markdown={task.text ?? ""} mentions breaks />
</div>
</div>
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
Expand Down
169 changes: 169 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,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 <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" })

// 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<br>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 (<br>) 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()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
Loading
Loading