Skip to content

Commit 43f649b

Browse files
authored
fix: prevent scrollbar flickering in chat view during content streaming (#6266)
1 parent 623fa2a commit 43f649b

File tree

2 files changed

+111
-82
lines changed

2 files changed

+111
-82
lines changed

webview-ui/src/components/chat/Markdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const Markdown = memo(({ markdown, partial }: { markdown?: string; partia
2121
onMouseEnter={() => setIsHovering(true)}
2222
onMouseLeave={() => setIsHovering(false)}
2323
style={{ position: "relative" }}>
24-
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -15, marginTop: -15 }}>
24+
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
2525
<MarkdownBlock markdown={markdown} />
2626
</div>
2727
{markdown && !partial && isHovering && (

webview-ui/src/components/common/MarkdownBlock.tsx

Lines changed: 110 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { memo } from "react"
1+
import React, { memo, useMemo } from "react"
22
import ReactMarkdown from "react-markdown"
33
import styled from "styled-components"
44
import { visit } from "unist-util-visit"
@@ -7,7 +7,6 @@ import remarkMath from "remark-math"
77
import remarkGfm from "remark-gfm"
88

99
import { vscode } from "@src/utils/vscode"
10-
import { useExtensionState } from "@src/context/ExtensionStateContext"
1110

1211
import CodeBlock from "./CodeBlock"
1312
import MermaidBlock from "./MermaidBlock"
@@ -117,6 +116,19 @@ const StyledMarkdown = styled.div`
117116
118117
p {
119118
white-space: pre-wrap;
119+
margin: 0.5em 0;
120+
}
121+
122+
/* Prevent layout shifts during streaming */
123+
pre {
124+
min-height: 3em;
125+
transition: height 0.2s ease-out;
126+
}
127+
128+
/* Code block container styling */
129+
div:has(> pre) {
130+
position: relative;
131+
contain: layout style;
120132
}
121133
122134
a {
@@ -133,18 +145,27 @@ const StyledMarkdown = styled.div`
133145
134146
/* Table styles for remark-gfm */
135147
table {
136-
width: 100%;
137148
border-collapse: collapse;
138149
margin: 1em 0;
150+
width: auto;
151+
min-width: 50%;
152+
max-width: 100%;
153+
table-layout: fixed;
154+
}
155+
156+
/* Table wrapper for horizontal scrolling */
157+
.table-wrapper {
139158
overflow-x: auto;
140-
display: block;
159+
margin: 1em 0;
141160
}
142161
143162
th,
144163
td {
145164
border: 1px solid var(--vscode-panel-border);
146165
padding: 8px 12px;
147166
text-align: left;
167+
word-wrap: break-word;
168+
overflow-wrap: break-word;
148169
}
149170
150171
th {
@@ -163,96 +184,104 @@ const StyledMarkdown = styled.div`
163184
`
164185

165186
const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
166-
const { theme: _theme } = useExtensionState()
187+
const components = useMemo(
188+
() => ({
189+
table: ({ children, ...props }: any) => {
190+
return (
191+
<div className="table-wrapper">
192+
<table {...props}>{children}</table>
193+
</div>
194+
)
195+
},
196+
a: ({ href, children, ...props }: any) => {
197+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
198+
// Only process file:// protocol or local file paths
199+
const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://")
200+
201+
if (!isLocalPath) {
202+
return
203+
}
204+
205+
e.preventDefault()
206+
207+
// Handle absolute vs project-relative paths
208+
let filePath = href.replace("file://", "")
209+
210+
// Extract line number if present
211+
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
212+
let values = undefined
213+
if (match) {
214+
filePath = match[1]
215+
values = { line: parseInt(match[2]) }
216+
}
217+
218+
// Add ./ prefix if needed
219+
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
220+
filePath = "./" + filePath
221+
}
222+
223+
vscode.postMessage({
224+
type: "openFile",
225+
text: filePath,
226+
values,
227+
})
228+
}
167229

168-
const components = {
169-
a: ({ href, children, ...props }: any) => {
170-
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
171-
// Only process file:// protocol or local file paths
172-
const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://")
230+
return (
231+
<a {...props} href={href} onClick={handleClick}>
232+
{children}
233+
</a>
234+
)
235+
},
236+
pre: ({ children, ..._props }: any) => {
237+
// The structure from react-markdown v9 is: pre > code > text
238+
const codeEl = children as React.ReactElement
173239

174-
if (!isLocalPath) {
175-
return
240+
if (!codeEl || !codeEl.props) {
241+
return <pre>{children}</pre>
176242
}
177243

178-
e.preventDefault()
179-
180-
// Handle absolute vs project-relative paths
181-
let filePath = href.replace("file://", "")
244+
const { className = "", children: codeChildren } = codeEl.props
182245

183-
// Extract line number if present
184-
const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
185-
let values = undefined
186-
if (match) {
187-
filePath = match[1]
188-
values = { line: parseInt(match[2]) }
246+
// Get the actual code text
247+
let codeString = ""
248+
if (typeof codeChildren === "string") {
249+
codeString = codeChildren
250+
} else if (Array.isArray(codeChildren)) {
251+
codeString = codeChildren.filter((child) => typeof child === "string").join("")
189252
}
190253

191-
// Add ./ prefix if needed
192-
if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
193-
filePath = "./" + filePath
254+
// Handle mermaid diagrams
255+
if (className.includes("language-mermaid")) {
256+
return (
257+
<div style={{ margin: "1em 0" }}>
258+
<MermaidBlock code={codeString} />
259+
</div>
260+
)
194261
}
195262

196-
vscode.postMessage({
197-
type: "openFile",
198-
text: filePath,
199-
values,
200-
})
201-
}
202-
203-
return (
204-
<a {...props} href={href} onClick={handleClick}>
205-
{children}
206-
</a>
207-
)
208-
},
209-
pre: ({ children, ..._props }: any) => {
210-
// The structure from react-markdown v9 is: pre > code > text
211-
const codeEl = children as React.ReactElement
212-
213-
if (!codeEl || !codeEl.props) {
214-
return <pre>{children}</pre>
215-
}
216-
217-
const { className = "", children: codeChildren } = codeEl.props
218-
219-
// Get the actual code text
220-
let codeString = ""
221-
if (typeof codeChildren === "string") {
222-
codeString = codeChildren
223-
} else if (Array.isArray(codeChildren)) {
224-
codeString = codeChildren.filter((child) => typeof child === "string").join("")
225-
}
226-
227-
// Handle mermaid diagrams
228-
if (className.includes("language-mermaid")) {
263+
// Extract language from className
264+
const match = /language-(\w+)/.exec(className)
265+
const language = match ? match[1] : "text"
266+
267+
// Wrap CodeBlock in a div to ensure proper separation
229268
return (
230269
<div style={{ margin: "1em 0" }}>
231-
<MermaidBlock code={codeString} />
270+
<CodeBlock source={codeString} language={language} />
232271
</div>
233272
)
234-
}
235-
236-
// Extract language from className
237-
const match = /language-(\w+)/.exec(className)
238-
const language = match ? match[1] : "text"
239-
240-
// Wrap CodeBlock in a div to ensure proper separation
241-
return (
242-
<div style={{ margin: "1em 0" }}>
243-
<CodeBlock source={codeString} language={language} />
244-
</div>
245-
)
246-
},
247-
code: ({ children, className, ...props }: any) => {
248-
// This handles inline code
249-
return (
250-
<code className={className} {...props}>
251-
{children}
252-
</code>
253-
)
254-
},
255-
}
273+
},
274+
code: ({ children, className, ...props }: any) => {
275+
// This handles inline code
276+
return (
277+
<code className={className} {...props}>
278+
{children}
279+
</code>
280+
)
281+
},
282+
}),
283+
[],
284+
)
256285

257286
return (
258287
<StyledMarkdown>

0 commit comments

Comments
 (0)