chat - allow to confirm from toast (#292117)

This commit is contained in:
Benjamin Pasero 2026-02-02 12:20:35 +01:00 committed by GitHub
parent b00c464cb6
commit a5914335df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 597 additions and 130 deletions

View File

@ -1746,39 +1746,6 @@ export function triggerUpload(): Promise<FileList | undefined> {
});
}
export interface INotification extends IDisposable {
readonly onClick: event.Event<void>;
}
function sanitizeNotificationText(text: string): string {
return text.replace(/`/g, '\''); // convert backticks to single quotes
}
export async function triggerNotification(message: string, options?: { detail?: string; sticky?: boolean }): Promise<INotification | undefined> {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return;
}
const disposables = new DisposableStore();
const notification = new Notification(sanitizeNotificationText(message), {
body: options?.detail ? sanitizeNotificationText(options.detail) : undefined,
requireInteraction: options?.sticky,
});
const onClick = new event.Emitter<void>();
disposables.add(addDisposableListener(notification, 'click', () => onClick.fire()));
disposables.add(addDisposableListener(notification, 'close', () => disposables.dispose()));
disposables.add(toDisposable(() => notification.close()));
return {
onClick: onClick.event,
dispose: () => disposables.dispose()
};
}
export enum DetectedFullscreenMode {
/**

View File

@ -849,6 +849,92 @@ export class DisposableMap<K, V extends IDisposable = IDisposable> implements ID
}
}
/**
* A set that manages the lifecycle of the values that it stores.
*/
export class DisposableSet<V extends IDisposable = IDisposable> implements IDisposable {
private readonly _store: Set<V>;
private _isDisposed = false;
constructor(store: Set<V> = new Set<V>()) {
this._store = store;
trackDisposable(this);
}
/**
* Disposes of all stored values and mark this object as disposed.
*
* Trying to use this object after it has been disposed of is an error.
*/
dispose(): void {
markAsDisposed(this);
this._isDisposed = true;
this.clearAndDisposeAll();
}
/**
* Disposes of all stored values and clear the set, but DO NOT mark this object as disposed.
*/
clearAndDisposeAll(): void {
if (!this._store.size) {
return;
}
try {
dispose(this._store.values());
} finally {
this._store.clear();
}
}
has(value: V): boolean {
return this._store.has(value);
}
get size(): number {
return this._store.size;
}
add(value: V): void {
if (this._isDisposed) {
console.warn(new Error('Trying to add a disposable to a DisposableSet that has already been disposed of. The added object will be leaked!').stack);
}
this._store.add(value);
setParentOfDisposable(value, this);
}
/**
* Delete the value from this set and also dispose of it.
*/
deleteAndDispose(value: V): void {
if (this._store.delete(value)) {
value.dispose();
}
}
/**
* Delete the value from this set but return it. The caller is
* responsible for disposing of the value.
*/
deleteAndLeak(value: V): V | undefined {
if (this._store.delete(value)) {
setParentOfDisposable(value, null);
return value;
}
return undefined;
}
values(): IterableIterator<V> {
return this._store.values();
}
[Symbol.iterator](): IterableIterator<V> {
return this._store[Symbol.iterator]();
}
}
/**
* Call `then` on a Promise, unless the returned disposable is disposed.
*/

View File

@ -5,7 +5,7 @@
import assert from 'assert';
import { Emitter } from '../../common/event.js';
import { DisposableStore, dispose, IDisposable, markAsSingleton, ReferenceCollection, thenIfNotDisposed, toDisposable } from '../../common/lifecycle.js';
import { DisposableSet, DisposableStore, dispose, IDisposable, markAsSingleton, ReferenceCollection, thenIfNotDisposed, toDisposable } from '../../common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite, throwIfDisposablesAreLeaked } from './utils.js';
class Disposable implements IDisposable {
@ -204,6 +204,174 @@ suite('DisposableStore', () => {
});
});
suite('DisposableSet', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('dispose should dispose all values and mark as disposed', () => {
const disposedValues = new Set<number>();
const set = new DisposableSet<IDisposable>();
set.add(toDisposable(() => { disposedValues.add(1); }));
set.add(toDisposable(() => { disposedValues.add(2); }));
set.add(toDisposable(() => { disposedValues.add(3); }));
assert.strictEqual(set.size, 3);
set.dispose();
assert.ok(disposedValues.has(1));
assert.ok(disposedValues.has(2));
assert.ok(disposedValues.has(3));
assert.strictEqual(set.size, 0);
});
test('dispose should call all child disposes even if a child throws on dispose', () => {
const disposedValues = new Set<number>();
const set = new DisposableSet<IDisposable>();
set.add(toDisposable(() => { disposedValues.add(1); }));
set.add(toDisposable(() => { throw new Error('I am error'); }));
set.add(toDisposable(() => { disposedValues.add(3); }));
let thrownError: any;
try {
set.dispose();
} catch (e) {
thrownError = e;
}
assert.ok(disposedValues.has(1));
assert.ok(disposedValues.has(3));
assert.strictEqual(thrownError.message, 'I am error');
});
test('clearAndDisposeAll should dispose values but not mark as disposed', () => {
const disposedValues = new Set<number>();
const set = new DisposableSet<IDisposable>();
const d1 = toDisposable(() => { disposedValues.add(1); });
set.add(d1);
set.clearAndDisposeAll();
assert.ok(disposedValues.has(1));
assert.strictEqual(set.size, 0);
// Can still add new values
const d2 = toDisposable(() => { disposedValues.add(2); });
set.add(d2);
assert.strictEqual(set.size, 1);
set.dispose();
assert.ok(disposedValues.has(2));
});
test('has should return true if value exists', () => {
const set = new DisposableSet<IDisposable>();
const d = toDisposable(() => { });
set.add(d);
const other = toDisposable(() => { });
assert.ok(set.has(d));
assert.ok(!set.has(other));
set.dispose();
other.dispose();
});
test('deleteAndDispose should remove and dispose the value', () => {
const disposedValues = new Set<number>();
const set = new DisposableSet<IDisposable>();
const d1 = toDisposable(() => { disposedValues.add(1); });
const d2 = toDisposable(() => { disposedValues.add(2); });
set.add(d1);
set.add(d2);
set.deleteAndDispose(d1);
assert.ok(disposedValues.has(1));
assert.ok(!disposedValues.has(2));
assert.strictEqual(set.size, 1);
assert.ok(!set.has(d1));
assert.ok(set.has(d2));
set.dispose();
assert.ok(disposedValues.has(2));
});
test('deleteAndLeak should remove but not dispose the value', () => {
const disposedValues = new Set<number>();
const set = new DisposableSet<IDisposable>();
const d1 = toDisposable(() => { disposedValues.add(1); });
const d2 = toDisposable(() => { disposedValues.add(2); });
set.add(d1);
set.add(d2);
const leaked = set.deleteAndLeak(d1);
assert.strictEqual(leaked, d1);
assert.ok(!disposedValues.has(1));
assert.ok(!disposedValues.has(2));
assert.strictEqual(set.size, 1);
set.dispose();
assert.ok(!disposedValues.has(1));
assert.ok(disposedValues.has(2));
// Caller is responsible for disposing
d1.dispose();
});
test('deleteAndLeak should return undefined if value not in set', () => {
const set = new DisposableSet<IDisposable>();
const d = toDisposable(() => { });
const leaked = set.deleteAndLeak(d);
assert.strictEqual(leaked, undefined);
set.dispose();
d.dispose();
});
test('values should iterate over all values', () => {
const set = new DisposableSet<IDisposable>();
const d1 = toDisposable(() => { });
const d2 = toDisposable(() => { });
set.add(d1);
set.add(d2);
const values = [...set.values()];
assert.strictEqual(values.length, 2);
assert.ok(values.includes(d1));
assert.ok(values.includes(d2));
set.dispose();
});
test('Symbol.iterator should allow for-of iteration', () => {
const set = new DisposableSet<IDisposable>();
const d1 = toDisposable(() => { });
const d2 = toDisposable(() => { });
set.add(d1);
set.add(d2);
const values: IDisposable[] = [];
for (const v of set) {
values.push(v);
}
assert.strictEqual(values.length, 2);
assert.ok(values.includes(d1));
assert.ok(values.includes(d2));
set.dispose();
});
});
suite('Reference Collection', () => {
ensureNoDisposablesAreLeakedInTestSuite();

View File

@ -15,6 +15,24 @@ import { AuthInfo, Credentials } from '../../request/common/request.js';
import { IPartsSplash } from '../../theme/common/themeService.js';
import { IColorScheme, IOpenedAuxiliaryWindow, IOpenedMainWindow, IOpenEmptyWindowOptions, IOpenWindowOptions, IPoint, IRectangle, IWindowOpenable } from '../../window/common/window.js';
export interface IToastOptions {
readonly id: string;
readonly title: string;
readonly body?: string;
readonly actions?: readonly string[];
readonly silent?: boolean;
}
export interface IToastResult {
readonly supported: boolean;
readonly clicked: boolean;
readonly actionIndex?: number;
}
export interface ICPUProperties {
model: string;
speed: number;
@ -231,6 +249,11 @@ export interface ICommonNativeHostService {
// Registry (Windows only)
windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise<string | undefined>;
// Toast Notifications
showToast(options: IToastOptions): Promise<IToastResult>;
clearToast(id: string): Promise<void>;
clearToasts(): Promise<void>;
// Zip
/**
* Creates a zip file at the specified path containing the provided files.

View File

@ -5,12 +5,12 @@
import * as fs from 'fs';
import { exec } from 'child_process';
import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, SaveDialogOptions, SaveDialogReturnValue, screen, shell, webContents } from 'electron';
import { app, BrowserWindow, clipboard, contentTracing, Display, Menu, MessageBoxOptions, MessageBoxReturnValue, Notification, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, powerMonitor, SaveDialogOptions, SaveDialogReturnValue, screen, shell, webContents } from 'electron';
import { arch, cpus, freemem, loadavg, platform, release, totalmem, type } from 'os';
import { promisify } from 'util';
import { memoize } from '../../../base/common/decorators.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js';
import { matchesSomeScheme, Schemas } from '../../../base/common/network.js';
import { dirname, join, posix, resolve, win32 } from '../../../base/common/path.js';
import { isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js';
@ -27,7 +27,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ
import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js';
import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js';
import { ILogService } from '../../log/common/log.js';
import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics } from '../common/native.js';
import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IToastOptions, IToastResult } from '../common/native.js';
import { IProductService } from '../../product/common/productService.js';
import { IPartsSplash } from '../../theme/common/themeService.js';
import { IThemeMainService } from '../../theme/electron-main/themeMainService.js';
@ -48,6 +48,7 @@ import { IConfigurationService } from '../../configuration/common/configuration.
import { IProxyAuthService } from './auth.js';
import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js';
import { randomPath } from '../../../base/common/extpath.js';
import { CancellationTokenSource } from '../../../base/common/cancellation.js';
export interface INativeHostMainService extends AddFirstParameterToFunctions<ICommonNativeHostService, Promise<unknown> /* only methods, not events */, number | undefined /* window ID */> { }
@ -1152,6 +1153,64 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
// #endregion
//#region Toast Notifications
private readonly activeToasts = this._register(new DisposableMap<string>());
async showToast(windowId: number | undefined, options: IToastOptions): Promise<IToastResult> {
if (!Notification.isSupported()) {
return { supported: false, clicked: false };
}
const toast = new Notification({
title: options.title,
body: options.body,
silent: options.silent,
actions: options.actions?.map(action => ({
type: 'button',
text: action
}))
});
const disposables = new DisposableStore();
this.activeToasts.set(options.id, disposables);
const cts = new CancellationTokenSource();
disposables.add(toDisposable(() => {
this.activeToasts.deleteAndDispose(options.id);
toast.removeAllListeners();
toast.close();
cts.dispose(true);
}));
return new Promise<IToastResult>(r => {
const resolve = (result: IToastResult) => {
r(result); // first return the result before...
disposables.dispose(); // ...disposing which would invalidate the result object
};
cts.token.onCancellationRequested(() => resolve({ supported: true, clicked: false }));
toast.on('click', () => resolve({ supported: true, clicked: true }));
toast.on('action', (_event, actionIndex) => resolve({ supported: true, clicked: true, actionIndex }));
toast.on('close', () => resolve({ supported: true, clicked: false }));
toast.on('failed', () => resolve({ supported: false, clicked: false }));
toast.show();
});
}
async clearToast(windowId: number | undefined, toastId: string): Promise<void> {
this.activeToasts.deleteAndDispose(toastId);
}
async clearToasts(): Promise<void> {
this.activeToasts.clearAndDisposeAll();
}
//#endregion
//#region Registry (windows)
async windowsGetStringRegKey(windowId: number | undefined, hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise<string | undefined> {

View File

@ -18,7 +18,7 @@ import { IDialogService } from '../../../platform/dialogs/common/dialogs.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { hasValidDiff, IAgentSession } from '../../contrib/chat/browser/agentSessions/agentSessionsModel.js';
import { IAgentSessionsService } from '../../contrib/chat/browser/agentSessions/agentSessionsService.js';
import { ChatViewPaneTarget, IChatWidgetService, isIChatViewViewContext } from '../../contrib/chat/browser/chat.js';
import { IChatWidgetService, isIChatViewViewContext } from '../../contrib/chat/browser/chat.js';
import { IChatEditorOptions } from '../../contrib/chat/browser/widgetHosts/editor/chatEditor.js';
import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/chatEditorInput.js';
import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js';
@ -464,7 +464,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
const chatViewWidget = this._chatWidgetService.getWidgetBySessionResource(originalResource);
if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) {
await this._chatWidgetService.openSession(modifiedResource, ChatViewPaneTarget, { preserveFocus: true });
await this._chatWidgetService.openSession(modifiedResource, undefined, { preserveFocus: true });
} else {
// Loading the session to ensure the session is created and editing session is transferred.
const ref = await this._chatService.loadSessionForResource(modifiedResource, ChatAgentLocation.Chat, CancellationToken.None);

View File

@ -6,9 +6,8 @@
import * as dom from '../../../../../base/browser/dom.js';
import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
import { alert, status } from '../../../../../base/browser/ui/aria/aria.js';
import { Event } from '../../../../../base/common/event.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js';
import { Disposable, DisposableMap, DisposableSet, toDisposable } from '../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
@ -23,6 +22,7 @@ import { IChatResponseViewModel } from '../../common/model/chatViewModel.js';
import { ChatConfiguration } from '../../common/constants.js';
import { IChatAccessibilityService, IChatWidgetService } from '../chat.js';
import { ChatWidget } from '../widget/chatWidget.js';
import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';
const CHAT_RESPONSE_PENDING_ALLOWANCE_MS = 4000;
export class ChatAccessibilityService extends Disposable implements IChatAccessibilityService {
@ -30,7 +30,7 @@ export class ChatAccessibilityService extends Disposable implements IChatAccessi
private _pendingSignalMap: DisposableMap<URI, AccessibilityProgressSignalScheduler> = this._register(new DisposableMap());
private readonly notifications: Set<DisposableStore> = new Set();
private readonly toasts = this._register(new DisposableSet());
constructor(
@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,
@ -54,14 +54,6 @@ export class ChatAccessibilityService extends Disposable implements IChatAccessi
}));
}
override dispose(): void {
for (const ds of Array.from(this.notifications)) {
ds.dispose();
}
this.notifications.clear();
super.dispose();
}
acceptRequest(uri: URI, skipRequestSignal?: boolean): void {
if (!skipRequestSignal) {
this._accessibilitySignalService.playSignal(AccessibilitySignal.chatRequestSent, { allowManyInParallel: true });
@ -120,39 +112,21 @@ export class ChatAccessibilityService extends Disposable implements IChatAccessi
await this._hostService.focus(targetWindow, { mode: FocusMode.Notify });
// Dispose any previous unhandled notifications to avoid replacement/coalescing.
for (const ds of Array.from(this.notifications)) {
ds.dispose();
this.notifications.delete(ds);
}
this.toasts.clearAndDisposeAll();
const title = widget?.viewModel?.model.title ? localize('chatTitle', "Chat: {0}", widget.viewModel.model.title) : localize('chat.untitledChat', "Untitled Chat");
const notification = await dom.triggerNotification(title,
{
detail: localize('notificationDetail', "New chat response.")
}
);
if (!notification) {
return;
}
const disposables = new DisposableStore();
disposables.add(notification);
this.notifications.add(disposables);
const cts = new CancellationTokenSource();
const disposable = toDisposable(() => cts.dispose(true));
this.toasts.add(disposable);
disposables.add(Event.once(notification.onClick)(async () => {
const { clicked } = await this._hostService.showToast({ title, body: localize('notificationDetail', "New chat response.") }, cts.token);
this.toasts.deleteAndDispose(disposable);
if (clicked) {
await this._hostService.focus(targetWindow, { mode: FocusMode.Force });
await this._widgetService.reveal(widget);
widget.focusInput();
disposables.dispose();
this.notifications.delete(disposables);
}));
disposables.add(this._hostService.onDidChangeFocus(focus => {
if (focus) {
disposables.dispose();
this.notifications.delete(disposables);
}
}));
}
}
}

View File

@ -8,6 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js';
import { Iterable } from '../../../../../base/common/iterator.js';
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
import { autorun } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
import { localize, localize2 } from '../../../../../nls.js';
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
@ -41,12 +42,18 @@ export const SkipToolConfirmationActionId = 'workbench.action.chat.skipTool';
export const AcceptToolPostConfirmationActionId = 'workbench.action.chat.acceptToolPostExecution';
export const SkipToolPostConfirmationActionId = 'workbench.action.chat.skipToolPostExecution';
export interface IToolConfirmationActionContext {
readonly sessionResource?: URI;
}
abstract class ToolConfirmationAction extends Action2 {
protected abstract getReason(): ConfirmedReason;
run(accessor: ServicesAccessor, ...args: unknown[]) {
run(accessor: ServicesAccessor, context?: IToolConfirmationActionContext) {
const chatWidgetService = accessor.get(IChatWidgetService);
const widget = chatWidgetService.lastFocusedWidget;
const widget = context?.sessionResource
? chatWidgetService.getWidgetBySessionResource(context.sessionResource)
: chatWidgetService.lastFocusedWidget;
const lastItem = widget?.viewModel?.getItems().at(-1);
if (!isResponseVM(lastItem)) {
return;

View File

@ -15,7 +15,7 @@ import { IEditorGroupsService, IEditorWorkingSet } from '../../../../../services
import { IEditorService } from '../../../../../services/editor/common/editorService.js';
import { ICommandService } from '../../../../../../platform/commands/common/commands.js';
import { IAgentSession, isSessionInProgressStatus } from '../agentSessionsModel.js';
import { ChatViewPaneTarget, IChatWidgetService } from '../../chat.js';
import { IChatWidgetService } from '../../chat.js';
import { AgentSessionProviders } from '../agentSessions.js';
import { IChatSessionsService } from '../../../common/chatSessionsService.js';
import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js';
@ -183,7 +183,7 @@ export class AgentSessionProjectionService extends Disposable implements IAgentS
private async _openSessionInChatPanel(session: IAgentSession): Promise<void> {
session.setRead(true);
await this.chatSessionsService.activateChatSessionItemProvider(session.providerType);
await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget, {
await this.chatWidgetService.openSession(session.resource, undefined, {
title: { preferred: session.label },
revealIfOpened: true
});

View File

@ -20,7 +20,7 @@ import { overviewRulerRangeHighlight } from '../../../../../editor/common/core/e
import { IEditorDecorationsCollection } from '../../../../../editor/common/editorCommon.js';
import { OverviewRulerLane } from '../../../../../editor/common/model.js';
import { themeColorFromId } from '../../../../../platform/theme/common/themeService.js';
import { ChatViewId, ChatViewPaneTarget, IChatWidget, IChatWidgetService } from '../chat.js';
import { ChatViewId, IChatWidget, IChatWidgetService } from '../chat.js';
import { IViewsService } from '../../../../services/views/common/viewsService.js';
import * as nls from '../../../../../nls.js';
import { IExplanationDiffInfo, IChangeExplanation as IChangeExplanationModel, IChatEditingExplanationModelManager } from './chatEditingExplanationModelManager.js';
@ -353,7 +353,7 @@ export class ChatEditingExplanationWidget extends Disposable implements IOverlay
const range = new Range(exp.startLineNumber, 1, exp.endLineNumber, 1);
let chatWidget: IChatWidget | undefined;
if (this._chatSessionResource) {
chatWidget = await this._chatWidgetService.openSession(this._chatSessionResource, ChatViewPaneTarget);
chatWidget = await this._chatWidgetService.openSession(this._chatSessionResource);
} else {
await this._viewsService.openView(ChatViewId, true);
chatWidget = this._chatWidgetService.lastFocusedWidget;

View File

@ -5,18 +5,20 @@
import * as dom from '../../../../base/browser/dom.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { Event } from '../../../../base/common/event.js';
import { Disposable, DisposableResourceMap, DisposableStore } from '../../../../base/common/lifecycle.js';
import { CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { Disposable, DisposableResourceMap, toDisposable } from '../../../../base/common/lifecycle.js';
import { autorunDelta, autorunIterableDelta } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { FocusMode } from '../../../../platform/native/common/native.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { IHostService } from '../../../services/host/browser/host.js';
import { IChatModel, IChatRequestNeedsInputInfo } from '../common/model/chatModel.js';
import { IChatService } from '../common/chatService/chatService.js';
import { IChatWidgetService } from './chat.js';
import { AcceptToolConfirmationActionId, IToolConfirmationActionContext } from './actions/chatToolActions.js';
/**
* Observes all live chat models and triggers OS notifications when any model
@ -33,6 +35,7 @@ export class ChatWindowNotifier extends Disposable implements IWorkbenchContribu
@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,
@IHostService private readonly _hostService: IHostService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ICommandService private readonly _commandService: ICommandService,
) {
super();
@ -89,36 +92,36 @@ export class ChatWindowNotifier extends Disposable implements IWorkbenchContribu
// Create OS notification
const notificationTitle = info.title ? localize('chatTitle', "Chat: {0}", info.title) : localize('chat.untitledChat', "Untitled Chat");
const notification = await dom.triggerNotification(notificationTitle, {
detail: info.detail ?? localize('notificationDetail', "Approval needed to continue.")
});
if (notification) {
const disposables = new DisposableStore();
const cts = new CancellationTokenSource();
this._activeNotifications.set(sessionResource, toDisposable(() => cts.dispose(true)));
this._activeNotifications.set(sessionResource, disposables);
try {
const result = await this._hostService.showToast({
title: this._sanitizeOSToastText(notificationTitle),
body: info.detail ? this._sanitizeOSToastText(info.detail) : localize('notificationDetail', "Approval needed to continue."),
actions: [localize('approveAction', "Approve"), localize('showChat', "Show")],
}, cts.token);
disposables.add(notification);
// Handle notification click - focus window and reveal chat
disposables.add(Event.once(notification.onClick)(async () => {
if (result.clicked || typeof result.actionIndex === 'number') {
await this._hostService.focus(targetWindow, { mode: FocusMode.Force });
const widget = await this._chatWidgetService.openSession(sessionResource);
widget?.focusInput();
this._clearNotification(sessionResource);
}));
// Clear notification when window gains focus
disposables.add(this._hostService.onDidChangeFocus(focus => {
if (focus) {
this._clearNotification(sessionResource);
if (result.actionIndex === 0 /* Approve */) {
await this._commandService.executeCommand(AcceptToolConfirmationActionId, { sessionResource } satisfies IToolConfirmationActionContext);
}
}));
}
} finally {
this._clearNotification(sessionResource);
}
}
private _sanitizeOSToastText(text: string): string {
return text.replace(/`/g, '\''); // convert backticks to single quotes
}
private _clearNotification(sessionResource: URI): void {
this._activeNotifications.deleteAndDispose(sessionResource);
}

