diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 3d1330415fdf1..465113185f049 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -812,6 +812,7 @@ "others": [ "--background-dark", "--background-light", + "--chat-current-response-min-height", "--dropdown-padding-bottom", "--dropdown-padding-top", "--insert-border-color", diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 3fa9e254fa832..c96c4ebcdcbd4 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -116,6 +116,7 @@ export interface IChatListItemRendererOptions { } export interface IChatWidgetViewOptions { + autoScroll?: boolean; renderInputOnTop?: boolean; renderFollowups?: boolean; renderStyle?: 'compact' | 'minimal'; diff --git a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts index ec7f3b92de7df..fce3719ff2f12 100644 --- a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @@ -102,11 +102,14 @@ const forceVerboseLayoutTracing = false ; export interface IChatRendererDelegate { + container: HTMLElement; getListLength(): number; readonly onDidScroll?: Event; } +const mostRecentResponseClassName = 'chat-most-recent-response'; + export class ChatListItemRenderer extends Disposable implements ITreeRenderer { static readonly ID = 'item'; @@ -406,8 +409,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { @@ -423,10 +427,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer) { - list.scrollTop = list.scrollHeight - list.renderHeight; + const newScrollTop = list.scrollHeight - list.renderHeight; + list.scrollTop = newScrollTop; } export interface IChatViewState { @@ -162,6 +167,12 @@ export class ChatWidget extends Disposable implements IChatWidget { private previousTreeScrollHeight: number = 0; + /** + * Whether the list is scroll-locked to the bottom. Initialize to true so that we can scroll to the bottom on first render. + * The initial render leads to a lot of `onDidChangeTreeContentHeight` as the renderer works out the real heights of rows. + */ + private scrollLock = true; + private readonly viewModelDisposables = this._register(new DisposableStore()); private _viewModel: ChatViewModel | undefined; private set viewModel(viewModel: ChatViewModel | undefined) { @@ -405,6 +416,19 @@ export class ChatWidget extends Disposable implements IChatWidget { this.createList(this.listContainer, { ...this.viewOptions.rendererOptions, renderStyle }); + const scrollDownButton = this._register(new Button(this.listContainer, { + supportIcons: true, + buttonBackground: asCssVariable(buttonSecondaryBackground), + buttonHoverBackground: asCssVariable(buttonSecondaryHoverBackground), + })); + scrollDownButton.element.classList.add('chat-scroll-down'); + scrollDownButton.label = `$(${Codicon.chevronDown.id})`; + scrollDownButton.setTitle(localize('scrollDownButtonLabel', "Scroll down")); + this._register(scrollDownButton.onDidClick(() => { + this.scrollLock = true; + revealLastElement(this.tree); + })); + this._register(this.editorOptions.onDidChange(() => this.onDidStyleChange())); this.onDidStyleChange(); @@ -581,6 +605,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const rendererDelegate: IChatRendererDelegate = { getListLength: () => this.tree.getNode(null).visibleChildrenCount, onDidScroll: this.onDidScroll, + container: listContainer }; // Create a dom element to hold UI from editor widgets embedded in chat messages @@ -654,6 +679,11 @@ export class ChatWidget extends Disposable implements IChatWidget { })); this._register(this.tree.onDidScroll(() => { this._onDidScroll.fire(); + + const lastItem = this.viewModel?.getItems().at(-1); + const lastResponseIsRendering = isResponseVM(lastItem) && lastItem.renderData; + const isScrolledDown = this.tree.scrollTop >= this.tree.scrollHeight - this.tree.renderHeight - 2; + this.container.classList.toggle('show-scroll-down', !isScrolledDown && Boolean(lastResponseIsRendering)); })); } @@ -675,18 +705,31 @@ export class ChatWidget extends Disposable implements IChatWidget { } private onDidChangeTreeContentHeight(): void { + // If the list was previously scrolled all the way down, ensure it stays scrolled down, if scroll lock is on if (this.tree.scrollHeight !== this.previousTreeScrollHeight) { - // Due to rounding, the scrollTop + renderHeight will not exactly match the scrollHeight. - // Consider the tree to be scrolled all the way down if it is within 2px of the bottom. - const lastElementWasVisible = this.tree.scrollTop + this.tree.renderHeight >= this.previousTreeScrollHeight - 2; - if (lastElementWasVisible) { - dom.scheduleAtNextAnimationFrame(dom.getWindow(this.listContainer), () => { - // Can't set scrollTop during this event listener, the list might overwrite the change - revealLastElement(this.tree); - }, 0); + const lastItem = this.viewModel?.getItems().at(-1); + const lastResponseIsRendering = isResponseVM(lastItem) && lastItem.renderData; + if (!lastResponseIsRendering || this.scrollLock) { + // Due to rounding, the scrollTop + renderHeight will not exactly match the scrollHeight. + // Consider the tree to be scrolled all the way down if it is within 2px of the bottom. + const lastElementWasVisible = this.tree.scrollTop + this.tree.renderHeight >= this.previousTreeScrollHeight - 2; + if (lastElementWasVisible) { + dom.scheduleAtNextAnimationFrame(dom.getWindow(this.listContainer), () => { + // Can't set scrollTop during this event listener, the list might overwrite the change + + // TODO This doesn't necessarily work on the first try because of the dynamic heights of items and + // the way ListView works. Twice is usually enough. But this would work better if this lived inside ListView somehow + revealLastElement(this.tree); + revealLastElement(this.tree); + }, 0); + } } } + // TODO@roblourens add `show-scroll-down` class when button should show + // Show the button when content height changes, the list is not fully scrolled down, and (the latest response is currently rendering OR I haven't yet scrolled all the way down since the last response) + // So for example it would not reappear if I scroll up and delete a message + this.previousTreeScrollHeight = this.tree.scrollHeight; this._onDidChangeContentHeight.fire(); } @@ -802,8 +845,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); if (events.some(e => e?.kind === 'addRequest') && this.visible) { - revealLastElement(this.tree); - this.focusInput(); + revealLastElement(this.tree); // Now we know how big they actually are... how do we know that 2 rounds is enough } if (this.chatEditingService.currentEditingSession && this.chatEditingService.currentEditingSession?.chatSessionId === this.viewModel?.sessionId) { @@ -835,10 +877,11 @@ export class ChatWidget extends Disposable implements IChatWidget { } })); - if (this.tree) { + if (this.tree && this.visible) { this.onDidChangeItems(); revealLastElement(this.tree); } + this.updateChatInputContext(); } @@ -906,6 +949,9 @@ export class ChatWidget extends Disposable implements IChatWidget { private async _acceptInput(opts: { query: string } | { prefix: string } | undefined, isVoiceInput?: boolean): Promise { if (this.viewModel) { this._onDidAcceptInput.fire(); + if (!this.viewOptions.autoScroll) { + this.scrollLock = false; + } const editorValue = this.getInput(); const requestId = this.chatAccessibilityService.acceptRequest(); @@ -1003,9 +1049,12 @@ export class ChatWidget extends Disposable implements IChatWidget { const inputPartMaxHeight = this._dynamicMessageLayoutData?.enabled ? this._dynamicMessageLayoutData.maxHeight : height; this.inputPart.layout(inputPartMaxHeight, width); const inputPartHeight = this.inputPart.inputPartHeight; - const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight; + const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight - 2; const listHeight = Math.max(0, height - inputPartHeight); + if (!this.viewOptions.autoScroll) { + this.listContainer.style.setProperty('--chat-current-response-min-height', listHeight * .75 + 'px'); + } this.tree.layout(listHeight, width); this.tree.getHTMLElement().style.height = `${listHeight}px`; @@ -1015,7 +1064,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.welcomeMessageContainer.style.height = `${listHeight - followupsOffset}px`; this.welcomeMessageContainer.style.paddingBottom = `${followupsOffset}px`; this.renderer.layout(width); - if (lastElementVisible) { + + const lastItem = this.viewModel?.getItems().at(-1); + const lastResponseIsRendering = isResponseVM(lastItem) && lastItem.renderData; + if (lastElementVisible && !lastResponseIsRendering) { revealLastElement(this.tree); } diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index f25f2a13d36de..59fa9037d2f64 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -181,6 +181,10 @@ background-color: var(--vscode-inputOption-activeBackground); } +.interactive-item-container.interactive-response.chat-most-recent-response { + min-height: var(--chat-current-response-min-height); +} + .interactive-item-container.interactive-response:not(.chat-response-loading) .chat-footer-toolbar { /* Complete response only */ display: initial; @@ -242,6 +246,7 @@ .interactive-list { overflow: hidden; + position: relative; /* For the scroll down button */ } .hc-black .interactive-request, @@ -1222,3 +1227,22 @@ have to be updated for changes to the rules above, or to support more deeply nes outline: none; border: none; } + +.interactive-session .chat-scroll-down { + display: none; + position: absolute; + bottom: 7px; + right: 12px; + border-radius: 100%; + width: initial; + width: 27px; + height: 27px; + + .codicon { + margin: 0px; + } +} + +.interactive-session.show-scroll-down .chat-scroll-down { + display: initial; +} diff --git a/src/vs/workbench/contrib/chat/common/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/chatViewModel.ts index 07ea863bca8b5..9b2312c945647 100644 --- a/src/vs/workbench/contrib/chat/common/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatViewModel.ts @@ -281,7 +281,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { } private onAddResponse(responseModel: IChatResponseModel) { - const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel); + const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this); this._register(response.onDidChange(() => { if (response.isComplete) { this.updateCodeBlockTextModels(response); @@ -393,7 +393,10 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi } get dataId() { - return this._model.id + `_${this._modelChangeCount}` + `_${ChatModelInitState[this._model.session.initState]}`; + return this._model.id + + `_${this._modelChangeCount}` + + `_${ChatModelInitState[this._model.session.initState]}` + + (this.isLast ? '_last' : ''); } get sessionId() { @@ -489,6 +492,10 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.isStale; } + get isLast(): boolean { + return this._chatViewModel.getItems().at(-1) === this; + } + renderData: IChatResponseRenderData | undefined = undefined; currentRenderedHeight: number | undefined; @@ -521,6 +528,7 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi constructor( private readonly _model: IChatResponseModel, + private readonly _chatViewModel: IChatViewModel, @ILogService private readonly logService: ILogService, @IChatAgentNameService private readonly chatAgentNameService: IChatAgentNameService, ) { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts index 5af1a5773f0ee..27866ae264068 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts @@ -150,6 +150,7 @@ export class InlineChatWidget { location, undefined, { + autoScroll: true, defaultElementHeight: 32, renderStyle: 'minimal', renderInputOnTop: false,