fix(code-block-view): auto-scroll streaming code blocks (#16673)

Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Signed-off-by: jd <59188306+zhangjiadi225@users.noreply.github.com>
This commit is contained in:
jd
2026-07-02 19:53:20 +08:00
committed by GitHub
parent 851a647468
commit 2f53df5227
11 changed files with 408 additions and 14 deletions

View File

@@ -9,6 +9,14 @@ import type { CodeEditorHandles } from '../types'
const mocks = vi.hoisted(() => {
const replacement = { changes: 'inserted-text' }
const scrollEffect = { type: 'scroll-to-bottom' }
const scrollDOM = {
addEventListener: vi.fn(),
clientHeight: 100,
removeEventListener: vi.fn(),
scrollHeight: 200,
scrollTop: 100
}
return {
codeMirrorProps: undefined as { onCreateEditor?: (view: unknown) => void } | undefined,
@@ -16,6 +24,12 @@ const mocks = vi.hoisted(() => {
focus: vi.fn(),
replacement,
replaceSelection: vi.fn(() => replacement),
scrollDOM,
scrollEffect,
scrollIntoView: vi.fn<(position: number, options: { y: string; x: string }) => typeof scrollEffect>(
() => scrollEffect
),
scrollListener: undefined as (() => void) | undefined,
scrollToLine: vi.fn()
}
})
@@ -23,12 +37,16 @@ const mocks = vi.hoisted(() => {
vi.mock('@uiw/react-codemirror', () => ({
default: (props: { onCreateEditor?: (view: unknown) => void }) => {
mocks.codeMirrorProps = props
mocks.scrollDOM.addEventListener.mockImplementation((event: string, listener: () => void) => {
if (event === 'scroll') mocks.scrollListener = listener
})
props.onCreateEditor?.({
dispatch: mocks.dispatch,
focus: mocks.focus,
scrollDOM: { scrollHeight: 120 },
scrollDOM: mocks.scrollDOM,
state: {
doc: {
length: 'Current content'.length,
toString: () => 'Current content'
},
replaceSelection: mocks.replaceSelection
@@ -44,6 +62,7 @@ vi.mock('@uiw/react-codemirror', () => ({
},
EditorView: {
lineWrapping: 'line-wrapping',
scrollIntoView: mocks.scrollIntoView,
theme: vi.fn(() => 'editor-theme')
}
}))
@@ -62,6 +81,13 @@ describe('CodeEditor', () => {
mocks.dispatch.mockClear()
mocks.focus.mockClear()
mocks.replaceSelection.mockClear()
mocks.scrollDOM.addEventListener.mockClear()
mocks.scrollDOM.removeEventListener.mockClear()
mocks.scrollDOM.clientHeight = 100
mocks.scrollDOM.scrollHeight = 200
mocks.scrollDOM.scrollTop = 100
mocks.scrollIntoView.mockClear()
mocks.scrollListener = undefined
mocks.scrollToLine.mockClear()
})
@@ -87,4 +113,73 @@ describe('CodeEditor', () => {
expect(mocks.dispatch).toHaveBeenCalledWith(mocks.replacement)
expect(mocks.focus).toHaveBeenCalledTimes(1)
})
it('scrolls the internal editor to the document bottom when streaming content grows', () => {
const { rerender } = render(
<CodeEditor
value="Current content"
language="markdown"
options={{ stream: true }}
expanded={false}
autoScrollToBottom
/>
)
mocks.dispatch.mockClear()
mocks.scrollIntoView.mockClear()
rerender(
<CodeEditor
value="Current content\nnext line"
language="markdown"
options={{ stream: true }}
expanded={false}
autoScrollToBottom
/>
)
expect(mocks.scrollIntoView).toHaveBeenCalledWith(expect.any(Number), {
y: 'end',
x: 'nearest'
})
expect(mocks.scrollIntoView.mock.calls[0][0]).toBeGreaterThan('Current content'.length)
expect(mocks.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
effects: mocks.scrollEffect
})
)
})
it('does not force the editor back to bottom after the user scrolls away', () => {
const { rerender } = render(
<CodeEditor
value="Current content"
language="markdown"
options={{ stream: true }}
expanded={false}
autoScrollToBottom
/>
)
mocks.scrollDOM.scrollTop = 20
mocks.scrollListener?.()
mocks.dispatch.mockClear()
mocks.scrollIntoView.mockClear()
rerender(
<CodeEditor
value="Current content\nnext line"
language="markdown"
options={{ stream: true }}
expanded={false}
autoScrollToBottom
/>
)
expect(mocks.scrollIntoView).not.toHaveBeenCalled()
expect(mocks.dispatch).toHaveBeenCalledWith(
expect.not.objectContaining({
effects: mocks.scrollEffect
})
)
})
})

View File

@@ -44,7 +44,8 @@ const CodeEditor = ({
editable = true,
readOnly = false,
expanded = true,
wrapped = true
wrapped = true,
autoScrollToBottom = false
}: CodeEditorProps) => {
const basicSetup = useMemo(() => {
return {
@@ -68,6 +69,10 @@ const CodeEditor = ({
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const editorViewRef = useRef<EditorView | null>(null)
const shouldStickToBottomRef = useRef(true)
const autoScrollToBottomRef = useRef(autoScrollToBottom)
const expandedRef = useRef(expanded)
const scrollCleanupRef = useRef<(() => void) | null>(null)
const langExtensions = useLanguageExtensions(language, options?.lint, languageConfig)
@@ -89,6 +94,28 @@ const CodeEditor = ({
editorViewRef.current?.focus()
}, [])
useEffect(() => {
autoScrollToBottomRef.current = autoScrollToBottom
expandedRef.current = expanded
if (!autoScrollToBottom || expanded) {
shouldStickToBottomRef.current = true
}
}, [autoScrollToBottom, expanded])
const updateShouldStickToBottom = useCallback((scrollElement: HTMLElement) => {
const distanceToBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
shouldStickToBottomRef.current = distanceToBottom <= 8
}, [])
const scrollToDocumentBottom = useCallback((view: EditorView) => {
view.dispatch({
effects: EditorView.scrollIntoView(view.state.doc.length, {
y: 'end',
x: 'nearest'
})
})
}, [])
// Calculate changes during streaming response to update EditorView
// Cannot handle user editing code during streaming response (and probably doesn't need to)
useEffect(() => {
@@ -100,12 +127,21 @@ const CodeEditor = ({
const changes = prepareCodeChanges(currentDoc, newContent)
if (changes && changes.length > 0) {
const shouldScrollToBottom = autoScrollToBottom && !expanded && shouldStickToBottomRef.current
editorViewRef.current.dispatch({
changes,
annotations: [Annotation.define<boolean>().of(true)]
annotations: [Annotation.define<boolean>().of(true)],
...(shouldScrollToBottom
? {
effects: EditorView.scrollIntoView(newContent.length, {
y: 'end',
x: 'nearest'
})
}
: {})
})
}
}, [options?.stream, value])
}, [autoScrollToBottom, expanded, options?.stream, value])
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: options?.keymap })
const blurExtension = useBlurHandler({ onBlur })
@@ -125,6 +161,19 @@ const CodeEditor = ({
const scrollToLine = useScrollToLine(editorViewRef)
useEffect(() => {
if (!autoScrollToBottom || expanded || !shouldStickToBottomRef.current || !editorViewRef.current) return
scrollToDocumentBottom(editorViewRef.current)
}, [autoScrollToBottom, expanded, scrollToDocumentBottom])
useEffect(() => {
return () => {
scrollCleanupRef.current?.()
scrollCleanupRef.current = null
}
}, [])
useImperativeHandle(ref, () => ({
save: handleSave,
getContent: () => editorViewRef.current?.state.doc.toString() ?? '',
@@ -147,8 +196,17 @@ const CodeEditor = ({
theme={theme}
extensions={customExtensions}
onCreateEditor={(view: EditorView) => {
scrollCleanupRef.current?.()
editorViewRef.current = view
onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0)
const scrollElement = view.scrollDOM
const handleScroll = () => {
if (autoScrollToBottomRef.current && !expandedRef.current) {
updateShouldStickToBottom(scrollElement)
}
}
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
scrollCleanupRef.current = () => scrollElement.removeEventListener('scroll', handleScroll)
}}
onChange={(value, viewUpdate) => {
if (onChange && viewUpdate.docChanged) onChange(value)

View File

@@ -115,4 +115,10 @@ export interface CodeEditorProps {
* @default true
*/
wrapped?: boolean
/**
* Keep the internal editor scroller pinned to the document bottom while content grows.
* Intended for streaming, collapsed code blocks.
* @default false
*/
autoScrollToBottom?: boolean
}

View File

@@ -101,4 +101,60 @@ describe('CodeBlockView', () => {
})
)
})
it('enables editor auto-scroll while a streaming code block is collapsed', () => {
MockUsePreferenceUtils.setMultiplePreferenceValues({
'chat.code.collapsible': true
})
render(
<CodeBlockView language="javascript" editable onSave={vi.fn()} isStreaming>
const value = 1
</CodeBlockView>
)
expect(mocks.CodeEditor).toHaveBeenCalledWith(
expect.objectContaining({
autoScrollToBottom: true,
expanded: false
}),
undefined
)
})
it('enables viewer auto-scroll while a streaming readonly code block is collapsed', () => {
MockUsePreferenceUtils.setMultiplePreferenceValues({
'chat.code.collapsible': true
})
render(
<CodeBlockView language="javascript" editable={false} onSave={vi.fn()} isStreaming>
const value = 1
</CodeBlockView>
)
expect(mocks.CodeViewer).toHaveBeenCalledWith(
expect.objectContaining({
autoScrollToBottom: true,
expanded: false
}),
undefined
)
})
it('leaves internal auto-scroll disabled when the streaming code block is expanded', () => {
render(
<CodeBlockView language="javascript" editable onSave={vi.fn()} isStreaming>
const value = 1
</CodeBlockView>
)
expect(mocks.CodeEditor).toHaveBeenCalledWith(
expect.objectContaining({
autoScrollToBottom: false,
expanded: true
}),
undefined
)
})
})

View File

@@ -38,6 +38,7 @@ interface Props {
language: string
onSave?: (newContent: string) => void
editable?: boolean
isStreaming?: boolean
}
/**
@@ -56,7 +57,8 @@ interface Props {
* - quick 工具
* - core 工具
*/
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave, editable = true }) => {
export const CodeBlockView: React.FC<Props> = memo((props) => {
const { children, language, onSave, editable = true, isStreaming = false } = props
const { t } = useTranslation()
const [codeExecutionEnabled] = usePreference('chat.code.execution.enabled')
@@ -288,6 +290,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
options={{ stream: true, lineNumbers: codeShowLineNumbers, ...codeEditor }}
expanded={shouldExpand}
wrapped={shouldWrap}
autoScrollToBottom={isStreaming && !shouldExpand}
/>
) : (
<CodeViewer
@@ -299,6 +302,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
wrapped={shouldWrap}
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
onRequestExpand={codeCollapsible ? () => setExpandOverride(true) : undefined}
autoScrollToBottom={isStreaming && !shouldExpand}
/>
),
[
@@ -310,6 +314,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
codeShowLineNumbers,
fontSize,
handleHeightChange,
isStreaming,
language,
onSave,
shouldExpand,

View File

@@ -66,6 +66,10 @@ interface CodeViewerProps {
* Callback to request expansion when multi-line selection is detected.
*/
onRequestExpand?: () => void
/**
* Keep the internal viewer scroller pinned to the bottom while content grows.
*/
autoScrollToBottom?: boolean
}
/**
@@ -85,7 +89,8 @@ const CodeViewer = ({
className,
expanded = true,
wrapped = true,
onRequestExpand
onRequestExpand,
autoScrollToBottom = false
}: CodeViewerProps) => {
const [_lineNumbers] = usePreference('chat.code.show_line_numbers')
const [_fontSize] = usePreference('chat.message.font_size')
@@ -94,6 +99,7 @@ const CodeViewer = ({
const scrollerRef = useRef<HTMLDivElement>(null)
const callerId = useRef(`${Date.now()}-${uuid()}`).current
const savedSelectionRef = useRef<SavedSelection | null>(null)
const shouldStickToBottomRef = useRef(true)
// Ensure the active selection actually belongs to this CodeViewer instance
const selectionBelongsToViewer = useCallback((sel: Selection | null) => {
const scroller = scrollerRef.current
@@ -109,6 +115,12 @@ const CodeViewer = ({
const rawLines = useMemo(() => (typeof value === 'string' ? value.trimEnd().split('\n') : []), [value])
useEffect(() => {
if (!autoScrollToBottom || expanded) {
shouldStickToBottomRef.current = true
}
}, [autoScrollToBottom, expanded])
// 计算行号数字位数
const gutterDigits = useMemo(
() => (lineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
@@ -242,6 +254,12 @@ const CodeViewer = ({
// 滚动事件处理:保存选择用于复制,但不恢复(避免选择高亮问题)
const handleScroll = useCallback(() => {
const scroller = scrollerRef.current
if (scroller && autoScrollToBottom && !expanded) {
const distanceToBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight
shouldStickToBottomRef.current = distanceToBottom <= 8
}
// 只保存选择状态用于复制,不在滚动时恢复选择
const saved = saveSelection()
if (saved) {
@@ -251,7 +269,7 @@ const CodeViewer = ({
endLine: saved.endLine
})
}
}, [saveSelection])
}, [autoScrollToBottom, expanded, saveSelection])
// 处理复制事件,确保跨虚拟滚动的复制能获取完整内容
const handleCopy = useCallback(
@@ -352,6 +370,7 @@ const CodeViewer = ({
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
// 使用代码高亮 Hook
const { tokenLines, highlightLines } = useCodeHighlight({
@@ -428,6 +447,13 @@ const CodeViewer = ({
onHeightChange?.(scrollerRef.current?.scrollHeight ?? 0)
}, [rawLines.length, onHeightChange])
useLayoutEffect(() => {
const scroller = scrollerRef.current
if (!scroller || !autoScrollToBottom || expanded || !shouldStickToBottomRef.current) return
scroller.scrollTop = scroller.scrollHeight
}, [autoScrollToBottom, expanded, rawLines.length, totalSize])
return (
<div ref={shikiThemeRef} style={expanded ? undefined : { height }}>
<div
@@ -447,7 +473,7 @@ const CodeViewer = ({
<div
className="shiki-list"
style={{
height: `${virtualizer.getTotalSize()}px`,
height: `${totalSize}px`,
width: '100%',
position: 'relative'
}}>

View File

@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { MockUsePreferenceUtils } from '@test-mocks/renderer/usePreference'
import { fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CodeViewer from '../CodeViewer'
const mocks = vi.hoisted(() => ({
highlightLines: vi.fn(),
measureElement: vi.fn(),
useVirtualizer: vi.fn((options: { count: number }) => ({
getTotalSize: () => options.count * 20,
getVirtualItems: () =>
Array.from({ length: options.count }, (_, index) => ({
index,
key: `row-${index}`,
start: index * 20
})),
measureElement: vi.fn()
}))
}))
vi.mock('@renderer/hooks/useCodeHighlight', () => ({
useCodeHighlight: ({ rawLines }: { rawLines: string[] }) => ({
tokenLines: rawLines.map((line) => [
{
content: line,
offset: 0,
color: 'inherit',
bgColor: 'inherit',
htmlStyle: {}
}
]),
highlightLines: mocks.highlightLines
})
}))
vi.mock('@renderer/hooks/useCodeStyle', () => ({
useCodeStyle: () => ({
getShikiPreProperties: vi.fn(async () => ({ class: 'shiki' })),
isShikiThemeDark: false
})
}))
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: mocks.useVirtualizer
}))
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'clientHeight')
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'scrollHeight')
function mockScrollGeometry(geometry: { scrollHeight: number; clientHeight: number }) {
Object.defineProperties(window.HTMLElement.prototype, {
clientHeight: { configurable: true, get: () => geometry.clientHeight },
scrollHeight: { configurable: true, get: () => geometry.scrollHeight }
})
}
function restoreDescriptor(key: 'clientHeight' | 'scrollHeight', descriptor?: PropertyDescriptor) {
if (descriptor) {
Object.defineProperty(window.HTMLElement.prototype, key, descriptor)
return
}
delete (window.HTMLElement.prototype as unknown as Record<string, unknown>)[key]
}
describe('CodeViewer', () => {
beforeEach(() => {
vi.clearAllMocks()
MockUsePreferenceUtils.resetMocks()
MockUsePreferenceUtils.setMultiplePreferenceValues({
'chat.code.show_line_numbers': false,
'chat.message.font_size': 14
})
mockScrollGeometry({ scrollHeight: 1200, clientHeight: 300 })
})
afterEach(() => {
restoreDescriptor('clientHeight', originalClientHeightDescriptor)
restoreDescriptor('scrollHeight', originalScrollHeightDescriptor)
})
it('keeps the collapsed internal scroller pinned to bottom while content grows', () => {
const { container, rerender } = render(
<CodeViewer value="line 1" language="typescript" expanded={false} maxHeight="350px" autoScrollToBottom />
)
const scroller = container.querySelector('.shiki-scroller') as HTMLElement
rerender(
<CodeViewer value="line 1\nline 2" language="typescript" expanded={false} maxHeight="350px" autoScrollToBottom />
)
expect(scroller.scrollTop).toBe(1200)
})
it('does not force the collapsed internal scroller back to bottom after the user scrolls away', () => {
const { container, rerender } = render(
<CodeViewer value="line 1" language="typescript" expanded={false} maxHeight="350px" autoScrollToBottom />
)
const scroller = container.querySelector('.shiki-scroller') as HTMLElement
scroller.scrollTop = 100
fireEvent.scroll(scroller)
rerender(
<CodeViewer value="line 1\nline 2" language="typescript" expanded={false} maxHeight="350px" autoScrollToBottom />
)
expect(scroller.scrollTop).toBe(100)
})
})

View File

@@ -38,7 +38,7 @@ const ChatMarkdown: FC<Props> = ({ block, postProcess, className, components })
}, [block.status, block.content, postProcess, t])
const hasStyleElement = STYLE_ELEMENT_REGEX.test(content)
const chatComponents = useChatMarkdownComponents({ blockId: block.id, hasStyleElement })
const chatComponents = useChatMarkdownComponents({ blockId: block.id, hasStyleElement, isStreaming })
const mergedComponents = useMemo(
() => (components ? { ...chatComponents, ...components } : chatComponents),
[chatComponents, components]

View File

@@ -14,6 +14,7 @@ interface Props {
className?: string
node?: Omit<Node, 'type'>
blockId: string // Message block id
isStreaming?: boolean
[key: string]: any
}
@@ -23,7 +24,7 @@ const INLINE_FILE_PATH_CODE_CLASS = `${INLINE_CODE_CLASS} max-w-full align-middl
const mergeClassNames = (...classNames: Array<string | undefined>) => classNames.filter(Boolean).join(' ')
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId, isStreaming = false }) => {
const languageMatch = /language-([\w-+]+)/.exec(className || '')
const isMultiline = children?.includes('\n')
const detectedLanguage = languageMatch?.[1] ?? (isMultiline ? 'text' : null)
@@ -87,7 +88,11 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
}
return (
<CodeBlockView language={language} onSave={handleSave} editable={canSaveCodeBlock}>
<CodeBlockView
language={language}
onSave={handleSave}
editable={canSaveCodeBlock}
isStreaming={isStreaming || isIncomplete}>
{children}
</CodeBlockView>
)

View File

@@ -240,6 +240,32 @@ describe('CodeBlock', () => {
)
})
it('should pass Streamdown incomplete fence state to standard code blocks', () => {
mocks.isCodeFenceIncomplete = true
render(<CodeBlock {...defaultProps} />)
expect(mocks.CodeBlockView).toHaveBeenCalledWith(
expect.objectContaining({
isStreaming: true
}),
undefined
)
})
it('should pass parent streaming state to standard code blocks after the fence is complete', () => {
mocks.isCodeFenceIncomplete = false
render(<CodeBlock {...defaultProps} isStreaming />)
expect(mocks.CodeBlockView).toHaveBeenCalledWith(
expect.objectContaining({
isStreaming: true
}),
undefined
)
})
it('should pass editable=false for HTML artifacts in readonly surfaces', () => {
mocks.messageListUi = { readonly: true }
const htmlProps = {

View File

@@ -30,13 +30,19 @@ interface Options {
blockId: string
/** Set true when the source contains a `<style>` element to enable shadow-DOM isolation. */
hasStyleElement?: boolean
/** True while the owning markdown block is still receiving stream chunks. */
isStreaming?: boolean
}
export function useChatMarkdownComponents({ blockId, hasStyleElement = false }: Options): Partial<Components> {
export function useChatMarkdownComponents({
blockId,
hasStyleElement = false,
isStreaming = false
}: Options): Partial<Components> {
return useMemo(() => {
const result: Partial<Components> = {
a: (props: any) => <Link {...props} />,
code: (props: any) => <CodeBlock {...props} blockId={blockId} />,
code: (props: any) => <CodeBlock {...props} blockId={blockId} isStreaming={isStreaming} />,
table: (props: any) => <Table {...props} blockId={blockId} />,
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
@@ -51,5 +57,5 @@ export function useChatMarkdownComponents({ blockId, hasStyleElement = false }:
result.style = MarkdownShadowDomRenderer as Components['style']
}
return result
}, [blockId, hasStyleElement])
}, [blockId, hasStyleElement, isStreaming])
}