Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/features/markstream-chat-rendering-optimization/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# MarkStream Chat Rendering Optimization Plan

## Chosen approach

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` with DeepChat custom components.

## 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

- 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 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

- `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

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.
46 changes: 46 additions & 0 deletions docs/features/markstream-chat-rendering-optimization/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# MarkStream Chat Rendering Optimization Spec

## User need

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

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.

The optimized path remains:

`ChatPage` → `MessageList` / `MessageListRow` → `MessageItemAssistant` → `MessageBlockContent` → `MarkdownRenderer` → `markstream-vue` `NodeRenderer`

## Acceptance criteria

- 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

- 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.
- Replacing DeepChat's custom code block/artifact preview components.

## Open questions

None for this implementation slice.
17 changes: 17 additions & 0 deletions docs/features/markstream-chat-rendering-optimization/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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] 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.
44 changes: 44 additions & 0 deletions docs/issues/chat-send-jitter/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Chat send jitter

## Issue

When the user sends a message while the chat is pinned to the bottom, the message list can visibly jump for one frame immediately after submit or flash when the first assistant chunk starts rendering.

## Impact

The MarkStream rendering work feels smoother during generation, but the first outgoing-turn UI updates can still feel unstable because the list scroll position is corrected twice and the pending assistant row can be replaced by a different keyed DOM node at stream start.

## Suspected root cause

`ChatPage` inserts both an optimistic user message and an empty pending assistant placeholder before the backend starts streaming. `useMessageWindow` estimates an empty pending assistant row as a normal assistant message (`ASSISTANT_BASE`, 136px), while the real DOM for that row is only the assistant header plus spinner. `MessageListRow` then reports the smaller measured height on the next animation frame, and `ChatPage.onMessageMeasure` scrolls to the bottom again, causing a visible submit-time adjustment.

At stream start, `chat.stream.updated` can set `isStreaming` before the real assistant record is available. The old placeholder was hidden immediately on `isStreaming`, leaving a one-frame gap. When the real assistant message then appears, `MessageList` keyed it by the real message id instead of the pending placeholder id, unmounting the spinner row and mounting a new assistant row.

## Fix plan

- Teach `useMessageWindow` to estimate empty pending assistant placeholders close to their actual spinner row height.
- Keep the change narrowly scoped to synthetic pending assistant rows so real assistant messages keep their existing estimates.
- Keep the pending assistant placeholder visible until the first real streaming assistant content exists.
- Carry the pending placeholder render key onto the first real assistant row so Vue patches the existing row instead of replacing it.
- Add regression tests covering the placeholder estimate, measurement delta, and stream-start row transition.

## Task checklist

- [x] Link GitHub bug issue
- [x] Update placeholder height estimate
- [x] Keep placeholder stable through stream-start
- [x] Add regression coverage
- [x] Run focused tests and required checks
- [x] Commit and push the PR branch

## Validation

- `pnpm exec vitest --config vitest.config.renderer.ts test/renderer/composables/useMessageWindow.test.ts test/renderer/components/MessageListRow.test.ts test/renderer/components/ChatPage.test.ts`
- `pnpm run format`
- `pnpm run i18n`
- `pnpm run lint`
- `pnpm run typecheck:web`

## Linked GitHub issue

https://github.com/ThinkInAIXYZ/deepchat/issues/1897
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@
"electron-vite": "5.0.0",
"jsdom": "^26.1.0",
"katex": "^0.16.47",
"markstream-vue": "1.0.5-beta.2",
"markstream-vue": "^1.0.5",
"mermaid": "^11.15.0",
"minimatch": "^10.2.5",
"monaco-editor": "^0.55.1",
Expand All @@ -199,7 +199,7 @@
"picocolors": "^1.1.1",
"pinia": "^3.0.4",
"reka-ui": "^2.9.7",
"stream-monaco": "^0.0.45",
"stream-monaco": "^0.0.46",
"tailwind-merge": "^3.6.0",
"tailwind-scrollbar-hide": "^4.0.0",
"tailwindcss": "^4.3.0",
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/components/artifacts/MarkdownArtifact.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
>
<MarkdownRenderer
:content="props.block.content || ''"
:final="true"
:smooth-streaming="false"
:virtualize-nodes="false"
:link-context="{ source: 'artifact' }"
@copy="handleCopyClick"
/>
Expand Down
17 changes: 12 additions & 5 deletions src/renderer/src/components/chat/MessageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
/>
<MessageListRow
v-for="item in allRenderedMessages"
:key="item.id"
:key="item.renderKey ?? item.id"
:item="item"
:is-generating="isGenerating"
:show-trace="traceMessageIdSet.has(item.id)"
:is-capturing="isCapturing"
:is-capturing="isCapturingValue"
:is-read-only="isReadOnly"
:disable-markdown-virtualization="shouldDisableMarkdownVirtualization"
@retry="onRetry"
@delete="onDelete"
@fork="onFork"
Expand Down Expand Up @@ -42,7 +43,7 @@
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { computed, unref } from 'vue'
import MessageBlockAction from '@/components/message/MessageBlockAction.vue'
import { useMessageCapture } from '@/composables/message/useMessageCapture'
import {
Expand All @@ -64,6 +65,7 @@ const props = withDefaults(
allMessagesForCapture?: MessageListItem[]
beforeSpacerHeight?: number
afterSpacerHeight?: number
disableMarkdownVirtualization?: boolean
}>(),
{
conversationId: '',
Expand All @@ -74,7 +76,8 @@ const props = withDefaults(
isReadOnly: false,
allMessagesForCapture: () => [],
beforeSpacerHeight: 0,
afterSpacerHeight: 0
afterSpacerHeight: 0,
disableMarkdownVirtualization: false
}
)

Expand All @@ -89,11 +92,15 @@ const emit = defineEmits<{
}>()

const traceMessageIdSet = computed(() => new Set(props.traceMessageIds))
const { isCapturing, captureMessage } = useMessageCapture()
const isCapturingValue = computed(() => Boolean(unref(isCapturing)))
const shouldDisableMarkdownVirtualization = computed(
() => props.disableMarkdownVirtualization || isCapturingValue.value
)
const allRenderedMessages = computed(() => props.messages)
const captureSearchMessages = computed(() =>
props.allMessagesForCapture.length > 0 ? props.allMessagesForCapture : props.messages
)
const { isCapturing, captureMessage } = useMessageCapture()

const onRetry = (messageId: string) => emit('retry', messageId)
const onDelete = (messageId: string) => emit('delete', messageId)
Expand Down
9 changes: 6 additions & 3 deletions src/renderer/src/components/chat/MessageListRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
)

Expand Down Expand Up @@ -103,7 +106,7 @@ const emitMeasuredHeight = () => {

measureFrame = window.requestAnimationFrame(() => {
measureFrame = null
const messageId = props.item?.id
const messageId = props.item?.renderKey ?? props.item?.id
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!messageId) return
const height = rowRef.value?.offsetHeight ?? 0
if (height <= 0 || Math.abs(height - lastMeasuredHeight) < 1) return
Expand All @@ -123,7 +126,7 @@ onMounted(() => {
})

watch(
() => props.item?.id,
() => props.item?.renderKey ?? props.item?.id,
() => {
lastMeasuredHeight = 0
emitMeasuredHeight()
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/components/chat/messageListItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ type DisplayMessageBase = {
conversationId: string
is_variant: number
variants?: DisplayMessage[]
renderKey?: string
orderSeq: number
messageType?: 'normal' | 'compaction'
compactionStatus?: 'compacting' | 'compacted'
Expand Down
Loading