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