mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
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:
@@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}}>
|
||||
|
||||
111
src/renderer/components/__tests__/CodeViewer.test.tsx
Normal file
111
src/renderer/components/__tests__/CodeViewer.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user