diff --git a/webview-ui/src/components/chat/Markdown.tsx b/webview-ui/src/components/chat/Markdown.tsx index ba838284d7d..87780d5df8d 100644 --- a/webview-ui/src/components/chat/Markdown.tsx +++ b/webview-ui/src/components/chat/Markdown.tsx @@ -21,7 +21,7 @@ export const Markdown = memo(({ markdown, partial }: { markdown?: string; partia onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} style={{ position: "relative" }}> -
+
{markdown && !partial && isHovering && ( diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index ca6c9316b10..cae609d9553 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -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" @@ -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" @@ -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 { @@ -133,11 +145,18 @@ 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, @@ -145,6 +164,8 @@ const StyledMarkdown = styled.div` border: 1px solid var(--vscode-panel-border); padding: 8px 12px; text-align: left; + word-wrap: break-word; + overflow-wrap: break-word; } th { @@ -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 ( +
+ {children}
+
+ ) + }, + a: ({ href, children, ...props }: any) => { + const handleClick = (e: React.MouseEvent) => { + // 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) => { - // Only process file:// protocol or local file paths - const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://") + return ( + + {children} + + ) + }, + 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
{children}
} - 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 ( +
+ +
+ ) } - vscode.postMessage({ - type: "openFile", - text: filePath, - values, - }) - } - - return ( - - {children} - - ) - }, - 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
{children}
- } - - 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 (
- +
) - } - - // 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 ( -
- -
- ) - }, - code: ({ children, className, ...props }: any) => { - // This handles inline code - return ( - - {children} - - ) - }, - } + }, + code: ({ children, className, ...props }: any) => { + // This handles inline code + return ( + + {children} + + ) + }, + }), + [], + ) return (