diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx
index 3151b5e1..7f254788 100644
--- a/desktop/src/components/chat/AssistantMessage.tsx
+++ b/desktop/src/components/chat/AssistantMessage.tsx
@@ -28,7 +28,11 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
-
+
{!isStreaming &&
}
{isStreaming && (
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index eef2fe32..93185a3c 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -643,6 +643,49 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelector('[data-virtual-message-item]')).not.toBeNull()
})
+ it('splits large virtualization spacers into content-visibility chunks', async () => {
+ useChatStore.setState({
+ sessions: {
+ [ACTIVE_TAB]: makeSessionState({
+ messages: Array.from({ length: 240 }, (_, index) => ({
+ id: `assistant-${index}`,
+ type: 'assistant_text',
+ content: `assistant transcript line ${index}`,
+ timestamp: index,
+ })),
+ }),
+ },
+ })
+
+ const { container } = render(
)
+ const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement
+ Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 })
+ Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 240 * 200 })
+ await waitForProgrammaticScrollReset()
+
+ // Scroll to middle so both top and bottom spacers are present
+ scrollArea.scrollTop = 20_000
+ await act(async () => {
+ fireEvent.scroll(scrollArea)
+ })
+
+ const topChunks = container.querySelectorAll('[data-virtual-spacer-chunk="top"]')
+ const bottomChunks = container.querySelectorAll('[data-virtual-spacer-chunk="bottom"]')
+ expect(topChunks.length).toBeGreaterThan(1)
+ expect(bottomChunks.length).toBeGreaterThan(1)
+
+ const firstTopChunk = topChunks[0] as HTMLElement
+ expect(firstTopChunk.style.contentVisibility).toBe('auto')
+ expect(firstTopChunk.style.containIntrinsicSize).toMatch(/^0 \d+px$/)
+
+ // Items inside the active window must NOT carry content-visibility (this
+ // is the regression guard that previous content-visibility rollout hit).
+ const visibleItems = container.querySelectorAll('[data-virtual-message-item]')
+ for (const item of visibleItems) {
+ expect((item as HTMLElement).style.contentVisibility).toBe('')
+ }
+ })
+
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
useChatStore.setState({
sessions: {
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 1b5a8b49..ed262bd5 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -1122,6 +1122,49 @@ function buildVirtualTranscriptWindow(
}
}
+const VIRTUAL_SPACER_CHUNK_PX = 800
+
+function VirtualSpacer({ height, position }: { height: number; position: 'top' | 'bottom' }) {
+ if (height <= 0) return null
+ if (height <= VIRTUAL_SPACER_CHUNK_PX) {
+ return (
+
+ )
+ }
+
+ // Splitting the spacer into chunks lets the WebView keep painting placeholder
+ // boxes via content-visibility:auto + contain-intrinsic-size, instead of
+ // leaving a single huge area unpainted while React reconciles the window.
+ const chunkCount = Math.max(1, Math.ceil(height / VIRTUAL_SPACER_CHUNK_PX))
+ const chunkHeight = Math.floor(height / chunkCount)
+ const remainder = height - chunkHeight * chunkCount
+ const chunks: Array<{ key: string; px: number }> = []
+ for (let i = 0; i < chunkCount; i++) {
+ const px = i === chunkCount - 1 ? chunkHeight + remainder : chunkHeight
+ chunks.push({ key: `${position}-${i}`, px })
+ }
+
+ return (
+
+ {chunks.map((chunk) => (
+
+ ))}
+
+ )
+}
+
const MeasuredRenderItem = memo(function MeasuredRenderItem({
itemKey,
onHeightChange,
@@ -1726,12 +1769,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
>
- {virtualTranscriptWindow.enabled && virtualTranscriptWindow.beforeHeight > 0 ? (
-
+ {virtualTranscriptWindow.enabled ? (
+
) : null}
{virtualTranscriptWindow.items.map(({ item, index }) => {
@@ -1753,12 +1792,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
)
})}
- {virtualTranscriptWindow.enabled && virtualTranscriptWindow.afterHeight > 0 ? (
-
+ {virtualTranscriptWindow.enabled ? (
+
) : null}
{streamingText.trim() && (
diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx
index 1fe526bb..f0fe5571 100644
--- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx
+++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi } from 'vitest'
+import { beforeEach, describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
@@ -16,7 +16,7 @@ vi.mock('../chat/MermaidRenderer', () => ({
),
}))
-import { MarkdownRenderer } from './MarkdownRenderer'
+import { MarkdownRenderer, __markdownParseCacheInternals } from './MarkdownRenderer'
function visibleMathText(container: HTMLElement): string {
const clone = container.cloneNode(true) as HTMLElement
@@ -288,3 +288,42 @@ describe('MarkdownRenderer', () => {
}
})
})
+
+describe('MarkdownRenderer parse cache', () => {
+ beforeEach(() => {
+ __markdownParseCacheInternals.reset()
+ })
+
+ it('uses the finalized cache for non-streaming content and hits on the second render', () => {
+ const content = '# heading one\n\nbody text body text'
+ render(
)
+ expect(__markdownParseCacheInternals.hasFinalized(content)).toBe(true)
+ expect(__markdownParseCacheInternals.finalizedSize()).toBe(1)
+
+ const beforeChars = __markdownParseCacheInternals.finalizedChars()
+ render(
)
+ expect(__markdownParseCacheInternals.finalizedSize()).toBe(1)
+ expect(__markdownParseCacheInternals.finalizedChars()).toBe(beforeChars)
+ })
+
+ it('routes streaming content into the streaming cache without evicting finalized entries', () => {
+ const finalizedContent = 'finalized assistant turn text'
+ render(
)
+ expect(__markdownParseCacheInternals.hasFinalized(finalizedContent)).toBe(true)
+
+ for (let i = 0; i < 8; i++) {
+ const chunk = `streaming partial ${i.toString().repeat(20)}`
+ render(
)
+ }
+
+ expect(__markdownParseCacheInternals.hasFinalized(finalizedContent)).toBe(true)
+ expect(__markdownParseCacheInternals.streamingSize()).toBeLessThanOrEqual(4)
+ })
+
+ it('caps the finalized cache to roughly 200 entries', () => {
+ for (let i = 0; i < 220; i++) {
+ render(
)
+ }
+ expect(__markdownParseCacheInternals.finalizedSize()).toBeLessThanOrEqual(200)
+ })
+})
diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx
index 419df4fe..e29707d8 100644
--- a/desktop/src/components/markdown/MarkdownRenderer.tsx
+++ b/desktop/src/components/markdown/MarkdownRenderer.tsx
@@ -13,6 +13,7 @@ type Props = {
variant?: 'default' | 'document' | 'compact'
className?: string
cache?: boolean
+ streaming?: boolean
onLinkClick?: (href: string, event: ReactMouseEvent
) => boolean | void
}
@@ -307,36 +308,90 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]
type MarkdownParseResult = ReturnType
-const MARKDOWN_PARSE_CACHE_MAX_ENTRIES = 48
-const MARKDOWN_PARSE_CACHE_MAX_CHARS = 1_800_000
-const markdownParseCache = new Map()
-let markdownParseCacheChars = 0
+type CacheEntry = {
+ parsed: MarkdownParseResult
+ chars: number
+}
-function getCachedMarkdownParse(content: string): MarkdownParseResult {
- const cached = markdownParseCache.get(content)
+const FINALIZED_CACHE_MAX_ENTRIES = 200
+const FINALIZED_CACHE_MAX_CHARS = 8_000_000
+const STREAMING_CACHE_MAX_ENTRIES = 4
+
+const finalizedMarkdownCache = new Map()
+const streamingMarkdownCache = new Map()
+let finalizedMarkdownCacheChars = 0
+
+function fnv1aHash(value: string): number {
+ let hash = 2166136261 >>> 0
+ for (let i = 0; i < value.length; i++) {
+ hash ^= value.charCodeAt(i)
+ hash = Math.imul(hash, 16777619)
+ }
+ return hash >>> 0
+}
+
+function buildMarkdownCacheKey(content: string): string {
+ return `${content.length}:${fnv1aHash(content).toString(36)}`
+}
+
+function evictFinalizedMarkdownEntries(): void {
+ while (
+ finalizedMarkdownCache.size > FINALIZED_CACHE_MAX_ENTRIES ||
+ finalizedMarkdownCacheChars > FINALIZED_CACHE_MAX_CHARS
+ ) {
+ const oldestKey = finalizedMarkdownCache.keys().next().value
+ if (typeof oldestKey !== 'string') break
+ const entry = finalizedMarkdownCache.get(oldestKey)
+ finalizedMarkdownCache.delete(oldestKey)
+ if (entry) finalizedMarkdownCacheChars -= entry.chars
+ }
+}
+
+function evictStreamingMarkdownEntries(): void {
+ while (streamingMarkdownCache.size > STREAMING_CACHE_MAX_ENTRIES) {
+ const oldestKey = streamingMarkdownCache.keys().next().value
+ if (typeof oldestKey !== 'string') break
+ streamingMarkdownCache.delete(oldestKey)
+ }
+}
+
+function getCachedMarkdownParse(content: string, streaming: boolean): MarkdownParseResult {
+ const cache = streaming ? streamingMarkdownCache : finalizedMarkdownCache
+ const key = buildMarkdownCacheKey(content)
+ const cached = cache.get(key)
if (cached) {
- markdownParseCache.delete(content)
- markdownParseCache.set(content, cached)
- return cached
+ cache.delete(key)
+ cache.set(key, cached)
+ return cached.parsed
}
const parsed = parseMarkdown(content)
- markdownParseCache.set(content, parsed)
- markdownParseCacheChars += content.length
+ const entry: CacheEntry = { parsed, chars: content.length }
+ cache.set(key, entry)
- while (
- markdownParseCache.size > MARKDOWN_PARSE_CACHE_MAX_ENTRIES ||
- markdownParseCacheChars > MARKDOWN_PARSE_CACHE_MAX_CHARS
- ) {
- const oldestContent = markdownParseCache.keys().next().value
- if (typeof oldestContent !== 'string') break
- markdownParseCache.delete(oldestContent)
- markdownParseCacheChars -= oldestContent.length
+ if (streaming) {
+ evictStreamingMarkdownEntries()
+ } else {
+ finalizedMarkdownCacheChars += content.length
+ evictFinalizedMarkdownEntries()
}
return parsed
}
+export const __markdownParseCacheInternals = {
+ finalizedSize: () => finalizedMarkdownCache.size,
+ streamingSize: () => streamingMarkdownCache.size,
+ finalizedChars: () => finalizedMarkdownCacheChars,
+ hasFinalized: (content: string) => finalizedMarkdownCache.has(buildMarkdownCacheKey(content)),
+ hasStreaming: (content: string) => streamingMarkdownCache.has(buildMarkdownCacheKey(content)),
+ reset: () => {
+ finalizedMarkdownCache.clear()
+ streamingMarkdownCache.clear()
+ finalizedMarkdownCacheChars = 0
+ },
+}
+
const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
@@ -396,10 +451,10 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className?
.join(' ')
}
-export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, onLinkClick }: Props) {
+export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, streaming = false, onLinkClick }: Props) {
const { html, codeBlocks, mathBlocks } = useMemo(
- () => cache ? getCachedMarkdownParse(content) : parseMarkdown(content),
- [cache, content],
+ () => cache ? getCachedMarkdownParse(content, streaming) : parseMarkdown(content),
+ [cache, content, streaming],
)
const proseClasses = useMemo(
() => getProseClasses(variant, className),