diff --git a/webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx b/webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx
new file mode 100644
index 00000000000..2393e9005d7
--- /dev/null
+++ b/webview-ui/src/components/chat/OpenMarkdownPreviewButton.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+import { SquareArrowOutUpRight } from "lucide-react"
+
+import { vscode } from "@src/utils/vscode"
+import { hasComplexMarkdown } from "@src/utils/markdown"
+import { StandardTooltip } from "@src/components/ui"
+
+interface OpenMarkdownPreviewButtonProps {
+ markdown: string | undefined
+ className?: string
+}
+
+export const OpenMarkdownPreviewButton = memo(({ markdown, className }: OpenMarkdownPreviewButtonProps) => {
+ if (!hasComplexMarkdown(markdown)) {
+ return null
+ }
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (markdown) {
+ vscode.postMessage({
+ type: "openMarkdownPreview",
+ text: markdown,
+ })
+ }
+ }
+
+ return (
+
+
+
+ )
+})
diff --git a/webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx b/webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx
new file mode 100644
index 00000000000..95f7aad21b0
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/OpenMarkdownPreviewButton.spec.tsx
@@ -0,0 +1,53 @@
+import React from "react"
+import { describe, expect, it, vi, beforeEach } from "vitest"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { TooltipProvider } from "@radix-ui/react-tooltip"
+
+import { OpenMarkdownPreviewButton } from "../OpenMarkdownPreviewButton"
+
+const { postMessageMock } = vi.hoisted(() => ({
+ postMessageMock: vi.fn(),
+}))
+
+vi.mock("@src/utils/vscode", () => ({
+ vscode: {
+ postMessage: postMessageMock,
+ },
+}))
+
+describe("OpenMarkdownPreviewButton", () => {
+ const complex = "# One\n## Two"
+ const simple = "Just text"
+
+ beforeEach(() => {
+ postMessageMock.mockClear()
+ })
+
+ it("does not render when markdown has fewer than 2 headings", () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.queryByLabelText("Open markdown in preview")).toBeNull()
+ })
+
+ it("renders when markdown has 2+ headings", () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByLabelText("Open markdown in preview")).toBeInTheDocument()
+ })
+
+ it("posts message on click", () => {
+ render(
+
+
+ ,
+ )
+ fireEvent.click(screen.getByLabelText("Open markdown in preview"))
+ expect(postMessageMock).toHaveBeenCalledWith({ type: "openMarkdownPreview", text: complex })
+ })
+})
diff --git a/webview-ui/src/utils/__tests__/markdown.spec.ts b/webview-ui/src/utils/__tests__/markdown.spec.ts
new file mode 100644
index 00000000000..97b3fdaaf2f
--- /dev/null
+++ b/webview-ui/src/utils/__tests__/markdown.spec.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest"
+
+import { countMarkdownHeadings, hasComplexMarkdown } from "../markdown"
+
+describe("markdown heading helpers", () => {
+ it("returns 0 for empty or undefined", () => {
+ expect(countMarkdownHeadings(undefined)).toBe(0)
+ expect(countMarkdownHeadings("")).toBe(0)
+ })
+
+ it("counts single and multiple headings", () => {
+ expect(countMarkdownHeadings("# One")).toBe(1)
+ expect(countMarkdownHeadings("# One\nContent")).toBe(1)
+ expect(countMarkdownHeadings("# One\n## Two")).toBe(2)
+ expect(countMarkdownHeadings("# One\n## Two\n### Three")).toBe(3)
+ })
+
+ it("handles all heading levels", () => {
+ const md = `# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6`
+ expect(countMarkdownHeadings(md)).toBe(6)
+ })
+
+ it("ignores headings inside code fences", () => {
+ const md = "# real\n```\n# not a heading\n```\n## real"
+ expect(countMarkdownHeadings(md)).toBe(2)
+ })
+
+ it("hasComplexMarkdown requires at least two headings", () => {
+ expect(hasComplexMarkdown("# One")).toBe(false)
+ expect(hasComplexMarkdown("# One\n## Two")).toBe(true)
+ })
+})
diff --git a/webview-ui/src/utils/markdown.ts b/webview-ui/src/utils/markdown.ts
new file mode 100644
index 00000000000..7a77b9866db
--- /dev/null
+++ b/webview-ui/src/utils/markdown.ts
@@ -0,0 +1,23 @@
+/**
+ * Counts the number of markdown headings in the given text.
+ * Matches headings from level 1 to 6 (e.g. #, ##, ###, etc.).
+ * Code fences are stripped before matching to avoid false positives.
+ */
+export function countMarkdownHeadings(text: string | undefined): number {
+ if (!text) return 0
+
+ // Remove fenced code blocks to avoid counting headings inside code
+ const withoutCodeBlocks = text.replace(/```[\s\S]*?```/g, "")
+
+ // Up to 3 leading spaces are allowed before the hashes per the markdown spec
+ const headingRegex = /^\s{0,3}#{1,6}\s+.+$/gm
+ const matches = withoutCodeBlocks.match(headingRegex)
+ return matches ? matches.length : 0
+}
+
+/**
+ * Returns true if the markdown contains at least two headings.
+ */
+export function hasComplexMarkdown(text: string | undefined): boolean {
+ return countMarkdownHeadings(text) >= 2
+}