View File

@ -108,8 +108,8 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService
await this.prepareSessionForMove(sessionResource, target);
}
// Load this session in chat view
if (target === ChatViewPaneTarget) {
// Load this session in chat view (preferred)
if (target === ChatViewPaneTarget || typeof target === 'undefined') {
const chatView = await this.viewsService.openView<ChatViewPane>(ChatViewId, !options?.preserveFocus);
if (chatView) {
await chatView.loadSession(sessionResource);

View File

@ -8,7 +8,7 @@ import { IActiveCodeEditor, ICodeEditor } from '../../../../editor/browser/edito
import { Position } from '../../../../editor/common/core/position.js';
import { Selection } from '../../../../editor/common/core/selection.js';
import { createDecorator, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { ChatViewPaneTarget, IChatWidgetService } from '../../chat/browser/chat.js';
import { IChatWidgetService } from '../../chat/browser/chat.js';
import { IChatEditingSession } from '../../chat/common/editing/chatEditingService.js';
import { IChatModel, IChatModelInputState, IChatRequestModel } from '../../chat/common/model/chatModel.js';
import { IChatService } from '../../chat/common/chatService/chatService.js';
@ -76,7 +76,7 @@ export async function askInPanelChat(accessor: ServicesAccessor, request: IChatR
newModel.inputModel.setState({ ...state });
const widget = await widgetService.openSession(newModelRef.object.sessionResource, ChatViewPaneTarget);
const widget = await widgetService.openSession(newModelRef.object.sessionResource);
newModelRef.dispose(); // can be freed after opening because the widget also holds a reference
widget?.acceptInput(request.message.text);

View File

@ -8,7 +8,7 @@ import { IStringDictionary } from '../../../../base/common/collections.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import * as glob from '../../../../base/common/glob.js';
import * as json from '../../../../base/common/json.js';
import { Disposable, DisposableStore, dispose, IDisposable, IReference, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { Disposable, dispose, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { LRUCache, Touch } from '../../../../base/common/map.js';
import * as Objects from '../../../../base/common/objects.js';
import { ValidationState, ValidationStatus } from '../../../../base/common/parsers.js';
@ -257,7 +257,7 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer
private _activatedTaskProviders: Set<string> = new Set();
private readonly notification = this._register(new MutableDisposable<DisposableStore>());
private readonly toast = this._register(new MutableDisposable<IDisposable>());
constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ -530,20 +530,11 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer
? nls.localize('task.longRunningTaskCompletedWithLabel', 'Task "{0}" finished in {1}.', taskLabel, durationText)
: nls.localize('task.longRunningTaskCompleted', 'Task finished in {0}.', durationText);
this._hostService.focus(targetWindow, { mode: FocusMode.Notify });
const notification = await dom.triggerNotification(message);
if (notification) {
const disposables = this.notification.value = new DisposableStore();
disposables.add(notification);
disposables.add(Event.once(notification.onClick)(() => {
this._hostService.focus(targetWindow, { mode: FocusMode.Force });
}));
disposables.add(this._hostService.onDidChangeFocus(focus => {
if (focus) {
disposables.dispose();
}
}));
const cts = new CancellationTokenSource();
this.toast.value = toDisposable(() => cts.dispose(true));
const { clicked } = await this._hostService.showToast({ title: message }, cts.token);
if (clicked) {
this._hostService.focus(targetWindow, { mode: FocusMode.Force });
}
}

View File

@ -42,7 +42,7 @@ import { IAgentSession } from '../../chat/browser/agentSessions/agentSessionsMod
import { AgentSessionsWelcomeEditorOptions, AgentSessionsWelcomeInput, AgentSessionsWelcomeWorkspaceKind } from './agentSessionsWelcomeInput.js';
import { IChatService } from '../../chat/common/chatService/chatService.js';
import { IChatModel } from '../../chat/common/model/chatModel.js';
import { ChatViewId, ChatViewPaneTarget, IChatWidgetService, ISessionTypePickerDelegate, IWorkspacePickerDelegate, IWorkspacePickerItem } from '../../chat/browser/chat.js';
import { ChatViewId, IChatWidgetService, ISessionTypePickerDelegate, IWorkspacePickerDelegate, IWorkspacePickerItem } from '../../chat/browser/chat.js';
import { ChatSessionPosition, getResourceForNewChatSession } from '../../chat/browser/chatSessions/chatSessions.contribution.js';
import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js';
import { AgentSessionsControl, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js';
@ -899,7 +899,7 @@ export class AgentSessionsWelcomePage extends EditorPane {
}
// Now proceed with opening chat and maximizing
if (sessionResource) {
await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget);
await this.chatWidgetService.openSession(sessionResource);
} else {
await this.commandService.executeCommand('workbench.action.chat.open');
}

View File

@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { IHostService } from './host.js';
import { IHostService, IToastOptions, IToastResult } from './host.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js';
import { IEditorService } from '../../editor/common/editorService.js';
@ -16,7 +16,7 @@ import { IWorkspace, IWorkspaceProvider } from '../../../browser/web.api.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { ILabelService, Verbosity } from '../../../../platform/label/common/label.js';
import { EventType, ModifierKeyEmitter, addDisposableListener, addDisposableThrottledListener, detectFullscreen, disposableWindowInterval, getActiveDocument, getActiveWindow, getWindowId, onDidRegisterWindow, trackFocus, getWindows as getDOMWindows } from '../../../../base/browser/dom.js';
import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { Disposable, DisposableSet, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/environmentService.js';
import { memoize } from '../../../../base/common/decorators.js';
import { parseLineAndColumnAware } from '../../../../base/common/extpath.js';
@ -43,6 +43,8 @@ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/c
import { URI } from '../../../../base/common/uri.js';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { showBrowserToast } from './toasts.js';
enum HostShutdownReason {
@ -106,6 +108,13 @@ export class BrowserHostService extends Disposable implements IHostService {
// Track modifier keys to detect keybinding usage
this._register(ModifierKeyEmitter.getInstance().event(() => this.updateShutdownReasonFromEvent()));
// Make sure to hide all toasts when the window gains focus
this._register(this.onDidChangeFocus(focus => {
if (focus) {
this.clearToasts();
}
}));
}
private onBeforeShutdown(e: BeforeShutdownEvent): void {
@ -723,6 +732,23 @@ export class BrowserHostService extends Disposable implements IHostService {
}
//#endregion
//#region Toast Notifications
private readonly activeToasts = this._register(new DisposableSet());
async showToast(options: IToastOptions, token: CancellationToken): Promise<IToastResult> {
return showBrowserToast({
onDidCreateToast: disposable => this.activeToasts.add(disposable),
onDidDisposeToast: disposable => this.activeToasts.deleteAndDispose(disposable)
}, options, token);
}
private async clearToasts(): Promise<void> {
this.activeToasts.clearAndDisposeAll();
}
//#endregion
}
registerSingleton(IHostService, BrowserHostService, InstantiationType.Delayed);

View File

@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { VSBuffer } from '../../../../base/common/buffer.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Event } from '../../../../base/common/event.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { FocusMode } from '../../../../platform/native/common/native.js';
@ -11,6 +12,22 @@ import { IWindowOpenable, IOpenWindowOptions, IOpenEmptyWindowOptions, IPoint, I
export const IHostService = createDecorator<IHostService>('hostService');
export interface IToastOptions {
readonly title: string;
readonly body?: string;
readonly actions?: readonly string[];
readonly silent?: boolean;
}
export interface IToastResult {
readonly supported: boolean;
readonly clicked: boolean;
readonly actionIndex?: number;
}
/**
* A set of methods supported in both web and native environments.
*
@ -143,4 +160,13 @@ export interface IHostService {
getNativeWindowHandle(windowId: number): Promise<VSBuffer | undefined>;
//#endregion
//#region Toast Notifications
/**
* Show an OS-level toast notification.
*/
showToast(options: IToastOptions, token: CancellationToken): Promise<IToastResult>;
//#endregion
}

View File

@ -0,0 +1,87 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { addDisposableListener } from '../../../../base/browser/dom.js';
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { IToastOptions, IToastResult } from './host.js';
export interface IShowToastController {
onDidCreateToast: (toast: IDisposable) => void;
onDidDisposeToast: (toast: IDisposable) => void;
}
export async function showBrowserToast(controller: IShowToastController, options: IToastOptions, token: CancellationToken): Promise<IToastResult> {
const toast = await triggerBrowserToast(options.title, {
detail: options.body,
sticky: !options.silent
});
if (!toast) {
return { supported: false, clicked: false };
}
const disposables = new DisposableStore();
controller.onDidCreateToast(toast);
const cts = new CancellationTokenSource(token);
disposables.add(toDisposable(() => {
controller.onDidDisposeToast(toast);
toast.dispose();
cts.dispose(true);
}));
return new Promise<IToastResult>(r => {
const resolve = (result: IToastResult) => {
r(result); // first return the result before...
disposables.dispose(); // ...disposing which would invalidate the result object
};
cts.token.onCancellationRequested(() => resolve({ supported: true, clicked: false }));
Event.once(toast.onClick)(() => resolve({ supported: true, clicked: true }));
Event.once(toast.onClose)(() => resolve({ supported: true, clicked: false }));
Event.once(toast.onError)(() => resolve({ supported: false, clicked: false }));
});
}
interface INotification extends IDisposable {
readonly onClick: Event<void>;
readonly onClose: Event<void>;
readonly onError: Event<void>;
}
async function triggerBrowserToast(message: string, options?: { detail?: string; sticky?: boolean }): Promise<INotification | undefined> {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return;
}
const disposables = new DisposableStore();
const notification = new Notification(message, {
body: options?.detail,
requireInteraction: options?.sticky,
});
const onClick = disposables.add(new Emitter<void>());
const onClose = disposables.add(new Emitter<void>());
const onError = disposables.add(new Emitter<void>());
disposables.add(addDisposableListener(notification, 'click', () => onClick.fire()));
disposables.add(addDisposableListener(notification, 'close', () => onClose.fire()));
disposables.add(addDisposableListener(notification, 'error', () => onError.fire()));
disposables.add(toDisposable(() => notification.close()));
return {
onClick: onClick.event,
onClose: onClose.event,
onError: onError.event,
dispose: () => disposables.dispose()
};
}

View File

@ -4,13 +4,13 @@
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { IHostService } from '../browser/host.js';
import { IHostService, IToastOptions, IToastResult } from '../browser/host.js';
import { FocusMode, INativeHostService } from '../../../../platform/native/common/native.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { ILabelService, Verbosity } from '../../../../platform/label/common/label.js';
import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';
import { IWindowOpenable, IOpenWindowOptions, isFolderToOpen, isWorkspaceToOpen, IOpenEmptyWindowOptions, IPoint, IRectangle, IOpenedAuxiliaryWindow, IOpenedMainWindow } from '../../../../platform/window/common/window.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { Disposable, DisposableSet, IDisposable } from '../../../../base/common/lifecycle.js';
import { NativeHostService } from '../../../../platform/native/common/nativeHostService.js';
import { INativeWorkbenchEnvironmentService } from '../../environment/electron-browser/environmentService.js';
import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js';
@ -18,6 +18,9 @@ import { disposableWindowInterval, getActiveDocument, getWindowId, getWindowsCou
import { memoize } from '../../../../base/common/decorators.js';
import { isAuxiliaryWindow } from '../../../../base/browser/window.js';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { showBrowserToast } from '../browser/toasts.js';
import { generateUuid } from '../../../../base/common/uuid.js';
class WorkbenchNativeHostService extends NativeHostService {
@ -49,6 +52,18 @@ class WorkbenchHostService extends Disposable implements IHostService {
);
this.onDidChangeFullScreen = Event.filter(this.nativeHostService.onDidChangeWindowFullScreen, e => hasWindow(e.windowId), this._store);
this.registerListeners();
}
private registerListeners(): void {
// Make sure to hide all OS toasts when the window gains focus
this._register(this.onDidChangeFocus(focus => {
if (focus) {
this.clearToasts();
}
}));
}
//#region Focus
@ -220,6 +235,35 @@ class WorkbenchHostService extends Disposable implements IHostService {
}
//#endregion
//#region Toast Notifications
private readonly activeBrowserToasts = this._register(new DisposableSet());
async showToast(options: IToastOptions, token: CancellationToken): Promise<IToastResult> {
const id = generateUuid();
token.onCancellationRequested(() => this.nativeHostService.clearToast(id));
// Try native OS notifications first
const nativeToast = await this.nativeHostService.showToast({ ...options, id });
if (nativeToast.supported) {
return nativeToast;
}
// Then fallback to browser notifications
return showBrowserToast({
onDidCreateToast: (toast: IDisposable) => this.activeBrowserToasts.add(toast),
onDidDisposeToast: (toast: IDisposable) => this.activeBrowserToasts.deleteAndDispose(toast)
}, options, token);
}
private async clearToasts(): Promise<void> {
await this.nativeHostService.clearToasts();
this.activeBrowserToasts.clearAndDisposeAll();
}
//#endregion
}
registerSingleton(IHostService, WorkbenchHostService, InstantiationType.Delayed);

View File

@ -160,7 +160,7 @@ import { BrowserElevatedFileService } from '../../services/files/browser/elevate
import { IElevatedFileService } from '../../services/files/common/elevatedFileService.js';
import { FilesConfigurationService, IFilesConfigurationService } from '../../services/filesConfiguration/common/filesConfigurationService.js';
import { IHistoryService } from '../../services/history/common/history.js';
import { IHostService } from '../../services/host/browser/host.js';
import { IHostService, IToastOptions, IToastResult } from '../../services/host/browser/host.js';
import { LabelService } from '../../services/label/common/labelService.js';
import { ILanguageDetectionService } from '../../services/languageDetection/common/languageDetectionWorkerService.js';
import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, PanelAlignment, Position as PartPosition, Parts } from '../../services/layout/browser/layoutService.js';
@ -1364,6 +1364,8 @@ export class TestHostService implements IHostService {
async getNativeWindowHandle(_windowId: number): Promise<VSBuffer | undefined> { return undefined; }
async showToast(_options: IToastOptions, token: CancellationToken): Promise<IToastResult> { return { supported: false, clicked: false }; }
readonly colorScheme = ColorScheme.DARK;
onDidChangeColorScheme = Event.None;
}

View File

@ -25,7 +25,7 @@ import { InMemoryFileSystemProvider } from '../../../platform/files/common/inMem
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
import { ISharedProcessService } from '../../../platform/ipc/electron-browser/services.js';
import { NullLogService } from '../../../platform/log/common/log.js';
import { INativeHostOptions, INativeHostService, IOSProperties, IOSStatistics } from '../../../platform/native/common/native.js';
import { INativeHostOptions, INativeHostService, IOSProperties, IOSStatistics, IToastOptions, IToastResult } from '../../../platform/native/common/native.js';
import { IProductService } from '../../../platform/product/common/productService.js';
import { AuthInfo, Credentials } from '../../../platform/request/common/request.js';
import { IStorageService } from '../../../platform/storage/common/storage.js';
@ -62,6 +62,7 @@ export class TestSharedProcessService implements ISharedProcessService {
}
export class TestNativeHostService implements INativeHostService {
declare readonly _serviceBrand: undefined;
readonly windowId = -1;
@ -175,6 +176,9 @@ export class TestNativeHostService implements INativeHostService {
async createZipFile(zipPath: URI, files: { path: string; contents: string }[]): Promise<void> { }
async profileRenderer(): Promise<any> { throw new Error(); }
async getScreenshot(rect?: IRectangle): Promise<VSBuffer | undefined> { return undefined; }
async showToast(options: IToastOptions): Promise<IToastResult> { return { supported: false, clicked: false }; }
async clearToast(id: string): Promise<void> { }
async clearToasts(): Promise<void> { }
}
export class TestExtensionTipsService extends AbstractNativeExtensionTipsService {