From 66fe91254e600c1ef8db02de220ffefbc519b03b Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:17:02 -0800 Subject: [PATCH] fix scrolling as content expands in thinking (#291750) * fix scrolling as content expands in thinking * add disposables? --- .../chatThinkingContentPart.ts | 79 +++++++++++++++++-- .../chatTerminalToolProgressPart.ts | 30 ++++++- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 0d29e172d50..cd47bd2f2aa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -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(); private readonly toolDisposables = this._register(new DisposableMap()); 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(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 47580c82591..5c9f4a76ab5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -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 { 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);