Path: blob/main/src/vs/workbench/contrib/chat/browser/viewsWelcome/chatViewWelcomeController.ts
5266 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}6970getMatchingWelcomeView(): IChatViewsWelcomeDescriptor | undefined {71const descriptors = chatViewsWelcomeRegistry.get();72const matchingDescriptors = descriptors.filter(descriptor => this.contextKeyService.contextMatchesRules(descriptor.when));73return matchingDescriptors.at(0);74}7576private update(force?: boolean): void {77const enabled = this.delegate.shouldShowWelcome();78if (this.enabled === enabled && !force) {79return;80}8182this.enabled = enabled;83this.enabledDisposables.clear();8485if (!enabled) {86this.container.classList.toggle('chat-view-welcome-visible', false);87this.renderDisposables.clear();88this._isShowingWelcome.set(false, undefined);89return;90}9192const descriptors = chatViewsWelcomeRegistry.get();93if (descriptors.length) {94this.render(descriptors);9596const descriptorKeys: Set<string> = new Set(descriptors.flatMap(d => d.when.keys()));97this.enabledDisposables.add(this.contextKeyService.onDidChangeContext(e => {98if (e.affectsSome(descriptorKeys)) {99this.render(descriptors);100}101}));102}103}104105private render(descriptors: ReadonlyArray<IChatViewsWelcomeDescriptor>): void {106this.renderDisposables.clear();107dom.clearNode(this.element!);108109const matchingDescriptors = descriptors.filter(descriptor => this.contextKeyService.contextMatchesRules(descriptor.when));110const enabledDescriptor = matchingDescriptors.at(0);111if (enabledDescriptor) {112const content: IChatViewWelcomeContent = {113icon: enabledDescriptor.icon,114title: enabledDescriptor.title,115message: enabledDescriptor.content116};117const welcomeView = this.renderDisposables.add(this.instantiationService.createInstance(ChatViewWelcomePart, content, { firstLinkToButton: true, location: this.location }));118this.element!.appendChild(welcomeView.element);119this.container.classList.toggle('chat-view-welcome-visible', true);120this._isShowingWelcome.set(true, undefined);121} else {122this.container.classList.toggle('chat-view-welcome-visible', false);123this._isShowingWelcome.set(false, undefined);124}125}126}127128export interface IChatViewWelcomeContent {129readonly icon?: ThemeIcon | URI;130readonly title: string;131readonly message: IMarkdownString;132readonly additionalMessage?: string | IMarkdownString;133tips?: IMarkdownString;134readonly inputPart?: HTMLElement;135readonly suggestedPrompts?: readonly IChatSuggestedPrompts[];136readonly useLargeIcon?: boolean;137}138139export interface IChatSuggestedPrompts {140readonly icon?: ThemeIcon;141readonly label: string;142readonly description?: string;143readonly prompt: string;144readonly uri?: URI;145}146147export interface IChatViewWelcomeRenderOptions {148readonly firstLinkToButton?: boolean;149readonly location: ChatAgentLocation;150readonly isWidgetAgentWelcomeViewContent?: boolean;151}152153export class ChatViewWelcomePart extends Disposable {154public readonly element: HTMLElement;155156constructor(157public readonly content: IChatViewWelcomeContent,158options: IChatViewWelcomeRenderOptions | undefined,159@IOpenerService private openerService: IOpenerService,160@ILogService private logService: ILogService,161@IChatWidgetService private chatWidgetService: IChatWidgetService,162@ITelemetryService private telemetryService: ITelemetryService,163@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,164@IContextMenuService private readonly contextMenuService: IContextMenuService,165) {166super();167168this.element = dom.$('.chat-welcome-view');169170try {171172// Icon173const icon = dom.append(this.element, $('.chat-welcome-view-icon'));174if (content.useLargeIcon) {175icon.classList.add('large-icon');176}177if (content.icon) {178if (ThemeIcon.isThemeIcon(content.icon)) {179const iconElement = renderIcon(content.icon);180icon.appendChild(iconElement);181} else if (URI.isUri(content.icon)) {182const cssUrl = asCSSUrl(content.icon);183const hash = new StringSHA1();184hash.update(cssUrl);185const iconId = `chat-welcome-icon-${hash.digest()}`;186const iconClass = `.chat-welcome-view-icon.${iconId}`;187188createCSSRule(iconClass, `189mask: ${cssUrl} no-repeat 50% 50%;190-webkit-mask: ${cssUrl} no-repeat 50% 50%;191background-color: var(--vscode-icon-foreground);192`);193icon.classList.add(iconId, 'custom-icon');194}195}196const title = dom.append(this.element, $('.chat-welcome-view-title'));197title.textContent = content.title;198199const message = dom.append(this.element, $('.chat-welcome-view-message'));200201const messageResult = this.renderMarkdownMessageContent(content.message, options);202dom.append(message, messageResult.element);203204// Additional message205if (content.additionalMessage) {206const disclaimers = dom.append(this.element, $('.chat-welcome-view-disclaimer'));207if (typeof content.additionalMessage === 'string') {208disclaimers.textContent = content.additionalMessage;209} else {210const additionalMessageResult = this.renderMarkdownMessageContent(content.additionalMessage, options);211disclaimers.appendChild(additionalMessageResult.element);212}213}214215// Render suggested prompts for both new user and regular modes216if (content.suggestedPrompts && content.suggestedPrompts.length) {217const suggestedPromptsContainer = dom.append(this.element, $('.chat-welcome-view-suggested-prompts'));218const titleElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompts-title'));219titleElement.textContent = localize('chatWidget.suggestedActions', 'Suggested Actions');220221for (const prompt of content.suggestedPrompts) {222const promptElement = dom.append(suggestedPromptsContainer, $('.chat-welcome-view-suggested-prompt'));223// Make the prompt element keyboard accessible224promptElement.setAttribute('role', 'button');225promptElement.setAttribute('tabindex', '0');226const promptAriaLabel = prompt.description227? localize('suggestedPromptAriaLabelWithDescription', 'Suggested prompt: {0}, {1}', prompt.label, prompt.description)228: localize('suggestedPromptAriaLabel', 'Suggested prompt: {0}', prompt.label);229promptElement.setAttribute('aria-label', promptAriaLabel);230const titleElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-title'));231titleElement.textContent = prompt.label;232const tooltip = localize('runPromptTitle', "Suggested prompt: {0}", prompt.prompt);233promptElement.title = tooltip;234titleElement.title = tooltip;235if (prompt.description) {236const descriptionElement = dom.append(promptElement, $('.chat-welcome-view-suggested-prompt-description'));237descriptionElement.textContent = prompt.description;238descriptionElement.title = prompt.description;239}240const executePrompt = () => {241type SuggestedPromptClickEvent = { suggestedPrompt: string };242243type SuggestedPromptClickData = {244owner: 'bhavyaus';245comment: 'Event used to gain insights into when suggested prompts are clicked.';246suggestedPrompt: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The suggested prompt clicked.' };247};248249this.telemetryService.publicLog2<SuggestedPromptClickEvent, SuggestedPromptClickData>('chat.clickedSuggestedPrompt', {250suggestedPrompt: prompt.prompt,251});252253if (!this.chatWidgetService.lastFocusedWidget) {254const widgets = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat);255if (widgets.length) {256widgets[0].setInput(prompt.prompt);257}258} else {259this.chatWidgetService.lastFocusedWidget.setInput(prompt.prompt);260}261};262// Add context menu handler263this._register(dom.addDisposableListener(promptElement, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => {264e.preventDefault();265e.stopImmediatePropagation();266267const actions = this.getPromptContextMenuActions(prompt);268269this.contextMenuService.showContextMenu({270getAnchor: () => ({ x: e.clientX, y: e.clientY }),271getActions: () => actions,272});273}));274// Add click handler275this._register(dom.addDisposableListener(promptElement, dom.EventType.CLICK, executePrompt));276// Add keyboard handler277this._register(dom.addDisposableListener(promptElement, dom.EventType.KEY_DOWN, (e) => {278const event = new StandardKeyboardEvent(e);279if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {280e.preventDefault();281e.stopPropagation();282executePrompt();283}284else if (event.equals(KeyCode.F10) && event.shiftKey) {285e.preventDefault();286e.stopPropagation();287const actions = this.getPromptContextMenuActions(prompt);288this.contextMenuService.showContextMenu({289getAnchor: () => promptElement,290getActions: () => actions,291});292}293}));294}295}296297// Tips298if (content.tips) {299const tips = dom.append(this.element, $('.chat-welcome-view-tips'));300const tipsResult = this._register(this.markdownRendererService.render(content.tips));301tips.appendChild(tipsResult.element);302}303} catch (err) {304this.logService.error('Failed to render chat view welcome content', err);305}306}307308private getPromptContextMenuActions(prompt: IChatSuggestedPrompts): IAction[] {309const actions: IAction[] = [];310if (prompt.uri) {311const uri = prompt.uri;312actions.push(new Action(313'chat.editPromptFile',314localize('editPromptFile', "Edit Prompt File"),315ThemeIcon.asClassName(Codicon.goToFile),316true,317async () => {318try {319await this.openerService.open(uri);320} catch (error) {321this.logService.error('Failed to open prompt file:', error);322}323}324));325}326return actions;327}328329public needsRerender(content: IChatViewWelcomeContent): boolean {330// Heuristic based on content that changes between states331return !!(332this.content.title !== content.title ||333this.content.message.value !== content.message.value ||334this.content.additionalMessage !== content.additionalMessage ||335this.content.tips?.value !== content.tips?.value ||336this.content.suggestedPrompts?.length !== content.suggestedPrompts?.length ||337this.content.suggestedPrompts?.some((prompt, index) => {338const incoming = content.suggestedPrompts?.[index];339return incoming?.label !== prompt.label || incoming?.description !== prompt.description;340}));341}342343private renderMarkdownMessageContent(content: IMarkdownString, options: IChatViewWelcomeRenderOptions | undefined): IRenderedMarkdown {344const messageResult = this._register(this.markdownRendererService.render(content));345// eslint-disable-next-line no-restricted-syntax346const firstLink = options?.firstLinkToButton ? messageResult.element.querySelector('a') : undefined;347if (firstLink) {348const target = firstLink.getAttribute('data-href');349const button = this._register(new Button(firstLink.parentElement!, defaultButtonStyles));350button.label = firstLink.textContent ?? '';351if (target) {352this._register(button.onDidClick(() => {353this.openerService.open(target, { allowCommands: true });354}));355}356firstLink.replaceWith(button.element);357}358return messageResult;359}360}361362363