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
5266 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
getMatchingWelcomeView(): IChatViewsWelcomeDescriptor | undefined {
72
const descriptors = chatViewsWelcomeRegistry.get();
73
const matchingDescriptors = descriptors.filter(descriptor => this.contextKeyService.contextMatchesRules(descriptor.when));
74
return matchingDescriptors.at(0);
75
}
76
77
private update(force?: boolean): void {
78
const enabled = this.delegate.shouldShowWelcome();
79
if (this.enabled === enabled && !force) {
80
return;
81
}
82
83
this.enabled = enabled;
84
this.enabledDisposables.clear();
85
86
if (!enabled) {
87
this.container.classList.toggle('chat-view-welcome-visible', false);
88
this.renderDisposables.clear();
89
this._isShowingWelcome.set(false, undefined);
90
return;
91
}
92
93
const descriptors = chatViewsWelcomeRegistry.get();
94
if (descriptors.length) {
95
this.render(descriptors);
96
97
const descriptorKeys: Set<string> = new Set(descriptors.flatMap(d => d.when.keys()));
98
this.enabledDisposables.add(this.contextKeyService.onDidChangeContext(e => {
99
if (e.affectsSome(descriptorKeys)) {
100
this.render(descriptors);
101
}
102
}));
103
}
104
}
105
106
private render(descriptors: ReadonlyArray<IChatViewsWelcomeDescriptor>): void {
107
this.renderDisposables.clear();
108
dom.clearNode(this.element!);
109
110
const matchingDescriptors = descriptors.filter(descriptor => this.contextKeyService.contextMatchesRules(descriptor.when));
111
const enabledDescriptor = matchingDescriptors.at(0);
112
if (enabledDescriptor) {
113
const content: IChatViewWelcomeContent = {
114
icon: enabledDescriptor.icon,
115
title: enabledDescriptor.title,
116
message: enabledDescriptor.content
117
};
118
const welcomeView = this.renderDisposables.add(this.instantiationService.createInstance(ChatViewWelcomePart, content, { firstLinkToButton: true, location: this.location }));
119
this.element!.appendChild(welcomeView.element);
120
this.container.classList.toggle('chat-view-welcome-visible', true);
121
this._isShowingWelcome.set(true, undefined);
122
} else {
123
this.container.classList.toggle('chat-view-welcome-visible', false);
124
this._isShowingWelcome.set(false, undefined);
125
}
126
}
127
}
128
129
export interface IChatViewWelcomeContent {
130
readonly icon?: ThemeIcon | URI;
131
readonly title: string;
132
readonly message: IMarkdownString;
133
readonly additionalMessage?: string | IMarkdownString;
134
tips?: IMarkdownString;
135
readonly inputPart?: HTMLElement;
136
readonly suggestedPrompts?: readonly IChatSuggestedPrompts[];
137
readonly useLargeIcon?: boolean;
138
}
139
140
export interface IChatSuggestedPrompts {
141
readonly icon?: ThemeIcon;
142
readonly label: string;
143
readonly description?: string;
144
readonly prompt: string;
145
readonly uri?: URI;
146
}
147
148
export interface IChatViewWelcomeRenderOptions {
149
readonly firstLinkToButton?: boolean;
150
readonly location: ChatAgentLocation;
151
readonly isWidgetAgentWelcomeViewContent?: boolean;
152
}
153
154
export class ChatViewWelcomePart extends Disposable {
155
public readonly element: HTMLElement;
156
157
constructor(
158
public readonly content: IChatViewWelcomeContent,
159
options: IChatViewWelcomeRenderOptions | undefined,
160
@IOpenerService private openerService: IOpenerService,
161
@ILogService private logService: ILogService,
162
@IChatWidgetService private chatWidgetService: IChatWidgetService,
163
@ITelemetryService private telemetryService: ITelemetryService,
164
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
165
@IContextMenuService private readonly contextMenuService: IContextMenuService,
166
) {
167
super();
168
169
this.element = dom.$('.chat-welcome-view');
170
171
try {
172
173
// Icon
174
const icon = dom.append(this.element, $('.chat-welcome-view-icon'));
175
if (content.useLargeIcon) {
176
icon.classList.add('large-icon');
177
}
178
if (content.icon) {
179
if (ThemeIcon.isThemeIcon(content.icon)) {
180
const iconElement = renderIcon(content.icon);
181
icon.appendChild(iconElement);
182
} else if (URI.isUri(content.icon)) {
183
const cssUrl = asCSSUrl(content.icon);
184
const hash = new StringSHA1();
185
hash.update(cssUrl);
186
const iconId = `chat-welcome-icon-${hash.digest()}`;
187
const iconClass = `.chat-welcome-view-icon.${iconId}`;
188
189
createCSSRule(iconClass, `
190
mask: ${cssUrl} no-repeat 50% 50%;
191
-webkit-mask: ${cssUrl} no-repeat 50% 50%;
192
background-color: var(--vscode-icon-foreground);
193
`);
194
icon.classList.add(iconId, 'custom-icon');
195
}
196
}
197
const title = dom.append(this.element, $('.chat-welcome-view-title'));
198
title.textContent = content.title;
199
200
const message = dom.append(this.element, $('.chat-welcome-view-message'));
201
202
const messageResult = this.renderMarkdownMessageContent(content.message, options);
203
dom.append(message, messageResult.element);
204
205
// Additional message
206
if (content.additionalMessage) {
207
const disclaimers = dom.append(this.element, $('.chat-welcome-view-disclaimer'));
208
if (typeof content.additionalMessage === 'string') {
209
disclaimers.textContent = content.additionalMessage;
210
} else {
211
const additionalMessageResult = this.renderMarkdownMessageContent(content.additionalMessage, options);
212
disclaimers.appendChild(additionalMessageResult.element);
213
}
214
}
215
216
// Render suggested prompts for both new user and regular modes
217
if (content.suggestedPrompts && content.suggestedPrompts.length) {
218
const suggestedPromptsContainer = dom.append(this.element, $('.chat-welcome-view-suggested-prompts'));
219
const titleElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompts-title'));
220
titleElement.textContent = localize('chatWidget.suggestedActions', 'Suggested Actions');
221
222
for (const prompt of content.suggestedPrompts) {
223
const promptElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompt'));
224
// Make the prompt element keyboard accessible
225
promptElement.setAttribute('role', 'button');
226
promptElement.setAttribute('tabindex', '0');
227
const promptAriaLabel = prompt.description
228
? localize('suggestedPromptAriaLabelWithDescription', 'Suggested prompt: {0}, {1}', prompt.label, prompt.description)
229
: localize('suggestedPromptAriaLabel', 'Suggested prompt: {0}', prompt.label);
230
promptElement.setAttribute('aria-label', promptAriaLabel);
231
const titleElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-title'));
232
titleElement.textContent = prompt.label;
233
const tooltip = localize('runPromptTitle', "Suggested prompt: {0}", prompt.prompt);
234
promptElement.title = tooltip;
235
titleElement.title = tooltip;
236
if (prompt.description) {
237
const descriptionElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-description'));
238
descriptionElement.textContent = prompt.description;
239
descriptionElement.title = prompt.description;
240
}
241
const executePrompt = () => {
242
type SuggestedPromptClickEvent = { suggestedPrompt: string };
243
244
type SuggestedPromptClickData = {
245
owner: 'bhavyaus';
246
comment: 'Event used to gain insights into when suggested prompts are clicked.';
247
suggestedPrompt: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The suggested prompt clicked.' };
248
};
249
250
this.telemetryService.publicLog2<SuggestedPromptClickEvent, SuggestedPromptClickData>('chat.clickedSuggestedPrompt', {
251
suggestedPrompt: prompt.prompt,
252
});
253
254
if (!this.chatWidgetService.lastFocusedWidget) {
255
const widgets = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat);
256
if (widgets.length) {
257
widgets[0].setInput(prompt.prompt);
258
}
259
} else {
260
this.chatWidgetService.lastFocusedWidget.setInput(prompt.prompt);
261
}
262
};
263
// Add context menu handler
264
this._register(dom.addDisposableListener(promptElement, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => {
265
e.preventDefault();
266
e.stopImmediatePropagation();
267
268
const actions = this.getPromptContextMenuActions(prompt);
269
270
this.contextMenuService.showContextMenu({
271
getAnchor: () => ({ x: e.clientX, y: e.clientY }),
272
getActions: () => actions,
273
});
274
}));
275
// Add click handler
276
this._register(dom.addDisposableListener(promptElement, dom.EventType.CLICK, executePrompt));
277
// Add keyboard handler
278
this._register(dom.addDisposableListener(promptElement, dom.EventType.KEY_DOWN, (e) => {
279
const event = new StandardKeyboardEvent(e);
280
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
281
e.preventDefault();
282
e.stopPropagation();
283
executePrompt();
284
}
285
else if (event.equals(KeyCode.F10) && event.shiftKey) {
286
e.preventDefault();
287
e.stopPropagation();
288
const actions = this.getPromptContextMenuActions(prompt);
289
this.contextMenuService.showContextMenu({
290
getAnchor: () => promptElement,
291
getActions: () => actions,
292
});
293
}
294
}));
295
}
296
}
297
298
// Tips
299
if (content.tips) {
300
const tips = dom.append(this.element, $('.chat-welcome-view-tips'));
301
const tipsResult = this._register(this.markdownRendererService.render(content.tips));
302
tips.appendChild(tipsResult.element);
303
}
304
} catch (err) {
305
this.logService.error('Failed to render chat view welcome content', err);
306
}
307
}
308
309
private getPromptContextMenuActions(prompt: IChatSuggestedPrompts): IAction[] {
310
const actions: IAction[] = [];
311
if (prompt.uri) {
312
const uri = prompt.uri;
313
actions.push(new Action(
314
'chat.editPromptFile',
315
localize('editPromptFile', "Edit Prompt File"),
316
ThemeIcon.asClassName(Codicon.goToFile),
317
true,
318
async () => {
319
try {
320
await this.openerService.open(uri);
321
} catch (error) {
322
this.logService.error('Failed to open prompt file:', error);
323
}
324
}
325
));
326
}
327
return actions;
328
}
329
330
public needsRerender(content: IChatViewWelcomeContent): boolean {
331
// Heuristic based on content that changes between states
332
return !!(
333
this.content.title !== content.title ||
334
this.content.message.value !== content.message.value ||
335
this.content.additionalMessage !== content.additionalMessage ||
336
this.content.tips?.value !== content.tips?.value ||
337
this.content.suggestedPrompts?.length !== content.suggestedPrompts?.length ||
338
this.content.suggestedPrompts?.some((prompt, index) => {
339
const incoming = content.suggestedPrompts?.[index];
340
return incoming?.label !== prompt.label || incoming?.description !== prompt.description;
341
}));
342
}
343
344
private renderMarkdownMessageContent(content: IMarkdownString, options: IChatViewWelcomeRenderOptions | undefined): IRenderedMarkdown {
345
const messageResult = this._register(this.markdownRendererService.render(content));
346
// eslint-disable-next-line no-restricted-syntax
347
const firstLink = options?.firstLinkToButton ? messageResult.element.querySelector('a') : undefined;
348
if (firstLink) {
349
const target = firstLink.getAttribute('data-href');
350
const button = this._register(new Button(firstLink.parentElement!, defaultButtonStyles));
351
button.label = firstLink.textContent ?? '';
352
if (target) {
353
this._register(button.onDidClick(() => {
354
this.openerService.open(target, { allowCommands: true });
355
}));
356
}
357
firstLink.replaceWith(button.element);
358
}
359
return messageResult;
360
}
361
}
362
363