Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ import { ThemeIcon } from '../../../../../../base/common/themables.js';

export abstract class ChatCollapsibleContentPart extends Disposable implements IChatContentPart {

/**
* Bubbling DOM event dispatched from the collapsible's root when the user
* clicks the collapse/expand button. Ancestors (e.g. the chat list widget)
* can listen to preserve the scroll anchor across the resulting height
* change instead of auto-scrolling to the bottom.
*/
public static readonly USER_TOGGLE_EVENT = 'chatCollapsibleUserToggle';

private _domNode?: HTMLElement;
private readonly _renderedTitleWithWidgets = this._register(new MutableDisposable<IRenderedMarkdown>());
protected readonly _titleFileWidgetStore = this._register(new DisposableStore());
Expand Down Expand Up @@ -101,6 +109,12 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I
this._register(collapseButton.onDidClick(() => {
const value = this._isExpanded.get();
this._isExpanded.set(!value, undefined);

// Signal to ancestors (e.g. the chat list widget) that this height change
// was caused by a user-initiated collapse/expand, so the list can preserve
// the user's scroll anchor instead of auto-scrolling to the bottom.
// See issue #274731.
this._domNode?.dispatchEvent(new CustomEvent(ChatCollapsibleContentPart.USER_TOGGLE_EVENT, { bubbles: true }));
}));

// Initialize the expanded state based on the subclass's isExpanded() method
Expand Down
37 changes: 37 additions & 0 deletions src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { getStickyScrollTargetItem, IChatRequestViewModel, IChatResponseViewMode
import { ChatAccessibilityProvider } from '../accessibility/chatAccessibilityProvider.js';
import { ChatTreeItem, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions } from '../chat.js';
import { CodeBlockPart } from './chatContentParts/codeBlockPart.js';
import { ChatCollapsibleContentPart } from './chatContentParts/chatCollapsibleContentPart.js';
import { ChatListDelegate, ChatListItemRenderer, IChatListItemTemplate, IChatRendererDelegate } from './chatListRenderer.js';
import { ChatEditorOptions } from './chatOptions.js';
import { ChatPendingDragController } from './chatPendingDragAndDrop.js';
Expand Down Expand Up @@ -189,6 +190,16 @@ export class ChatListWidget extends Disposable {
private _settingChangeCounter: number = 0;
private _visibleChangeCount: number = 0;

/**
* Timestamp (performance.now()) of the last user-initiated collapsible
* toggle inside this list. Height changes that happen shortly after a user
* toggle are treated as user-driven layout growth and should preserve the
* user's scroll anchor instead of snapping the viewport to the bottom.
* See issue #274731.
*/
private _lastUserToggleTime: number = 0;
private static readonly USER_TOGGLE_SUPPRESS_WINDOW_MS = 250;

private readonly _container: HTMLElement;
private readonly _scrollDownButton: Button;
private readonly _lastItemIdContextKey: IContextKey<string[]>;
Expand Down Expand Up @@ -458,6 +469,14 @@ export class ChatListWidget extends Disposable {
// Set initial at-bottom state (scrollLock defaults to true)
this.updateScrollDownButtonVisibility();

// Observe user-initiated collapsible toggles (thinking section, tool
// wrappers, etc.). When the user expands/collapses a section, we want
// the resulting height change to preserve their scroll position rather
// than auto-scrolling to the bottom (issue #274731).
this._register(dom.addDisposableListener(this._container, ChatCollapsibleContentPart.USER_TOGGLE_EVENT, () => {
this._lastUserToggleTime = performance.now();
}));

// Handle context menu internally
this._register(this._tree.onContextMenu(e => {
this.handleContextMenu(e);
Expand Down Expand Up @@ -667,12 +686,30 @@ export class ChatListWidget extends Disposable {
*/
private _updateElementHeight(element: ChatTreeItem, height?: number): void {
if (this._tree.hasElement(element) && this._visible) {
// If the user just clicked to expand/collapse a section inside this
// list, treat the incoming height change as user-driven and skip
// auto-scroll so the expanded content grows downward from its
// current position instead of pushing visible content upward
// (issue #274731).
if (this._isWithinUserToggleWindow()) {
this._tree.updateElementHeight(element, height);
return;
}
Comment on lines +694 to +697
this._withPersistedAutoScroll(() => {
this._tree.updateElementHeight(element, height);
});
}
}

/**
* Returns true if a user-initiated collapsible toggle happened recently
* enough that the resulting height change should not trigger auto-scroll.
*/
private _isWithinUserToggleWindow(): boolean {
return this._lastUserToggleTime !== 0
&& performance.now() - this._lastUserToggleTime <= ChatListWidget.USER_TOGGLE_SUPPRESS_WINDOW_MS;
}

/**
* Scroll to reveal an element.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Codicon } from '../../../../../../../base/common/codicons.js';
import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js';
import { ChatThinkingContentPart, getToolInvocationIcon, maybePickFunWorkingMessage } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js';
import { IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js';
import { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js';
Expand Down Expand Up @@ -2106,4 +2107,74 @@ suite('ChatThinkingContentPart', () => {
assert.strictEqual(disposed, true, 'Factory disposable should be disposed with thinking part');
});
});

suite('User toggle signal for scroll anchoring (issue #274731)', () => {
setup(() => {
mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed);
});

test('clicking the collapse button fires a bubbling USER_TOGGLE_EVENT so the chat list can preserve scroll anchor', () => {
const content = createThinkingPart('**Analyzing code**');
const context = createMockRenderContext(true); // isComplete = true so click actually toggles expand state

const part = store.add(instantiationService.createInstance(
ChatThinkingContentPart,
content,
context,
mockMarkdownRenderer,
true
));

// Wrap in an ancestor to verify the event bubbles (chat list widget
// listens at the container level, not on the part itself).
const ancestor = mainWindow.document.createElement('div');
ancestor.appendChild(part.domNode);
mainWindow.document.body.appendChild(ancestor);
disposables.add(toDisposable(() => ancestor.remove()));

let toggleEventCount = 0;
const listener = () => { toggleEventCount++; };
ancestor.addEventListener(ChatCollapsibleContentPart.USER_TOGGLE_EVENT, listener);
disposables.add(toDisposable(() => ancestor.removeEventListener(ChatCollapsibleContentPart.USER_TOGGLE_EVENT, listener)));

// Simulate a user clicking the collapse/expand button.
const button = part.domNode.querySelector('.monaco-button') as HTMLElement;
assert.ok(button, 'Should have collapse button');
button.click();

assert.strictEqual(
toggleEventCount, 1,
'Clicking the collapse button should dispatch exactly one bubbling USER_TOGGLE_EVENT so the chat list preserves the scroll anchor instead of pushing content upward.'
);
});

test('no click means no USER_TOGGLE_EVENT (baseline: rendering and streaming updates do not falsely trigger scroll-anchor preservation)', () => {
const content = createThinkingPart('**Analyzing code**');
const context = createMockRenderContext(false);

const part = store.add(instantiationService.createInstance(
ChatThinkingContentPart,
content,
context,
mockMarkdownRenderer,
false
));

mainWindow.document.body.appendChild(part.domNode);
disposables.add(toDisposable(() => part.domNode.remove()));

let toggleEventCount = 0;
const listener = () => { toggleEventCount++; };
part.domNode.addEventListener(ChatCollapsibleContentPart.USER_TOGGLE_EVENT, listener);
disposables.add(toDisposable(() => part.domNode.removeEventListener(ChatCollapsibleContentPart.USER_TOGGLE_EVENT, listener)));

// No user click: mounting, rendering, and automatic state changes
// must NOT dispatch the user-toggle event. Otherwise the chat list
// would incorrectly suppress auto-scroll during streaming.
assert.strictEqual(
toggleEventCount, 0,
'USER_TOGGLE_EVENT must only fire in response to explicit user clicks, not construction or rendering.'
);
});
});
});