mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-20 15:19:54 +01:00
Add incremental chat rendering experiment (#310801)
This commit is contained in:
@@ -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."),
|
||||
|
||||
+23
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
+53
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+70
@@ -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;
|
||||
}
|
||||
}
|
||||
+128
@@ -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;
|
||||
}
|
||||
}
|
||||
+291
@@ -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();
|
||||
}
|
||||
}
|
||||
+112
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
-3
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+445
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user