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 `