Path: blob/main/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts
5248 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 { mainWindow } from '../../../../base/browser/window.js';7import { CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { Disposable, DisposableResourceMap, toDisposable } from '../../../../base/common/lifecycle.js';9import { autorunDelta, autorunIterableDelta } from '../../../../base/common/observable.js';10import { URI } from '../../../../base/common/uri.js';11import { localize } from '../../../../nls.js';12import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';13import { ICommandService } from '../../../../platform/commands/common/commands.js';14import { FocusMode } from '../../../../platform/native/common/native.js';15import { IWorkbenchContribution } from '../../../common/contributions.js';16import { IHostService } from '../../../services/host/browser/host.js';17import { IChatModel, IChatRequestNeedsInputInfo } from '../common/model/chatModel.js';18import { IChatService } from '../common/chatService/chatService.js';19import { IChatWidgetService } from './chat.js';20import { AcceptToolConfirmationActionId, IToolConfirmationActionContext } from './actions/chatToolActions.js';2122/**23* Observes all live chat models and triggers OS notifications when any model24* transitions to needing input (confirmation/elicitation).25*/26export class ChatWindowNotifier extends Disposable implements IWorkbenchContribution {2728static readonly ID = 'workbench.contrib.chatWindowNotifier';2930private readonly _activeNotifications = this._register(new DisposableResourceMap());3132constructor(33@IChatService private readonly _chatService: IChatService,34@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,35@IHostService private readonly _hostService: IHostService,36@IConfigurationService private readonly _configurationService: IConfigurationService,37@ICommandService private readonly _commandService: ICommandService,38) {39super();4041const modelTrackers = this._register(new DisposableResourceMap());4243this._register(autorunIterableDelta(44reader => this._chatService.chatModels.read(reader),45({ addedValues, removedValues }) => {46for (const model of addedValues) {47modelTrackers.set(model.sessionResource, this._trackModel(model));48}49for (const model of removedValues) {50modelTrackers.deleteAndDispose(model.sessionResource);51}52}53));54}5556private _trackModel(model: IChatModel) {57return autorunDelta(model.requestNeedsInput, ({ lastValue, newValue }) => {58const currentNeedsInput = !!newValue;59const previousNeedsInput = !!lastValue;6061// Only notify on transition from false -> true62if (!previousNeedsInput && currentNeedsInput && newValue) {63this._notifyIfNeeded(model.sessionResource, newValue);64} else if (previousNeedsInput && !currentNeedsInput) {65// Clear any active notification for this session when input is no longer needed66this._clearNotification(model.sessionResource);67}68});69}7071private async _notifyIfNeeded(sessionResource: URI, info: IChatRequestNeedsInputInfo): Promise<void> {72// Check configuration73if (!this._configurationService.getValue<boolean>('chat.notifyWindowOnConfirmation')) {74return;75}7677// Find the widget to determine the target window78const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource);79const targetWindow = widget ? dom.getWindow(widget.domNode) : mainWindow;8081// Only notify if window doesn't have focus82if (targetWindow.document.hasFocus()) {83return;84}8586// Clear any existing notification for this session87this._clearNotification(sessionResource);8889// Focus window in notify mode (flash taskbar/dock)90await this._hostService.focus(targetWindow, { mode: FocusMode.Notify });9192// Create OS notification93const notificationTitle = info.title ? localize('chatTitle', "Chat: {0}", info.title) : localize('chat.untitledChat', "Untitled Chat");9495const cts = new CancellationTokenSource();96this._activeNotifications.set(sessionResource, toDisposable(() => cts.dispose(true)));9798try {99const result = await this._hostService.showToast({100title: this._sanitizeOSToastText(notificationTitle),101body: info.detail ? this._sanitizeOSToastText(info.detail) : localize('notificationDetail', "Approval needed to continue."),102actions: [localize('allowAction', "Allow")],103}, cts.token);104105if (result.clicked || typeof result.actionIndex === 'number') {106await this._hostService.focus(targetWindow, { mode: FocusMode.Force });107108const widget = await this._chatWidgetService.openSession(sessionResource);109widget?.focusInput();110111if (result.actionIndex === 0 /* Allow */) {112await this._commandService.executeCommand(AcceptToolConfirmationActionId, { sessionResource } satisfies IToolConfirmationActionContext);113}114}115} finally {116this._clearNotification(sessionResource);117}118}119120private _sanitizeOSToastText(text: string): string {121return text.replace(/`/g, '\''); // convert backticks to single quotes122}123124private _clearNotification(sessionResource: URI): void {125this._activeNotifications.deleteAndDispose(sessionResource);126}127}128129130