Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts
4780 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { asCSSUrl } from '../../../../../base/browser/cssValue.js';
7
import * as dom from '../../../../../base/browser/dom.js';
8
import { createCSSRule } from '../../../../../base/browser/domStylesheets.js';
9
import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js';
10
import { IRenderedMarkdown } from '../../../../../base/browser/markdownRenderer.js';
11
import { Button } from '../../../../../base/browser/ui/button/button.js';
12
import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js';
13
import { Action, IAction } from '../../../../../base/common/actions.js';
14
import { Codicon } from '../../../../../base/common/codicons.js';
15
import { Event } from '../../../../../base/common/event.js';
16
import { StringSHA1 } from '../../../../../base/common/hash.js';
17
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
18
import { KeyCode } from '../../../../../base/common/keyCodes.js';
19
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
20
import { IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
21
import { ThemeIcon } from '../../../../../base/common/themables.js';
22
import { URI } from '../../../../../base/common/uri.js';
23
import { localize } from '../../../../../nls.js';
24
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
25
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
26
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
27
import { ILogService } from '../../../../../platform/log/common/log.js';
28
import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
29
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
30
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
31
import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';
32
import { ChatAgentLocation } from '../../common/constants.js';
33
import { IChatWidgetService } from '../chat.js';
34
import { chatViewsWelcomeRegistry, IChatViewsWelcomeDescriptor } from './chatViewsWelcome.js';
35
36
const $ = dom.$;
37
38
export interface IViewWelcomeDelegate {
39
readonly onDidChangeViewWelcomeState: Event<void>;
40
shouldShowWelcome(): boolean;
41
}
42
43
export class ChatViewWelcomeController extends Disposable {
44
private element: HTMLElement | undefined;
45
46
private enabled = false;
47
private readonly enabledDisposables = this._register(new DisposableStore());
48
private readonly renderDisposables = this._register(new DisposableStore());
49
50
private readonly _isShowingWelcome: ISettableObservable<boolean> = observableValue(this, false);
51
public get isShowingWelcome(): IObservable<boolean> {
52
return this._isShowingWelcome;
53
}
54
55
constructor(
56
private readonly container: HTMLElement,
57
private readonly delegate: IViewWelcomeDelegate,
58
private readonly location: ChatAgentLocation,
59
@IContextKeyService private contextKeyService: IContextKeyService,
60
@IInstantiationService private instantiationService: IInstantiationService,
61
) {
62
super();
63
64
this.element = dom.append(this.container, dom.$('.chat-view-welcome'));
65
this._register(Event.runAndSubscribe(
66
delegate.onDidChangeViewWelcomeState,
67
() => this.update()));
68
this._register(chatViewsWelcomeRegistry.onDidChange(() => this.update(true)));
69
}
70
71
private update(force?: boolean): void {
72
const enabled = this.delegate.shouldShowWelcome();
73
if (this.enabled === enabled && !force) {
74
return;
75
}
76
77
this.enabled = enabled;
78
this.enabledDisposables.clear();
79
80
if (!enabled) {
81
this.container.classList.toggle('chat-view-welcome-visible', false);
82
this.renderDisposables.clear();
83
this._isShowingWelcome.set(false, undefined);
84
return;
85
}
86
87
const descriptors = chatViewsWelcomeRegistry.get();
88
if (descriptors.length) {
89
this.render(descriptors);
90
91
const descriptorKeys: Set<string> = new Set(descriptors.flatMap(d => d.when.keys()));
92
this.enabledDisposables.add(this.contextKeyService.onDidChangeContext(e => {
93
if (e.affectsSome(descriptorKeys)) {
94
this.render(descriptors);
95
}
96
}));
97
}
98
}
99
100
private render(descriptors: ReadonlyArray<IChatViewsWelcomeDescriptor>): void {
101
this.renderDisposables.clear();
102
dom.clearNode(this.element!);
103
104
const matchingDescriptors = descriptors.filter(descriptor => this.contextKeyService.contextMatchesRules(descriptor.when));
105
const enabledDescriptor = matchingDescriptors.at(0);
106
if (enabledDescriptor) {
107
const content: IChatViewWelcomeContent = {
108
icon: enabledDescriptor.icon,
109
title: enabledDescriptor.title,
110
message: enabledDescriptor.content
111
};
112
const welcomeView = this.renderDisposables.add(this.instantiationService.createInstance(ChatViewWelcomePart, content, { firstLinkToButton: true, location: this.location }));
113
this.element!.appendChild(welcomeView.element);
114
this.container.classList.toggle('chat-view-welcome-visible', true);
115
this._isShowingWelcome.set(true, undefined);
116
} else {
117
this.container.classList.toggle('chat-view-welcome-visible', false);
118
this._isShowingWelcome.set(false, undefined);
119
}
120
}
121
}
122
123
export interface IChatViewWelcomeContent {
124
readonly icon?: ThemeIcon | URI;
125
readonly title: string;
126
readonly message: IMarkdownString;
127
readonly additionalMessage?: string | IMarkdownString;
128
tips?: IMarkdownString;
129
readonly inputPart?: HTMLElement;
130
readonly suggestedPrompts?: readonly IChatSuggestedPrompts[];
131
readonly useLargeIcon?: boolean;
132
}
133
134
export interface IChatSuggestedPrompts {
135
readonly icon?: ThemeIcon;
136
readonly label: string;
137
readonly description?: string;
138
readonly prompt: string;
139
readonly uri?: URI;
140
}
141
142
export interface IChatViewWelcomeRenderOptions {
143
readonly firstLinkToButton?: boolean;
144
readonly location: ChatAgentLocation;
145
readonly isWidgetAgentWelcomeViewContent?: boolean;
146
}
147
148
export class ChatViewWelcomePart extends Disposable {
149
public readonly element: HTMLElement;
150
151
constructor(
152
public readonly content: IChatViewWelcomeContent,
153
options: IChatViewWelcomeRenderOptions | undefined,
154
@IOpenerService private openerService: IOpenerService,
155
@ILogService private logService: ILogService,
156
@IChatWidgetService private chatWidgetService: IChatWidgetService,
157
@ITelemetryService private telemetryService: ITelemetryService,
158
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
159
@IContextMenuService private readonly contextMenuService: IContextMenuService,
160
) {
161
super();
162
163
this.element = dom.$('.chat-welcome-view');
164
165
try {
166
167
// Icon
168
const icon = dom.append(this.element, $('.chat-welcome-view-icon'));
169
if (content.useLargeIcon) {
170
icon.classList.add('large-icon');
171
}
172
if (content.icon) {
173
if (ThemeIcon.isThemeIcon(content.icon)) {
174
const iconElement = renderIcon(content.icon);
175
icon.appendChild(iconElement);
176
} else if (URI.isUri(content.icon)) {
177
const cssUrl = asCSSUrl(content.icon);
178
const hash = new StringSHA1();
179
hash.update(cssUrl);
180
const iconId = `chat-welcome-icon-${hash.digest()}`;
181
const iconClass = `.chat-welcome-view-icon.${iconId}`;
182
183
createCSSRule(iconClass, `
184
mask: ${cssUrl} no-repeat 50% 50%;
185
-webkit-mask: ${cssUrl} no-repeat 50% 50%;
186
background-color: var(--vscode-icon-foreground);
187
`);
188
icon.classList.add(iconId, 'custom-icon');
189
}
190
}
191
const title = dom.append(this.element, $('.chat-welcome-view-title'));
192
title.textContent = content.title;
193
194
const message = dom.append(this.element, $('.chat-welcome-view-message'));
195
196
const messageResult = this.renderMarkdownMessageContent(content.message, options);
197
dom.append(message, messageResult.element);
198
199
// Additional message
200
if (content.additionalMessage) {
201
const disclaimers = dom.append(this.element, $('.chat-welcome-view-disclaimer'));
202
if (typeof content.additionalMessage === 'string') {
203
disclaimers.textContent = content.additionalMessage;
204
} else {
205
const additionalMessageResult = this.renderMarkdownMessageContent(content.additionalMessage, options);
206
disclaimers.appendChild(additionalMessageResult.element);
207
}
208
}
209
210
// Render suggested prompts for both new user and regular modes
211
if (content.suggestedPrompts && content.suggestedPrompts.length) {
212
const suggestedPromptsContainer = dom.append(this.element, $('.chat-welcome-view-suggested-prompts'));
213
const titleElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompts-title'));
214
titleElement.textContent = localize('chatWidget.suggestedActions', 'Suggested Actions');
215
216
for (const prompt of content.suggestedPrompts) {
217
const promptElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompt'));
218
// Make the prompt element keyboard accessible
219
promptElement.setAttribute('role', 'button');
220
promptElement.setAttribute('tabindex', '0');
221
const promptAriaLabel = prompt.description
222
? localize('suggestedPromptAriaLabelWithDescription', 'Suggested prompt: {0}, {1}', prompt.label, prompt.description)
223
: localize('suggestedPromptAriaLabel', 'Suggested prompt: {0}', prompt.label);
224
promptElement.setAttribute('aria-label', promptAriaLabel);
225
const titleElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-title'));
226
titleElement.textContent = prompt.label;
227
const tooltip = localize('runPromptTitle', "Suggested prompt: {0}", prompt.prompt);
228
promptElement.title = tooltip;
229
titleElement.title = tooltip;
230
if (prompt.description) {
231
const descriptionElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-description'));
232
descriptionElement.textContent = prompt.description;
233
descriptionElement.title = prompt.description;
234
}
235
const executePrompt = () => {
236
type SuggestedPromptClickEvent = { suggestedPrompt: string };
237
238
type SuggestedPromptClickData = {
239
owner: 'bhavyaus';
240
comment: 'Event used to gain insights into when suggested prompts are clicked.';
241
suggestedPrompt: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The suggested prompt clicked.' };
242
};
243
244
this.telemetryService.publicLog2<SuggestedPromptClickEvent, SuggestedPromptClickData>('chat.clickedSuggestedPrompt', {
245
suggestedPrompt: prompt.prompt,
246
});
247
248
if (!this.chatWidgetService.lastFocusedWidget) {
249
const widgets = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat);
250
if (widgets.length) {
251
widgets[0].setInput(prompt.prompt);
252
}
253
} else {
254
this.chatWidgetService.lastFocusedWidget.setInput(prompt.prompt);
255
}
256
};
257
// Add context menu handler
258
this._register(dom.addDisposableListener(promptElement, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => {
259
e.preventDefault();
260
e.stopImmediatePropagation();
261
262
const actions = this.getPromptContextMenuActions(prompt);
263
264
this.contextMenuService.showContextMenu({
265
getAnchor: () => ({ x: e.clientX, y: e.clientY }),
266
getActions: () => actions,
267
});
268
}));
269
// Add click handler
270
this._register(dom.addDisposableListener(promptElement, dom.EventType.CLICK, executePrompt));
271
// Add keyboard handler
272
this._register(dom.addDisposableListener(promptElement, dom.EventType.KEY_DOWN, (e) => {
273
const event = new StandardKeyboardEvent(e);
274
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
275
e.preventDefault();
276
e.stopPropagation();
277
executePrompt();
278
}
279
else if (event.equals(KeyCode.F10) && event.shiftKey) {
280
e.preventDefault();
281
e.stopPropagation();
282
const actions = this.getPromptContextMenuActions(prompt);
283
this.contextMenuService.showContextMenu({
284
getAnchor: () => promptElement,
285
getActions: () => actions,
286
});
287
}
288
}));
289
}
290
}
291
292
// Tips
293
if (content.tips) {
294
const tips = dom.append(this.element, $('.chat-welcome-view-tips'));
295
const tipsResult = this._register(this.markdownRendererService.render(content.tips));
296
tips.appendChild(tipsResult.element);
297
}
298
} catch (err) {
299
this.logService.error('Failed to render chat view welcome content', err);
300
}
301
}
302
303
private getPromptContextMenuActions(prompt: IChatSuggestedPrompts): IAction[] {
304
const actions: IAction[] = [];
305
if (prompt.uri) {
306
const uri = prompt.uri;
307
actions.push(new Action(
308
'chat.editPromptFile',
309
localize('editPromptFile', "Edit Prompt File"),
310
ThemeIcon.asClassName(Codicon.goToFile),
311
true,
312
async () => {
313
try {
314
await this.openerService.open(uri);
315
} catch (error) {
316
this.logService.error('Failed to open prompt file:', error);
317
}
318
}
319
));
320
}
321
return actions;
322
}
323
324
public needsRerender(content: IChatViewWelcomeContent): boolean {
325
// Heuristic based on content that changes between states
326
return !!(
327
this.content.title !== content.title ||
328
this.content.message.value !== content.message.value ||
329
this.content.additionalMessage !== content.additionalMessage ||
330
this.content.tips?.value !== content.tips?.value ||
331
this.content.suggestedPrompts?.length !== content.suggestedPrompts?.length ||
332
this.content.suggestedPrompts?.some((prompt, index) => {
333
const incoming = content.suggestedPrompts?.[index];
334
return incoming?.label !== prompt.label || incoming?.description !== prompt.description;
335
}));
336
}
337
338
private renderMarkdownMessageContent(content: IMarkdownString, options: IChatViewWelcomeRenderOptions | undefined): IRenderedMarkdown {
339
const messageResult = this._register(this.markdownRendererService.render(content));
340
// eslint-disable-next-line no-restricted-syntax
341
const firstLink = options?.firstLinkToButton ? messageResult.element.querySelector('a') : undefined;
342
if (firstLink) {
343
const target = firstLink.getAttribute('data-href');
344
const button = this._register(new Button(firstLink.parentElement!, defaultButtonStyles));
345
button.label = firstLink.textContent ?? '';
346
if (target) {
347
this._register(button.onDidClick(() => {
348
this.openerService.open(target, { allowCommands: true });
349
}));
350
}
351
firstLink.replaceWith(button.element);
352
}
353
return messageResult;
354
}
355
}
356
357