diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index b2bf38ec6c7..59dfc331f35 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -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", diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d01f50e0b8b..e4c798f2561 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -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."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animation.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animation.ts new file mode 100644 index 00000000000..1b50c9f5b2d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animation.ts @@ -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; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animationRegistry.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animationRegistry.ts new file mode 100644 index 00000000000..d977ab3deea --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animationRegistry.ts @@ -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 IIncrementalRenderingAnimation>; + +export type AnimationStyleName = keyof typeof ANIMATION_STYLES; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.ts new file mode 100644 index 00000000000..72650fefb92 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.ts @@ -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 }); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/buffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/buffer.ts new file mode 100644 index 00000000000..6864e078472 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/buffer.ts @@ -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; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/bufferRegistry.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/bufferRegistry.ts new file mode 100644 index 00000000000..9159d910c5b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/bufferRegistry.ts @@ -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 IIncrementalRenderingBuffer>; + +export type BufferModeName = keyof typeof BUFFER_MODES; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/offBuffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/offBuffer.ts new file mode 100644 index 00000000000..2608716e1fe --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/offBuffer.ts @@ -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; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts new file mode 100644 index 00000000000..f6258db1a3f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts @@ -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; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.ts new file mode 100644 index 00000000000..0473987dac8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.ts @@ -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; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts new file mode 100644 index 00000000000..80ecb6679d8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts @@ -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(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(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(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/media/chatIncrementalRendering.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/media/chatIncrementalRendering.css new file mode 100644 index 00000000000..2f6babf9656 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/media/chatIncrementalRendering.css @@ -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; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 521496533d4..a0e4141bf70 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -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(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(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()); 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) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 19aa792fa2c..db8f2fb6f0c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -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(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 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('chatIncrementalRenderingSettings', { + animationStyle: this.configService.getValue(ChatConfiguration.IncrementalRenderingStyle) ?? 'none', + buffering: this.configService.getValue(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(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('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(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 { + + 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; + 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'); + }); +});