Skip to content

Commit 00a718e

Browse files
authored
Add incremental chat rendering experiment (#310801)
1 parent ea6aac9 commit 00a718e

File tree

16 files changed

+1497
-16
lines changed

16 files changed

+1497
-16
lines changed

build/lib/stylelint/vscode-known-variables.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,8 @@
936936
"--background-light",
937937
"--chat-editing-last-edit-shift",
938938
"--chat-current-response-min-height",
939+
"--chat-smooth-delay",
940+
"--chat-smooth-duration",
939941
"--inline-chat-frame-progress",
940942
"--insert-border-color",
941943
"--last-tab-margin-right",

src/vs/workbench/contrib/chat/browser/chat.contribution.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,40 @@ configurationRegistry.registerConfiguration({
349349
description: nls.localize('chat.experimental.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."),
350350
default: null
351351
},
352+
[ChatConfiguration.IncrementalRendering]: {
353+
type: 'boolean',
354+
description: nls.localize('chat.experimental.incrementalRendering.enabled', "Enables incremental rendering with optional block-level animation when streaming chat responses."),
355+
default: false,
356+
tags: ['experimental'],
357+
},
358+
[ChatConfiguration.IncrementalRenderingStyle]: {
359+
type: 'string',
360+
enum: ['none', 'fade', 'rise', 'blur', 'scale', 'slide', 'reveal'],
361+
enumDescriptions: [
362+
nls.localize('chat.experimental.incrementalRendering.animationStyle.none', "No animation. Content appears instantly."),
363+
nls.localize('chat.experimental.incrementalRendering.animationStyle.fade', "Simple opacity fade from 0 to 1."),
364+
nls.localize('chat.experimental.incrementalRendering.animationStyle.rise', "Content fades in while rising upward."),
365+
nls.localize('chat.experimental.incrementalRendering.animationStyle.blur', "Content fades in from a blurred state."),
366+
nls.localize('chat.experimental.incrementalRendering.animationStyle.scale', "Content scales up from slightly smaller."),
367+
nls.localize('chat.experimental.incrementalRendering.animationStyle.slide', "Content slides in from the left."),
368+
nls.localize('chat.experimental.incrementalRendering.animationStyle.reveal', "Content reveals top-to-bottom with a soft gradient edge."),
369+
],
370+
description: nls.localize('chat.experimental.incrementalRendering.animationStyle', "Controls the animation style for incremental rendering."),
371+
default: 'fade',
372+
tags: ['experimental'],
373+
},
374+
[ChatConfiguration.IncrementalRenderingBuffering]: {
375+
type: 'string',
376+
enum: ['off', 'word', 'paragraph'],
377+
enumDescriptions: [
378+
nls.localize('chat.experimental.incrementalRendering.buffering.off', "Renders content immediately as tokens arrive."),
379+
nls.localize('chat.experimental.incrementalRendering.buffering.word', "Reveals content word by word."),
380+
nls.localize('chat.experimental.incrementalRendering.buffering.paragraph', "Buffers content until a paragraph break before rendering."),
381+
],
382+
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."),
383+
default: 'word',
384+
tags: ['experimental'],
385+
},
352386
'chat.detectParticipant.enabled': {
353387
type: 'boolean',
354388
description: nls.localize('chat.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."),
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/**
7+
* Animation strategy for incremental rendering. Applied as a post-processing
8+
* decoration after the markdown has been correctly rendered.
9+
*
10+
* Animation is separate from buffering — it controls *how* rendered
11+
* content appears, while buffering controls *when* we render.
12+
*/
13+
export interface IIncrementalRenderingAnimation {
14+
/**
15+
* Apply entrance animation to newly appeared DOM children.
16+
*
17+
* @param children The live HTMLCollection of the container's children.
18+
* @param fromIndex Index of the first new child to animate.
19+
* @param currentCount Total number of children currently in the DOM.
20+
* @param elapsed Milliseconds since the animation batch started.
21+
*/
22+
animate(children: HTMLCollection, fromIndex: number, currentCount: number, elapsed: number): void;
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IIncrementalRenderingAnimation } from './animation.js';
7+
import { BlockAnimation } from './blockAnimations.js';
8+
9+
/**
10+
* Registry of all available animation styles.
11+
* To add a new animation, add an entry here.
12+
*/
13+
export const ANIMATION_STYLES = {
14+
none: (): IIncrementalRenderingAnimation => ({ animate() { } }),
15+
fade: (): IIncrementalRenderingAnimation => new BlockAnimation('fade'),
16+
rise: (): IIncrementalRenderingAnimation => new BlockAnimation('rise'),
17+
blur: (): IIncrementalRenderingAnimation => new BlockAnimation('blur'),
18+
scale: (): IIncrementalRenderingAnimation => new BlockAnimation('scale'),
19+
slide: (): IIncrementalRenderingAnimation => new BlockAnimation('slide'),
20+
reveal: (): IIncrementalRenderingAnimation => new BlockAnimation('reveal'),
21+
} as const satisfies Record<string, () => IIncrementalRenderingAnimation>;
22+
23+
export type AnimationStyleName = keyof typeof ANIMATION_STYLES;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IIncrementalRenderingAnimation } from './animation.js';
7+
8+
/** Duration of the animation applied to newly rendered blocks. */
9+
export const ANIMATION_DURATION_MS = 600;
10+
11+
/**
12+
* Delay (ms) between each successive new child's animation start.
13+
* Creates a cascading top-to-bottom reveal across a batch of new
14+
* block-level elements.
15+
*/
16+
const STAGGER_DELAY_MS = 150;
17+
18+
/**
19+
* Block-level CSS animation styles: fade, rise, blur, scale, slide,
20+
* and lineFade. Each applies a CSS class and staggered timing
21+
* variables to new top-level children so they reveal sequentially.
22+
*/
23+
export class BlockAnimation implements IIncrementalRenderingAnimation {
24+
25+
constructor(private readonly _style: 'fade' | 'rise' | 'blur' | 'scale' | 'slide' | 'reveal') { }
26+
27+
animate(children: HTMLCollection, fromIndex: number, currentCount: number, elapsed: number): void {
28+
const className = `chat-smooth-animate-${this._style}`;
29+
30+
for (let i = fromIndex; i < currentCount; i++) {
31+
const child = children[i] as HTMLElement;
32+
if (!child.classList) {
33+
continue;
34+
}
35+
36+
const staggerOffset = (i - fromIndex) * STAGGER_DELAY_MS;
37+
const childDelay = -elapsed + staggerOffset;
38+
39+
child.classList.add(className);
40+
child.style.setProperty('--chat-smooth-duration', `${ANIMATION_DURATION_MS}ms`);
41+
child.style.setProperty('--chat-smooth-delay', `${childDelay}ms`);
42+
43+
child.addEventListener('animationend', (e) => {
44+
if (e.target !== child) {
45+
return;
46+
}
47+
child.classList.remove(className);
48+
child.style.removeProperty('--chat-smooth-duration');
49+
child.style.removeProperty('--chat-smooth-delay');
50+
}, { once: true });
51+
}
52+
}
53+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/**
7+
* A buffering strategy determines how much incoming markdown content
8+
* must accumulate before a render is triggered.
9+
*
10+
* Buffering is separate from animation — it controls *when* we render,
11+
* while animation controls *how* rendered content appears.
12+
*/
13+
export interface IIncrementalRenderingBuffer {
14+
/**
15+
* Given the full markdown string and the markdown that was last
16+
* rendered to the real DOM, return `true` if the buffer should
17+
* be handled entirely within _flushRender (e.g. shadow measurement).
18+
* In that case the orchestrator should pass everything through
19+
* without updating `_renderedMarkdown`.
20+
*/
21+
readonly handlesFlush: boolean;
22+
23+
/**
24+
* Determine the renderable prefix of `fullMarkdown`. The returned
25+
* string must be a prefix of `fullMarkdown` (or `fullMarkdown`
26+
* itself). Content beyond the returned prefix stays buffered.
27+
*
28+
* @param fullMarkdown The complete markdown accumulated so far.
29+
* @param lastRendered The markdown last rendered to the DOM.
30+
* @returns The prefix to render now.
31+
*/
32+
getRenderable(fullMarkdown: string, lastRendered: string): string;
33+
34+
/**
35+
* For buffers that handle flushing themselves (e.g. line buffer
36+
* with shadow DOM measurement), this is called during
37+
* `_flushRender` to decide whether to commit the pending content.
38+
*
39+
* @param markdown The pending markdown to potentially commit.
40+
* @returns The markdown to actually commit, or `undefined` to skip.
41+
*/
42+
filterFlush?(markdown: string): string | undefined;
43+
44+
/**
45+
* Whether the buffer needs another rAF frame to continue revealing
46+
* content (e.g. typewriter drip-feeding words). When `true`, the
47+
* orchestrator re-schedules a render after the current flush.
48+
*/
49+
readonly needsNextFrame?: boolean;
50+
51+
/**
52+
* Called when the buffer is no longer needed.
53+
*/
54+
dispose?(): void;
55+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IIncrementalRenderingBuffer } from './buffer.js';
7+
import { OffBuffer } from './offBuffer.js';
8+
import { ParagraphBuffer } from './paragraphBuffer.js';
9+
import { WordBuffer } from './wordBuffer.js';
10+
11+
/**
12+
* Registry of all available buffering strategies.
13+
* To add a new buffer, add an entry here.
14+
*/
15+
export const BUFFER_MODES = {
16+
off: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new OffBuffer(),
17+
word: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new WordBuffer(),
18+
paragraph: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new ParagraphBuffer(),
19+
} as const satisfies Record<string, (domNode: HTMLElement) => IIncrementalRenderingBuffer>;
20+
21+
export type BufferModeName = keyof typeof BUFFER_MODES;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IIncrementalRenderingBuffer } from './buffer.js';
7+
8+
/**
9+
* No buffering — renders everything immediately as tokens arrive.
10+
* Content is still rAF-coalesced by the orchestrator.
11+
*/
12+
export class OffBuffer implements IIncrementalRenderingBuffer {
13+
readonly handlesFlush = false;
14+
15+
getRenderable(fullMarkdown: string, _lastRendered: string): string {
16+
return fullMarkdown;
17+
}
18+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IIncrementalRenderingBuffer } from './buffer.js';
7+
8+
/**
9+
* Maximum number of characters that may accumulate beyond the last
10+
* paragraph boundary before a render is forced.
11+
*/
12+
const MAX_BUFFERED_CHARS = 4000;
13+
14+
/**
15+
* Finds the last `\n\n` block boundary that is NOT inside an open
16+
* fenced code block. This prevents splitting a render in the middle
17+
* of a code fence, which would cause the code block element to update
18+
* in place (same DOM index) without triggering a new-child animation.
19+
*
20+
* The scan counts backtick-fence openings/closings from the start of
21+
* the string. A `\n\n` is only a valid boundary when the fence depth
22+
* is 0 (i.e. outside any code block).
23+
*
24+
* @internal Exported for testing.
25+
*/
26+
export function lastBlockBoundary(text: string): number {
27+
let lastValid = -1;
28+
let inFence = false;
29+
30+
for (let i = 0; i < text.length; i++) {
31+
// Detect fenced code blocks: ``` or ~~~ at the start of a line.
32+
if ((i === 0 || text[i - 1] === '\n') &&
33+
((text[i] === '`' && text[i + 1] === '`' && text[i + 2] === '`') ||
34+
(text[i] === '~' && text[i + 1] === '~' && text[i + 2] === '~'))) {
35+
inFence = !inFence;
36+
i += 2; // skip past the triple backtick/tilde
37+
continue;
38+
}
39+
// Detect block boundary outside code fences.
40+
if (!inFence && text[i] === '\n' && text[i + 1] === '\n') {
41+
lastValid = i;
42+
}
43+
}
44+
45+
return lastValid;
46+
}
47+
48+
/**
49+
* Buffers content at paragraph boundaries (`\n\n` outside code fences).
50+
* This avoids rendering partially formed blocks — text mid-paragraph,
51+
* incomplete list groups, or half a code fence.
52+
*/
53+
export class ParagraphBuffer implements IIncrementalRenderingBuffer {
54+
readonly handlesFlush = false;
55+
56+
getRenderable(fullMarkdown: string, lastRendered: string): string {
57+
const lastBlock = lastBlockBoundary(fullMarkdown);
58+
let renderable = lastBlock === -1
59+
? lastRendered // no complete block yet — keep current
60+
: fullMarkdown.slice(0, lastBlock + 2);
61+
62+
// Escape hatch: if too much content has accumulated without a
63+
// block boundary, render what we have.
64+
if (fullMarkdown.length - renderable.length > MAX_BUFFERED_CHARS) {
65+
renderable = fullMarkdown;
66+
}
67+
68+
return renderable;
69+
}
70+
}

0 commit comments

Comments
 (0)