Skip to content

Commit 476aed8

Browse files
authored
thinking content perf improvements (#307878)
* thinking content perf * cleanup comments * address comments
1 parent b8c85f0 commit 476aed8

5 files changed

Lines changed: 89 additions & 23 deletions

File tree

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { $, clearNode, getWindow, hide, scheduleAtNextAnimationFrame } from '../../../../../../base/browser/dom.js';
6+
import { $, clearNode, DisposableResizeObserver, getWindow, hide, scheduleAtNextAnimationFrame } from '../../../../../../base/browser/dom.js';
77
import { alert } from '../../../../../../base/browser/ui/aria/aria.js';
88
import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js';
99
import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js';
@@ -242,10 +242,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
242242
private pendingRemovals: { toolCallId: string; toolLabel: string }[] = [];
243243
private pendingRemovalFlushDisposable: IDisposable | undefined;
244244
private pendingScrollDisposable: IDisposable | undefined;
245-
private mutationObserverDisposable: IDisposable | undefined;
245+
private wrapperResizeObserverDisposable: IDisposable | undefined;
246+
private viewportResizeObserverDisposable: IDisposable | undefined;
246247
private isUpdatingDimensions: boolean = false;
247248
private lastKnownContentHeight: number = 0;
248249
private lastKnownScrollTop: number = 0;
250+
private _cachedViewportWidth: number = 0;
249251
private titleShimmerSpan: HTMLElement | undefined;
250252
private titleDetailContainer: HTMLElement | undefined;
251253
private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget;
@@ -476,19 +478,22 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
476478
}));
477479
this._register(this.scrollableElement.onScroll(e => this.handleScroll(e.scrollTop)));
478480

479-
// check for content changes to update scroll dimensions
480-
const mutationObserver = new MutationObserver(() => {
481+
// Use a ResizeObserver on the wrapper to detect content growth and drive scrolling.
482+
const wrapperResizeObserver = this._register(new DisposableResizeObserver(() => {
481483
if (!this.streamingCompleted && this.domNode.classList.contains('chat-used-context-collapsed')) {
482-
this.syncDimensionsAndScheduleScroll();
484+
this.updateScrollDimensionsOnResize();
483485
}
484-
});
485-
mutationObserver.observe(this.wrapper, {
486-
childList: true,
487-
subtree: true,
488-
characterData: true
489-
});
490-
this.mutationObserverDisposable = { dispose: () => mutationObserver.disconnect() };
491-
this._register(this.mutationObserverDisposable);
486+
}));
487+
this.wrapperResizeObserverDisposable = this._register(wrapperResizeObserver.observe(this.wrapper));
488+
489+
// Track viewport width changes (only changes on panel resize, not during streaming)
490+
const viewportResizeObserver = this._register(new DisposableResizeObserver((entries) => {
491+
const entry = entries[0];
492+
if (entry) {
493+
this._cachedViewportWidth = entry.borderBoxSize[0]?.inlineSize ?? entry.contentRect.width;
494+
}
495+
}));
496+
this.viewportResizeObserverDisposable = this._register(viewportResizeObserver.observe(this.scrollableElement.getDomNode()));
492497

493498
this._register(this._onDidChangeHeight.event(() => {
494499
this.syncDimensionsAndScheduleScroll();
@@ -533,10 +538,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
533538
this.domNode.classList.toggle('chat-thinking-fade-bottom', maxScrollTop > 0 && currentScrollTop < maxScrollTop - 5);
534539
}
535540

536-
// Schedule a batched scroll dimension update for the next animation frame.
537-
// All calls during a single frame (from updateThinking, MutationObserver, etc.)
538-
// are coalesced into one layout read, avoiding forced synchronous layouts
539-
// during tree splice operations.
541+
// Fallback for non-ResizeObserver updates (onDidChangeHeight, initial setup).
542+
// During streaming, ResizeObserver drives updates via updateScrollDimensionsFromCache().
540543
private syncDimensionsAndScheduleScroll(): void {
541544
if (this.pendingScrollDisposable) {
542545
return;
@@ -555,11 +558,54 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
555558
} finally {
556559
this.isUpdatingDimensions = false;
557560
}
558-
// Use the cached values from updateScrollDimensions to avoid extra layout reads
559561
this.updateFadeClasses(this.lastKnownScrollTop, this.lastKnownContentHeight);
562+
this.updateDropdownClickability();
560563
});
561564
}
562565

