From 2f53df5227d85fc10dfa165edb656a6ddde68b3e Mon Sep 17 00:00:00 2001 From: jd <59188306+zhangjiadi225@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:53:20 +0800 Subject: [PATCH] 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> --- .../__tests__/code-editor.test.tsx | 97 ++++++++++++++- .../composites/code-editor/code-editor.tsx | 64 +++++++++- .../composites/code-editor/types.ts | 6 + .../__tests__/CodeBlockView.test.tsx | 56 +++++++++ .../components/CodeBlockView/view.tsx | 7 +- src/renderer/components/CodeViewer.tsx | 32 ++++- .../components/__tests__/CodeViewer.test.tsx | 111 ++++++++++++++++++ .../chat/messages/markdown/ChatMarkdown.tsx | 2 +- .../chat/messages/markdown/CodeBlock.tsx | 9 +- .../markdown/__tests__/CodeBlock.test.tsx | 26 ++++ .../markdown/useChatMarkdownComponents.tsx | 12 +- 11 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 src/renderer/components/__tests__/CodeViewer.test.tsx diff --git a/packages/ui/src/components/composites/code-editor/__tests__/code-editor.test.tsx b/packages/ui/src/components/composites/code-editor/__tests__/code-editor.test.tsx index c849b83019..96dd29fdb6 100644 --- a/packages/ui/src/components/composites/code-editor/__tests__/code-editor.test.tsx +++ b/packages/ui/src/components/composites/code-editor/__tests__/code-editor.test.tsx @@ -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( + + ) + mocks.dispatch.mockClear() + mocks.scrollIntoView.mockClear() + + rerender( + + ) + + 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( + + ) + + mocks.scrollDOM.scrollTop = 20 + mocks.scrollListener?.() + mocks.dispatch.mockClear() + mocks.scrollIntoView.mockClear() + + rerender( + + ) + + expect(mocks.scrollIntoView).not.toHaveBeenCalled() + expect(mocks.dispatch).toHaveBeenCalledWith( + expect.not.objectContaining({ + effects: mocks.scrollEffect + }) + ) + }) }) diff --git a/packages/ui/src/components/composites/code-editor/code-editor.tsx b/packages/ui/src/components/composites/code-editor/code-editor.tsx index be80ebc5f7..073ae57183 100644 --- a/packages/ui/src/components/composites/code-editor/code-editor.tsx +++ b/packages/ui/src/components/composites/code-editor/code-editor.tsx @@ -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(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().of(true)] + annotations: [Annotation.define().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) diff --git a/packages/ui/src/components/composites/code-editor/types.ts b/packages/ui/src/components/composites/code-editor/types.ts index 3a7a644d9a..6e87b959ea 100644 --- a/packages/ui/src/components/composites/code-editor/types.ts +++ b/packages/ui/src/components/composites/code-editor/types.ts @@ -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 } diff --git a/src/renderer/components/CodeBlockView/__tests__/CodeBlockView.test.tsx b/src/renderer/components/CodeBlockView/__tests__/CodeBlockView.test.tsx index 6c0c18d78e..b505063396 100644 --- a/src/renderer/components/CodeBlockView/__tests__/CodeBlockView.test.tsx +++ b/src/renderer/components/CodeBlockView/__tests__/CodeBlockView.test.tsx @@ -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( + + const value = 1 + + ) + + 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( + + const value = 1 + + ) + + 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( + + const value = 1 + + ) + + expect(mocks.CodeEditor).toHaveBeenCalledWith( + expect.objectContaining({ + autoScrollToBottom: false, + expanded: true + }), + undefined + ) + }) }) diff --git a/src/renderer/components/CodeBlockView/view.tsx b/src/renderer/components/CodeBlockView/view.tsx index 18a543c665..4e58302faf 100644 --- a/src/renderer/components/CodeBlockView/view.tsx +++ b/src/renderer/components/CodeBlockView/view.tsx @@ -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 = memo(({ children, language, onSave, editable = true }) => { +export const CodeBlockView: React.FC = 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 = memo(({ children, language, onSave options={{ stream: true, lineNumbers: codeShowLineNumbers, ...codeEditor }} expanded={shouldExpand} wrapped={shouldWrap} + autoScrollToBottom={isStreaming && !shouldExpand} /> ) : ( = 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 = memo(({ children, language, onSave codeShowLineNumbers, fontSize, handleHeightChange, + isStreaming, language, onSave, shouldExpand, diff --git a/src/renderer/components/CodeViewer.tsx b/src/renderer/components/CodeViewer.tsx index d3b4fa09b6..cc8038abe3 100644 --- a/src/renderer/components/CodeViewer.tsx +++ b/src/renderer/components/CodeViewer.tsx @@ -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(null) const callerId = useRef(`${Date.now()}-${uuid()}`).current const savedSelectionRef = useRef(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 (
diff --git a/src/renderer/components/__tests__/CodeViewer.test.tsx b/src/renderer/components/__tests__/CodeViewer.test.tsx new file mode 100644 index 0000000000..79ffae11e8 --- /dev/null +++ b/src/renderer/components/__tests__/CodeViewer.test.tsx @@ -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)[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( + + ) + const scroller = container.querySelector('.shiki-scroller') as HTMLElement + + rerender( + + ) + + 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( + + ) + const scroller = container.querySelector('.shiki-scroller') as HTMLElement + scroller.scrollTop = 100 + fireEvent.scroll(scroller) + + rerender( + + ) + + expect(scroller.scrollTop).toBe(100) + }) +}) diff --git a/src/renderer/components/chat/messages/markdown/ChatMarkdown.tsx b/src/renderer/components/chat/messages/markdown/ChatMarkdown.tsx index 79e356d31c..49bc77aa6d 100644 --- a/src/renderer/components/chat/messages/markdown/ChatMarkdown.tsx +++ b/src/renderer/components/chat/messages/markdown/ChatMarkdown.tsx @@ -38,7 +38,7 @@ const ChatMarkdown: FC = ({ 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] diff --git a/src/renderer/components/chat/messages/markdown/CodeBlock.tsx b/src/renderer/components/chat/messages/markdown/CodeBlock.tsx index 0617f0d66f..e51d656c89 100644 --- a/src/renderer/components/chat/messages/markdown/CodeBlock.tsx +++ b/src/renderer/components/chat/messages/markdown/CodeBlock.tsx @@ -14,6 +14,7 @@ interface Props { className?: string node?: Omit 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) => classNames.filter(Boolean).join(' ') -const CodeBlock: React.FC = ({ children, className, node, blockId }) => { +const CodeBlock: React.FC = ({ 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 = ({ children, className, node, blockId }) => { } return ( - + {children} ) diff --git a/src/renderer/components/chat/messages/markdown/__tests__/CodeBlock.test.tsx b/src/renderer/components/chat/messages/markdown/__tests__/CodeBlock.test.tsx index d13765539c..87e1d0add7 100644 --- a/src/renderer/components/chat/messages/markdown/__tests__/CodeBlock.test.tsx +++ b/src/renderer/components/chat/messages/markdown/__tests__/CodeBlock.test.tsx @@ -240,6 +240,32 @@ describe('CodeBlock', () => { ) }) + it('should pass Streamdown incomplete fence state to standard code blocks', () => { + mocks.isCodeFenceIncomplete = true + + render() + + 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() + + 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 = { diff --git a/src/renderer/components/chat/messages/markdown/useChatMarkdownComponents.tsx b/src/renderer/components/chat/messages/markdown/useChatMarkdownComponents.tsx index d9cfea008d..718cae78a9 100644 --- a/src/renderer/components/chat/messages/markdown/useChatMarkdownComponents.tsx +++ b/src/renderer/components/chat/messages/markdown/useChatMarkdownComponents.tsx @@ -30,13 +30,19 @@ interface Options { blockId: string /** Set true when the source contains a `