fix scrolling as content expands in thinking (#291750)

* fix scrolling as content expands in thinking

* add disposables?
This commit is contained in:
Justin Chen
2026-01-29 15:17:02 -08:00
committed by GitHub
parent 3c7595e8d0
commit 66fe91254e
2 changed files with 98 additions and 11 deletions
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { $, clearNode, hide } from '../../../../../../base/browser/dom.js';
import { $, clearNode, getWindow, hide, scheduleAtNextAnimationFrame } from '../../../../../../base/browser/dom.js';
import { alert } from '../../../../../../base/browser/ui/aria/aria.js';
import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js';
import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js';
@@ -151,6 +151,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
private readonly toolWrappersByCallId = new Map<string, HTMLElement>();
private readonly toolDisposables = this._register(new DisposableMap<string, DisposableStore>());
private pendingRemovals: { toolCallId: string; toolLabel: string }[] = [];
private pendingScrollDisposable: IDisposable | undefined;
private mutationObserverDisposable: IDisposable | undefined;
private isUpdatingDimensions: boolean = false;
private getRandomWorkingMessage(): string {
if (this.availableWorkingMessages.length === 0) {
@@ -310,11 +313,25 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
}));
this._register(this.scrollableElement.onScroll(e => this.handleScroll(e.scrollTop)));
// check for content changes to update scroll dimensions
const mutationObserver = new MutationObserver(() => {
if (!this.streamingCompleted) {
this.syncDimensionsAndScheduleScroll();
}
});
mutationObserver.observe(this.wrapper, {
childList: true,
subtree: true,
characterData: true
});
this.mutationObserverDisposable = { dispose: () => mutationObserver.disconnect() };
this._register(this.mutationObserverDisposable);
this._register(this._onDidChangeHeight.event(() => {
setTimeout(() => this.scrollToBottomIfEnabled(), 0);
this.syncDimensionsAndScheduleScroll();
}));
setTimeout(() => this.scrollToBottomIfEnabled(), 0);
this.syncDimensionsAndScheduleScroll();
this.updateDropdownClickability();
return this.scrollableElement.getDomNode();
@@ -325,7 +342,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
}
private handleScroll(scrollTop: number): void {
if (!this.scrollableElement) {
if (!this.scrollableElement || this.isUpdatingDimensions) {
return;
}
@@ -340,8 +357,39 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
}
}
private scrollToBottomIfEnabled(): void {
if (!this.scrollableElement || !this.autoScrollEnabled) {
// try to schedule scroll
private syncDimensionsAndScheduleScroll(): void {
if (this.autoScrollEnabled && this.scrollableElement) {
this.isUpdatingDimensions = true;
try {
this.updateScrollDimensions();
this.scrollToBottom();
} finally {
this.isUpdatingDimensions = false;
}
return;
}
// debounce animation
if (this.pendingScrollDisposable) {
return;
}
this.pendingScrollDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => {
this.pendingScrollDisposable = undefined;
if (this._store.isDisposed) {
return;
}
this.isUpdatingDimensions = true;
try {
this.updateScrollDimensions();
} finally {
this.isUpdatingDimensions = false;
}
});
}
private updateScrollDimensions(): void {
if (!this.scrollableElement) {
return;
}
@@ -359,6 +407,15 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
height: viewportHeight,
scrollHeight: contentHeight
});
}
private scrollToBottom(): void {
if (!this.scrollableElement) {
return;
}
const contentHeight = this.wrapper.scrollHeight;
const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT);
if (contentHeight > viewportHeight) {
this.scrollableElement.setScrollPosition({ scrollTop: contentHeight - viewportHeight });
@@ -515,7 +572,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
this.renderMarkdown(next, reuseExisting);
if (this.fixedScrollingMode && this.scrollableElement) {
setTimeout(() => this.scrollToBottomIfEnabled(), 0);
this.syncDimensionsAndScheduleScroll();
}
const extractedTitle = extractTitleFromThinkingContent(raw);
@@ -561,6 +618,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
}
this.streamingCompleted = true;
if (this.mutationObserverDisposable) {
this.mutationObserverDisposable.dispose();
this.mutationObserverDisposable = undefined;
}
if (this.workingSpinnerElement) {
this.workingSpinnerElement.remove();
this.workingSpinnerElement = undefined;
@@ -1129,7 +1191,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
this.appendToWrapper(itemWrapper);
if (this.fixedScrollingMode && this.scrollableElement) {
setTimeout(() => this.scrollToBottomIfEnabled(), 0);
this.syncDimensionsAndScheduleScroll();
}
}
@@ -1241,6 +1303,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
this.workingSpinnerElement = undefined;
this.workingSpinnerLabel = undefined;
}
this.pendingScrollDisposable?.dispose();
super.dispose();
}
}
@@ -396,7 +396,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
truncatedCommand,
contentElement,
context,
initialExpanded
initialExpanded,
isComplete
));
this._thinkingCollapsibleWrapper = wrapper;
@@ -407,6 +408,10 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
this._thinkingCollapsibleWrapper?.expand();
}
public markCollapsibleWrapperComplete(): void {
this._thinkingCollapsibleWrapper?.markComplete();
}
private async _initializeTerminalActions(): Promise<void> {
if (this._store.isDisposed) {
return;
@@ -638,6 +643,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
this._addActions(terminalInstance, this._terminalData.terminalToolSessionId);
const resolvedCommand = this._getResolvedCommand(terminalInstance);
// update title
this.markCollapsibleWrapperComplete();
// Auto-collapse on success
if (resolvedCommand?.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) {
this._toggleOutput(false);
@@ -656,6 +664,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
const resolvedImmediately = await tryResolveCommand();
if (resolvedImmediately?.endMarker) {
commandDetectionListener.clear();
// update title
this.markCollapsibleWrapperComplete();
// Auto-collapse on success
if (resolvedImmediately.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) {
this._toggleOutput(false);
@@ -1515,19 +1525,22 @@ export class ContinueInBackgroundAction extends Action implements IAction {
class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart {
private readonly _terminalContentElement: HTMLElement;
private readonly _commandText: string;
private _isComplete: boolean;
constructor(
commandText: string,
contentElement: HTMLElement,
context: IChatContentPartRenderContext,
initialExpanded: boolean,
isComplete: boolean,
@IHoverService hoverService: IHoverService,
) {
const title = `Ran \`${commandText}\``;
const title = isComplete ? `Ran \`${commandText}\`` : `Running \`${commandText}\``;
super(title, context, undefined, hoverService);
this._terminalContentElement = contentElement;
this._commandText = commandText;
this._isComplete = isComplete;
this.domNode.classList.add('chat-terminal-thinking-collapsible');
@@ -1543,7 +1556,10 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart
const labelElement = this._collapseButton.labelElement;
labelElement.textContent = '';
const ranText = document.createTextNode(localize('chat.terminal.ran.prefix', "Ran "));
const prefixText = this._isComplete
? localize('chat.terminal.ran.prefix', "Ran ")
: localize('chat.terminal.running.prefix', "Running ");
const ranText = document.createTextNode(prefixText);
const codeElement = document.createElement('code');
codeElement.textContent = this._commandText;
@@ -1551,6 +1567,14 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart
labelElement.appendChild(codeElement);
}
public markComplete(): void {
if (this._isComplete) {
return;
}
this._isComplete = true;
this._setCodeFormattedTitle();
}
protected override initContent(): HTMLElement {
const listWrapper = dom.$('.chat-used-context-list.chat-terminal-thinking-content');
listWrapper.appendChild(this._terminalContentElement);