566+
/**
567+
* Update scroll dimensions from ResizeObserver callback (post-layout, so reads are cheap).
568+
*/
569+
private updateScrollDimensionsOnResize(): void {
570+
if (!this.scrollableElement || this._store.isDisposed) {
571+
return;
572+
}
573+
574+
const isCollapsed = this.domNode.classList.contains('chat-used-context-collapsed');
575+
if (!isCollapsed) {
576+
return;
577+
}
578+
579+
const contentHeight = this.wrapper.scrollHeight;
580+
if (!contentHeight) {
581+
return;
582+
}
583+
584+
this.lastKnownContentHeight = contentHeight;
585+
const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT);
586+
587+
this.isUpdatingDimensions = true;
588+
try {
589+
this.scrollableElement.setScrollDimensions({
590+
width: this._cachedViewportWidth || this.scrollableElement.getDomNode().clientWidth,
591+
scrollWidth: this.wrapper.scrollWidth,
592+
height: viewportHeight,
593+
scrollHeight: contentHeight
594+
});
595+
596+
this.lastKnownScrollTop = this.scrollableElement.getScrollPosition().scrollTop;
597+
598+
if (this.autoScrollEnabled) {
599+
this.scrollToBottom(contentHeight);
600+
}
601+
} finally {
602+
this.isUpdatingDimensions = false;
603+
}
604+
605+
this.updateFadeClasses(this.lastKnownScrollTop, this.lastKnownContentHeight);
606+
this.updateDropdownClickability(contentHeight);
607+
}
608+
563609
private updateScrollDimensions(): number | undefined {
564610
if (!this.scrollableElement) {
565611
return undefined;
@@ -575,7 +621,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
575621
const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT);
576622

577623
this.scrollableElement.setScrollDimensions({
578-
width: this.scrollableElement.getDomNode().clientWidth,
624+
width: this._cachedViewportWidth || this.scrollableElement.getDomNode().clientWidth,
579625
scrollWidth: this.wrapper.scrollWidth,
580626
height: viewportHeight,
581627
scrollHeight: contentHeight
@@ -617,7 +663,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
617663
const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT);
618664

619665
this.scrollableElement.setScrollDimensions({
620-
width: this.scrollableElement.getDomNode().clientWidth,
666+
width: this._cachedViewportWidth || this.scrollableElement.getDomNode().clientWidth,
621667
scrollWidth: this.wrapper.scrollWidth,
622668
height: viewportHeight,
623669
scrollHeight: contentHeight
@@ -882,9 +928,14 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
882928
this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom');
883929
this.streamingCompleted = true;
884930

885-
if (this.mutationObserverDisposable) {
886-
this.mutationObserverDisposable.dispose();
887-
this.mutationObserverDisposable = undefined;
931+
if (this.wrapperResizeObserverDisposable) {
932+
this.wrapperResizeObserverDisposable.dispose();
933+
this.wrapperResizeObserverDisposable = undefined;
934+
}
935+
936+
if (this.viewportResizeObserverDisposable) {
937+
this.viewportResizeObserverDisposable.dispose();
938+
this.viewportResizeObserverDisposable = undefined;
888939
}
889940

890941
if (this.workingSpinnerElement) {

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@
448448
}
449449

450450
.chat-tool-invocation-part {
451+
contain: layout style;
452+
451453
.chat-confirmation-widget {
452454
border: none;
453455
font-size: var(--vscode-chat-font-size-body-s);
@@ -521,6 +523,7 @@
521523
-webkit-background-clip: text;
522524
-webkit-text-fill-color: transparent;
523525
animation: chat-thinking-shimmer 2s linear infinite;
526+
will-change: background-position;
524527
}
525528

526529
}

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
position: relative;
1414
color: var(--vscode-descriptionForeground);
1515

16+
contain: layout style;
17+
1618
.chat-thinking-external-resources {
1719
margin-top: 4px;
1820
margin-left: 5px;
@@ -90,6 +92,7 @@
9092
-webkit-background-clip: text;
9193
-webkit-text-fill-color: transparent;
9294
animation: chat-thinking-shimmer 2s linear infinite;
95+
will-change: background-position;
9396
}
9497
}
9598

@@ -106,6 +109,7 @@
106109
-webkit-background-clip: text;
107110
-webkit-text-fill-color: transparent;
108111
animation: chat-thinking-shimmer 2s linear infinite;
112+
will-change: background-position;
109113
}
110114

111115
&:not(.chat-used-context-collapsed) .chat-used-context-list.chat-thinking-collapsible {

src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
335335
}
336336

337337
const normalizedHeight = Math.ceil(height);
338+
if (normalizedHeight === template.currentElement.currentRenderedHeight) {
339+
return;
340+
}
338341
template.currentElement.currentRenderedHeight = normalizedHeight;
339342
if (template.currentElement !== this._elementBeingRendered) {
340343
this._onDidChangeItemHeight.fire({ element: template.currentElement, height: normalizedHeight });

src/vs/workbench/contrib/chat/browser/widget/media/chat.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
cursor: default;
4545
user-select: text;
4646
-webkit-user-select: text;
47+
48+
contain: layout style;
4749
}
4850

4951
.interactive-item-container:not(:has(.chat-extensions-content-part)) .header {
@@ -2570,6 +2572,8 @@ have to be updated for changes to the rules above, or to support more deeply nes
25702572
margin: 0 0 14px 0;
25712573
font-size: 13px;
25722574

2575+
contain: layout style;
2576+
25732577
/* Tool calls transition from a progress to a collapsible list part, which needs to have this top padding.
25742578
The working progress also can be replaced by a tool progress part. So align this padding so the text doesn't appear to shift. */
25752579
padding-top: 2px;
@@ -2633,6 +2637,7 @@ have to be updated for changes to the rules above, or to support more deeply nes
26332637
-webkit-background-clip: text;
26342638
-webkit-text-fill-color: transparent;
26352639
animation: chat-thinking-shimmer 2s linear infinite;
2640+
will-change: background-position;
26362641
}
26372642
}
26382643

0 commit comments

Comments
 (0)