From c4fa8cf86dd778ed8423549a53ea336ee10493b0 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 7 Jul 2026 18:45:07 +0800 Subject: [PATCH 1/7] feat: optimize chat markdown rendering --- .../plan.md | 55 ++++++++++ .../spec.md | 44 ++++++++ .../tasks.md | 12 +++ .../src/components/chat/MessageList.vue | 5 +- .../src/components/chat/MessageListRow.vue | 5 +- .../components/markdown/MarkdownRenderer.vue | 38 ++++++- .../message/MessageBlockContent.vue | 8 ++ .../message/MessageItemAssistant.vue | 2 + src/renderer/src/pages/ChatPage.vue | 8 +- test/renderer/components/ChatPage.test.ts | 12 ++- .../components/MarkdownRenderer.test.ts | 100 ++++++++++++++++-- test/renderer/components/MessageList.test.ts | 19 +++- .../message/MessageBlockContent.test.ts | 64 ++++++++++- 13 files changed, 349 insertions(+), 23 deletions(-) create mode 100644 docs/features/markstream-chat-rendering-optimization/plan.md create mode 100644 docs/features/markstream-chat-rendering-optimization/spec.md create mode 100644 docs/features/markstream-chat-rendering-optimization/tasks.md diff --git a/docs/features/markstream-chat-rendering-optimization/plan.md b/docs/features/markstream-chat-rendering-optimization/plan.md new file mode 100644 index 000000000..b2d663560 --- /dev/null +++ b/docs/features/markstream-chat-rendering-optimization/plan.md @@ -0,0 +1,55 @@ +# MarkStream Chat Rendering Optimization Plan + +## Chosen combined approach + +The best risk-adjusted plan is a hybrid: optimize MarkStream inside each Markdown message, add compatibility guardrails around chat search, and fix stale measurement cleanup. Avoid outer list virtualization for now because `MessageList` also owns tool/action/artifact rows, DOM search, screenshot capture, spotlight jump, and scroll anchoring. + +## Current path + +`ChatPage` builds display messages, `MessageList` renders rows, assistant rows render `MessageBlockContent`, and text parts render `MarkdownRenderer`, which wraps `markstream-vue`'s `NodeRenderer`. + +## Implementation approach + +1. Add explicit render-state props to `MarkdownRenderer`: + - `streaming?: boolean` for DeepChat's live block state; + - `final?: boolean` for MarkStream's parser/render completion state; + - `virtualizeNodes?: boolean` for callers that need all DOM nodes mounted. +2. Compute MarkStream options in `MarkdownRenderer`: + - `mode="chat"` for chat Markdown; + - `final` defaults to `!streaming`; + - `smoothStreaming` resolves to `'auto'` while streaming, otherwise `false`; + - `typewriter` and `codeBlockStream` only while streaming; + - `fade=false` for stability; + - `batchRendering`, `deferNodesUntilVisible`, `viewportPriority` enabled; + - node virtualization enabled only for non-streaming content and when `virtualizeNodes` is true. +3. Update `MessageBlockContent` to derive a single streaming flag from block status and part loading state, pass it to `MarkdownRenderer`, and disable node virtualization for search-result messages or when the parent list disables it. +4. Propagate `disableMarkdownVirtualization` from `ChatPage` through `MessageList` and `MessageListRow` to assistant Markdown while inline chat search is open. +5. Clear `useMessageWindow` measured heights on session switch so row estimates do not carry over. +6. Update focused tests/mocks to assert the new props without requiring real MarkStream rendering. + +## Affected interfaces + +- `MarkdownRenderer.vue` gains optional `streaming`, `final`, and `virtualizeNodes` props. +- `MessageBlockContent.vue` gains optional `disableMarkdownVirtualization` prop. +- `MessageItemAssistant.vue`, `MessageListRow.vue`, and `MessageList.vue` gain optional pass-through `disableMarkdownVirtualization` prop. +- Existing callers that do not pass these props continue to render as completed/static Markdown. + +## Compatibility + +- Artifact and workspace Markdown renderers keep default non-streaming behavior. +- Existing `smoothStreaming` prop remains accepted for compatibility but is resolved together with `streaming`. +- Custom component registration remains scoped by message/thread/link context. +- Chat search disables per-message Markdown node virtualization while active so DOM highlights can see all nodes. + +## Test strategy + +- Run focused renderer/list tests for: + - `test/renderer/components/MarkdownRenderer.test.ts` + - `test/renderer/components/message/MessageBlockContent.test.ts` + - `test/renderer/components/MessageList.test.ts` + - `test/renderer/components/ChatPage.test.ts` +- Run project-required checks after code changes: + - `pnpm run format` + - `pnpm run i18n` + - `pnpm run lint` + - `pnpm run typecheck:web` diff --git a/docs/features/markstream-chat-rendering-optimization/spec.md b/docs/features/markstream-chat-rendering-optimization/spec.md new file mode 100644 index 000000000..89da7ab37 --- /dev/null +++ b/docs/features/markstream-chat-rendering-optimization/spec.md @@ -0,0 +1,44 @@ +# MarkStream Chat Rendering Optimization Spec + +## User need + +DeepChat's assistant messages can stream long Markdown responses with code blocks, Mermaid diagrams, tables, references, and search highlights. The chat UI should stay responsive during streaming and remain stable after completion, while preserving existing artifact preview, reference navigation, search, capture, jump, and scroll behavior. + +## Goal + +Apply MarkStream's chat/streaming guidance to the existing per-message Markdown rendering path with the smallest safe combination of optimizations: + +- make streaming/final state explicit when rendering Markdown; +- use MarkStream's chat mode and streaming-friendly props for live assistant content; +- enable conservative node-level rendering/virtualization helpers for completed Markdown; +- keep Markdown nodes fully mounted while chat search is active or a message is a search result; +- clear stale row height measurements across session switches; +- avoid changing the outer `MessageList` virtualization or chat scroll model in this slice. + +## Acceptance criteria + +- Streaming assistant text blocks pass `final=false` and completed text blocks pass `final=true` to `markstream-vue`. +- Streaming blocks use MarkStream chat streaming defaults (`mode="chat"`, smooth streaming auto, typewriter, batching, code-block streaming) without reintroducing completion flicker. +- Completed Markdown can opt into MarkStream node-level deferral/virtualization without changing DeepChat's outer message list semantics. +- Chat search keeps all Markdown nodes mounted so DOM highlight/search behavior remains reliable. +- Session changes reset message height measurements so jump/anchor estimates cannot reuse stale row heights. +- Existing Markdown custom components for links, references, Mermaid, code blocks, and artifact preview remain wired. +- Existing tests for `MarkdownRenderer`, `MessageBlockContent`, `MessageList`, and `ChatPage` are updated or continue to pass. + +## Constraints + +- Prefer local changes in the existing render chain and avoid broad refactors. +- Do not replace `MessageList` with `MarkstreamVirtualTimeline` in this slice; DeepChat has row-level behaviors (tool calls, artifacts, search, jump, capture, scroll anchoring) that need a separate design. +- Keep `fade=false` initially to avoid visible row repaint/flicker at stream completion. +- Preserve Monaco-backed code block rendering and artifact preview behavior. + +## Non-goals + +- Full outer chat-list virtualization. +- Redesign of DOM-based chat search/highlight. +- New performance instrumentation UI. +- CSS layer reordering for MarkStream styles. + +## Open questions + +None for this implementation slice. diff --git a/docs/features/markstream-chat-rendering-optimization/tasks.md b/docs/features/markstream-chat-rendering-optimization/tasks.md new file mode 100644 index 000000000..df4672d8a --- /dev/null +++ b/docs/features/markstream-chat-rendering-optimization/tasks.md @@ -0,0 +1,12 @@ +# MarkStream Chat Rendering Optimization Tasks + +- [x] Create SDD spec, plan, and task documents. +- [x] Reconfirm MarkStream Vue props and existing focused tests. +- [x] Add streaming/final MarkStream option handling in `MarkdownRenderer.vue`. +- [x] Pass explicit streaming/final state from `MessageBlockContent.vue`. +- [x] Enable streaming code-block mode for live Markdown. +- [x] Add a Markdown node virtualization guard for chat search/search-result messages. +- [x] Clear message height measurements on session switch. +- [x] Remove the unused `messageListRef` wiring from `ChatPage.vue`. +- [x] Update focused component tests. +- [x] Run format, i18n, lint, typecheck:web, and focused renderer/list tests. diff --git a/src/renderer/src/components/chat/MessageList.vue b/src/renderer/src/components/chat/MessageList.vue index e9e9214d5..20c02e518 100644 --- a/src/renderer/src/components/chat/MessageList.vue +++ b/src/renderer/src/components/chat/MessageList.vue @@ -14,6 +14,7 @@ :show-trace="traceMessageIdSet.has(item.id)" :is-capturing="isCapturing" :is-read-only="isReadOnly" + :disable-markdown-virtualization="disableMarkdownVirtualization" @retry="onRetry" @delete="onDelete" @fork="onFork" @@ -64,6 +65,7 @@ const props = withDefaults( allMessagesForCapture?: MessageListItem[] beforeSpacerHeight?: number afterSpacerHeight?: number + disableMarkdownVirtualization?: boolean }>(), { conversationId: '', @@ -74,7 +76,8 @@ const props = withDefaults( isReadOnly: false, allMessagesForCapture: () => [], beforeSpacerHeight: 0, - afterSpacerHeight: 0 + afterSpacerHeight: 0, + disableMarkdownVirtualization: false } ) diff --git a/src/renderer/src/components/chat/MessageListRow.vue b/src/renderer/src/components/chat/MessageListRow.vue index 74f2b4f93..e554f01a2 100644 --- a/src/renderer/src/components/chat/MessageListRow.vue +++ b/src/renderer/src/components/chat/MessageListRow.vue @@ -38,6 +38,7 @@ :show-trace="showTrace" :is-capturing-image="isCapturing" :is-read-only="isReadOnly" + :disable-markdown-virtualization="disableMarkdownVirtualization" @retry="onRetry" @delete="onDelete" @fork="onFork" @@ -67,12 +68,14 @@ const props = withDefaults( showTrace?: boolean isCapturing?: boolean isReadOnly?: boolean + disableMarkdownVirtualization?: boolean }>(), { isGenerating: false, showTrace: false, isCapturing: false, - isReadOnly: false + isReadOnly: false, + disableMarkdownVirtualization: false } ) diff --git a/src/renderer/src/components/markdown/MarkdownRenderer.vue b/src/renderer/src/components/markdown/MarkdownRenderer.vue index fd4d2e636..7e88b8b97 100644 --- a/src/renderer/src/components/markdown/MarkdownRenderer.vue +++ b/src/renderer/src/components/markdown/MarkdownRenderer.vue @@ -4,11 +4,20 @@ > (), { - smoothStreaming: true + smoothStreaming: true, + streaming: false, + final: undefined, + virtualizeNodes: true } ) const themeStore = useThemeStore() @@ -93,6 +108,23 @@ const codeBlockMonacoOption = computed(() => ({ fontFamily: uiSettingsStore.formattedCodeFontFamily, wordWrap: 'on' as const })) +const isStreaming = computed( + () => props.final === false || (props.streaming && props.final !== true) +) +const resolvedFinal = computed(() => props.final ?? !isStreaming.value) +const resolvedSmoothStreaming = computed(() => { + if (!props.smoothStreaming || resolvedFinal.value) { + return false + } + + return 'auto' as const +}) +const shouldVirtualizeNodes = computed(() => props.virtualizeNodes && !isStreaming.value) +const resolvedNodeVirtual = computed(() => + shouldVirtualizeNodes.value ? ('auto' as const) : false +) +const maxLiveNodes = computed(() => (shouldVirtualizeNodes.value ? 220 : 0)) +const liveNodeBuffer = computed(() => (shouldVirtualizeNodes.value ? 60 : 0)) const { navigateLink } = useMarkdownLinkNavigation({ linkContext: effectiveLinkContext }) diff --git a/src/renderer/src/components/message/MessageBlockContent.vue b/src/renderer/src/components/message/MessageBlockContent.vue index 11faad7f8..77c1e02a1 100644 --- a/src/renderer/src/components/message/MessageBlockContent.vue +++ b/src/renderer/src/components/message/MessageBlockContent.vue @@ -7,6 +7,9 @@ :content="part.content" :loading="part.loading" :smooth-streaming="shouldSmoothStream" + :streaming="isStreamingPart(part)" + :final="!isStreamingPart(part)" + :virtualize-nodes="shouldVirtualizeNodes" :message-id="messageId" :thread-id="threadId" :link-context="{ @@ -51,6 +54,7 @@ const props = defineProps<{ messageId: string threadId: string isSearchResult?: boolean + disableMarkdownVirtualization?: boolean }>() const { processedContent } = useBlockContent(props) @@ -58,6 +62,10 @@ const lastArtifactSnapshot = ref('') const shouldSmoothStream = computed( () => props.block.status === 'pending' || props.block.status === 'loading' ) +const isStreamingPart = (part: ProcessedPart) => shouldSmoothStream.value || Boolean(part.loading) +const shouldVirtualizeNodes = computed( + () => !props.disableMarkdownVirtualization && !props.isSearchResult +) const artifactSnapshot = computed(() => processedContent.value diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index b35dcd917..647f69ba3 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -55,6 +55,7 @@ :message-id="currentMessage.id" :thread-id="currentThreadId" :is-search-result="isSearchResult" + :disable-markdown-virtualization="disableMarkdownVirtualization" /> () const messageSearchRoot = ref() const bottomScrollAnchor = ref(null) -const messageListRef = ref<{ - scrollToBottom?: () => void - forceUpdate?: (clear?: boolean) => void -} | null>(null) const planFloatLayer = ref(null) const chatInputHeroHostRef = ref(null) const pendingDeleteMessageId = ref(null) @@ -427,6 +424,7 @@ let userScrollAwayIntentUntil = 0 let cancelSessionRestoreTask: (() => void) | null = null let cancelSessionRestoreScrollIntentListeners: (() => void) | null = null let cancelPlanUpdatedListener: (() => void) | null = null +let clearMessageWindowMeasurements = () => {} let sessionRestoreRequestId = 0 let planFloatResizeObserver: ResizeObserver | null = null let sessionRestoreResizeObserver: ResizeObserver | null = null @@ -947,6 +945,7 @@ watch( cancelSessionRestoreTask?.() cancelSessionRestoreTask = null cancelSessionRestoreScrollSettle() + clearMessageWindowMeasurements() messageStore.clear() pendingInputStore.clear() if (id) { @@ -1274,6 +1273,7 @@ const displayMessages = computed(() => { const messageWindow = useMessageWindow({ messages: displayMessages }) +clearMessageWindowMeasurements = messageWindow.clearMeasurements const findFirstEntryWithBottomAtOrAfter = ( entries: Array<{ bottom: number }>, diff --git a/test/renderer/components/ChatPage.test.ts b/test/renderer/components/ChatPage.test.ts index eacce1d98..72f870f5c 100644 --- a/test/renderer/components/ChatPage.test.ts +++ b/test/renderer/components/ChatPage.test.ts @@ -340,10 +340,14 @@ const setup = async (options: SetupOptions = {}) => { afterSpacerHeight: { type: Number, default: 0 + }, + disableMarkdownVirtualization: { + type: Boolean, + default: false } }, template: - '
' + '
' }) })) vi.doMock('@/components/chat/ChatInputBox.vue', () => ({ @@ -1917,10 +1921,16 @@ describe('ChatPage', () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true })) await flushPromises() expect(wrapper.find('.chat-search-bar-stub').exists()).toBe(true) + expect( + wrapper.find('.message-list-stub').attributes('data-disable-markdown-virtualization') + ).toBe('true') window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) await flushPromises() expect(wrapper.find('.chat-search-bar-stub').exists()).toBe(false) + expect( + wrapper.find('.message-list-stub').attributes('data-disable-markdown-virtualization') + ).toBe('false') }) it('renders subagent sessions as read-only display mode', async () => { diff --git a/test/renderer/components/MarkdownRenderer.test.ts b/test/renderer/components/MarkdownRenderer.test.ts index 106e0e427..ed8f9809c 100644 --- a/test/renderer/components/MarkdownRenderer.test.ts +++ b/test/renderer/components/MarkdownRenderer.test.ts @@ -87,9 +87,45 @@ const setup = async (props: Record = {}) => { props: { final: { type: Boolean, - default: false + default: undefined + }, + mode: { + type: String, + default: undefined }, smoothStreaming: { + type: [Boolean, String], + default: false + }, + typewriter: { + type: Boolean, + default: false + }, + batchRendering: { + type: Boolean, + default: false + }, + deferNodesUntilVisible: { + type: Boolean, + default: false + }, + viewportPriority: { + type: Boolean, + default: false + }, + nodeVirtual: { + type: [Boolean, String], + default: false + }, + maxLiveNodes: { + type: Number, + default: undefined + }, + liveNodeBuffer: { + type: Number, + default: undefined + }, + codeBlockStream: { type: Boolean, default: false } @@ -101,7 +137,16 @@ const setup = async (props: Record = {}) => { { 'data-testid': 'node-renderer', 'data-final': String(props.final), - 'data-smooth-streaming': String(props.smoothStreaming) + 'data-mode': props.mode, + 'data-smooth-streaming': String(props.smoothStreaming), + 'data-typewriter': String(props.typewriter), + 'data-batch-rendering': String(props.batchRendering), + 'data-defer-nodes-until-visible': String(props.deferNodesUntilVisible), + 'data-viewport-priority': String(props.viewportPriority), + 'data-node-virtual': String(props.nodeVirtual), + 'data-max-live-nodes': String(props.maxLiveNodes), + 'data-live-node-buffer': String(props.liveNodeBuffer), + 'data-code-block-stream': String(props.codeBlockStream) }, [ customComponents.code_block?.({ @@ -234,12 +279,21 @@ describe('MarkdownRenderer', () => { ) }) - it('enables smooth streaming by default', async () => { + it('renders static markdown as final chat content by default', async () => { const { wrapper } = await setup() + const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') - expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-smooth-streaming')).toBe( - 'true' - ) + expect(nodeRenderer.attributes('data-mode')).toBe('chat') + expect(nodeRenderer.attributes('data-final')).toBe('true') + expect(nodeRenderer.attributes('data-smooth-streaming')).toBe('false') + expect(nodeRenderer.attributes('data-typewriter')).toBe('false') + expect(nodeRenderer.attributes('data-batch-rendering')).toBe('true') + expect(nodeRenderer.attributes('data-defer-nodes-until-visible')).toBe('true') + expect(nodeRenderer.attributes('data-viewport-priority')).toBe('true') + expect(nodeRenderer.attributes('data-node-virtual')).toBe('auto') + expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('220') + expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('60') + expect(nodeRenderer.attributes('data-code-block-stream')).toBe('false') }) it('marks the root for scoped code block scrollbar stabilization', async () => { @@ -248,13 +302,32 @@ describe('MarkdownRenderer', () => { expect(wrapper.classes()).toContain('markdown-renderer-root') }) - it('passes explicit smooth streaming to NodeRenderer', async () => { + it('passes streaming options to NodeRenderer for live content', async () => { const { wrapper } = await setup({ + streaming: true, + final: false, smoothStreaming: true }) + const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') + + expect(nodeRenderer.attributes('data-final')).toBe('false') + expect(nodeRenderer.attributes('data-smooth-streaming')).toBe('auto') + expect(nodeRenderer.attributes('data-typewriter')).toBe('true') + expect(nodeRenderer.attributes('data-node-virtual')).toBe('false') + expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('0') + expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('0') + expect(nodeRenderer.attributes('data-code-block-stream')).toBe('true') + }) + + it('disables smooth streaming when requested for live content', async () => { + const { wrapper } = await setup({ + streaming: true, + final: false, + smoothStreaming: false + }) expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-smooth-streaming')).toBe( - 'true' + 'false' ) expect(wrapper.get('[data-testid="node-renderer"]').attributes('data-final')).toBe('false') }) @@ -268,6 +341,17 @@ describe('MarkdownRenderer', () => { expect(nodeRenderer.attributes('data-final')).toBe('true') }) + it('allows callers to disable completed-node virtualization', async () => { + const { wrapper } = await setup({ + virtualizeNodes: false + }) + const nodeRenderer = wrapper.get('[data-testid="node-renderer"]') + + expect(nodeRenderer.attributes('data-node-virtual')).toBe('false') + expect(nodeRenderer.attributes('data-max-live-nodes')).toBe('0') + expect(nodeRenderer.attributes('data-live-node-buffer')).toBe('0') + }) + it('routes reference clicks through the shared markdown link navigator', async () => { getSearchResultsMock.mockResolvedValueOnce([ { diff --git a/test/renderer/components/MessageList.test.ts b/test/renderer/components/MessageList.test.ts index 8c1b84e30..d43cca22a 100644 --- a/test/renderer/components/MessageList.test.ts +++ b/test/renderer/components/MessageList.test.ts @@ -44,10 +44,14 @@ vi.mock('@/components/message/MessageItemAssistant.vue', () => ({ isReadOnly: { type: Boolean, default: false + }, + disableMarkdownVirtualization: { + type: Boolean, + default: false } }, template: - '
{{ message.id }}
' + '
{{ message.id }}
' }) })) @@ -204,4 +208,17 @@ describe('MessageList', () => { expect(wrapper.find('.rate-limit-block-stub').text()).toBe('rate_limit') expect(wrapper.findAll('.assistant-item')).toHaveLength(0) }) + + it('passes markdown virtualization disable state to assistant rows', () => { + const wrapper = mount(MessageList, { + props: { + messages: [createMessage('a1', 'assistant', 1)], + disableMarkdownVirtualization: true + } + }) + + expect(wrapper.find('.assistant-item').attributes('data-disable-markdown-virtualization')).toBe( + 'true' + ) + }) }) diff --git a/test/renderer/components/message/MessageBlockContent.test.ts b/test/renderer/components/message/MessageBlockContent.test.ts index 8b24d11db..292273ceb 100644 --- a/test/renderer/components/message/MessageBlockContent.test.ts +++ b/test/renderer/components/message/MessageBlockContent.test.ts @@ -64,13 +64,25 @@ vi.mock('@/components/markdown/MarkdownRenderer.vue', () => ({ type: Boolean, default: false }, + streaming: { + type: Boolean, + default: false + }, + final: { + type: Boolean, + default: true + }, + virtualizeNodes: { + type: Boolean, + default: true + }, linkContext: { type: Object as () => MarkdownLinkContext | undefined, default: undefined } }, template: - '
{{ content }}
' + '
{{ content }}
' }) })) @@ -173,7 +185,7 @@ describe('MessageBlockContent', () => { expect(markdown.text()).toContain('plain markdown content') }) - it('disables smooth streaming for completed content blocks', async () => { + it('marks completed content blocks as final static markdown', async () => { const wrapper = mount(MessageBlockContent, { props: { block: createBlock({ @@ -187,7 +199,11 @@ describe('MessageBlockContent', () => { await flushPromises() - expect(wrapper.get('.markdown-stub').attributes('data-smooth-streaming')).toBe('false') + const markdown = wrapper.get('.markdown-stub') + expect(markdown.attributes('data-smooth-streaming')).toBe('false') + expect(markdown.attributes('data-streaming')).toBe('false') + expect(markdown.attributes('data-final')).toBe('true') + expect(markdown.attributes('data-virtualize-nodes')).toBe('true') }) it.each(['pending', 'loading'] as const)( @@ -206,7 +222,47 @@ describe('MessageBlockContent', () => { await flushPromises() - expect(wrapper.get('.markdown-stub').attributes('data-smooth-streaming')).toBe('true') + const markdown = wrapper.get('.markdown-stub') + expect(markdown.attributes('data-smooth-streaming')).toBe('true') + expect(markdown.attributes('data-streaming')).toBe('true') + expect(markdown.attributes('data-final')).toBe('false') + expect(markdown.attributes('data-virtualize-nodes')).toBe('true') } ) + + it('keeps all nodes mounted for searchable result messages', async () => { + const wrapper = mount(MessageBlockContent, { + props: { + block: createBlock({ + status: 'success', + content: 'searchable markdown content' + }), + messageId: 'm6', + threadId: 's6', + isSearchResult: true + } + }) + + await flushPromises() + + expect(wrapper.get('.markdown-stub').attributes('data-virtualize-nodes')).toBe('false') + }) + + it('keeps all nodes mounted when chat search disables markdown virtualization', async () => { + const wrapper = mount(MessageBlockContent, { + props: { + block: createBlock({ + status: 'success', + content: 'plain markdown content' + }), + messageId: 'm7', + threadId: 's7', + disableMarkdownVirtualization: true + } + }) + + await flushPromises() + + expect(wrapper.get('.markdown-stub').attributes('data-virtualize-nodes')).toBe('false') + }) }) From 39e64802031da01406a05cf5d6ca1621d9387bfb Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Tue, 7 Jul 2026 19:40:31 +0800 Subject: [PATCH 2/7] feat: tune markstream chat rendering --- .../plan.md | 127 ++++++++++++------ .../spec.md | 40 +++--- .../tasks.md | 11 +- .../components/artifacts/MarkdownArtifact.vue | 3 + .../src/components/chat/MessageList.vue | 12 +- .../components/markdown/MarkdownRenderer.vue | 49 ++++++- .../message/MessageBlockContent.vue | 5 +- .../sidepanel/viewer/WorkspacePreviewPane.vue | 3 + .../components/MarkdownArtifact.test.ts | 59 ++++++++ .../components/MarkdownRenderer.test.ts | 60 ++++++++- test/renderer/components/MessageList.test.ts | 26 +++- .../components/WorkspacePreviewPane.test.ts | 21 ++- .../message/MessageBlockContent.test.ts | 2 +- 13 files changed, 337 insertions(+), 81 deletions(-) create mode 100644 test/renderer/components/MarkdownArtifact.test.ts diff --git a/docs/features/markstream-chat-rendering-optimization/plan.md b/docs/features/markstream-chat-rendering-optimization/plan.md index b2d663560..c0297d233 100644 --- a/docs/features/markstream-chat-rendering-optimization/plan.md +++ b/docs/features/markstream-chat-rendering-optimization/plan.md @@ -1,55 +1,98 @@ # MarkStream Chat Rendering Optimization Plan -## Chosen combined approach +## Chosen approach -The best risk-adjusted plan is a hybrid: optimize MarkStream inside each Markdown message, add compatibility guardrails around chat search, and fix stale measurement cleanup. Avoid outer list virtualization for now because `MessageList` also owns tool/action/artifact rows, DOM search, screenshot capture, spotlight jump, and scroll anchoring. +Use MarkStream's own chat-mode and performance presets inside each Markdown message, while keeping DeepChat's existing outer message windowing and row behavior. This gives the main performance gain where Markdown is expensive without breaking chat-level features that depend on the current DOM/scroll model. ## Current path -`ChatPage` builds display messages, `MessageList` renders rows, assistant rows render `MessageBlockContent`, and text parts render `MarkdownRenderer`, which wraps `markstream-vue`'s `NodeRenderer`. - -## Implementation approach - -1. Add explicit render-state props to `MarkdownRenderer`: - - `streaming?: boolean` for DeepChat's live block state; - - `final?: boolean` for MarkStream's parser/render completion state; - - `virtualizeNodes?: boolean` for callers that need all DOM nodes mounted. -2. Compute MarkStream options in `MarkdownRenderer`: - - `mode="chat"` for chat Markdown; - - `final` defaults to `!streaming`; - - `smoothStreaming` resolves to `'auto'` while streaming, otherwise `false`; - - `typewriter` and `codeBlockStream` only while streaming; - - `fade=false` for stability; - - `batchRendering`, `deferNodesUntilVisible`, `viewportPriority` enabled; - - node virtualization enabled only for non-streaming content and when `virtualizeNodes` is true. -3. Update `MessageBlockContent` to derive a single streaming flag from block status and part loading state, pass it to `MarkdownRenderer`, and disable node virtualization for search-result messages or when the parent list disables it. -4. Propagate `disableMarkdownVirtualization` from `ChatPage` through `MessageList` and `MessageListRow` to assistant Markdown while inline chat search is open. -5. Clear `useMessageWindow` measured heights on session switch so row estimates do not carry over. -6. Update focused tests/mocks to assert the new props without requiring real MarkStream rendering. +`ChatPage` builds display messages, `MessageList` renders rows, assistant rows render `MessageBlockContent`, and text parts render `MarkdownRenderer`, which wraps `markstream-vue`'s `NodeRenderer` with DeepChat custom components. -## Affected interfaces +## Rendering strategy + +### Streaming / live content + +- Keep `mode="chat"` stable. +- Pass `final=false` while the block or part is still loading. +- Use `smooth-streaming="auto"` unless the caller disables smooth streaming. +- Enable `typewriter` and `code-block-stream` for the generating message. +- Use incremental rendering by setting live node limit to `0` while streaming. +- Tune MarkStream batch props for small frame-friendly chunks: + - small initial/render batch sizes; + - short render delay; + - low per-frame budget; + - idle timeout for catch-up. +- Keep `fade=false` to avoid repeated opacity animation during streaming updates. + +### Completed / static content -- `MarkdownRenderer.vue` gains optional `streaming`, `final`, and `virtualizeNodes` props. -- `MessageBlockContent.vue` gains optional `disableMarkdownVirtualization` prop. -- `MessageItemAssistant.vue`, `MessageListRow.vue`, and `MessageList.vue` gain optional pass-through `disableMarkdownVirtualization` prop. -- Existing callers that do not pass these props continue to render as completed/static Markdown. +- Pass `final=true` after generation completes. +- Disable smooth-streaming/typewriter/code-block-stream. +- Use MarkStream node virtualization and viewport priority for completed long Markdown: + - `node-virtual="auto"` when allowed; + - `max-live-nodes` and `live-node-buffer` bounded; + - deferred offscreen nodes; + - viewport-priority rendering. +- Keep the custom code block, Mermaid, reference, and link mappings registered through scoped `custom-id`. -## Compatibility +### Compatibility guardrails + +- Inline chat search disables Markdown node virtualization for all rendered messages while search is open. +- Search-result messages always disable Markdown node virtualization. +- Streaming content disables Markdown node virtualization even if the caller allows virtualization. +- Message capture keeps using the complete display message list through `allMessagesForCapture`. +- Session switch clears message-window measurements before loading the new session. + +## Affected interfaces -- Artifact and workspace Markdown renderers keep default non-streaming behavior. -- Existing `smoothStreaming` prop remains accepted for compatibility but is resolved together with `streaming`. -- Custom component registration remains scoped by message/thread/link context. -- Chat search disables per-message Markdown node virtualization while active so DOM highlights can see all nodes. +- `MarkdownRenderer.vue` + - `streaming?: boolean` + - `final?: boolean` + - `virtualizeNodes?: boolean` + - internal MarkStream tuning constants for streaming and completed content. +- `MessageBlockContent.vue` + - derives streaming/final state from block status and processed part loading; + - passes virtualization guard to MarkdownRenderer. +- `MessageItemAssistant.vue`, `MessageListRow.vue`, `MessageList.vue` + - pass through `disableMarkdownVirtualization`. +- `ChatPage.vue` + - disables Markdown virtualization while inline chat search is open; + - clears row measurements during session switch. ## Test strategy -- Run focused renderer/list tests for: - - `test/renderer/components/MarkdownRenderer.test.ts` - - `test/renderer/components/message/MessageBlockContent.test.ts` - - `test/renderer/components/MessageList.test.ts` - - `test/renderer/components/ChatPage.test.ts` -- Run project-required checks after code changes: - - `pnpm run format` - - `pnpm run i18n` - - `pnpm run lint` - - `pnpm run typecheck:web` +Focused tests should assert both behavior and configuration: + +- `MarkdownRenderer.test.ts` + - static completed defaults; + - streaming configuration; + - smooth streaming opt-out; + - node virtualization opt-out; + - custom preview/reference behavior remains wired. +- `MessageBlockContent.test.ts` + - pending/loading parts pass streaming/final state; + - completed parts pass final/static state; + - search-result and parent disable flags disable node virtualization. +- `MessageList.test.ts` + - disable flag propagates through list rows. +- `ChatPage.test.ts` + - chat search disables markdown virtualization; + - session switch clears measurements. + +Project checks before handoff: + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm run typecheck:web` +- focused renderer/list/page Vitest command. + +## Self-review loop + +After implementation: + +1. Inspect `git diff` for accidental unrelated changes. +2. Re-read the render path and verify streaming/static/search/capture/jump invariants. +3. Run focused tests and project checks. +4. Fix any issues and repeat diff/test review until clean. +5. Push the branch and update the PR body with concrete behavior and validation. diff --git a/docs/features/markstream-chat-rendering-optimization/spec.md b/docs/features/markstream-chat-rendering-optimization/spec.md index 89da7ab37..a13ebd2a6 100644 --- a/docs/features/markstream-chat-rendering-optimization/spec.md +++ b/docs/features/markstream-chat-rendering-optimization/spec.md @@ -2,42 +2,44 @@ ## User need -DeepChat's assistant messages can stream long Markdown responses with code blocks, Mermaid diagrams, tables, references, and search highlights. The chat UI should stay responsive during streaming and remain stable after completion, while preserving existing artifact preview, reference navigation, search, capture, jump, and scroll behavior. +DeepChat's assistant messages can stream long Markdown responses with code blocks, Mermaid diagrams, tables, references, artifacts, and search highlights. Rendering must feel smooth while content is generating and stay fast when browsing completed long conversations. ## Goal -Apply MarkStream's chat/streaming guidance to the existing per-message Markdown rendering path with the smallest safe combination of optimizations: +Build a high-performance, high-experience Markdown rendering path using `markstream-vue` for both streaming and completed chat content while preserving DeepChat's current behavior. -- make streaming/final state explicit when rendering Markdown; -- use MarkStream's chat mode and streaming-friendly props for live assistant content; -- enable conservative node-level rendering/virtualization helpers for completed Markdown; -- keep Markdown nodes fully mounted while chat search is active or a message is a search result; -- clear stale row height measurements across session switches; -- avoid changing the outer `MessageList` virtualization or chat scroll model in this slice. +The optimized path remains: + +`ChatPage` → `MessageList` / `MessageListRow` → `MessageItemAssistant` → `MessageBlockContent` → `MarkdownRenderer` → `markstream-vue` `NodeRenderer` ## Acceptance criteria -- Streaming assistant text blocks pass `final=false` and completed text blocks pass `final=true` to `markstream-vue`. -- Streaming blocks use MarkStream chat streaming defaults (`mode="chat"`, smooth streaming auto, typewriter, batching, code-block streaming) without reintroducing completion flicker. -- Completed Markdown can opt into MarkStream node-level deferral/virtualization without changing DeepChat's outer message list semantics. -- Chat search keeps all Markdown nodes mounted so DOM highlight/search behavior remains reliable. -- Session changes reset message height measurements so jump/anchor estimates cannot reuse stale row heights. -- Existing Markdown custom components for links, references, Mermaid, code blocks, and artifact preview remain wired. -- Existing tests for `MarkdownRenderer`, `MessageBlockContent`, `MessageList`, and `ChatPage` are updated or continue to pass. +- Streaming assistant text blocks pass explicit live state to `markstream-vue` (`mode="chat"`, `final=false`) and use MarkStream's streaming-friendly features. +- Completed assistant text blocks pass `final=true` and use MarkStream's completed-content virtualization/deferral features for long Markdown. +- Streaming Markdown uses incremental rendering settings that prioritize frame budget and smooth typing cadence instead of large per-token DOM commits. +- Static/completed Markdown uses a virtualized node window for long documents to preserve scrollback responsiveness and memory usage. +- Code blocks remain Monaco/custom-renderer backed and support DeepChat artifact preview behavior. +- Mermaid, references, links, artifact previews, tool calls, message capture, spotlight jump, scroll anchoring, and inline chat search remain compatible. +- Chat search and search-result messages keep Markdown DOM available by disabling node virtualization where DOM search/highlighting needs it. +- Session changes reset message height measurements so old session row heights cannot affect new session windowing. +- The implementation is explicit, documented, and covered by focused renderer/list/page tests. +- The PR description clearly explains the concrete performance/UX behavior, non-goals, and validation. ## Constraints -- Prefer local changes in the existing render chain and avoid broad refactors. -- Do not replace `MessageList` with `MarkstreamVirtualTimeline` in this slice; DeepChat has row-level behaviors (tool calls, artifacts, search, jump, capture, scroll anchoring) that need a separate design. -- Keep `fade=false` initially to avoid visible row repaint/flicker at stream completion. +- Use `markstream-vue`; do not replace it with another renderer. +- Prefer focused changes in the existing render chain and avoid broad unrelated refactors. +- Do not replace the outer `MessageList` with `MarkstreamVirtualTimeline` in this slice; DeepChat's row-level tool/action/artifact/search/capture/jump/anchor behaviors need separate design before an outer virtual timeline can safely own them. +- Keep `fade=false` to avoid opacity restart flicker during high-frequency streaming. - Preserve Monaco-backed code block rendering and artifact preview behavior. +- Do not claim benchmark numbers unless measured in this task. ## Non-goals - Full outer chat-list virtualization. - Redesign of DOM-based chat search/highlight. - New performance instrumentation UI. -- CSS layer reordering for MarkStream styles. +- Replacing DeepChat's custom code block/artifact preview components. ## Open questions diff --git a/docs/features/markstream-chat-rendering-optimization/tasks.md b/docs/features/markstream-chat-rendering-optimization/tasks.md index df4672d8a..c615bf899 100644 --- a/docs/features/markstream-chat-rendering-optimization/tasks.md +++ b/docs/features/markstream-chat-rendering-optimization/tasks.md @@ -7,6 +7,11 @@ - [x] Enable streaming code-block mode for live Markdown. - [x] Add a Markdown node virtualization guard for chat search/search-result messages. - [x] Clear message height measurements on session switch. -- [x] Remove the unused `messageListRef` wiring from `ChatPage.vue`. -- [x] Update focused component tests. -- [x] Run format, i18n, lint, typecheck:web, and focused renderer/list tests. +- [x] Update focused component tests for the first pass. +- [x] Tune MarkStream streaming batch settings for smoother live rendering. +- [x] Tune completed-node virtualization settings for responsive static scrollback. +- [x] Add/adjust tests for batch tuning props and compatibility guardrails. +- [x] Run self-review pass for render-path invariants and unrelated changes. +- [x] Run focused renderer/list/page tests. +- [x] Run format, i18n, lint, and typecheck:web. +- [x] Push branch and update PR description. diff --git a/src/renderer/src/components/artifacts/MarkdownArtifact.vue b/src/renderer/src/components/artifacts/MarkdownArtifact.vue index 3e31f83fa..ea6009461 100644 --- a/src/renderer/src/components/artifacts/MarkdownArtifact.vue +++ b/src/renderer/src/components/artifacts/MarkdownArtifact.vue @@ -5,6 +5,9 @@ > diff --git a/src/renderer/src/components/chat/MessageList.vue b/src/renderer/src/components/chat/MessageList.vue index 20c02e518..f8052f99d 100644 --- a/src/renderer/src/components/chat/MessageList.vue +++ b/src/renderer/src/components/chat/MessageList.vue @@ -12,9 +12,9 @@ :item="item" :is-generating="isGenerating" :show-trace="traceMessageIdSet.has(item.id)" - :is-capturing="isCapturing" + :is-capturing="isCapturingValue" :is-read-only="isReadOnly" - :disable-markdown-virtualization="disableMarkdownVirtualization" + :disable-markdown-virtualization="shouldDisableMarkdownVirtualization" @retry="onRetry" @delete="onDelete" @fork="onFork" @@ -43,7 +43,7 @@