Add incremental chat rendering experiment (#310801)

This commit is contained in:
Paul
2026-04-17 12:25:09 -07:00
committed by GitHub
parent ea6aac971b
commit 00a718eb5c
16 changed files with 1497 additions and 16 deletions
@@ -936,6 +936,8 @@
"--background-light",
"--chat-editing-last-edit-shift",
"--chat-current-response-min-height",
"--chat-smooth-delay",
"--chat-smooth-duration",
"--inline-chat-frame-progress",
"--insert-border-color",
"--last-tab-margin-right",
@@ -349,6 +349,40 @@ configurationRegistry.registerConfiguration({
description: nls.localize('chat.experimental.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."),
default: null
},
[ChatConfiguration.IncrementalRendering]: {
type: 'boolean',
description: nls.localize('chat.experimental.incrementalRendering.enabled', "Enables incremental rendering with optional block-level animation when streaming chat responses."),
default: false,
tags: ['experimental'],
},
[ChatConfiguration.IncrementalRenderingStyle]: {
type: 'string',
enum: ['none', 'fade', 'rise', 'blur', 'scale', 'slide', 'reveal'],
enumDescriptions: [
nls.localize('chat.experimental.incrementalRendering.animationStyle.none', "No animation. Content appears instantly."),
nls.localize('chat.experimental.incrementalRendering.animationStyle.fade', "Simple opacity fade from 0 to 1."),
nls.localize('chat.experimental.incrementalRendering.animationStyle.rise', "Content fades in while rising upward."),
nls.localize('chat.experimental.incrementalRendering.animationStyle.blur', "Content fades in from a blurred state."),
nls.localize('chat.experimental.incrementalRendering.animationStyle.scale', "Content scales up from slightly smaller."),
nls.localize('chat.experimental.incrementalRendering.animationStyle.slide', "Content slides in from the left."),
nls.localize('chat.experimental.incrementalRendering.animationStyle.reveal', "Content reveals top-to-bottom with a soft gradient edge."),
],
description: nls.localize('chat.experimental.incrementalRendering.animationStyle', "Controls the animation style for incremental rendering."),
default: 'fade',
tags: ['experimental'],
},
[ChatConfiguration.IncrementalRenderingBuffering]: {
type: 'string',
enum: ['off', 'word', 'paragraph'],
enumDescriptions: [
nls.localize('chat.experimental.incrementalRendering.buffering.off', "Renders content immediately as tokens arrive."),
nls.localize('chat.experimental.incrementalRendering.buffering.word', "Reveals content word by word."),
nls.localize('chat.experimental.incrementalRendering.buffering.paragraph', "Buffers content until a paragraph break before rendering."),
],
description: nls.localize('chat.experimental.incrementalRendering.buffering', "Controls how content is buffered before rendering during incremental rendering. Lower buffering levels render faster but may show incomplete sentences or partially formed markdown."),
default: 'word',
tags: ['experimental'],
},
'chat.detectParticipant.enabled': {
type: 'boolean',
description: nls.localize('chat.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."),
@@ -0,0 +1,23 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Animation strategy for incremental rendering. Applied as a post-processing
* decoration after the markdown has been correctly rendered.
*
* Animation is separate from buffering — it controls *how* rendered
* content appears, while buffering controls *when* we render.
*/
export interface IIncrementalRenderingAnimation {
/**
* Apply entrance animation to newly appeared DOM children.
*
* @param children The live HTMLCollection of the container's children.
* @param fromIndex Index of the first new child to animate.
* @param currentCount Total number of children currently in the DOM.
* @param elapsed Milliseconds since the animation batch started.
*/
animate(children: HTMLCollection, fromIndex: number, currentCount: number, elapsed: number): void;
}
@@ -0,0 +1,23 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IIncrementalRenderingAnimation } from './animation.js';
import { BlockAnimation } from './blockAnimations.js';
/**
* Registry of all available animation styles.
* To add a new animation, add an entry here.
*/
export const ANIMATION_STYLES = {
none: (): IIncrementalRenderingAnimation => ({ animate() { } }),
fade: (): IIncrementalRenderingAnimation => new BlockAnimation('fade'),
rise: (): IIncrementalRenderingAnimation => new BlockAnimation('rise'),
blur: (): IIncrementalRenderingAnimation => new BlockAnimation('blur'),
scale: (): IIncrementalRenderingAnimation => new BlockAnimation('scale'),
slide: (): IIncrementalRenderingAnimation => new BlockAnimation('slide'),
reveal: (): IIncrementalRenderingAnimation => new BlockAnimation('reveal'),
} as const satisfies Record<string, () => IIncrementalRenderingAnimation>;
export type AnimationStyleName = keyof typeof ANIMATION_STYLES;
@@ -0,0 +1,53 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IIncrementalRenderingAnimation } from './animation.js';
/** Duration of the animation applied to newly rendered blocks. */
export const ANIMATION_DURATION_MS = 600;
/**
* Delay (ms) between each successive new child's animation start.
* Creates a cascading top-to-bottom reveal across a batch of new
* block-level elements.
*/
const STAGGER_DELAY_MS = 150;
/**
* Block-level CSS animation styles: fade, rise, blur, scale, slide,
* and lineFade. Each applies a CSS class and staggered timing
* variables to new top-level children so they reveal sequentially.
*/
export class BlockAnimation implements IIncrementalRenderingAnimation {
constructor(private readonly _style: 'fade' | 'rise' | 'blur' | 'scale' | 'slide' | 'reveal') { }
animate(children: HTMLCollection, fromIndex: number, currentCount: number, elapsed: number): void {
const className = `chat-smooth-animate-${this._style}`;
for (let i = fromIndex; i < currentCount; i++) {
const child = children[i] as HTMLElement;
if (!child.classList) {
continue;
}
const staggerOffset = (i - fromIndex) * STAGGER_DELAY_MS;
const childDelay = -elapsed + staggerOffset;
child.classList.add(className);
child.style.setProperty('--chat-smooth-duration', `${ANIMATION_DURATION_MS}ms`);
child.style.setProperty('--chat-smooth-delay', `${childDelay}ms`);
child.addEventListener('animationend', (e) => {
if (e.target !== child) {
return;
}
child.classList.remove(className);
child.style.removeProperty('--chat-smooth-duration');
child.style.removeProperty('--chat-smooth-delay');
}, { once: true });
}
}
}
@@ -0,0 +1,55 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* A buffering strategy determines how much incoming markdown content
* must accumulate before a render is triggered.
*
* Buffering is separate from animation — it controls *when* we render,
* while animation controls *how* rendered content appears.
*/
export interface IIncrementalRenderingBuffer {
/**
* Given the full markdown string and the markdown that was last
* rendered to the real DOM, return `true` if the buffer should
* be handled entirely within _flushRender (e.g. shadow measurement).
* In that case the orchestrator should pass everything through
* without updating `_renderedMarkdown`.
*/
readonly handlesFlush: boolean;
/**
* Determine the renderable prefix of `fullMarkdown`. The returned
* string must be a prefix of `fullMarkdown` (or `fullMarkdown`
* itself). Content beyond the returned prefix stays buffered.
*
* @param fullMarkdown The complete markdown accumulated so far.
* @param lastRendered The markdown last rendered to the DOM.
* @returns The prefix to render now.
*/
getRenderable(fullMarkdown: string, lastRendered: string): string;
/**
* For buffers that handle flushing themselves (e.g. line buffer
* with shadow DOM measurement), this is called during
* `_flushRender` to decide whether to commit the pending content.
*
* @param markdown The pending markdown to potentially commit.
* @returns The markdown to actually commit, or `undefined` to skip.
*/
filterFlush?(markdown: string): string | undefined;
/**
* Whether the buffer needs another rAF frame to continue revealing
* content (e.g. typewriter drip-feeding words). When `true`, the
* orchestrator re-schedules a render after the current flush.
*/
readonly needsNextFrame?: boolean;
/**
* Called when the buffer is no longer needed.
*/
dispose?(): void;
}
@@ -0,0 +1,21 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IIncrementalRenderingBuffer } from './buffer.js';
import { OffBuffer } from './offBuffer.js';
import { ParagraphBuffer } from './paragraphBuffer.js';
import { WordBuffer } from './wordBuffer.js';
/**
* Registry of all available buffering strategies.
* To add a new buffer, add an entry here.
*/
export const BUFFER_MODES = {
off: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new OffBuffer(),
word: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new WordBuffer(),
paragraph: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new ParagraphBuffer(),
} as const satisfies Record<string, (domNode: HTMLElement) => IIncrementalRenderingBuffer>;
export type BufferModeName = keyof typeof BUFFER_MODES;
@@ -0,0 +1,18 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IIncrementalRenderingBuffer } from './buffer.js';
/**
* No buffering — renders everything immediately as tokens arrive.
* Content is still rAF-coalesced by the orchestrator.
*/
export class OffBuffer implements IIncrementalRenderingBuffer {
readonly handlesFlush = false;
getRenderable(fullMarkdown: string, _lastRendered: string): string {
return fullMarkdown;
}
}
@@ -0,0 +1,70 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IIncrementalRenderingBuffer } from './buffer.js';
/**
* Maximum number of characters that may accumulate beyond the last
* paragraph boundary before a render is forced.
*/
const MAX_BUFFERED_CHARS = 4000;
/**
* Finds the last `\n\n` block boundary that is NOT inside an open
* fenced code block. This prevents splitting a render in the middle
* of a code fence, which would cause the code block element to update
* in place (same DOM index) without triggering a new-child animation.
*
* The scan counts backtick-fence openings/closings from the start of
* the string. A `\n\n` is only a valid boundary when the fence depth
* is 0 (i.e. outside any code block).
*
* @internal Exported for testing.
*/
export function lastBlockBoundary(text: string): number {
let lastValid = -1;
let inFence = false;
for (let i = 0; i < text.length; i++) {
// Detect fenced code blocks: ``` or ~~~ at the start of a line.
if ((i === 0 || text[i - 1] === '\n') &&
((text[i] === '`' && text[i + 1] === '`' && text[i + 2] === '`') ||
(text[i] === '~' && text[i + 1] === '~' && text[i + 2] === '~'))) {
inFence = !inFence;
i += 2; // skip past the triple backtick/tilde
continue;
}
// Detect block boundary outside code fences.
if (!inFence && text[i] === '\n' && text[i + 1] === '\n') {
lastValid = i;
}
}
return lastValid;
}
/**
* Buffers content at paragraph boundaries (`\n\n` outside code fences).
* This avoids rendering partially formed blocks — text mid-paragraph,
* incomplete list groups, or half a code fence.
*/
export class ParagraphBuffer implements IIncrementalRenderingBuffer {
readonly handlesFlush = false;
getRenderable(fullMarkdown: string, lastRendered: string): string {
const lastBlock = lastBlockBoundary(fullMarkdown);
let renderable = lastBlock === -1
? lastRendered // no complete block yet — keep current
: fullMarkdown.slice(0, lastBlock + 2);
// Escape hatch: if too much content has accumulated without a
// block boundary, render what we have.
if (fullMarkdown.length - renderable.length > MAX_BUFFERED_CHARS) {
renderable = fullMarkdown;
}
return renderable;
}
}
@@ -0,0 +1,128 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getNWords } from '../../../../../common/model/chatWordCounter.js';
import { IIncrementalRenderingBuffer } from './buffer.js';
/**
* Minimum reveal rate in words/sec. Ensures content always progresses
* even when the estimated rate is very low or unknown.
*/
const MIN_RATE = 40;
/**
* Maximum reveal rate in words/sec. Caps the rate to prevent
* dumping too much content at once.
*/
const MAX_RATE = 2000;
/**
* Minimum rate used after the response is complete, to drain
* buffered content quickly.
*/
const MIN_RATE_AFTER_COMPLETE = 80;
/**
* Fallback rate when no estimate is available yet.
*/
const DEFAULT_RATE = 8;
/**
* Word buffer: drip-feeds words at a rate matching the model's
* token production speed, similar to the original 50ms progressive
* render but driven by rAF for smoother output.
*
* The reveal rate is set externally via {@link setRate} from the
* model's `impliedWordLoadRate` estimate. Words are revealed based
* on elapsed time since the last render, so the output speed
* naturally matches the model's generation speed.
*/
export class WordBuffer implements IIncrementalRenderingBuffer {
readonly handlesFlush = true;
/** The full markdown received so far. */
private _fullMarkdown: string = '';
/** Number of words currently revealed to the DOM. */
private _revealedWordCount: number = 0;
/** The markdown string last committed to the DOM. */
private _lastCommittedMarkdown: string = '';
/** Whether there are still unrevealed words to show. */
private _needsNextFrame: boolean = false;
/** Timestamp of the last successful commit. */
private _lastCommitTime: number = 0;
/** Estimated word production rate (words/sec). */
private _rate: number = DEFAULT_RATE;
get needsNextFrame(): boolean {
return this._needsNextFrame;
}
/**
* Set the estimated word production rate from the model's
* `impliedWordLoadRate`. Called by the orchestrator.
*/
setRate(rate: number | undefined, isComplete: boolean): void {
if (isComplete) {
this._rate = typeof rate === 'number'
? Math.max(rate, MIN_RATE_AFTER_COMPLETE)
: MIN_RATE_AFTER_COMPLETE;
} else {
this._rate = typeof rate === 'number'
? Math.min(Math.max(rate, MIN_RATE), MAX_RATE)
: DEFAULT_RATE;
}
}
getRenderable(fullMarkdown: string, _lastRendered: string): string {
this._fullMarkdown = fullMarkdown;
return fullMarkdown;
}
filterFlush(markdown: string): string | undefined {
this._fullMarkdown = markdown;
const now = Date.now();
if (this._lastCommitTime === 0) {
// First frame — reveal 1 word to get started.
this._lastCommitTime = now;
this._revealedWordCount = 1;
} else {
// Compute how many words to reveal based on elapsed time
// and the estimated rate, matching the original approach.
const elapsed = now - this._lastCommitTime;
const newWords = Math.floor(elapsed / 1000 * this._rate);
if (newWords > 0) {
this._revealedWordCount += newWords;
this._lastCommitTime = now;
}
}
const result = getNWords(this._fullMarkdown, this._revealedWordCount);
if (result.isFullString) {
this._needsNextFrame = false;
// Reset to the actual word count so that when new tokens
// arrive, drip-feeding resumes from the correct position
// instead of instantly dumping everything.
this._revealedWordCount = result.returnedWordCount;
this._lastCommittedMarkdown = this._fullMarkdown;
return this._fullMarkdown;
}
this._needsNextFrame = true;
if (result.value.length <= this._lastCommittedMarkdown.length) {
return undefined;
}
this._lastCommittedMarkdown = result.value;
return result.value;
}
}
@@ -0,0 +1,291 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/chatIncrementalRendering.css';
import { getWindow } from '../../../../../../../base/browser/dom.js';
import { Disposable } from '../../../../../../../base/common/lifecycle.js';
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { ChatConfiguration } from '../../../../common/constants.js';
import { IIncrementalRenderingBuffer } from './buffers/buffer.js';
import { WordBuffer } from './buffers/wordBuffer.js';
import { BUFFER_MODES, BufferModeName } from './buffers/bufferRegistry.js';
import { IIncrementalRenderingAnimation } from './animations/animation.js';
import { ANIMATION_STYLES, AnimationStyleName } from './animations/animationRegistry.js';
import { ANIMATION_DURATION_MS } from './animations/blockAnimations.js';
/**
* Incremental markdown streaming renderer — rAF-batched, append-only.
*
* Orchestrates two independent concerns:
* - **Buffering** (when to render): controlled by an {@link IIncrementalRenderingBuffer}.
* - **Animation** (how it appears): controlled by an {@link IIncrementalRenderingAnimation}.
*
* The renderer works *with* the existing markdown rendering pipeline.
* Each update re-renders through the standard `doRenderMarkdown()` path,
* so code blocks, tables, KaTeX, and all markdown features render correctly.
*
* If the new markdown is NOT a pure append, `tryMorph()` returns `false`
* and the caller falls back to a full re-render.
*/
export class IncrementalDOMMorpher extends Disposable {
private _lastMarkdown: string = '';
/**
* The markdown that was last rendered to the DOM. May lag behind
* `_lastMarkdown` while content is being buffered.
*/
private _renderedMarkdown: string = '';
/**
* High-water mark: the number of top-level children that have been
* fully revealed. Children at indices >= this value are "new"
* and get animated on each render.
*/
private _revealedChildCount: number = 0;
/**
* Timestamp when children at indices >= `_revealedChildCount`
* first appeared. 0 means no animation is in progress.
*/
private _animationStartTime: number = 0;
/**
* The total child count at the end of the most recent render in
* the current animation batch.
*/
private _batchChildCount: number = 0;
private _rafScheduled: boolean = false;
private _pendingMarkdown: string | undefined;
private _rafHandle: number | undefined;
private _renderCallback: ((newMarkdown: string) => void) | undefined;
private _buffer: IIncrementalRenderingBuffer;
private _animation: IIncrementalRenderingAnimation;
constructor(
private readonly _domNode: HTMLElement,
@IConfigurationService private readonly _configService: IConfigurationService,
) {
super();
this._buffer = this._createBuffer();
this._animation = this._createAnimation();
this._register(this._configService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(ChatConfiguration.IncrementalRenderingStyle)) {
this._animation = this._createAnimation();
}
if (e.affectsConfiguration(ChatConfiguration.IncrementalRenderingBuffering)) {
this._buffer.dispose?.();
this._buffer = this._createBuffer();
}
}));
}
// ---- strategy factories ----
private _createBuffer(): IIncrementalRenderingBuffer {
const raw = this._configService.getValue<string>(ChatConfiguration.IncrementalRenderingBuffering);
const factory = Object.prototype.hasOwnProperty.call(BUFFER_MODES, raw)
? BUFFER_MODES[raw as BufferModeName]
: BUFFER_MODES.paragraph;
return factory(this._domNode);
}
private _createAnimation(): IIncrementalRenderingAnimation {
const raw = this._configService.getValue<string>(ChatConfiguration.IncrementalRenderingStyle);
const factory = Object.prototype.hasOwnProperty.call(ANIMATION_STYLES, raw)
? ANIMATION_STYLES[raw as AnimationStyleName]
: ANIMATION_STYLES.fade;
return factory();
}
// ---- public API ----
/**
* Register the callback that performs the actual markdown re-render.
*/
setRenderCallback(cb: (newMarkdown: string) => void): void {
this._renderCallback = cb;
}
/**
* Forward the stream's word-rate estimate to the active buffer
* (word buffer or line buffer).
*/
updateStreamRate(rate: number, isComplete: boolean): void {
if (this._buffer instanceof WordBuffer) {
this._buffer.setRate(rate, isComplete);
}
}
/**
* Seeds the renderer with the initial markdown string.
*
* @param animateInitial When `true`, the children already in the
* DOM receive the entrance animation.
*/
seed(markdown: string, animateInitial?: boolean): void {
this._lastMarkdown = markdown;
this._animationStartTime = 0;
// For drip-feed buffers (word), clear the DOM and let the
// buffer reveal content from scratch — the initial
// doRenderMarkdown() ran to initialize pipeline state but
// the visible content should be built up by the buffer.
if (this._buffer.handlesFlush && markdown.length > 0) {
this._renderedMarkdown = '';
this._revealedChildCount = 0;
// Clear the DOM so the buffer starts from empty.
while (this._domNode.firstChild) {
this._domNode.removeChild(this._domNode.firstChild);
}
// Schedule the first drip-feed render.
this._pendingMarkdown = markdown;
this._scheduleRender();
return;
}
this._renderedMarkdown = markdown;
this._revealedChildCount = animateInitial ? 0 : this._domNode.children.length;
if (animateInitial) {
this._animateNewChildren();
}
}
/**
* Attempts an incremental DOM update via rAF-batched re-render.
*
* @returns `true` if absorbed, `false` if a full re-render is needed.
*/
tryMorph(newMarkdown: string): boolean {
if (!newMarkdown.startsWith(this._lastMarkdown)) {
return false;
}
const appended = newMarkdown.slice(this._lastMarkdown.length);
if (appended.length === 0) {
return true;
}
this._lastMarkdown = newMarkdown;
// Buffers that handle flushing themselves (e.g. line buffer)
// don't update _renderedMarkdown here — _flushRender decides.
if (this._buffer.handlesFlush) {
this._pendingMarkdown = newMarkdown;
this._scheduleRender();
return true;
}
const renderable = this._buffer.getRenderable(newMarkdown, this._renderedMarkdown);
if (renderable.length > this._renderedMarkdown.length) {
this._renderedMarkdown = renderable;
this._pendingMarkdown = renderable;
this._scheduleRender();
}
return true;
}
// ---- rAF batching ----
private _scheduleRender(): void {
if (this._rafScheduled) {
return;
}
this._rafScheduled = true;
const win = getWindow(this._domNode);
this._rafHandle = win.requestAnimationFrame(() => {
this._rafScheduled = false;
this._rafHandle = undefined;
this._flushRender();
});
}
private _flushRender(): void {
let markdown = this._pendingMarkdown;
this._pendingMarkdown = undefined;
if (markdown === undefined || !this._renderCallback) {
return;
}
// Let the buffer filter the flush (e.g. line buffer may skip).
if (this._buffer.filterFlush) {
const filtered = this._buffer.filterFlush(markdown);
if (filtered === undefined) {
// Buffer says skip — but if it needs another frame
// (e.g. typewriter still revealing words), re-schedule
// with the same pending content.
if (this._buffer.needsNextFrame) {
this._pendingMarkdown = markdown;
this._scheduleRender();
}
return;
}
markdown = filtered;
}
this._renderedMarkdown = markdown;
this._renderCallback(markdown);
this._animateNewChildren();
// If the buffer has more content to reveal, keep the rAF
// loop running even though no new tokens arrived.
if (this._buffer.needsNextFrame) {
this._pendingMarkdown = this._lastMarkdown;
this._scheduleRender();
}
}
// ---- animation ----
private _animateNewChildren(): void {
const children = this._domNode.children;
const currentCount = children.length;
if (currentCount <= this._revealedChildCount) {
return;
}
const now = Date.now();
if (this._animationStartTime !== 0 && (now - this._animationStartTime) >= ANIMATION_DURATION_MS) {
this._revealedChildCount = this._batchChildCount;
this._animationStartTime = 0;
this._batchChildCount = 0;
}
if (currentCount <= this._revealedChildCount) {
return;
}
if (this._animationStartTime === 0) {
this._animationStartTime = now;
}
this._batchChildCount = currentCount;
const elapsed = now - this._animationStartTime;
this._animation.animate(children, this._revealedChildCount, currentCount, elapsed);
}
// ---- lifecycle ----
override dispose(): void {
if (this._rafHandle !== undefined) {
getWindow(this._domNode).cancelAnimationFrame(this._rafHandle);
this._rafHandle = undefined;
}
this._rafScheduled = false;
this._pendingMarkdown = undefined;
this._renderCallback = undefined;
this._buffer.dispose?.();
super.dispose();
}
}
@@ -0,0 +1,112 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/* ---- Smooth streaming block-level animations ---- */
/*
* Applied to newly inserted top-level children (paragraphs, code blocks,
* list items, etc.) as a post-processing decoration after they have been
* correctly rendered through the standard markdown pipeline.
*
* Each new child receives a staggered animation-delay so the batch
* reveals sequentially from top to bottom. Animating individual
* children (rather than wrapping them in a container div) avoids
* margin-collapsing changes that cause layout shifts during scrolling.
*
* The animation class and custom properties are removed after completion
* via an `animationend` listener in the renderer.
*/
/* ---- fade (default) ---- */
.chat-smooth-animate-fade {
opacity: 0;
animation: chatSmoothFade var(--chat-smooth-duration, 600ms) ease-out var(--chat-smooth-delay, 0ms) forwards;
}
@keyframes chatSmoothFade {
from { opacity: 0; }
to { opacity: 1; }
}
/* ---- rise ---- */
.chat-smooth-animate-rise {
opacity: 0;
transform: translateY(4px);
animation: chatSmoothRise var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards;
}
@keyframes chatSmoothRise {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- blur ---- */
.chat-smooth-animate-blur {
opacity: 0;
filter: blur(2px);
animation: chatSmoothBlur var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards;
}
@keyframes chatSmoothBlur {
from { opacity: 0; filter: blur(2px); }
to { opacity: 1; filter: blur(0); }
}
/* ---- scale ---- */
.chat-smooth-animate-scale {
opacity: 0;
transform: scale(0.95);
animation: chatSmoothScale var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards;
}
@keyframes chatSmoothScale {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
/* ---- slide ---- */
.chat-smooth-animate-slide {
opacity: 0;
transform: translateX(-4px);
animation: chatSmoothSlide var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards;
}
@keyframes chatSmoothSlide {
from { opacity: 0; transform: translateX(-4px); }
to { opacity: 1; transform: translateX(0); }
}
/* ---- reveal ---- */
.chat-smooth-animate-reveal {
-webkit-mask-image: linear-gradient(to bottom, black calc(100% - 48px), transparent);
mask-image: linear-gradient(to bottom, black calc(100% - 48px), transparent);
-webkit-mask-size: 100% 0%;
mask-size: 100% 0%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
animation: chatSmoothReveal var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards;
}
@keyframes chatSmoothReveal {
from {
-webkit-mask-size: 100% 0%;
mask-size: 100% 0%;
}
to {
-webkit-mask-size: 100% calc(100% + 48px);
mask-size: 100% calc(100% + 48px);
}
}
/* ---- Respect prefers-reduced-motion ---- */
@media (prefers-reduced-motion: reduce) {
.chat-smooth-animate-fade,
.chat-smooth-animate-rise,
.chat-smooth-animate-blur,
.chat-smooth-animate-scale,
.chat-smooth-animate-slide,
.chat-smooth-animate-reveal {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
-webkit-mask-image: none !important;
mask-image: none !important;
}
}
@@ -14,6 +14,7 @@ import { wrapTablesWithScrollable } from './chatMarkdownTableScrolling.js';
import { coalesce } from '../../../../../../base/common/arrays.js';
import { findLast } from '../../../../../../base/common/arraysFind.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { Lazy } from '../../../../../../base/common/lazy.js';
import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
@@ -59,6 +60,7 @@ import { IDisposableReference } from './chatCollections.js';
import { EditorPool } from './chatContentCodePools.js';
import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js';
import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js';
import { IncrementalDOMMorpher } from './chatIncrementalRendering/chatIncrementalRendering.js';
import './media/chatMarkdownPart.css';
const $ = dom.$;
@@ -107,8 +109,11 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
private readonly mathLayoutParticipants = new Set<() => void>();
/** Incremental rendering morpher — only created when the experiment is enabled. */
private _incrementalMorpher: IncrementalDOMMorpher | undefined;
constructor(
private readonly markdown: IChatMarkdownContent,
private markdown: IChatMarkdownContent,
context: IChatContentPartRenderContext,
private readonly editorPool: EditorPool,
fillInIncompleteTokens = false,
@@ -142,6 +147,36 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
const enableMath = configurationService.getValue<boolean>(ChatConfiguration.EnableMath);
// Initialize incremental rendering morpher when the experiment is enabled.
// Only create for actively streaming responses (!element.isComplete),
// not for completed responses loaded from history — even if
// fillInIncompleteTokens is true (e.g. canceled or incomplete responses).
const incrementalRenderingEnabled = configurationService.getValue<boolean>(ChatConfiguration.IncrementalRendering);
if (incrementalRenderingEnabled && isResponseVM(element) && fillInIncompleteTokens && !element.isComplete) {
this._incrementalMorpher = this._register(instantiationService.createInstance(IncrementalDOMMorpher, this.domNode));
this._incrementalMorpher.setRenderCallback((newMd) => {
// Temporarily swap this.markdown to the buffered content
// for doRenderMarkdown(), then restore it. The morpher may
// render a subset of the full markdown (word/paragraph
// buffering), but this.markdown must always reflect the
// latest full content from tryIncrementalUpdate so that
// hasSameContent() returns true and avoids unnecessary
// re-diffs on the next renderElement call.
const savedMarkdown = this.markdown;
const content = new MarkdownString(newMd, this.markdown.content);
content.baseUri = URI.revive(this.markdown.content.baseUri);
content.uris = this.markdown.content.uris;
this.markdown = { ...this.markdown, content };
doRenderMarkdown();
this.markdown = savedMarkdown;
// Notify the list that our height changed so it can
// update scroll position. The morpher renders via rAF,
// outside the normal renderElement flow, so the list
// won't pick this up without an explicit notification.
this._onDidChangeHeight.fire();
});
}
const renderStore = this._register(new MutableDisposable<DisposableStore>());
const doRenderMarkdown = () => {
@@ -173,7 +208,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
breaks: true,
};
const result = store.add(renderer.render(markdown.content, {
const result = store.add(renderer.render(this.markdown.content, {
sanitizerConfig: MarkedKatexSupport.getSanitizerOptions({
allowedTags: allowedChatMarkdownHtmlTags,
allowedAttributes: allowedMarkdownHtmlAttributes,
@@ -306,7 +341,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
}
const markdownDecorationsRenderer = instantiationService.createInstance(ChatMarkdownDecorationsRenderer);
store.add(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element));
store.add(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(this.markdown, result.element));
const layoutParticipants = new Lazy(() => {
const observer = new ResizeObserver(() => this.mathLayoutParticipants.forEach(layout => layout()));
@@ -339,6 +374,13 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
// Always render immediately
doRenderMarkdown();
// Seed the morpher *after* the initial render so it captures
// the correct markdown baseline. Pass `animateInitial: true`
// so the initial DOM children receive the entrance animation —
// this is important when a markdown part first appears (e.g.
// after thinking content) and already contains visible content.
this._incrementalMorpher?.seed(markdown.content.value, /* animateInitial */ true);
if (enableMath && !MarkedKatexSupport.getExtension(dom.getWindow(context.container))) {
// KaTeX not yet loaded - load it and re-render when ready
MarkedKatexSupport.loadExtension(dom.getWindow(context.container))
@@ -425,6 +467,45 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP
return false;
}
/**
* Attempts an incremental DOM update for smooth streaming instead of
* tearing down and rebuilding the entire markdown part.
*
* The morpher checks that the new content is a pure append, then
* schedules a rAF-batched re-render through the full markdown
* pipeline. Code blocks, tables, and all markdown features are
* rendered correctly because the update goes through the standard
* `doRenderMarkdown()` path.
*
* @param newMarkdown The new (appended) markdown content.
* @returns `true` if the incremental update succeeded and the caller
* should treat this part as unchanged. `false` if a full
* re-render is needed.
*/
tryIncrementalUpdate(newMarkdown: IChatMarkdownContent): boolean {
if (!this._incrementalMorpher) {
return false;
}
const success = this._incrementalMorpher.tryMorph(newMarkdown.content.value);
if (success) {
// Update the stored markdown so hasSameContent() returns true
// for subsequent diffs with the same content, allowing the
// progressive render to detect "caught up" and "complete" states.
this.markdown = newMarkdown;
}
return success;
}
/**
* Forward the stream's word-rate estimate to the morpher's buffer.
*/
updateStreamRate(rate: number, isComplete: boolean): void {
this._incrementalMorpher?.updateStreamRate(rate, isComplete);
}
layout(width: number): void {
this.allRefs.forEach((ref, index) => {
if (ref.object instanceof CodeBlockPart) {
@@ -100,6 +100,7 @@ import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/
import { autorun, observableValue } from '../../../../../base/common/observable.js';
import { isEqual } from '../../../../../base/common/resources.js';
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js';
import { ChatPendingDragController } from './chatPendingDragAndDrop.js';
import { HookType } from '../../common/promptSyntax/hookTypes.js';
@@ -235,6 +236,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
private readonly _inlineTextModels: InlineTextModelCollection;
/** Whether we have already logged the incremental-rendering telemetry event for this renderer instance. */
private _incrementalRenderingTelemetryLogged = false;
/**
* Prevents re-announcement of already rendered chat progress
* by screen readers
@@ -260,6 +264,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
@IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService,
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
) {
super();
@@ -857,25 +862,41 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
// - And it has some content
// - And the response is not complete
// - Or, we previously started a progressive rendering of this element (if the element is complete, we will finish progressive rendering with a very fast rate)
const incrementalRendering = this.configService.getValue<boolean>(ChatConfiguration.IncrementalRendering);
if (isResponseVM(element) && index === this.delegate.getListLength() - 1 && (!element.isComplete || element.renderData)) {
this.traceLayout('renderElement', `start progressive render, index=${index}`);
const timer = templateData.elementDisposables.add(new dom.WindowIntervalTimer());
const runProgressiveRender = (initial?: boolean) => {
try {
if (this.doNextProgressiveRender(element, index, templateData, !!initial)) {
if (incrementalRendering && !element.renderData) {
// Incremental rendering: event-driven flow, no timer.
// renderElement is called each time the model changes, so
// this method runs on every content update.
this.logIncrementalRenderingTelemetry();
this.doIncrementalRender(element, index, templateData);
} else {
const timer = templateData.elementDisposables.add(new dom.WindowIntervalTimer());
const runProgressiveRender = (initial?: boolean) => {
try {
if (this.doNextProgressiveRender(element, index, templateData, !!initial)) {
timer.cancel();
}
} catch (err) {
// Kill the timer if anything went wrong, avoid getting stuck in a nasty rendering loop.
timer.cancel();
this.logService.error(err);
}
} catch (err) {
// Kill the timer if anything went wrong, avoid getting stuck in a nasty rendering loop.
timer.cancel();
this.logService.error(err);
}
};
timer.cancelAndSet(runProgressiveRender, 50, dom.getWindow(templateData.rowContainer));
runProgressiveRender(true);
};
timer.cancelAndSet(runProgressiveRender, 50, dom.getWindow(templateData.rowContainer));
runProgressiveRender(true);
}
} else {
if (isResponseVM(element)) {
// When incremental rendering was active during this response,
// notify any active morpher that the stream is complete
// so it switches to a fast drain rate before we render.
if (incrementalRendering) {
const rate = this.getProgressiveRenderRate(element);
this._updateMorpherRate(templateData, rate, true);
}
this.renderChatResponseBasic(element, index, templateData);
} else if (isRequestVM(element)) {
this.renderChatRequest(element, index, templateData);
@@ -1404,6 +1425,90 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
templateData.value.appendChild(progressPart.domNode);
}
/**
* Smooth streaming render path event-driven, rAF-batched.
*
* Does a render pass that feeds the full content through
* `getNextProgressiveRenderContent` `diff` `renderChatContentDiff`,
* where the morpher intercepts markdown appends and schedules
* rAF-batched re-renders through the standard markdown pipeline.
*
* Called on every `renderElement` invocation (which fires each time
* the model changes). On completion/cancellation the morpher's
* content is already correctly rendered, so we do a final diff pass
* (not a destructive re-render) to finalize non-markdown parts like
* thinking indicators, error details, and code citations.
*/
private doIncrementalRender(element: IChatResponseViewModel, index: number, templateData: IChatListItemTemplate): void {
if (!this._isVisible) {
return;
}
// Always update the word buffer's reveal rate, including on the
// completion pass so the buffer switches to a fast drain rate.
const rate = this.getProgressiveRenderRate(element);
this._updateMorpherRate(templateData, rate, element.isComplete || element.isCanceled);
if (element.isCanceled || element.isComplete) {
// The morpher has already rendered the markdown content
// correctly through the standard pipeline. Clear renderData
// and do a final diff pass to pick up non-markdown parts
// (error details, code citations, thinking finalization)
// without tearing down what the morpher built.
element.renderData = undefined;
templateData.rowContainer.classList.toggle('chat-response-loading', false);
this.renderChatResponseBasic(element, index, templateData);
return;
}
templateData.rowContainer.classList.toggle('chat-response-loading', true);
const contentForThisTurn = this.getNextProgressiveRenderContent(element, templateData);
const partsToRender = this.diff(templateData.renderedParts ?? [], contentForThisTurn.content, element);
const contentIsAlreadyRendered = partsToRender.every(part => part === null);
if (!contentIsAlreadyRendered) {
this.renderChatContentDiff(partsToRender, contentForThisTurn.content, element, index, templateData);
}
}
/**
* Propagate the stream's word-rate estimate to any active morpher's
* word buffer so it reveals content at the model's speed.
*/
private _updateMorpherRate(templateData: IChatListItemTemplate, rate: number, isComplete: boolean): void {
const renderedParts = templateData.renderedParts;
if (!renderedParts) {
return;
}
for (const part of renderedParts) {
if (part instanceof ChatMarkdownContentPart) {
part.updateStreamRate(rate, isComplete);
}
}
}
private logIncrementalRenderingTelemetry(): void {
if (this._incrementalRenderingTelemetryLogged) {
return;
}
this._incrementalRenderingTelemetryLogged = true;
type IncrementalRenderingSettingsEvent = {
animationStyle: string;
buffering: string;
};
type IncrementalRenderingSettingsClassification = {
animationStyle: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The animation style selected for incremental rendering.' };
buffering: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The buffering mode selected for incremental rendering.' };
owner: 'pwang347';
comment: 'Tracks which incremental rendering settings are in use.';
};
this.telemetryService.publicLog2<IncrementalRenderingSettingsEvent, IncrementalRenderingSettingsClassification>('chatIncrementalRenderingSettings', {
animationStyle: this.configService.getValue<string>(ChatConfiguration.IncrementalRenderingStyle) ?? 'none',
buffering: this.configService.getValue<string>(ChatConfiguration.IncrementalRenderingBuffering) ?? 'word',
});
}
/**
* @returns true if progressive rendering should be considered complete- the element's data is fully rendered or the view is not visible
*/
@@ -1491,6 +1596,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
return;
}
// Incremental rendering: try an incremental DOM morph instead of
// tearing down and rebuilding the entire markdown part.
if (partToRender.kind === 'markdownContent'
&& alreadyRenderedPart instanceof ChatMarkdownContentPart
&& this.configService.getValue<boolean>(ChatConfiguration.IncrementalRendering)
) {
if (alreadyRenderedPart.tryIncrementalUpdate(partToRender)) {
renderedParts[contentIndex] = alreadyRenderedPart;
return;
}
}
alreadyRenderedPart.dispose();
// Replace old DOM from thinking wrapper to prevent accumulation
@@ -1577,6 +1694,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
// An unregistered setting for development- skip the word counting and smoothing, just render content as it comes in
const renderImmediately = this.configService.getValue<boolean>('chat.experimental.renderMarkdownImmediately') === true;
// When incremental rendering is enabled, skip word-counting for markdown.
// The morpher's own buffer + rAF loop is the sole rate limiter.
const incrementalRendering = this.configService.getValue<boolean>(ChatConfiguration.IncrementalRendering) === true;
const renderableResponse = annotateSpecialMarkdownContent(element.response.value);
this.traceLayout('getNextProgressiveRenderContent', `Want to render ${data.numWordsToRender} at ${data.rate} words/s, counting...`);
@@ -1590,7 +1711,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
let moreContentAvailable = false;
for (let i = 0; i < renderableResponse.length; i++) {
const part = renderableResponse[i];
if (part.kind === 'markdownContent' && !renderImmediately) {
if (part.kind === 'markdownContent' && !renderImmediately && !incrementalRendering) {
const wordCountResult = getNWords(part.content.value, numNeededWords);
this.traceLayout('getNextProgressiveRenderContent', ` Chunk ${i}: Want to render ${numNeededWords} words and found ${wordCountResult.returnedWordCount} words. Total words in chunk: ${wordCountResult.totalWordCount}`);
numNeededWords -= wordCountResult.returnedWordCount;
@@ -70,6 +70,10 @@ export enum ChatConfiguration {
ToolConfirmationCarousel = 'chat.tools.confirmationCarousel.enabled',
DefaultNewSessionMode = 'chat.newSession.defaultMode',
AgentHostClientTools = 'chat.agentHost.clientTools',
IncrementalRendering = 'chat.experimental.incrementalRendering.enabled',
IncrementalRenderingStyle = 'chat.experimental.incrementalRendering.animationStyle',
IncrementalRenderingBuffering = 'chat.experimental.incrementalRendering.buffering',
}
/**
@@ -0,0 +1,445 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { mainWindow } from '../../../../../../../base/browser/window.js';
import { DisposableStore } from '../../../../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
import { BlockAnimation, ANIMATION_DURATION_MS } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.js';
import { lastBlockBoundary } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.js';
import { WordBuffer } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.js';
import { IncrementalDOMMorpher } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.js';
import { ChatConfiguration } from '../../../../common/constants.js';
suite('lastBlockBoundary', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('returns -1 for empty string', () => {
assert.strictEqual(lastBlockBoundary(''), -1);
});
test('returns -1 for text without any block boundary', () => {
assert.strictEqual(lastBlockBoundary('hello world'), -1);
});
test('returns -1 for single newline', () => {
assert.strictEqual(lastBlockBoundary('hello\nworld'), -1);
});
test('finds a single block boundary', () => {
const text = 'hello\n\nworld';
assert.strictEqual(lastBlockBoundary(text), 5);
});
test('finds the last block boundary among multiple', () => {
const text = 'a\n\nb\n\nc';
assert.strictEqual(lastBlockBoundary(text), 4);
});
test('ignores block boundaries inside a fenced code block', () => {
const text = '```\ncode\n\nmore code\n```';
assert.strictEqual(lastBlockBoundary(text), -1);
});
test('finds boundary after closing a code fence', () => {
const text = '```\ncode\n```\n\nafter fence';
assert.strictEqual(lastBlockBoundary(text), 12);
});
test('ignores boundary inside fence but finds one outside', () => {
const text = 'before\n\n```\ninside\n\nfence\n```\n\nafter';
// First \n\n at index 6 (before fence), inside fence at ~18, after fence at ~28
const result = lastBlockBoundary(text);
// The last valid boundary should be the one after the closing ```
assert.ok(result > 6, `Expected boundary after fence close, got ${result}`);
});
test('handles code fence at the very start of the string', () => {
const text = '```\ncode\n```\n\ntext';
assert.strictEqual(lastBlockBoundary(text), 12);
});
test('handles unclosed code fence (all subsequent boundaries ignored)', () => {
const text = '```\ncode\n\nmore\n\nstill inside';
assert.strictEqual(lastBlockBoundary(text), -1);
});
test('handles multiple code fences', () => {
const text = '```\nfirst\n```\n\nbetween\n\n```\nsecond\n```\n\nend';
const result = lastBlockBoundary(text);
// Last valid \n\n is after the second closing fence
assert.ok(result > 20, `Expected last boundary near end, got ${result}`);
});
test('handles triple backticks mid-line (not a fence)', () => {
// Triple backticks must be at the start of a line to count as a fence
const text = 'text ``` not a fence\n\nafter';
assert.strictEqual(lastBlockBoundary(text), 20);
});
test('ignores block boundaries inside a tilde-fenced code block', () => {
const text = '~~~\ncode\n\nmore code\n~~~';
assert.strictEqual(lastBlockBoundary(text), -1);
});
test('finds boundary after closing a tilde fence', () => {
const text = '~~~\ncode\n~~~\n\nafter fence';
assert.strictEqual(lastBlockBoundary(text), 12);
});
test('handles unclosed tilde fence', () => {
const text = '~~~\ncode\n\nmore\n\nstill inside';
assert.strictEqual(lastBlockBoundary(text), -1);
});
test('handles mixed backtick and tilde fences', () => {
const text = '~~~\ntilde code\n\ninside tilde\n~~~\n\n```\nbacktick code\n\ninside backtick\n```\n\nafter both';
const result = lastBlockBoundary(text);
// The last valid boundary should be after the closing ```
assert.ok(result > 40, `Expected boundary after both fences, got ${result}`);
});
});
suite('IncrementalDOMMorpher', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
let disposables: DisposableStore;
let instantiationService: ReturnType<typeof workbenchInstantiationService>;
let configService: TestConfigurationService;
setup(() => {
disposables = store.add(new DisposableStore());
instantiationService = workbenchInstantiationService(undefined, disposables);
configService = new TestConfigurationService();
configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, 'fade');
instantiationService.stub(IConfigurationService, configService);
});
teardown(() => {
disposables.dispose();
});
function createMorpher(domNode?: HTMLElement): IncrementalDOMMorpher {
const node = domNode ?? mainWindow.document.createElement('div');
return store.add(instantiationService.createInstance(IncrementalDOMMorpher, node));
}
suite('tryMorph', () => {
test('returns false for non-append edit', () => {
const morpher = createMorpher();
morpher.seed('hello');
assert.strictEqual(morpher.tryMorph('goodbye'), false);
});
test('returns true when content is identical (no-op)', () => {
const morpher = createMorpher();
morpher.seed('hello');
assert.strictEqual(morpher.tryMorph('hello'), true);
});
test('returns true for appended content', () => {
const morpher = createMorpher();
morpher.seed('hello');
assert.strictEqual(morpher.tryMorph('hello world'), true);
});
test('returns false when prefix changes', () => {
const morpher = createMorpher();
morpher.seed('hello world');
assert.strictEqual(morpher.tryMorph('Hello world!'), false);
});
test('successive appends all succeed', () => {
const morpher = createMorpher();
morpher.seed('a');
assert.strictEqual(morpher.tryMorph('ab'), true);
assert.strictEqual(morpher.tryMorph('abc'), true);
assert.strictEqual(morpher.tryMorph('abcd'), true);
});
test('fails after a non-append edit even if previous appends succeeded', () => {
const morpher = createMorpher();
morpher.seed('hello');
assert.strictEqual(morpher.tryMorph('hello world'), true);
// Now a rewrite of earlier content
assert.strictEqual(morpher.tryMorph('hi world'), false);
});
test('invokes render callback on rAF with block-boundary content', () => {
const rendered: string[] = [];
const morpher = createMorpher();
morpher.setRenderCallback(md => rendered.push(md));
morpher.seed('');
// Append content with a block boundary
morpher.tryMorph('paragraph one\n\nparagraph two');
// The callback fires asynchronously via rAF, not synchronously
assert.strictEqual(rendered.length, 0, 'Should not render synchronously');
});
test('returns true for content without block boundary (buffered)', () => {
const morpher = createMorpher();
morpher.seed('');
// No \n\n — content is buffered
assert.strictEqual(morpher.tryMorph('partial paragraph'), true);
});
});
suite('seed', () => {
test('sets baseline markdown', () => {
const morpher = createMorpher();
morpher.seed('initial content');
// After seeding, tryMorph with same content is a no-op
assert.strictEqual(morpher.tryMorph('initial content'), true);
// And appending works
assert.strictEqual(morpher.tryMorph('initial content more'), true);
});
test('with animateInitial=false uses existing child count as watermark', () => {
const domNode = mainWindow.document.createElement('div');
domNode.appendChild(mainWindow.document.createElement('p'));
domNode.appendChild(mainWindow.document.createElement('p'));
const morpher = createMorpher(domNode);
morpher.seed('some content', false);
// No animation classes should be applied since all children are "revealed"
for (const child of Array.from(domNode.children)) {
assert.strictEqual(
(child as HTMLElement).classList.contains('chat-smooth-animate-fade'),
false,
'Existing children should not be animated when animateInitial is false'
);
}
});
test('with animateInitial=true animates existing children', () => {
const domNode = mainWindow.document.createElement('div');
domNode.appendChild(mainWindow.document.createElement('p'));
domNode.appendChild(mainWindow.document.createElement('p'));
const morpher = createMorpher(domNode);
morpher.seed('some content', true);
// Children should have the animation class
for (const child of Array.from(domNode.children)) {
assert.strictEqual(
(child as HTMLElement).classList.contains('chat-smooth-animate-fade'),
true,
'Existing children should be animated when animateInitial is true'
);
}
});
});
suite('animation style', () => {
test('defaults to fade for invalid config value', () => {
configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, 'invalid-style');
const domNode = mainWindow.document.createElement('div');
domNode.appendChild(mainWindow.document.createElement('p'));
const morpher = createMorpher(domNode);
morpher.seed('content', true);
const child = domNode.children[0] as HTMLElement;
assert.strictEqual(child.classList.contains('chat-smooth-animate-fade'), true, 'Should fall back to fade');
});
test('uses configured animation style', () => {
configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, 'rise');
const domNode = mainWindow.document.createElement('div');
domNode.appendChild(mainWindow.document.createElement('p'));
const morpher = createMorpher(domNode);
morpher.seed('content', true);
const child = domNode.children[0] as HTMLElement;
assert.strictEqual(child.classList.contains('chat-smooth-animate-rise'), true, 'Should use rise style');
});
for (const style of ['fade', 'rise', 'blur', 'scale', 'slide'] as const) {
test(`applies ${style} animation class`, () => {
configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, style);
const domNode = mainWindow.document.createElement('div');
domNode.appendChild(mainWindow.document.createElement('p'));
const morpher = createMorpher(domNode);
morpher.seed('content', true);
const child = domNode.children[0] as HTMLElement;
assert.strictEqual(
child.classList.contains(`chat-smooth-animate-${style}`),
true,
`Should have chat-smooth-animate-${style} class`
);
});
}
});
suite('dispose', () => {
test('clears pending state on dispose', () => {
const morpher = createMorpher();
morpher.seed('');
morpher.setRenderCallback(() => { });
morpher.tryMorph('hello\n\nworld');
// Dispose before rAF fires
morpher.dispose();
// No error should occur — rAF is cancelled
});
});
});
suite('BlockAnimation', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('applies animation class and custom properties to new children', () => {
const anim = new BlockAnimation('fade');
const container = mainWindow.document.createElement('div');
const child = container.appendChild(mainWindow.document.createElement('p'));
anim.animate(container.children, 0, 1, 0);
assert.strictEqual(child.classList.contains('chat-smooth-animate-fade'), true);
assert.strictEqual(child.style.getPropertyValue('--chat-smooth-duration'), `${ANIMATION_DURATION_MS}ms`);
assert.ok(child.style.getPropertyValue('--chat-smooth-delay') !== '');
});
test('does not strip animation class on bubbled animationend from nested element', () => {
const anim = new BlockAnimation('rise');
const container = mainWindow.document.createElement('div');
const parent = container.appendChild(mainWindow.document.createElement('div'));
const nested = parent.appendChild(mainWindow.document.createElement('span'));
anim.animate(container.children, 0, 1, 0);
assert.strictEqual(parent.classList.contains('chat-smooth-animate-rise'), true);
// Simulate animationend bubbling from nested child
const bubbledEvent = new AnimationEvent('animationend', { bubbles: true });
nested.dispatchEvent(bubbledEvent);
// Parent should still have the animation class
assert.strictEqual(
parent.classList.contains('chat-smooth-animate-rise'),
true,
'Animation class should not be removed by bubbled event'
);
assert.strictEqual(
parent.style.getPropertyValue('--chat-smooth-duration'),
`${ANIMATION_DURATION_MS}ms`,
'Custom properties should not be removed by bubbled event'
);
});
test('strips animation class on direct animationend from the animated element', () => {
const anim = new BlockAnimation('blur');
const container = mainWindow.document.createElement('div');
const child = container.appendChild(mainWindow.document.createElement('p'));
anim.animate(container.children, 0, 1, 0);
assert.strictEqual(child.classList.contains('chat-smooth-animate-blur'), true);
// Simulate direct animationend on the child itself
const directEvent = new AnimationEvent('animationend', { bubbles: true });
child.dispatchEvent(directEvent);
assert.strictEqual(
child.classList.contains('chat-smooth-animate-blur'),
false,
'Animation class should be removed after direct animationend'
);
assert.strictEqual(
child.style.getPropertyValue('--chat-smooth-duration'),
'',
'Custom property should be removed after direct animationend'
);
});
test('staggers delay across multiple new children', () => {
const anim = new BlockAnimation('fade');
const container = mainWindow.document.createElement('div');
container.appendChild(mainWindow.document.createElement('p'));
container.appendChild(mainWindow.document.createElement('p'));
container.appendChild(mainWindow.document.createElement('p'));
anim.animate(container.children, 0, 3, 0);
const delays = Array.from(container.children).map(
c => parseInt((c as HTMLElement).style.getPropertyValue('--chat-smooth-delay'))
);
// Each successive child should have a larger delay
assert.ok(delays[1] > delays[0], `Second delay ${delays[1]} should be greater than first ${delays[0]}`);
assert.ok(delays[2] > delays[1], `Third delay ${delays[2]} should be greater than second ${delays[1]}`);
});
});
suite('WordBuffer', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('setRate with isComplete uses at least MIN_RATE_AFTER_COMPLETE', () => {
const buffer = new WordBuffer();
// Setting a low rate with isComplete should floor to 80
buffer.setRate(10, true);
// Verify by checking filterFlush behavior: with rate=80,
// after enough elapsed time, words should be revealed faster
// than at rate=10.
const md = 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10';
const result1 = buffer.filterFlush(md);
// First call reveals 1 word
assert.ok(result1 !== undefined, 'First flush should reveal content');
});
test('setRate with undefined rate and isComplete defaults to MIN_RATE_AFTER_COMPLETE', () => {
const buffer = new WordBuffer();
buffer.setRate(undefined, true);
const md = 'word1 word2 word3';
const result = buffer.filterFlush(md);
assert.ok(result !== undefined, 'Should reveal content with default complete rate');
});
test('setRate during streaming clamps between MIN_RATE and MAX_RATE', () => {
const buffer = new WordBuffer();
// Rate below MIN_RATE should be clamped up
buffer.setRate(1, false);
const md = 'word1 word2 word3';
const result = buffer.filterFlush(md);
assert.ok(result !== undefined, 'Should reveal content even with low rate (clamped to MIN_RATE)');
});
test('setRate with undefined rate during streaming defaults to DEFAULT_RATE', () => {
const buffer = new WordBuffer();
buffer.setRate(undefined, false);
const md = 'word1 word2';
const result = buffer.filterFlush(md);
assert.ok(result !== undefined, 'Should reveal content with default streaming rate');
});
test('needsNextFrame is true when words remain unrevealed', () => {
const buffer = new WordBuffer();
buffer.setRate(1, false);
// First flush reveals 1 word, but there are more
buffer.filterFlush('word1 word2 word3 word4 word5');
assert.strictEqual(buffer.needsNextFrame, true, 'Should need another frame when words remain');
});
test('needsNextFrame is false when all words are revealed', () => {
const buffer = new WordBuffer();
buffer.setRate(2000, false);
// With a very high rate and single word, all content is revealed
buffer.filterFlush('hello');
assert.strictEqual(buffer.needsNextFrame, false, 'Should not need another frame when all words shown');
});
});