From b8645b0122712130606a2c289b3cd4deb4711daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 23 May 2026 23:45:29 +0800 Subject: [PATCH] perf(desktop): rework markdown cache and paint off-window spacers via content-visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues remained on long transcripts. (1) The markdown parse cache was 48 entries / 1.8MB and keyed by full content string, so a 2.6MB session thrashed constantly and every streaming chunk evicted finalized history entries because each delta produced a new key. (2) The virtualization spacers above and below the active window were single huge divs, leaving the WebView nothing to paint during reconciliation and producing the literal blank gaps users were seeing. The markdown cache now keys on `${len}:${fnv1a}` and splits into a 200-entry / 8MB finalized cache plus a small 4-entry streaming cache that cannot evict finalized parses. AssistantMessage forwards a `streaming` flag (and stops disabling caching entirely while streaming, so revisiting a settled turn no longer reparses). The spacers are now broken into ~800px chunks via a VirtualSpacer component; each chunk uses `content-visibility: auto` with a `contain-intrinsic-size` pinned to its real height, which is the combination that lets the WebView paint placeholder boxes even when off-screen paint is deferred — without restoring the regression the previous content-visibility rollout caused on in-window items. Tested: 726/726 desktop vitest suites pass, including 3 new markdown-cache cases and 1 new spacer-chunk + in-window-no-content-visibility regression guard Co-Authored-By: Claude Opus 4.7 --- .../src/components/chat/AssistantMessage.tsx | 6 +- .../src/components/chat/MessageList.test.tsx | 43 ++++++++ desktop/src/components/chat/MessageList.tsx | 59 ++++++++--- .../markdown/MarkdownRenderer.test.tsx | 43 +++++++- .../components/markdown/MarkdownRenderer.tsx | 99 ++++++++++++++----- 5 files changed, 213 insertions(+), 37 deletions(-) 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 ( +