Path: blob/main/src/vs/workbench/contrib/chat/browser/chatContentParts/chatConfirmationWidget.ts
3296 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 * as dom from '../../../../../base/browser/dom.js';6import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';7import { Button, ButtonWithDropdown, IButton, IButtonOptions } from '../../../../../base/browser/ui/button/button.js';8import { Action, Separator } from '../../../../../base/common/actions.js';9import { Emitter, Event } from '../../../../../base/common/event.js';10import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js';11import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';12import type { ThemeIcon } from '../../../../../base/common/themables.js';13import { IMarkdownRenderResult, MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';14import { localize } from '../../../../../nls.js';15import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';16import { MenuId } from '../../../../../platform/actions/common/actions.js';17import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';18import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';19import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';20import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';21import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';22import { FocusMode } from '../../../../../platform/native/common/native.js';23import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';24import { IHostService } from '../../../../services/host/browser/host.js';25import { IViewsService } from '../../../../services/views/common/viewsService.js';26import { showChatView } from '../chat.js';27import './media/chatConfirmationWidget.css';2829export interface IChatConfirmationButton<T> {30label: string;31isSecondary?: boolean;32tooltip?: string;33data: T;34disabled?: boolean;35onDidChangeDisablement?: Event<boolean>;36moreActions?: (IChatConfirmationButton<T> | Separator)[];37}3839export interface IChatConfirmationWidgetOptions<T> {40title: string | IMarkdownString;41message: string | IMarkdownString;42subtitle?: string | IMarkdownString;43buttons: IChatConfirmationButton<T>[];44toolbarData?: { arg: any; partType: string; partSource?: string };45}4647export class ChatQueryTitlePart extends Disposable {48private readonly _onDidChangeHeight = this._register(new Emitter<void>());49public readonly onDidChangeHeight = this._onDidChangeHeight.event;50private readonly _renderedTitle = this._register(new MutableDisposable<IMarkdownRenderResult>());5152public get title() {53return this._title;54}5556public set title(value: string | IMarkdownString) {57this._title = value;5859const next = this._renderer.render(this.toMdString(value), {60asyncRenderCallback: () => this._onDidChangeHeight.fire(),61});6263const previousEl = this._renderedTitle.value?.element;64if (previousEl?.parentElement) {65previousEl.parentElement.replaceChild(next.element, previousEl);66} else {67this.element.appendChild(next.element); // unreachable?68}6970this._renderedTitle.value = next;71}7273constructor(74private readonly element: HTMLElement,75private _title: IMarkdownString | string,76subtitle: string | IMarkdownString | undefined,77private readonly _renderer: MarkdownRenderer,78) {79super();8081element.classList.add('chat-query-title-part');8283this._renderedTitle.value = _renderer.render(this.toMdString(_title), {84asyncRenderCallback: () => this._onDidChangeHeight.fire(),85});86element.append(this._renderedTitle.value.element);87if (subtitle) {88const str = this.toMdString(subtitle);89const renderedTitle = this._register(_renderer.render(str, {90asyncRenderCallback: () => this._onDidChangeHeight.fire(),91}));92const wrapper = document.createElement('small');93wrapper.appendChild(renderedTitle.element);94element.append(wrapper);95}96}9798private toMdString(value: string | IMarkdownString) {99if (typeof value === 'string') {100return new MarkdownString('', { supportThemeIcons: true }).appendText(value);101} else {102return new MarkdownString(value.value, { supportThemeIcons: true, isTrusted: value.isTrusted });103}104}105}106107abstract class BaseSimpleChatConfirmationWidget<T> extends Disposable {108private _onDidClick = this._register(new Emitter<IChatConfirmationButton<T>>());109get onDidClick(): Event<IChatConfirmationButton<T>> { return this._onDidClick.event; }110111protected _onDidChangeHeight = this._register(new Emitter<void>());112get onDidChangeHeight(): Event<void> { return this._onDidChangeHeight.event; }113114private _domNode: HTMLElement;115get domNode(): HTMLElement {116return this._domNode;117}118119private get showingButtons() {120return !this.domNode.classList.contains('hideButtons');121}122123setShowButtons(showButton: boolean): void {124this.domNode.classList.toggle('hideButtons', !showButton);125}126127private readonly messageElement: HTMLElement;128protected readonly markdownRenderer: MarkdownRenderer;129private readonly title: string | IMarkdownString;130131private readonly notification = this._register(new MutableDisposable<DisposableStore>());132133constructor(134options: IChatConfirmationWidgetOptions<T>,135@IInstantiationService protected readonly instantiationService: IInstantiationService,136@IContextMenuService contextMenuService: IContextMenuService,137@IConfigurationService private readonly _configurationService: IConfigurationService,138@IHostService private readonly _hostService: IHostService,139@IViewsService private readonly _viewsService: IViewsService,140@IContextKeyService contextKeyService: IContextKeyService,141) {142super();143144const { title, subtitle, message, buttons } = options;145this.title = title;146147148const elements = dom.h('.chat-confirmation-widget-container@container', [149dom.h('.chat-confirmation-widget@root', [150dom.h('.chat-confirmation-widget-title@title'),151dom.h('.chat-confirmation-widget-message@message'),152dom.h('.chat-buttons-container@buttonsContainer', [153dom.h('.chat-buttons@buttons'),154dom.h('.chat-toolbar@toolbar'),155]),156]),157]);158configureAccessibilityContainer(elements.container, title, message);159this._domNode = elements.root;160this.markdownRenderer = this.instantiationService.createInstance(MarkdownRenderer, {});161162const titlePart = this._register(instantiationService.createInstance(163ChatQueryTitlePart,164elements.title,165title,166subtitle,167this.markdownRenderer,168));169170this._register(titlePart.onDidChangeHeight(() => this._onDidChangeHeight.fire()));171172this.messageElement = elements.message;173174// Create buttons175buttons.forEach(buttonData => {176const buttonOptions: IButtonOptions = { ...defaultButtonStyles, secondary: buttonData.isSecondary, title: buttonData.tooltip, disabled: buttonData.disabled };177178let button: IButton;179if (buttonData.moreActions) {180button = new ButtonWithDropdown(elements.buttons, {181...buttonOptions,182contextMenuProvider: contextMenuService,183addPrimaryActionToDropdown: false,184actions: buttonData.moreActions.map(action => {185if (action instanceof Separator) {186return action;187}188return this._register(new Action(189action.label,190action.label,191undefined,192!action.disabled,193() => {194this._onDidClick.fire(action);195return Promise.resolve();196},197));198}),199});200} else {201button = new Button(elements.buttons, buttonOptions);202}203204this._register(button);205button.label = buttonData.label;206this._register(button.onDidClick(() => this._onDidClick.fire(buttonData)));207if (buttonData.onDidChangeDisablement) {208this._register(buttonData.onDidChangeDisablement(disabled => button.enabled = !disabled));209}210});211212// Create toolbar if actions are provided213if (options?.toolbarData) {214const overlay = contextKeyService.createOverlay([215['chatConfirmationPartType', options.toolbarData.partType],216['chatConfirmationPartSource', options.toolbarData.partSource],217]);218const nestedInsta = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, overlay])));219this._register(nestedInsta.createInstance(220MenuWorkbenchToolBar,221elements.toolbar,222MenuId.ChatConfirmationMenu,223{224// buttonConfigProvider: () => ({ showLabel: false, showIcon: true }),225menuOptions: {226arg: options.toolbarData.arg,227shouldForwardArgs: true,228}229}230));231}232}233234protected renderMessage(element: HTMLElement, listContainer: HTMLElement): void {235this.messageElement.append(element);236237if (this.showingButtons && this._configurationService.getValue<boolean>('chat.notifyWindowOnConfirmation')) {238const targetWindow = dom.getWindow(listContainer);239if (!targetWindow.document.hasFocus()) {240this.notifyConfirmationNeeded(targetWindow);241}242}243}244245private async notifyConfirmationNeeded(targetWindow: Window): Promise<void> {246247// Focus Window248this._hostService.focus(targetWindow, { mode: FocusMode.Notify });249250// Notify251const title = renderAsPlaintext(this.title);252const notification = await dom.triggerNotification(title ? localize('notificationTitle', "Chat: {0}", title) : localize('defaultTitle', "Chat: Confirmation Required"),253{254detail: localize('notificationDetail', "The current chat session requires your confirmation to proceed.")255}256);257if (notification) {258const disposables = this.notification.value = new DisposableStore();259disposables.add(notification);260261disposables.add(Event.once(notification.onClick)(() => {262this._hostService.focus(targetWindow, { mode: FocusMode.Force });263showChatView(this._viewsService);264}));265266disposables.add(this._hostService.onDidChangeFocus(focus => {267if (focus) {268disposables.dispose();269}270}));271}272}273}274275/** @deprecated Use ChatConfirmationWidget instead */276export class SimpleChatConfirmationWidget<T> extends BaseSimpleChatConfirmationWidget<T> {277private _renderedMessage: HTMLElement | undefined;278279constructor(280private readonly _container: HTMLElement,281options: IChatConfirmationWidgetOptions<T>,282@IInstantiationService instantiationService: IInstantiationService,283@IContextMenuService contextMenuService: IContextMenuService,284@IConfigurationService configurationService: IConfigurationService,285@IHostService hostService: IHostService,286@IViewsService viewsService: IViewsService,287@IContextKeyService contextKeyService: IContextKeyService,288) {289super(options, instantiationService, contextMenuService, configurationService, hostService, viewsService, contextKeyService);290this.updateMessage(options.message);291}292293public updateMessage(message: string | IMarkdownString): void {294this._renderedMessage?.remove();295const renderedMessage = this._register(this.markdownRenderer.render(296typeof message === 'string' ? new MarkdownString(message) : message,297{ asyncRenderCallback: () => this._onDidChangeHeight.fire() }298));299this.renderMessage(renderedMessage.element, this._container);300this._renderedMessage = renderedMessage.element;301}302}303304export interface IChatConfirmationWidget2Options<T> {305title: string | IMarkdownString;306message: string | IMarkdownString | HTMLElement;307icon?: ThemeIcon;308subtitle?: string | IMarkdownString;309buttons: IChatConfirmationButton<T>[];310toolbarData?: { arg: any; partType: string; partSource?: string };311}312313abstract class BaseChatConfirmationWidget<T> extends Disposable {314private _onDidClick = this._register(new Emitter<IChatConfirmationButton<T>>());315get onDidClick(): Event<IChatConfirmationButton<T>> { return this._onDidClick.event; }316317protected _onDidChangeHeight = this._register(new Emitter<void>());318get onDidChangeHeight(): Event<void> { return this._onDidChangeHeight.event; }319320private _domNode: HTMLElement;321get domNode(): HTMLElement {322return this._domNode;323}324325private _buttonsDomNode: HTMLElement;326327private get showingButtons() {328return !this.domNode.classList.contains('hideButtons');329}330331setShowButtons(showButton: boolean): void {332this.domNode.classList.toggle('hideButtons', !showButton);333}334335private readonly messageElement: HTMLElement;336protected readonly markdownRenderer: MarkdownRenderer;337private readonly title: string | IMarkdownString;338339private readonly notification = this._register(new MutableDisposable<DisposableStore>());340341constructor(342options: IChatConfirmationWidget2Options<T>,343@IInstantiationService protected readonly instantiationService: IInstantiationService,344@IContextMenuService private readonly contextMenuService: IContextMenuService,345@IConfigurationService private readonly _configurationService: IConfigurationService,346@IHostService private readonly _hostService: IHostService,347@IViewsService private readonly _viewsService: IViewsService,348@IContextKeyService contextKeyService: IContextKeyService,349) {350super();351352const { title, subtitle, message, buttons, icon } = options;353this.title = title;354355const elements = dom.h('.chat-confirmation-widget-container@container', [356dom.h('.chat-confirmation-widget2@root', [357dom.h('.chat-confirmation-widget-title', [358dom.h('.chat-title@title'),359dom.h('.chat-toolbar-container@buttonsContainer', [360dom.h('.chat-toolbar@toolbar'),361]),362]),363dom.h('.chat-confirmation-widget-message@message'),364dom.h('.chat-confirmation-widget-buttons', [365dom.h('.chat-buttons@buttons'),366]),367]),]);368369configureAccessibilityContainer(elements.container, title, message);370this._domNode = elements.root;371this._buttonsDomNode = elements.buttons;372373this.markdownRenderer = this.instantiationService.createInstance(MarkdownRenderer, {});374375const titlePart = this._register(instantiationService.createInstance(376ChatQueryTitlePart,377elements.title,378new MarkdownString(icon ? `$(${icon.id}) ${typeof title === 'string' ? title : title.value}` : typeof title === 'string' ? title : title.value),379subtitle,380this.markdownRenderer,381));382383this._register(titlePart.onDidChangeHeight(() => this._onDidChangeHeight.fire()));384385this.messageElement = elements.message;386387this.updateButtons(buttons);388389// Create toolbar if actions are provided390if (options?.toolbarData) {391const overlay = contextKeyService.createOverlay([392['chatConfirmationPartType', options.toolbarData.partType],393['chatConfirmationPartSource', options.toolbarData.partSource],394]);395const nestedInsta = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, overlay])));396this._register(nestedInsta.createInstance(397MenuWorkbenchToolBar,398elements.toolbar,399MenuId.ChatConfirmationMenu,400{401// buttonConfigProvider: () => ({ showLabel: false, showIcon: true }),402menuOptions: {403arg: options.toolbarData.arg,404shouldForwardArgs: true,405}406}407));408}409}410411updateButtons(buttons: IChatConfirmationButton<T>[]) {412while (this._buttonsDomNode.children.length > 0) {413this._buttonsDomNode.children[0].remove();414}415for (const buttonData of buttons) {416const buttonOptions: IButtonOptions = { ...defaultButtonStyles, secondary: buttonData.isSecondary, title: buttonData.tooltip, disabled: buttonData.disabled };417418let button: IButton;419if (buttonData.moreActions) {420button = new ButtonWithDropdown(this._buttonsDomNode, {421...buttonOptions,422contextMenuProvider: this.contextMenuService,423addPrimaryActionToDropdown: false,424actions: buttonData.moreActions.map(action => {425if (action instanceof Separator) {426return action;427}428return this._register(new Action(429action.label,430action.label,431undefined,432!action.disabled,433() => {434this._onDidClick.fire(action);435return Promise.resolve();436},437));438}),439});440} else {441button = new Button(this._buttonsDomNode, buttonOptions);442}443444this._register(button);445button.label = buttonData.label;446this._register(button.onDidClick(() => this._onDidClick.fire(buttonData)));447if (buttonData.onDidChangeDisablement) {448this._register(buttonData.onDidChangeDisablement(disabled => button.enabled = !disabled));449}450}451}452453protected renderMessage(element: HTMLElement | IMarkdownString | string, listContainer: HTMLElement): void {454if (!dom.isHTMLElement(element)) {455const messageElement = this._register(this.markdownRenderer.render(456typeof element === 'string' ? new MarkdownString(element) : element,457{ asyncRenderCallback: () => this._onDidChangeHeight.fire() }458));459element = messageElement.element;460}461462for (const child of this.messageElement.children) {463child.remove();464}465this.messageElement.append(element);466467if (this.showingButtons && this._configurationService.getValue<boolean>('chat.notifyWindowOnConfirmation')) {468const targetWindow = dom.getWindow(listContainer);469if (!targetWindow.document.hasFocus()) {470this.notifyConfirmationNeeded(targetWindow);471}472}473}474475private async notifyConfirmationNeeded(targetWindow: Window): Promise<void> {476477// Focus Window478this._hostService.focus(targetWindow, { mode: FocusMode.Notify });479480// Notify481const title = renderAsPlaintext(this.title);482const notification = await dom.triggerNotification(title ? localize('notificationTitle', "Chat: {0}", title) : localize('defaultTitle', "Chat: Confirmation Required"),483{484detail: localize('notificationDetail', "The current chat session requires your confirmation to proceed.")485}486);487if (notification) {488const disposables = this.notification.value = new DisposableStore();489disposables.add(notification);490491disposables.add(Event.once(notification.onClick)(() => {492this._hostService.focus(targetWindow, { mode: FocusMode.Force });493showChatView(this._viewsService);494}));495496disposables.add(this._hostService.onDidChangeFocus(focus => {497if (focus) {498disposables.dispose();499}500}));501}502}503}504export class ChatConfirmationWidget<T> extends BaseChatConfirmationWidget<T> {505private _renderedMessage: HTMLElement | undefined;506507constructor(508private readonly _container: HTMLElement,509options: IChatConfirmationWidget2Options<T>,510@IInstantiationService instantiationService: IInstantiationService,511@IContextMenuService contextMenuService: IContextMenuService,512@IConfigurationService configurationService: IConfigurationService,513@IHostService hostService: IHostService,514@IViewsService viewsService: IViewsService,515@IContextKeyService contextKeyService: IContextKeyService,516) {517super(options, instantiationService, contextMenuService, configurationService, hostService, viewsService, contextKeyService);518this.renderMessage(options.message, this._container);519}520521public updateMessage(message: string | IMarkdownString): void {522this._renderedMessage?.remove();523const renderedMessage = this._register(this.markdownRenderer.render(524typeof message === 'string' ? new MarkdownString(message) : message,525{ asyncRenderCallback: () => this._onDidChangeHeight.fire() }526));527this.renderMessage(renderedMessage.element, this._container);528this._renderedMessage = renderedMessage.element;529}530}531export class ChatCustomConfirmationWidget<T> extends BaseChatConfirmationWidget<T> {532constructor(533container: HTMLElement,534options: IChatConfirmationWidget2Options<T>,535@IInstantiationService instantiationService: IInstantiationService,536@IContextMenuService contextMenuService: IContextMenuService,537@IConfigurationService configurationService: IConfigurationService,538@IHostService hostService: IHostService,539@IViewsService viewsService: IViewsService,540@IContextKeyService contextKeyService: IContextKeyService,541) {542super(options, instantiationService, contextMenuService, configurationService, hostService, viewsService, contextKeyService);543this.renderMessage(options.message, container);544}545}546547function configureAccessibilityContainer(container: HTMLElement, title: string | IMarkdownString, message?: string | IMarkdownString | HTMLElement): void {548container.tabIndex = 0;549const titleAsString = typeof title === 'string' ? title : title.value;550const messageAsString = typeof message === 'string' ? message : message && 'value' in message ? message.value : message && 'textContent' in message ? message.textContent : '';551container.setAttribute('aria-label', localize('chat.confirmationWidget.ariaLabel', "Chat Confirmation Dialog {0} {1}", titleAsString, messageAsString));552container.classList.add('chat-confirmation-widget-container');553}554555556