Просмотр исходного кода

fix: prevent scrollbar flickering in chat view during content streaming (#6266)

Daniel 5 месяцев назад
Родитель
Сommit
43f649b03f

+ 1 - 1
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" }}>
-			<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 && (

+ 110 - 81
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 (
+					<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>