Skip to content
Merged
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
2 changes: 1 addition & 1 deletion webview-ui/src/components/chat/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const Markdown = memo(({ markdown, partial }: { markdown?: string; partia
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ position: "relative" }}>
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -15, marginTop: -15 }}>
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
<MarkdownBlock markdown={markdown} />
</div>
{markdown && !partial && isHovering && (
Expand Down
191 changes: 110 additions & 81 deletions webview-ui/src/components/common/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { memo } from "react"
import React, { memo, useMemo } from "react"
import ReactMarkdown from "react-markdown"
import styled from "styled-components"
import { visit } from "unist-util-visit"
Expand All @@ -7,7 +7,6 @@ import remarkMath from "remark-math"
import remarkGfm from "remark-gfm"

import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"

import CodeBlock from "./CodeBlock"
import MermaidBlock from "./MermaidBlock"
Expand Down Expand Up @@ -117,6 +116,19 @@ const StyledMarkdown = styled.div`

p {
white-space: pre-wrap;
margin: 0.5em 0;
}

/* Prevent layout shifts during streaming */
pre {
min-height: 3em;
transition: height 0.2s ease-out;
}

/* Code block container styling */
div:has(> pre) {
position: relative;
contain: layout style;
}

a {
Expand All @@ -133,18 +145,27 @@ const StyledMarkdown = styled.div`

/* Table styles for remark-gfm */
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
width: auto;
min-width: 50%;
max-width: 100%;
table-layout: fixed;
}

/* Table wrapper for horizontal scrolling */
.table-wrapper {
overflow-x: auto;
display: block;
margin: 1em 0;
}

th,
td {
border: 1px solid var(--vscode-panel-border);
padding: 8px 12px;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
}

th {
Expand All @@ -163,96 +184,104 @@ const StyledMarkdown = styled.div`
`

const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
const { theme: _theme } = useExtensionState()
const components = useMemo(
() => ({
table: ({ children, ...props }: any) => {
return (
<div className="table-wrapper">
<table {...props}>{children}</table>
</div>
)
},
a: ({ href, children, ...props }: any) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Only process file:// protocol or local file paths
const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://")

if (!isLocalPath) {
return
}

e.preventDefault()

// Handle absolute vs project-relative paths
let filePath = href.replace("file://", "")

// Extract line number if present
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
let values = undefined
if (match) {
filePath = match[1]
values = { line: parseInt(match[2]) }
}

// Add ./ prefix if needed
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
filePath = "./" + filePath
}

vscode.postMessage({
type: "openFile",
text: filePath,
values,
})
}

const components = {
a: ({ href, children, ...props }: any) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Only process file:// protocol or local file paths
const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://")
return (
<a {...props} href={href} onClick={handleClick}>
{children}
</a>
)
},
pre: ({ children, ..._props }: any) => {
// The structure from react-markdown v9 is: pre > code > text
const codeEl = children as React.ReactElement

if (!isLocalPath) {
return
if (!codeEl || !codeEl.props) {
return <pre>{children}</pre>
}

e.preventDefault()

// Handle absolute vs project-relative paths
let filePath = href.replace("file://", "")
const { className = "", children: codeChildren } = codeEl.props

// Extract line number if present
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
let values = undefined
if (match) {
filePath = match[1]
values = { line: parseInt(match[2]) }
// Get the actual code text
let codeString = ""
if (typeof codeChildren === "string") {
codeString = codeChildren
} else if (Array.isArray(codeChildren)) {
codeString = codeChildren.filter((child) => typeof child === "string").join("")
}

// Add ./ prefix if needed
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
filePath = "./" + filePath
// Handle mermaid diagrams
if (className.includes("language-mermaid")) {
return (
<div style={{ margin: "1em 0" }}>
<MermaidBlock code={codeString} />
</div>
)
}

vscode.postMessage({
type: "openFile",
text: filePath,
values,
})
}

return (
<a {...props} href={href} onClick={handleClick}>
{children}
</a>
)
},
pre: ({ children, ..._props }: any) => {
// The structure from react-markdown v9 is: pre > code > text
const codeEl = children as React.ReactElement

if (!codeEl || !codeEl.props) {
return <pre>{children}</pre>
}

const { className = "", children: codeChildren } = codeEl.props

// Get the actual code text
let codeString = ""
if (typeof codeChildren === "string") {
codeString = codeChildren
} else if (Array.isArray(codeChildren)) {
codeString = codeChildren.filter((child) => typeof child === "string").join("")
}

// Handle mermaid diagrams
if (className.includes("language-mermaid")) {
// Extract language from className
const match = /language-(\w+)/.exec(className)
const language = match ? match[1] : "text"

// Wrap CodeBlock in a div to ensure proper separation
return (
<div style={{ margin: "1em 0" }}>
<MermaidBlock code={codeString} />
<CodeBlock source={codeString} language={language} />
</div>
)
}

// Extract language from className
const match = /language-(\w+)/.exec(className)
const language = match ? match[1] : "text"

// Wrap CodeBlock in a div to ensure proper separation
return (
<div style={{ margin: "1em 0" }}>
<CodeBlock source={codeString} language={language} />
</div>
)
},
code: ({ children, className, ...props }: any) => {
// This handles inline code
return (
<code className={className} {...props}>
{children}
</code>
)
},
}
},
code: ({ children, className, ...props }: any) => {
// This handles inline code
return (
<code className={className} {...props}>
{children}
</code>
)
},
}),
[],
)

return (
<StyledMarkdown>
Expand Down