Skip to content

Commit b0689b4

Browse files
Try a dynamic height rendering approach (microsoft#188126)
* Try a dynamic height rendering approach * Working implementation Summary: 1. dispose the view model & ChatInputPart when we dispose the ChatWidget 2. handle async/next tick code better using disposables 3. layout onDidChangeItems & when a ChatItem height changes for better accuracy 4. use new useDynamicMessageLayout for inputOnTop
1 parent 3eed931 commit b0689b4

File tree

4 files changed

+98
-25
lines changed

4 files changed

+98
-25
lines changed

src/vs/workbench/contrib/chat/browser/actions/quickQuestionActions/multipleByScrollQuickQuestionAction.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,18 @@ import { ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget';
2222
import { ChatModel } from 'vs/workbench/contrib/chat/common/chatModel';
2323
import { IChatService } from 'vs/workbench/contrib/chat/common/chatService';
2424

25+
interface IChatQuickQuestionModeOptions {
26+
renderInputOnTop: boolean;
27+
useDynamicMessageLayout: boolean;
28+
}
29+
2530
class BaseChatQuickQuestionMode implements IQuickQuestionMode {
2631
private _currentTimer: any | undefined;
2732
private _input: IQuickPick<IQuickPickItem> | undefined;
2833
private _currentChat: QuickChat | undefined;
2934

3035
constructor(
31-
private readonly renderInputOnTop: boolean
36+
private readonly _options: IChatQuickQuestionModeOptions
3237
) { }
3338

3439
run(accessor: ServicesAccessor, query: string): void {
@@ -62,12 +67,17 @@ class BaseChatQuickQuestionMode implements IQuickQuestionMode {
6267
this._input.hideInput = true;
6368

6469

65-
const containerList = dom.$('.interactive-list');
66-
const containerSession = dom.$('.interactive-session', undefined, containerList);
67-
containerSession.style.height = '500px';
68-
containerList.style.position = 'relative';
70+
const containerSession = dom.$('.interactive-session');
6971
this._input.widget = containerSession;
7072

73+
this._currentChat ??= instantiationService.createInstance(QuickChat, {
74+
providerId: providerInfo.id,
75+
...this._options
76+
});
77+
// show needs to come before the current chat rendering
78+
this._input.show();
79+
this._currentChat.render(containerSession);
80+
7181
const clearButton = {
7282
iconClass: ThemeIcon.asClassName(Codicon.clearAll),
7383
tooltip: localize('clear', "Clear"),
@@ -92,12 +102,6 @@ class BaseChatQuickQuestionMode implements IQuickQuestionMode {
92102

93103
//#endregion
94104

95-
this._currentChat ??= instantiationService.createInstance(QuickChat, {
96-
providerId: providerInfo.id,
97-
renderInputOnTop: this.renderInputOnTop,
98-
});
99-
this._currentChat.render(containerSession);
100-
101105
disposableStore.add(this._input.onDidAccept(() => {
102106
this._currentChat?.acceptInput();
103107
}));
@@ -110,7 +114,6 @@ class BaseChatQuickQuestionMode implements IQuickQuestionMode {
110114
}
111115
}));
112116

113-
this._input.show();
114117
this._currentChat.layout();
115118
this._currentChat.focus();
116119

@@ -134,7 +137,7 @@ class QuickChat extends Disposable {
134137
private _currentParentElement?: HTMLElement;
135138

136139
constructor(
137-
private readonly chatViewOptions: IChatViewOptions & { renderInputOnTop: boolean },
140+
private readonly _options: IChatViewOptions & IChatQuickQuestionModeOptions,
138141
@IInstantiationService private readonly instantiationService: IInstantiationService,
139142
@IContextKeyService private readonly contextKeyService: IContextKeyService,
140143
@IChatService private readonly chatService: IChatService,
@@ -157,14 +160,15 @@ class QuickChat extends Disposable {
157160
}
158161

159162
render(parent: HTMLElement): void {
160-
this.widget?.dispose();
161163
this._currentParentElement = parent;
164+
this._scopedContextKeyService?.dispose();
162165
this._scopedContextKeyService = this._register(this.contextKeyService.createScoped(parent));
163166
const scopedInstantiationService = this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]));
167+
this.widget?.dispose();
164168
this.widget = this._register(
165169
scopedInstantiationService.createInstance(
166170
ChatWidget,
167-
{ resource: true, renderInputOnTop: this.chatViewOptions.renderInputOnTop },
171+
{ resource: true, renderInputOnTop: this._options.renderInputOnTop },
168172
{
169173
listForeground: editorForeground,
170174
listBackground: editorBackground,
@@ -173,6 +177,9 @@ class QuickChat extends Disposable {
173177
}));
174178
this.widget.render(parent);
175179
this.widget.setVisible(true);
180+
if (this._options.useDynamicMessageLayout) {
181+
this.widget.setDynamicChatTreeItemLayout(2, 600);
182+
}
176183
this.updateModel();
177184
if (this._currentQuery) {
178185
this.widget.inputEditor.setSelection({
@@ -203,7 +210,7 @@ class QuickChat extends Disposable {
203210
}
204211

205212
async openChatView(): Promise<void> {
206-
const widget = await this._chatWidgetService.revealViewForProvider(this.chatViewOptions.providerId);
213+
const widget = await this._chatWidgetService.revealViewForProvider(this._options.providerId);
207214
if (!widget?.viewModel || !this.model) {
208215
return;
209216
}
@@ -233,13 +240,13 @@ class QuickChat extends Disposable {
233240
}
234241

235242
layout(): void {
236-
if (this._currentParentElement) {
243+
if (!this._options.useDynamicMessageLayout && this._currentParentElement) {
237244
this.widget.layout(500, this._currentParentElement.offsetWidth);
238245
}
239246
}
240247

241248
private updateModel(): void {
242-
this.model ??= this.chatService.startSession(this.chatViewOptions.providerId, CancellationToken.None);
249+
this.model ??= this.chatService.startSession(this._options.providerId, CancellationToken.None);
243250
if (!this.model) {
244251
throw new Error('Could not start chat session');
245252
}
@@ -252,7 +259,10 @@ AskQuickQuestionAction.registerMode(
252259
QuickQuestionMode.InputOnTopChat,
253260
class InputOnTopChatQuickQuestionMode extends BaseChatQuickQuestionMode {
254261
constructor() {
255-
super(true);
262+
super({
263+
renderInputOnTop: true,
264+
useDynamicMessageLayout: true
265+
});
256266
}
257267
}
258268
);
@@ -261,7 +271,10 @@ AskQuickQuestionAction.registerMode(
261271
QuickQuestionMode.InputOnBottomChat,
262272
class InputOnBottomChatQuickQuestionMode extends BaseChatQuickQuestionMode {
263273
constructor() {
264-
super(false);
274+
super({
275+
renderInputOnTop: false,
276+
useDynamicMessageLayout: false
277+
});
265278
}
266279
}
267280
);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
305305
return this.commandService.executeCommand(followup.commandId, ...(followup.args ?? []));
306306
}));
307307
}
308+
309+
element.currentRenderedHeight = templateData.rowContainer.offsetHeight;
308310
}
309311

310312
private renderWelcomeMessage(element: IChatWelcomeMessageViewModel, templateData: IChatListItemTemplate) {
@@ -329,6 +331,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
329331
templateData.elementDisposables.add(result);
330332
}
331333
}
334+
335+
element.currentRenderedHeight = templateData.rowContainer.offsetHeight;
332336
}
333337

334338
/**

src/vs/workbench/contrib/chat/browser/chatWidget.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import * as dom from 'vs/base/browser/dom';
77
import { ITreeContextMenuEvent, ITreeElement } from 'vs/base/browser/ui/tree/tree';
8+
import { disposableTimeout } from 'vs/base/common/async';
89
import { CancellationToken } from 'vs/base/common/cancellation';
910
import { Emitter } from 'vs/base/common/event';
1011
import { Disposable, DisposableStore, IDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle';
@@ -76,7 +77,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
7677

7778
private previousTreeScrollHeight: number = 0;
7879

79-
private viewModelDisposables = new DisposableStore();
80+
private viewModelDisposables = this._register(new DisposableStore());
8081
private _viewModel: ChatViewModel | undefined;
8182
private set viewModel(viewModel: ChatViewModel | undefined) {
8283
if (this._viewModel === viewModel) {
@@ -92,8 +93,11 @@ export class ChatWidget extends Disposable implements IChatWidget {
9293

9394
this.slashCommandsPromise = undefined;
9495
this.lastSlashCommands = undefined;
96+
9597
this.getSlashCommands().then(() => {
96-
this.onDidChangeItems();
98+
if (!this._isDisposed) {
99+
this.onDidChangeItems();
100+
}
97101
});
98102

99103
this._onDidChangeViewModel.fire();
@@ -135,6 +139,12 @@ export class ChatWidget extends Disposable implements IChatWidget {
135139
return this.inputPart.inputUri;
136140
}
137141

142+
private _isDisposed: boolean = false;
143+
public override dispose(): void {
144+
this._isDisposed = true;
145+
super.dispose();
146+
}
147+
138148
render(parent: HTMLElement): void {
139149
const viewId = 'viewId' in this.viewContext ? this.viewContext.viewId : undefined;
140150
this.editorOptions = this._register(this.instantiationService.createInstance(ChatEditorOptions, viewId, this.styles.listForeground, this.styles.inputEditorBackground, this.styles.resultEditorBackground));
@@ -193,6 +203,10 @@ export class ChatWidget extends Disposable implements IChatWidget {
193203
}
194204
});
195205

206+
if (this._dynamicMessageLayoutData) {
207+
this.layoutDynamicChatTreeItemMode();
208+
}
209+
196210
const lastItem = treeItems[treeItems.length - 1]?.element;
197211
if (lastItem && isResponseVM(lastItem) && lastItem.isComplete) {
198212
this.renderFollowups(lastItem.replyFollowups);
@@ -216,13 +230,13 @@ export class ChatWidget extends Disposable implements IChatWidget {
216230
this.renderer.setVisible(visible);
217231

218232
if (visible) {
219-
setTimeout(() => {
233+
this._register(disposableTimeout(() => {
220234
// Progressive rendering paused while hidden, so start it up again.
221235
// Do it after a timeout because the container is not visible yet (it should be but offsetHeight returns 0 here)
222236
if (this.visible) {
223237
this.onDidChangeItems();
224238
}
225-
}, 0);
239+
}, 0));
226240
}
227241
}
228242

@@ -336,7 +350,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
336350
}
337351

338352
private createInput(container: HTMLElement, options?: { renderFollowups: boolean }): void {
339-
this.inputPart = this.instantiationService.createInstance(ChatInputPart, { renderFollowups: options?.renderFollowups ?? true });
353+
this.inputPart = this._register(this.instantiationService.createInstance(ChatInputPart, { renderFollowups: options?.renderFollowups ?? true }));
340354
this.inputPart.render(container, '', this);
341355

342356
this._register(this.inputPart.onDidFocus(() => this._onDidFocus.fire()));
@@ -471,6 +485,47 @@ export class ChatWidget extends Disposable implements IChatWidget {
471485
this.listContainer.style.height = `${height - inputPartHeight}px`;
472486
}
473487

488+
private _dynamicMessageLayoutData?: { numOfMessages: number; maxHeight: number };
489+
490+
// An alternative to layout, this allows you to specify the number of ChatTreeItems
491+
// you want to show, and the max height of the container. It will then layout the
492+
// tree to show that many items.
493+
setDynamicChatTreeItemLayout(numOfChatTreeItems: number, maxHeight: number) {
494+
this._dynamicMessageLayoutData = { numOfMessages: numOfChatTreeItems, maxHeight };
495+
this._register(this.renderer.onDidChangeItemHeight(() => this.layoutDynamicChatTreeItemMode()));
496+
}
497+
498+
layoutDynamicChatTreeItemMode(allowRecurse = true): void {
499+
if (!this.viewModel) {
500+
return;
501+
}
502+
const inputHeight = this.inputPart.layout(this._dynamicMessageLayoutData!.maxHeight, this.container.offsetWidth);
503+
504+
const totalMessages = this.viewModel.getItems();
505+
// grab the last N messages
506+
const messages = totalMessages.slice(-this._dynamicMessageLayoutData!.numOfMessages);
507+
508+
const needsRerender = messages.some(m => m.currentRenderedHeight === undefined);
509+
const listHeight = needsRerender
510+
? this._dynamicMessageLayoutData!.maxHeight
511+
: messages.reduce((acc, message) => acc + message.currentRenderedHeight!, 0);
512+
513+
this.layout(
514+
Math.min(
515+
// we add an additional 25px in order to show that there is scrollable content
516+
inputHeight + listHeight + (totalMessages.length > 2 ? 25 : 0),
517+
this._dynamicMessageLayoutData!.maxHeight
518+
),
519+
this.container.offsetWidth
520+
);
521+
522+
if (needsRerender && allowRecurse) {
523+
// TODO: figure out a better place to reveal the last element
524+
revealLastElement(this.tree);
525+
this.layoutDynamicChatTreeItemMode(false);
526+
}
527+
}
528+
474529
saveState(): void {
475530
this.inputPart.saveState();
476531
}

src/vs/workbench/contrib/chat/common/chatViewModel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,4 +368,5 @@ export interface IChatWelcomeMessageViewModel {
368368
readonly username: string;
369369
readonly avatarIconUri?: URI;
370370
readonly content: IChatWelcomeMessageContent[];
371+
currentRenderedHeight?: number;
371372
}

0 commit comments

Comments
 (0)