From 7e07af9368ef6c61846f689234a4924e3a5326e4 Mon Sep 17 00:00:00 2001 From: Robo Date: Wed, 15 Apr 2026 20:54:14 +0900 Subject: [PATCH 01/15] feat(macOS): enable cross app update (#309812) * feat(macOS): enable cross app update ICrossAppIPCService: - Owns the single crossAppIPC connection for the application - Exposes onDidConnect, onDidDisconnect, onDidReceiveMessage events Secret sharing refactoring: - MacOSCrossAppSecretSharing now takes ICrossAppIPCService instead of creating its own crossAppIPC connection * temp: bump distro * fix: remove embedder app checks in macOS update service * fix: avoid reentrancy from squirrel events when suspended * chore: update build --- .npmrc | 2 +- package.json | 2 +- src/vs/code/electron-main/app.ts | 16 +- .../electron-main/crossAppIpcService.ts | 140 ++++++++++++++++++ .../macOSCrossAppSecretSharing.ts | 119 ++++++--------- .../electron-main/abstractUpdateService.ts | 2 +- .../update/electron-main/crossAppUpdateIpc.ts | 99 +++++-------- .../electron-main/updateService.darwin.ts | 28 ++-- .../browser/account.contribution.ts | 4 +- 9 files changed, 252 insertions(+), 160 deletions(-) create mode 100644 src/vs/platform/crossAppIpc/electron-main/crossAppIpcService.ts diff --git a/.npmrc b/.npmrc index 8255ca3e4b0b6..afaf9fccd338b 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" target="39.8.7" -ms_build_id="13797146" +ms_build_id="13841579" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/package.json b/package.json index 9553ff406d4b6..966cff92094a6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.117.0", - "distro": "55ed6ee8e0b2c21bfd4ddc57b6ff1dff5fcc9b2d", + "distro": "53515a4534020f9b4ffb3172649d2fdd0a5d9f63", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 10169a02c7923..fd75a516d4d84 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -143,6 +143,7 @@ import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js' import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js'; import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../platform/networkFilter/common/networkFilterService.js'; +import { CrossAppIPCService, ICrossAppIPCService } from '../../platform/crossAppIpc/electron-main/crossAppIpcService.js'; import ErrorTelemetry from '../../platform/telemetry/electron-main/errorTelemetry.js'; /** @@ -1090,6 +1091,9 @@ export class CodeApplication extends Disposable { // Encryption services.set(IEncryptionMainService, new SyncDescriptor(EncryptionMainService)); + // Cross-app IPC + services.set(ICrossAppIPCService, new SyncDescriptor(CrossAppIPCService)); + // Browser View services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */)); services.set(IBrowserViewGroupMainService, new SyncDescriptor(BrowserViewGroupMainService, undefined, false /* proxied to other processes */)); @@ -1236,17 +1240,24 @@ export class CodeApplication extends Disposable { mainProcessElectronServer.registerChannel('userDataProfiles', userDataProfilesService); sharedProcessClient.then(client => client.registerChannel('userDataProfiles', userDataProfilesService)); + // Initialize cross-app IPC on supported platforms so all consumers + // (update coordination, secret sharing, etc.) share one connection. + const crossAppIPCService = accessor.get(ICrossAppIPCService); + if (isMacintosh || isWindows) { + crossAppIPCService.initialize(); + } + // Update (with cross-app coordination on macOS/Windows where crossAppIPC is available) const localUpdateService = accessor.get(IUpdateService); let effectiveUpdateService: IUpdateService = localUpdateService; const isInsiderOrExploration = this.productService.quality === 'insider' || this.productService.quality === 'exploration'; - if (isWindows && isInsiderOrExploration) { + if ((isMacintosh || isWindows) && isInsiderOrExploration) { const updateCoordinator = this._register(new CrossAppUpdateCoordinator( localUpdateService as AbstractUpdateService, this.logService, this.lifecycleMainService, + crossAppIPCService, )); - updateCoordinator.initialize(); effectiveUpdateService = updateCoordinator; } const updateChannel = new UpdateChannel(effectiveUpdateService); @@ -1262,6 +1273,7 @@ export class CodeApplication extends Disposable { this.environmentMainService, accessor.get(ILaunchMainService), this.lifecycleMainService, + crossAppIPCService, )); } diff --git a/src/vs/platform/crossAppIpc/electron-main/crossAppIpcService.ts b/src/vs/platform/crossAppIpc/electron-main/crossAppIpcService.ts new file mode 100644 index 0000000000000..3c5173607cf03 --- /dev/null +++ b/src/vs/platform/crossAppIpc/electron-main/crossAppIpcService.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as electron from 'electron'; +import { TimeoutTimer } from '../../../base/common/async.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; + +export const ICrossAppIPCService = createDecorator('crossAppIPCService'); + +export interface ICrossAppIPCMessage { + readonly type: string; + readonly data?: unknown; +} + +export interface ICrossAppIPCService { + readonly _serviceBrand: undefined; + + /** Whether the Electron crossAppIPC API is supported in this build. */ + readonly isSupported: boolean; + + /** Whether initialize() has been called and successfully set up the IPC. */ + readonly initialized: boolean; + + /** Whether the IPC connection is active. */ + readonly connected: boolean; + + /** Whether this app is the IPC server (`true`) or client (`false`). Only meaningful when connected. */ + readonly isServer: boolean; + + /** Fires when the peer connects. The boolean indicates whether this app is the server. */ + readonly onDidConnect: Event; + + /** Fires when the peer disconnects. The string is the disconnect reason. */ + readonly onDidDisconnect: Event; + + /** Fires when a message is received from the peer. */ + readonly onDidReceiveMessage: Event; + + /** Send a message to the peer. No-op if not connected. */ + sendMessage(msg: ICrossAppIPCMessage): void; + + /** Initialize the IPC connection. Call once during startup. */ + initialize(): void; +} + +/** + * Manages the single crossAppIPC connection for the entire application. + */ +export class CrossAppIPCService extends Disposable implements ICrossAppIPCService { + + declare readonly _serviceBrand: undefined; + + private ipc: Electron.CrossAppIPC | undefined; + private _connected = false; + private _isServer = false; + private readonly reconnectTimer = this._register(new TimeoutTimer()); + + private readonly _onDidConnect = this._register(new Emitter()); + readonly onDidConnect: Event = this._onDidConnect.event; + + private readonly _onDidDisconnect = this._register(new Emitter()); + readonly onDidDisconnect: Event = this._onDidDisconnect.event; + + private readonly _onDidReceiveMessage = this._register(new Emitter()); + readonly onDidReceiveMessage: Event = this._onDidReceiveMessage.event; + + get isSupported(): boolean { + const crossAppIPC: Electron.CrossAppIPCModule | undefined = (electron as typeof electron & { crossAppIPC?: Electron.CrossAppIPCModule }).crossAppIPC; + return crossAppIPC !== undefined; + } + get initialized(): boolean { return this.ipc !== undefined; } + get connected(): boolean { return this._connected; } + get isServer(): boolean { return this._isServer; } + + constructor( + @ILogService private readonly logService: ILogService, + ) { + super(); + } + + initialize(): void { + if (this.ipc) { + return; // Already initialized + } + + const crossAppIPC: Electron.CrossAppIPCModule | undefined = (electron as typeof electron & { crossAppIPC?: Electron.CrossAppIPCModule }).crossAppIPC; + + if (!crossAppIPC) { + this.logService.info('CrossAppIPCService: crossAppIPC not available'); + return; + } + + const ipc = crossAppIPC.createCrossAppIPC(); + this.ipc = ipc; + + ipc.on('connected', () => { + this._connected = true; + this._isServer = ipc.isServer; + this.logService.info(`CrossAppIPCService: connected (isServer=${ipc.isServer})`); + this._onDidConnect.fire(ipc.isServer); + }); + + ipc.on('message', (messageEvent) => { + this._onDidReceiveMessage.fire(messageEvent.data as ICrossAppIPCMessage); + }); + + ipc.on('disconnected', (reason) => { + this.logService.info(`CrossAppIPCService: disconnected (${reason})`); + this._connected = false; + this._isServer = false; + this._onDidDisconnect.fire(reason); + + // Reconnect to wait for the peer's next launch. + // Delay briefly to allow the old Mach bootstrap service to be + // deregistered before re-creating the server endpoint (macOS). + if (reason === 'peer-disconnected') { + this.reconnectTimer.cancelAndSet(() => ipc.connect(), 1000); + } + }); + + ipc.connect(); + this.logService.info('CrossAppIPCService: connecting to peer'); + } + + sendMessage(msg: ICrossAppIPCMessage): void { + if (this.ipc?.connected) { + this.ipc.postMessage(msg); + } + } + + override dispose(): void { + this.ipc?.close(); + super.dispose(); + } +} diff --git a/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts b/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts index 701db6ec8dea1..6dfea9bc15a96 100644 --- a/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts +++ b/src/vs/platform/secrets/electron-main/macOSCrossAppSecretSharing.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as electron from 'electron'; import { execFile } from 'child_process'; import { dirname } from '../../../base/common/path.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import { IEncryptionMainService } from '../../encryption/common/encryptionService.js'; import { IStorageMainService } from '../../storage/electron-main/storageMainService.js'; @@ -17,6 +16,7 @@ import { IStorageMain } from '../../storage/electron-main/storageMain.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILaunchMainService } from '../../launch/electron-main/launchMainService.js'; import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; +import { ICrossAppIPCService } from '../../crossAppIpc/electron-main/crossAppIpcService.js'; const MIGRATION_STATE_KEY = 'crossAppSecretSharing.migrationDone'; @@ -43,7 +43,7 @@ interface CrossAppSecretMessage { * * **Demand-driven**: Only the agents app initiates migration. If it * detects that migration hasn't been done yet, it: - * 1. Creates a crossAppIPC connection and starts listening. + * 1. Waits for the crossAppIPC connection (managed by ICrossAppIPCService). * 2. Spawns Code.app with `--share-secrets-with-agents-app`, which * either starts Code.app fresh or (if already running) forwards * the arg to the existing instance via the node IPC socket. @@ -60,10 +60,10 @@ interface CrossAppSecretMessage { */ export class MacOSCrossAppSecretSharing extends Disposable { - private ipc: Electron.CrossAppIPC | undefined; private readonly isEmbeddedApp: boolean; private readonly applicationStorage: IStorageMain; private _onHostMigrationComplete: (() => void) | undefined; + private readonly hostHandshakeListeners = this._register(new DisposableStore()); constructor( storageMainService: IStorageMainService, @@ -73,6 +73,7 @@ export class MacOSCrossAppSecretSharing extends Disposable { environmentMainService: IEnvironmentMainService, launchMainService: ILaunchMainService, lifecycleMainService: ILifecycleMainService, + private readonly crossAppIPCService: ICrossAppIPCService, ) { super(); this.isEmbeddedApp = !!(process as INodeProcess).isEmbeddedApp; @@ -119,47 +120,41 @@ export class MacOSCrossAppSecretSharing extends Disposable { // will write secrets into applicationStorage. await this.applicationStorage.whenInit; - const crossAppIPC: Electron.CrossAppIPCModule | undefined = electron.crossAppIPC; - if (!crossAppIPC) { - this.logService.info('[CrossAppSecretSharing] crossAppIPC not available'); + if (!this.crossAppIPCService.initialized) { + this.logService.info('[CrossAppSecretSharing] crossAppIPC not initialized, skipping migration'); return; } this.logService.info('[CrossAppSecretSharing] Migration needed, starting...'); - const ipc = crossAppIPC.createCrossAppIPC(); - this.ipc = ipc; - - ipc.on('connected', () => { - this.logService.info(`[CrossAppSecretSharing] connected (isServer=${ipc.isServer})`); - this.logService.info('[CrossAppSecretSharing] Requesting secrets from host app'); - this.sendMessage({ type: CrossAppSecretMessageType.SecretRequest }); - }); - - ipc.on('message', (messageEvent) => { - const msg = messageEvent.data as CrossAppSecretMessage | undefined; - if (msg?.type === CrossAppSecretMessageType.SecretResponse) { - this.handleSecretResponse(msg.data ?? {}); + // Listen for connection — when connected, request secrets + this._register(this.crossAppIPCService.onDidConnect(isServer => { + this.logService.info(`[CrossAppSecretSharing] Connected (isServer=${isServer}), requesting secrets from host app`); + this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretRequest }); + })); + + // Listen for messages + this._register(this.crossAppIPCService.onDidReceiveMessage(msg => { + const secretMsg = msg as CrossAppSecretMessage; + if (secretMsg?.type === CrossAppSecretMessageType.SecretResponse) { + this.handleSecretResponse(secretMsg.data ?? {}); } - }); - - ipc.on('disconnected', (reason) => { - this.logService.info(`[CrossAppSecretSharing] disconnected (${reason})`); - this.ipc = undefined; - }); + })); - ipc.connect(); + // If already connected (e.g. service was initialized before storage was ready), + // send the request immediately. + if (this.crossAppIPCService.connected) { + this.logService.info(`[CrossAppSecretSharing] Already connected (isServer=${this.crossAppIPCService.isServer}), requesting secrets from host app`); + this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretRequest }); + } // Spawn Code.app with --share-secrets-with-agents-app this.spawnHostApp(); - // If Code.app doesn't connect within 30s (e.g. not installed, - // failed to launch), give up and clean up the IPC. + // Timeout: if migration doesn't complete within 30s, give up setTimeout(() => { - if (this.ipc && !this.isMigrationDone()) { - this.logService.warn('[CrossAppSecretSharing] Migration timed out, closing IPC'); - this.ipc.close(); - this.ipc = undefined; + if (!this.isMigrationDone()) { + this.logService.warn('[CrossAppSecretSharing] Migration timed out'); } }, 30_000); } @@ -187,9 +182,8 @@ export class MacOSCrossAppSecretSharing extends Disposable { return; } - const crossAppIPC: Electron.CrossAppIPCModule | undefined = electron.crossAppIPC; - if (!crossAppIPC) { - this.logService.info('[CrossAppSecretSharing] crossAppIPC not available'); + if (!this.crossAppIPCService.initialized) { + this.logService.info('[CrossAppSecretSharing] crossAppIPC not initialized'); onComplete?.(); return; } @@ -198,34 +192,25 @@ export class MacOSCrossAppSecretSharing extends Disposable { this.logService.info('[CrossAppSecretSharing] Host app responding to secret sharing request'); - const ipc = crossAppIPC.createCrossAppIPC(); - this.ipc = ipc; + // Dispose previous listeners if initializeAsHostApp is called again + // (e.g. via repeated onDidRequestShareSecrets events). + this.hostHandshakeListeners.clear(); - ipc.on('connected', () => { - this.logService.info(`[CrossAppSecretSharing] connected (isServer=${ipc.isServer})`); - }); - - ipc.on('message', (messageEvent) => { - const msg = messageEvent.data as CrossAppSecretMessage | undefined; - if (msg?.type === CrossAppSecretMessageType.SecretRequest) { + // Listen for messages from the agents app + this.hostHandshakeListeners.add(this.crossAppIPCService.onDidReceiveMessage(msg => { + const secretMsg = msg as CrossAppSecretMessage; + if (secretMsg?.type === CrossAppSecretMessageType.SecretRequest) { this.handleSecretRequest(); - } else if (msg?.type === CrossAppSecretMessageType.SecretAck) { + } else if (secretMsg?.type === CrossAppSecretMessageType.SecretAck) { this.handleSecretAck(); } - }); + })); - ipc.on('disconnected', (reason) => { - this.logService.info(`[CrossAppSecretSharing] disconnected (${reason})`); - this.ipc = undefined; - - // If the agents app disconnected before sending SecretAck - // (e.g. it crashed), ensure the host app can still quit - // when it was launched solely for migration. + // If disconnected before ack, still allow the host to quit + this.hostHandshakeListeners.add(this.crossAppIPCService.onDidDisconnect(() => { this._onHostMigrationComplete?.(); this._onHostMigrationComplete = undefined; - }); - - ipc.connect(); + })); } private isMigrationDone(): boolean { @@ -284,10 +269,8 @@ export class MacOSCrossAppSecretSharing extends Disposable { } } - this.sendMessage({ type: CrossAppSecretMessageType.SecretResponse, data: secrets }); + this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretResponse, data: secrets }); this.logService.info('[CrossAppSecretSharing] Sent secrets response with', Object.keys(secrets).length, 'keys'); - - // Don't close yet — wait for SecretAck from agents app } private async handleSecretResponse(secrets: Record): Promise { @@ -317,7 +300,7 @@ export class MacOSCrossAppSecretSharing extends Disposable { // Tell the host app migration is done so it can also record it. // Don't close here — let the host close first after receiving the ack. - this.sendMessage({ type: CrossAppSecretMessageType.SecretAck }); + this.crossAppIPCService.sendMessage({ type: CrossAppSecretMessageType.SecretAck }); } private handleSecretAck(): void { @@ -327,20 +310,6 @@ export class MacOSCrossAppSecretSharing extends Disposable { const onComplete = this._onHostMigrationComplete; this._onHostMigrationComplete = undefined; - // Host closes — this triggers 'disconnected' on the agents side - this.ipc?.close(); - onComplete?.(); } - - private sendMessage(msg: CrossAppSecretMessage): void { - if (this.ipc?.connected) { - this.ipc.postMessage(msg); - } - } - - override dispose(): void { - this.ipc?.close(); - super.dispose(); - } } diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index e13d1ba5fc79c..fb5142e88787d 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -86,7 +86,7 @@ export abstract class AbstractUpdateService implements IUpdateService { private _hasCheckedForOverwriteOnQuit: boolean = false; private readonly overwriteUpdatesCheckInterval = new IntervalTimer(); private _internalOrg: string | undefined = undefined; - private _suspended = false; + protected _suspended = false; private readonly _onStateChange = new Emitter(); readonly onStateChange: Event = this._onStateChange.event; diff --git a/src/vs/platform/update/electron-main/crossAppUpdateIpc.ts b/src/vs/platform/update/electron-main/crossAppUpdateIpc.ts index 0b8e64bc7b77d..37582c9610ba5 100644 --- a/src/vs/platform/update/electron-main/crossAppUpdateIpc.ts +++ b/src/vs/platform/update/electron-main/crossAppUpdateIpc.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as electron from 'electron'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; +import { ICrossAppIPCService } from '../../crossAppIpc/electron-main/crossAppIpcService.js'; import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; import { IUpdateService, State } from '../common/update.js'; @@ -61,7 +61,6 @@ export class CrossAppUpdateCoordinator extends Disposable implements IUpdateServ declare readonly _serviceBrand: undefined; - private ipc: Electron.CrossAppIPC | undefined; private mode: 'standalone' | 'server' | 'client' = 'standalone'; private _state: State; @@ -81,6 +80,7 @@ export class CrossAppUpdateCoordinator extends Disposable implements IUpdateServ private readonly localUpdateService: AbstractUpdateService, private readonly logService: ILogService, private readonly lifecycleMainService: ILifecycleMainService, + private readonly crossAppIPCService: ICrossAppIPCService, ) { super(); @@ -89,65 +89,31 @@ export class CrossAppUpdateCoordinator extends Disposable implements IUpdateServ // Track local service state changes (used in standalone/server mode) this.registerLocalStateListener(); - } - private registerLocalStateListener(): void { - this.localStateListener = this.localUpdateService.onStateChange(state => { - this.updateState(state); - this.broadcastState(state); - }); - } + // Subscribe to cross-app IPC events + this._register(this.crossAppIPCService.onDidConnect(isServer => { + this.handleConnect(isServer); + })); - initialize(): void { - const crossAppIPC: Electron.CrossAppIPCModule | undefined = electron.crossAppIPC; - - if (!crossAppIPC) { - this.logService.info('CrossAppUpdateCoordinator: crossAppIPC not available, running in standalone mode'); - return; + // If the service is already connected (e.g. another consumer initialized + // it earlier), run the connect logic immediately. + if (this.crossAppIPCService.connected) { + this.handleConnect(this.crossAppIPCService.isServer); } - const ipc = crossAppIPC.createCrossAppIPC(); - this.ipc = ipc; - - ipc.on('connected', () => { - this.logService.info(`CrossAppUpdateCoordinator: connected (isServer=${ipc.isServer})`); - - if (ipc.isServer) { - this.mode = 'server'; - // Broadcast current state to the newly connected client - this.broadcastState(this.localUpdateService.state); - } else { - this.mode = 'client'; - // Suspend the local update service and stop listening to its state - // changes. All update operations are proxied to the server, so - // neither automatic nor manual checks go through the local service. - this.localUpdateService.suspend(); - this.localStateListener?.dispose(); - this.localStateListener = undefined; - // Request current state from the server - this.sendMessage({ type: CrossAppUpdateMessageType.RequestInitialState }); - } - }); - - ipc.on('message', (messageEvent) => { - this.handleMessage(messageEvent.data as CrossAppUpdateMessage); - }); + this._register(this.crossAppIPCService.onDidReceiveMessage(msg => { + this.handleMessage(msg as CrossAppUpdateMessage); + })); - ipc.on('disconnected', (reason) => { + this._register(this.crossAppIPCService.onDidDisconnect(reason => { this.logService.info(`CrossAppUpdateCoordinator: disconnected (${reason}), was ${this.mode}`); if (this.mode === 'client') { - // Resume the local update service — we're now the only app this.localUpdateService.resume(); this.registerLocalStateListener(); - // Sync coordinator state with the local service this.updateState(this.localUpdateService.state); } - // If the server was waiting for a quit confirmation and the client - // disconnected, treat it as an implicit confirmation — the client - // quit successfully but the IPC pipe was torn down before the - // QuitConfirmed message could be delivered. if (this.mode === 'server' && this.pendingQuitAndInstall) { this.logService.info('CrossAppUpdateCoordinator: client disconnected during pending quit, treating as confirmed'); this.pendingQuitAndInstall = false; @@ -157,17 +123,29 @@ export class CrossAppUpdateCoordinator extends Disposable implements IUpdateServ } this.mode = 'standalone'; + })); + } - // Reconnect to wait for the peer's next launch. - // Delay briefly to allow the old Mach bootstrap service to be - // deregistered before re-creating the server endpoint (macOS). - if (reason === 'peer-disconnected') { - setTimeout(() => ipc.connect(), 1000); - } - }); + private handleConnect(isServer: boolean): void { + this.logService.info(`CrossAppUpdateCoordinator: connected (isServer=${isServer})`); + + if (isServer) { + this.mode = 'server'; + this.broadcastState(this.localUpdateService.state); + } else { + this.mode = 'client'; + this.localUpdateService.suspend(); + this.localStateListener?.dispose(); + this.localStateListener = undefined; + this.sendMessage({ type: CrossAppUpdateMessageType.RequestInitialState }); + } + } - ipc.connect(); - this.logService.info('CrossAppUpdateCoordinator: connecting to peer'); + private registerLocalStateListener(): void { + this.localStateListener = this.localUpdateService.onStateChange(state => { + this.updateState(state); + this.broadcastState(state); + }); } private handleMessage(msg: CrossAppUpdateMessage): void { @@ -256,9 +234,7 @@ export class CrossAppUpdateCoordinator extends Disposable implements IUpdateServ } private sendMessage(msg: CrossAppUpdateMessage): void { - if (this.ipc?.connected) { - this.ipc.postMessage(msg); - } + this.crossAppIPCService.sendMessage(msg); } // --- IUpdateService implementation --- @@ -296,7 +272,7 @@ export class CrossAppUpdateCoordinator extends Disposable implements IUpdateServ * If no peer is connected (standalone), proceeds directly. */ private doCoordinatedQuitAndInstall(): void { - if (this.ipc?.connected) { + if (this.crossAppIPCService.connected) { // Ask the client to quit; it will respond with QuitConfirmed/QuitVetoed, // or disconnect (treated as implicit confirmation). this.pendingQuitAndInstall = true; @@ -329,7 +305,6 @@ export class CrossAppUpdateCoordinator extends Disposable implements IUpdateServ override dispose(): void { this.localStateListener?.dispose(); - this.ipc?.close(); super.dispose(); } } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index 3ddb310e2e2d4..0c5efaf80b817 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -20,7 +20,6 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; -import { INodeProcess } from '../../../base/common/platform.js'; export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { @@ -72,13 +71,6 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected override async initialize(): Promise { await super.initialize(); - // In the embedded app we still want to detect available updates via HTTP, - // but we must not wire up Electron's autoUpdater (which auto-downloads). - if ((process as INodeProcess).isEmbeddedApp) { - this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); - return; - } - this.onRawError(this.onError, this, this.disposables); this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); this.onRawUpdateAvailable(this.onUpdateAvailable, this, this.disposables); @@ -126,13 +118,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background, internalOrg }); if (!url) { - return; - } - - // In the embedded app, always check without triggering Electron's auto-download. - if ((process as INodeProcess).isEmbeddedApp) { - this.logService.info('update#doCheckForUpdates - embedded app: checking for update without auto-download'); - this.checkForUpdateNoDownload(url, /* canInstall */ false); + this.setState(State.Idle(UpdateType.Archive)); return; } @@ -179,6 +165,11 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau private onUpdateAvailable(): void { this.logService.trace('update#onUpdateAvailable - Electron autoUpdater reported update available'); + if (this._suspended) { + this.logService.trace('update#onUpdateAvailable - suspended, ignoring'); + return; + } + if (this.state.type !== StateType.CheckingForUpdates && this.state.type !== StateType.Overwriting) { return; } @@ -187,7 +178,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau } private onUpdateDownloaded(update: IUpdate): void { - if (this.state.type !== StateType.Downloading) { + if (this._suspended || this.state.type !== StateType.Downloading) { return; } @@ -200,6 +191,11 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau private onUpdateNotAvailable(): void { this.logService.trace('update#onUpdateNotAvailable - Electron autoUpdater reported no update available'); + if (this._suspended) { + this.logService.trace('update#onUpdateNotAvailable - suspended, ignoring'); + return; + } + if (this.state.type !== StateType.CheckingForUpdates) { return; } diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index e5cfdcbd4c786..275220227bdd6 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -33,7 +33,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IHostService } from '../../../../workbench/services/host/browser/host.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { URI } from '../../../../base/common/uri.js'; -import { isWindows } from '../../../../base/common/platform.js'; +import { isWindows, isMacintosh } from '../../../../base/common/platform.js'; import { UpdateHoverWidget } from './updateHoverWidget.js'; import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ChatStatusDashboard } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; @@ -125,7 +125,7 @@ async function runSessionsUpdateAction( ): Promise { if (state.type === StateType.AvailableForDownload) { const isInsiderOrExploration = productService.quality === 'insider' || productService.quality === 'exploration'; - const hasCrossAppCoordinator = isWindows && isInsiderOrExploration; + const hasCrossAppCoordinator = (isWindows || isMacintosh) && isInsiderOrExploration; if (!hasCrossAppCoordinator) { const { confirmed } = await dialogService.confirm({ message: localize('sessionsUpdateFromVSCode.title', "Update from VS Code"), From 0ca67ba95838f39613337863faf7b5fded29eefb Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 15 Apr 2026 11:49:30 +0200 Subject: [PATCH 02/15] Use model id from endpoint which is always set (#298236) --- .../extension/prompt/node/chatMLFetcher.ts | 2 +- .../networking/node/chatWebSocketManager.ts | 7 +++-- .../node/test/chatWebSocketManager.spec.ts | 30 +++++++++---------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index a9fc517955ba3..d6f8220892b09 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -1118,7 +1118,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._telemetryService.sendGHTelemetryEvent('request.sent', telemetryData.properties, telemetryData.measurements); const requestStart = Date.now(); - const handle = connection.sendRequest(request, { userInitiated: !!userInitiatedRequest, turnId, requestId: ourRequestId, countTokens, tokenCountMax: chatEndpointInfo.maxOutputTokens, modelMaxPromptTokens: chatEndpointInfo.modelMaxPromptTokens, summarizedAtRoundId }, cancellationToken); + const handle = connection.sendRequest(request, { userInitiated: !!userInitiatedRequest, turnId, requestId: ourRequestId, model: chatEndpointInfo.model, countTokens, tokenCountMax: chatEndpointInfo.maxOutputTokens, modelMaxPromptTokens: chatEndpointInfo.modelMaxPromptTokens, summarizedAtRoundId }, cancellationToken); const extendedBaseTelemetryData = baseTelemetryData.extendedBy({ modelCallId }); const processor = this._instantiationService.createInstance(OpenAIResponsesProcessor, extendedBaseTelemetryData, this._telemetryService, modelRequestId.headerRequestId, modelRequestId.gitHubRequestId, modelRequestId.serverExperiments, getResponsesApiCompactionThresholdFromBody(request)); diff --git a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts index f339e325e59a0..b443d60a02a8d 100644 --- a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts +++ b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts @@ -82,6 +82,7 @@ export interface IChatWebSocketRequestOptions { userInitiated: boolean; turnId: string; requestId: string; + model: string; countTokens: () => Promise; tokenCountMax: number; modelMaxPromptTokens: number; @@ -596,7 +597,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec const promptTokenCountPromise = options.countTokens(); let promptTokenCount = -1; promptTokenCountPromise.then(count => { promptTokenCount = count; }, () => { promptTokenCount = -2; }); - const request = new ChatWebSocketActiveRequest(requestId, body.model, options.summarizedAtRoundId, this._configurationService, this._logService); + const request = new ChatWebSocketActiveRequest(requestId, options.model, options.summarizedAtRoundId, this._configurationService, this._logService); request.onDidSettle(({ outcome, closeCode, closeReason, serverErrorMessage, serverErrorCode }) => { if (this._activeRequest === request) { this._activeRequest = undefined; @@ -615,7 +616,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec hadActiveRequest, requestId, gitHubRequestId: this.gitHubRequestId, - modelId: body.model, + modelId: options.model, requestOutcome: outcome, statefulMarkerMatched, previousResponseIdUnset, @@ -674,7 +675,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec hadActiveRequest, requestId, gitHubRequestId: this.gitHubRequestId, - modelId: body.model, + modelId: options.model, statefulMarkerMatched, previousResponseIdUnset, hasCompactionData, diff --git a/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts b/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts index 3e29cf8c2906a..133a1590261d7 100644 --- a/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts +++ b/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts @@ -126,7 +126,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -144,7 +144,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: false, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: false, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -161,7 +161,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -180,7 +180,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -200,7 +200,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -221,7 +221,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -240,7 +240,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -263,7 +263,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -283,7 +283,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -320,7 +320,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -335,7 +335,7 @@ describe('ChatWebSocketManager', () => { const cts = disposables.add(new CancellationTokenSource()); const handle = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts.token, ); @@ -355,7 +355,7 @@ describe('ChatWebSocketManager', () => { const cts1 = disposables.add(new CancellationTokenSource()); const handle1 = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts1.token, ); @@ -365,7 +365,7 @@ describe('ChatWebSocketManager', () => { const cts2 = disposables.add(new CancellationTokenSource()); const handle2 = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: false, turnId: 'turn-2', requestId: 'req-2', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: false, turnId: 'turn-2', requestId: 'req-2', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts2.token, ); @@ -382,7 +382,7 @@ describe('ChatWebSocketManager', () => { const cts1 = disposables.add(new CancellationTokenSource()); const handle1 = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts1.token, ); @@ -394,7 +394,7 @@ describe('ChatWebSocketManager', () => { const cts2 = disposables.add(new CancellationTokenSource()); const handle2 = connection.sendRequest( { model: 'test-model', messages: [], stream: true }, - { userInitiated: false, turnId: 'turn-2', requestId: 'req-2', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + { userInitiated: false, turnId: 'turn-2', requestId: 'req-2', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, cts2.token, ); From 60543332cf5b2dad48abda197e3ae47536a4beca Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 15 Apr 2026 14:29:03 +0200 Subject: [PATCH 03/15] fix opening sessions (#310112) --- .../sessions/browser/sessionsManagementService.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 65c43e2baede7..1bff116851beb 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -431,21 +431,6 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } } })); - - // Open the chat view when the active chat changes - let lastActiveChatResource: URI | undefined; - this._activeSessionDisposables.add(autorun(reader => { - const activeChat = activeChatObs.read(reader); - if (activeChat && (!lastActiveChatResource || !this.uriIdentityService.extUri.isEqual(activeChat.resource, lastActiveChatResource))) { - lastActiveChatResource = activeChat.resource; - if (activeChat.status.read(reader) === SessionStatus.Untitled) { - this._isNewChatInSessionContext.set(true); - } else { - this._isNewChatInSessionContext.set(false); - this.chatWidgetService.openSession(activeChat.resource, ChatViewPaneTarget); - } - } - })); } else { this._activeChatObservable = undefined; this._activeSession.set(undefined, undefined); From 8da5adecfc17061a5ac3a4b7a0f8ab7cf0476c01 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 15 Apr 2026 05:46:31 -0700 Subject: [PATCH 04/15] Switch to VS Code ACR and add Agents app coverage (#309834) Co-authored-by: Copilot --- build/azure-pipelines/common/sanity-tests.yml | 34 +++-------- build/azure-pipelines/product-build.yml | 30 ++++------ .../azure-pipelines/product-sanity-tests.yml | 30 ++++------ test/sanity/containers/alpine.dockerfile | 9 --- test/sanity/containers/centos.dockerfile | 24 -------- test/sanity/containers/debian-10.dockerfile | 42 -------------- test/sanity/containers/debian-12.dockerfile | 28 --------- test/sanity/containers/fedora.dockerfile | 21 ------- test/sanity/containers/opensuse.dockerfile | 21 ------- test/sanity/containers/redhat.dockerfile | 6 -- test/sanity/containers/ubuntu.dockerfile | 53 ----------------- test/sanity/scripts/run-docker.cmd | 23 ++------ test/sanity/scripts/run-docker.sh | 22 ++----- test/sanity/src/context.ts | 52 +++++++++++++++++ test/sanity/src/desktop.test.ts | 57 +++++++++++++++++++ test/sanity/src/devTunnel.test.ts | 5 +- test/sanity/src/index.ts | 8 ++- 17 files changed, 154 insertions(+), 311 deletions(-) delete mode 100644 test/sanity/containers/alpine.dockerfile delete mode 100644 test/sanity/containers/centos.dockerfile delete mode 100644 test/sanity/containers/debian-10.dockerfile delete mode 100644 test/sanity/containers/debian-12.dockerfile delete mode 100644 test/sanity/containers/fedora.dockerfile delete mode 100644 test/sanity/containers/opensuse.dockerfile delete mode 100644 test/sanity/containers/redhat.dockerfile delete mode 100644 test/sanity/containers/ubuntu.dockerfile diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index 7778d30a58c4b..0eb397c35243a 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -14,9 +14,6 @@ parameters: - name: arch type: string default: amd64 - - name: baseImage - type: string - default: "" - name: args type: string default: "" @@ -42,8 +39,6 @@ jobs: TEST_DIR: $(Build.SourcesDirectory)/test/sanity LOG_FILE: $(TEST_DIR)/results.xml SCREENSHOTS_DIR: $(TEST_DIR)/screenshots - DOCKER_CACHE_DIR: $(Pipeline.Workspace)/docker-cache - DOCKER_CACHE_FILE: $(DOCKER_CACHE_DIR)/${{ parameters.container }}.tar steps: - checkout: self fetchDepth: 1 @@ -94,10 +89,9 @@ jobs: displayName: Install Node.js (Windows ARM64) - ${{ else }}: - - task: NodeTool@0 + - task: UseNode@1 inputs: - versionSource: fromFile - versionFilePath: .nvmrc + version: '22.22.0' displayName: Install Node.js - script: npm config set registry "$(NPM_REGISTRY)" --location=project @@ -153,25 +147,17 @@ jobs: # Linux Docker container - ${{ if ne(parameters.container, '') }}: - - task: Cache@2 + - task: Docker@1 + displayName: Login to Container Registry inputs: - key: 'docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" | $(TEST_DIR)/containers/${{ parameters.container }}.dockerfile' - path: $(DOCKER_CACHE_DIR) - restoreKeys: docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" - cacheHitVar: DOCKER_CACHE_HIT - displayName: Download Docker Image - - - bash: | - docker load -i "$(DOCKER_CACHE_FILE)" - rm -f "$(DOCKER_CACHE_FILE)" - condition: eq(variables.DOCKER_CACHE_HIT, 'true') - displayName: Load Docker Image + azureSubscriptionEndpoint: vscode + azureContainerRegistry: vscodehub.azurecr.io + command: login - bash: | $(TEST_DIR)/scripts/run-docker.sh \ --container "${{ parameters.container }}" \ --arch "${{ parameters.arch }}" \ - --base-image "${{ parameters.baseImage }}" \ --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ @@ -184,12 +170,6 @@ jobs: GITHUB_ACCOUNT: $(sanity-tests-account) GITHUB_PASSWORD: $(sanity-tests-password) - - bash: | - mkdir -p "$(DOCKER_CACHE_DIR)" - docker save -o "$(DOCKER_CACHE_FILE)" "${{ parameters.container }}" - condition: and(succeeded(), ne(variables.DOCKER_CACHE_HIT, 'true')) - displayName: Save Docker Image - - ${{ if eq(parameters.os, 'windows') }}: - script: | @echo off diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 82e798c06d205..62a263442d8d6 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -578,8 +578,7 @@ extends: name: fedora_36_amd64 displayName: Fedora 36 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora - baseImage: fedora:36 + container: fedora-36 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: @@ -588,8 +587,7 @@ extends: name: fedora_36_arm64 displayName: Fedora 36 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora - baseImage: fedora:36 + container: fedora-36 arch: arm64 # Fedora 40 @@ -599,8 +597,7 @@ extends: name: fedora_40_amd64 displayName: Fedora 40 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora - baseImage: fedora:40 + container: fedora-40 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: @@ -609,8 +606,7 @@ extends: name: fedora_40_arm64 displayName: Fedora 40 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora - baseImage: fedora:40 + container: fedora-40 arch: arm64 # openSUSE Leap 16.0 @@ -667,8 +663,7 @@ extends: name: ubuntu_22_04_amd64 displayName: Ubuntu 22.04 amd64 poolName: 1es-ubuntu-22.04-x64 - container: ubuntu - baseImage: ubuntu:22.04 + container: ubuntu-22 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: @@ -677,8 +672,7 @@ extends: name: ubuntu_22_04_arm32 displayName: Ubuntu 22.04 arm32 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:22.04 + container: ubuntu-22 arch: arm - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: @@ -687,8 +681,7 @@ extends: name: ubuntu_22_04_arm64 displayName: Ubuntu 22.04 arm64 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:22.04 + container: ubuntu-22 arch: arm64 # Ubuntu 24.04 @@ -698,8 +691,7 @@ extends: name: ubuntu_24_04_amd64 displayName: Ubuntu 24.04 amd64 poolName: 1es-ubuntu-22.04-x64 - container: ubuntu - baseImage: ubuntu:24.04 + container: ubuntu-24 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: @@ -708,8 +700,7 @@ extends: name: ubuntu_24_04_arm32 displayName: Ubuntu 24.04 arm32 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:24.04 + container: ubuntu-24 arch: arm - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: @@ -718,8 +709,7 @@ extends: name: ubuntu_24_04_arm64 displayName: Ubuntu 24.04 arm64 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:24.04 + container: ubuntu-24 arch: arm64 - ${{ if and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false), or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'))) }}: diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index 8f555f30a1f58..a3926138f25ed 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -184,8 +184,7 @@ extends: name: fedora_36_amd64 displayName: Fedora 36 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora - baseImage: fedora:36 + container: fedora-36 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -193,8 +192,7 @@ extends: name: fedora_36_arm64 displayName: Fedora 36 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora - baseImage: fedora:36 + container: fedora-36 arch: arm64 # Fedora 40 @@ -203,8 +201,7 @@ extends: name: fedora_40_amd64 displayName: Fedora 40 amd64 poolName: 1es-ubuntu-22.04-x64 - container: fedora - baseImage: fedora:40 + container: fedora-40 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -212,8 +209,7 @@ extends: name: fedora_40_arm64 displayName: Fedora 40 arm64 poolName: 1es-azure-linux-3-arm64 - container: fedora - baseImage: fedora:40 + container: fedora-40 arch: arm64 # openSUSE Leap 16.0 @@ -264,8 +260,7 @@ extends: name: ubuntu_22_04_amd64 displayName: Ubuntu 22.04 amd64 poolName: 1es-ubuntu-22.04-x64 - container: ubuntu - baseImage: ubuntu:22.04 + container: ubuntu-22 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -273,8 +268,7 @@ extends: name: ubuntu_22_04_arm32 displayName: Ubuntu 22.04 arm32 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:22.04 + container: ubuntu-22 arch: arm - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -282,8 +276,7 @@ extends: name: ubuntu_22_04_arm64 displayName: Ubuntu 22.04 arm64 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:22.04 + container: ubuntu-22 arch: arm64 # Ubuntu 24.04 @@ -292,8 +285,7 @@ extends: name: ubuntu_24_04_amd64 displayName: Ubuntu 24.04 amd64 poolName: 1es-ubuntu-22.04-x64 - container: ubuntu - baseImage: ubuntu:24.04 + container: ubuntu-24 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -301,8 +293,7 @@ extends: name: ubuntu_24_04_arm32 displayName: Ubuntu 24.04 arm32 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:24.04 + container: ubuntu-24 arch: arm - template: build/azure-pipelines/common/sanity-tests.yml@self @@ -310,6 +301,5 @@ extends: name: ubuntu_24_04_arm64 displayName: Ubuntu 24.04 arm64 poolName: 1es-azure-linux-3-arm64 - container: ubuntu - baseImage: ubuntu:24.04 + container: ubuntu-24 arch: arm64 diff --git a/test/sanity/containers/alpine.dockerfile b/test/sanity/containers/alpine.dockerfile deleted file mode 100644 index 61ac9439a1836..0000000000000 --- a/test/sanity/containers/alpine.dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -ARG BASE_IMAGE=mcr.microsoft.com/devcontainers/base:alpine-3.22 -FROM ${BASE_IMAGE} - -# Node.js 22 -RUN apk add --no-cache nodejs - -# Chromium -RUN apk add --no-cache chromium -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser diff --git a/test/sanity/containers/centos.dockerfile b/test/sanity/containers/centos.dockerfile deleted file mode 100644 index 6d46c33a5af7a..0000000000000 --- a/test/sanity/containers/centos.dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -ARG BASE_IMAGE=quay.io/centos/centos:stream9 -FROM ${BASE_IMAGE} - -# Node.js 22 -RUN dnf module enable -y nodejs:22 && \ - dnf install -y nodejs - -# Chromium -RUN dnf install -y epel-release && \ - dnf install -y chromium - -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser - -# Desktop Bus -RUN dnf install -y dbus-x11 && \ - mkdir -p /run/dbus - -# X11 Server -RUN dnf install -y xorg-x11-server-Xvfb - -# VS Code dependencies -RUN dnf install -y \ - ca-certificates \ - xdg-utils diff --git a/test/sanity/containers/debian-10.dockerfile b/test/sanity/containers/debian-10.dockerfile deleted file mode 100644 index 174e17e788504..0000000000000 --- a/test/sanity/containers/debian-10.dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -ARG MIRROR -ARG BASE_IMAGE=debian:10 -FROM ${MIRROR}${BASE_IMAGE} - -# Update to archive repos since Debian 10 is EOL -RUN sed -i 's|http://deb.debian.org|http://archive.debian.org|g' /etc/apt/sources.list && \ - sed -i 's|http://security.debian.org|http://archive.debian.org|g' /etc/apt/sources.list && \ - sed -i '/buster-updates/d' /etc/apt/sources.list && \ - echo "deb http://archive.debian.org/debian bullseye main" >> /etc/apt/sources.list - -# Utilities -RUN apt-get update && \ - apt-get install -y curl - -# Upgrade libstdc++6 from bullseye (required by Node.js 22) -RUN apt-get install -y -t bullseye libstdc++6 - -# Node.js (arm32/arm64 use official builds, others use NodeSource) -ARG TARGETARCH -RUN if [ "$TARGETARCH" = "arm" ]; then \ - apt-get install -y libatomic1 && \ - curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ - elif [ "$TARGETARCH" = "arm64" ]; then \ - curl -fsSL https://nodejs.org/dist/v22.21.1/node-v22.21.1-linux-arm64.tar.gz | tar -xz -C /usr/local --strip-components=1; \ - else \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs; \ - fi - -# Chromium -RUN apt-get install -y chromium -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium - -# Desktop Bus -RUN apt-get install -y dbus-x11 && \ - mkdir -p /run/dbus - -# X11 Server -RUN apt-get install -y xvfb - -# Install newer libxkbfile1 from Debian 11 since Debian 10 version is too old -RUN apt-get install -y -t bullseye libxkbfile1 diff --git a/test/sanity/containers/debian-12.dockerfile b/test/sanity/containers/debian-12.dockerfile deleted file mode 100644 index 8c5ac782729af..0000000000000 --- a/test/sanity/containers/debian-12.dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -ARG MIRROR -ARG BASE_IMAGE=debian:bookworm -FROM ${MIRROR}${BASE_IMAGE} - -# Utilities -RUN apt-get update && \ - apt-get install -y curl - -# Node.js (arm32 uses official tarball since NodeSource dropped armhf support) -ARG TARGETARCH -RUN if [ "$TARGETARCH" = "arm" ]; then \ - apt-get install -y libatomic1 && \ - curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ - else \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs; \ - fi - -# Chromium -RUN apt-get install -y chromium -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium - -# Desktop Bus -RUN apt-get install -y dbus-x11 && \ - mkdir -p /run/dbus - -# X11 Server -RUN apt-get install -y xvfb diff --git a/test/sanity/containers/fedora.dockerfile b/test/sanity/containers/fedora.dockerfile deleted file mode 100644 index 9b97ec60578f7..0000000000000 --- a/test/sanity/containers/fedora.dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -ARG MIRROR -ARG BASE_IMAGE=fedora:36 -FROM ${MIRROR}${BASE_IMAGE} - -# Node.js 22 -RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && \ - dnf install -y nodejs-22.21.1 - -# Chromium -RUN dnf install -y chromium -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser - -# Desktop Bus -RUN dnf install -y dbus-x11 && \ - mkdir -p /run/dbus - -# X11 Server -RUN dnf install -y xorg-x11-server-Xvfb - -# VS Code dependencies -RUN dnf install -y xdg-utils diff --git a/test/sanity/containers/opensuse.dockerfile b/test/sanity/containers/opensuse.dockerfile deleted file mode 100644 index 4f53a6e9cfa10..0000000000000 --- a/test/sanity/containers/opensuse.dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -ARG BASE_IMAGE=opensuse/leap:16.0 -FROM ${BASE_IMAGE} - -# Node.js 22 -RUN zypper install -y nodejs22 - -# Chromium -RUN zypper install -y chromium pciutils Mesa-libGL1 -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium - -# X11 Server -RUN zypper install -y xorg-x11-server-Xvfb - -# Desktop Bus -RUN zypper install -y dbus-1-x11 && \ - mkdir -p /run/dbus - -# VS Code dependencies -RUN zypper install -y \ - liberation-fonts \ - libgtk-3-0 diff --git a/test/sanity/containers/redhat.dockerfile b/test/sanity/containers/redhat.dockerfile deleted file mode 100644 index 03fca173549e7..0000000000000 --- a/test/sanity/containers/redhat.dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -ARG BASE_IMAGE=redhat/ubi9:9.7 -FROM ${BASE_IMAGE} - -# Node.js 22 -RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && \ - dnf install -y nodejs-22.21.1 diff --git a/test/sanity/containers/ubuntu.dockerfile b/test/sanity/containers/ubuntu.dockerfile deleted file mode 100644 index 949dbbd797de7..0000000000000 --- a/test/sanity/containers/ubuntu.dockerfile +++ /dev/null @@ -1,53 +0,0 @@ -ARG MIRROR -ARG BASE_IMAGE=ubuntu:22.04 -FROM ${MIRROR}${BASE_IMAGE} - -# Use Azure package mirrors -ARG TARGETARCH -RUN if [ "$TARGETARCH" = "amd64" ]; then \ - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then \ - sed -i 's|http://archive.ubuntu.com|http://azure.archive.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources; \ - else \ - sed -i 's|http://archive.ubuntu.com|http://azure.archive.ubuntu.com|g' /etc/apt/sources.list; \ - fi; \ - else \ - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then \ - sed -i 's|http://ports.ubuntu.com|http://azure.ports.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources; \ - else \ - sed -i 's|http://ports.ubuntu.com|http://azure.ports.ubuntu.com|g' /etc/apt/sources.list; \ - fi; \ - fi - -# Utilities -RUN apt-get update && \ - apt-get install -y curl iproute2 - -# Node.js (arm32 uses official tarball since NodeSource dropped armhf support) -ARG TARGETARCH -RUN if [ "$TARGETARCH" = "arm" ]; then \ - apt-get install -y libatomic1 && \ - curl -fsSL https://nodejs.org/dist/v20.18.3/node-v20.18.3-linux-armv7l.tar.gz | tar -xz -C /usr/local --strip-components=1; \ - else \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs; \ - fi - -# No UI on arm32 on Ubuntu 24.04 -ARG BASE_IMAGE -ARG TARGETARCH -RUN if [ "$TARGETARCH" != "arm" ] || [ "$BASE_IMAGE" != "ubuntu:24.04" ]; then \ - # X11 Server \ - apt-get install -y xvfb && \ - # Desktop Bus \ - apt-get install -y dbus-x11 && \ - mkdir -p /run/dbus; \ - fi - -# VS Code dependencies -RUN apt-get install -y libasound2 || apt-get install -y libasound2t64 && \ - apt-get install -y libgtk-3-0 || apt-get install -y libgtk-3-0t64 && \ - apt-get install -y libcurl4 || apt-get install -y libcurl4t64 && \ - apt-get install -y \ - libgbm1 \ - libnss3 \ - xdg-utils diff --git a/test/sanity/scripts/run-docker.cmd b/test/sanity/scripts/run-docker.cmd index fd1ab024eb8ad..88d7534dd1782 100644 --- a/test/sanity/scripts/run-docker.cmd +++ b/test/sanity/scripts/run-docker.cmd @@ -4,8 +4,7 @@ setlocal enabledelayedexpansion set ROOT=%~dp0.. set CONTAINER= set ARCH=amd64 -set MIRROR=mcr.microsoft.com/mirror/docker/library/ -set BASE_IMAGE= +set REGISTRY=vscodehub.azurecr.io/vscode-linux-build-agent/sanity-tests set ARGS= :parse_args @@ -20,11 +19,6 @@ if "%~1"=="--arch" ( shift & shift goto :parse_args ) -if "%~1"=="--base-image" ( - set BASE_IMAGE=%~2 - shift & shift - goto :parse_args -) set "ARGS=!ARGS! %~1" shift goto :parse_args @@ -42,17 +36,10 @@ if not "%ARCH%"=="%HOST_ARCH%" ( docker run --privileged --rm tonistiigi/binfmt --install all >nul 2>&1 ) -set BASE_IMAGE_ARG= -if not "%BASE_IMAGE%"=="" set BASE_IMAGE_ARG=--build-arg "BASE_IMAGE=%BASE_IMAGE%" +set IMAGE=%REGISTRY%:%CONTAINER%-%ARCH% -echo Building container image: %CONTAINER% -docker buildx build ^ - --platform "linux/%ARCH%" ^ - --build-arg "MIRROR=%MIRROR%" ^ - %BASE_IMAGE_ARG% ^ - --tag "%CONTAINER%" ^ - --file "%ROOT%\containers\%CONTAINER%.dockerfile" ^ - "%ROOT%\containers" +echo Pulling container image: %IMAGE% +docker pull --platform "linux/%ARCH%" "%IMAGE%" echo Running sanity tests in container docker run ^ @@ -60,5 +47,5 @@ docker run ^ --platform "linux/%ARCH%" ^ --volume "%ROOT%:/root" ^ --entrypoint sh ^ - "%CONTAINER%" ^ + "%IMAGE%" ^ /root/containers/entrypoint.sh %ARGS% diff --git a/test/sanity/scripts/run-docker.sh b/test/sanity/scripts/run-docker.sh index b91f78197d8f8..949f867bdb9bf 100755 --- a/test/sanity/scripts/run-docker.sh +++ b/test/sanity/scripts/run-docker.sh @@ -3,15 +3,13 @@ set -e CONTAINER="" ARCH="amd64" -MIRROR="mcr.microsoft.com/mirror/docker/library/" -BASE_IMAGE="" +REGISTRY="vscodehub.azurecr.io/vscode-linux-build-agent/sanity-tests" ARGS="" while [ $# -gt 0 ]; do case "$1" in --container) CONTAINER="$2"; shift 2 ;; --arch) ARCH="$2"; shift 2 ;; - --base-image) BASE_IMAGE="$2"; shift 2 ;; *) ARGS="$ARGS $1"; shift ;; esac done @@ -23,20 +21,10 @@ fi SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +IMAGE="$REGISTRY:$CONTAINER-$ARCH" -# Only build if image doesn't exist (i.e., not loaded from cache) -if ! docker image inspect "$CONTAINER" > /dev/null 2>&1; then - echo "Building container image: $CONTAINER" - docker buildx build \ - --platform "linux/$ARCH" \ - --build-arg "MIRROR=$MIRROR" \ - ${BASE_IMAGE:+--build-arg "BASE_IMAGE=$BASE_IMAGE"} \ - --tag "$CONTAINER" \ - --file "$ROOT_DIR/containers/$CONTAINER.dockerfile" \ - "$ROOT_DIR/containers" -else - echo "Using cached container image: $CONTAINER" -fi +echo "Pulling container image: $IMAGE" +docker pull --platform "linux/$ARCH" "$IMAGE" echo "Running sanity tests in container" docker run \ @@ -46,5 +34,5 @@ docker run \ ${GITHUB_ACCOUNT:+--env GITHUB_ACCOUNT} \ ${GITHUB_PASSWORD:+--env GITHUB_PASSWORD} \ --entrypoint sh \ - "$CONTAINER" \ + "$IMAGE" \ /root/containers/entrypoint.sh $ARGS diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index f32c69311474f..9f92c6cec0bd7 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -989,6 +989,58 @@ export class TestContext { return filePath; } + /** + * Validates that the Agents app binary exists in the specified installation directory. + * @param dir The directory of the VS Code installation. + * @returns The path to the Agents entry point executable. + */ + public validateAgentsEntryPoint(dir: string): void { + let filePath: string = ''; + + switch (os.platform()) { + case 'darwin': { + let appName: string; + let agentsAppName: string; + switch (this.options.quality) { + case 'stable': + appName = 'Visual Studio Code.app'; + agentsAppName = 'Visual Studio Code Agents.app'; + break; + case 'insider': + appName = 'Visual Studio Code - Insiders.app'; + agentsAppName = 'Visual Studio Code Agents - Insiders.app'; + break; + case 'exploration': + appName = 'Visual Studio Code - Exploration.app'; + agentsAppName = 'Visual Studio Code Agents - Exploration.app'; + break; + } + filePath = path.join(dir, appName, 'Contents', 'Applications', agentsAppName); + break; + } + case 'win32': { + let exeName: string; + switch (this.options.quality) { + case 'stable': + exeName = 'Agents.exe'; + break; + case 'insider': + exeName = 'Agents - Insiders.exe'; + break; + case 'exploration': + exeName = 'Agents - Exploration.exe'; + break; + } + filePath = path.join(dir, exeName); + break; + } + } + + if (!filePath || !fs.existsSync(filePath)) { + this.error(`Agents entry point does not exist: ${filePath}`); + } + } + /** * Returns the entry point executable for the VS Code CLI in the specified directory. * @param dir The directory containing unpacked CLI files. diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts index 297a939f19cd9..6b76e4737ef1d 100644 --- a/test/sanity/src/desktop.test.ts +++ b/test/sanity/src/desktop.test.ts @@ -12,27 +12,33 @@ export function setup(context: TestContext) { context.test('desktop-darwin-x64', ['darwin', 'x64', 'desktop'], async () => { const dir = await context.downloadAndUnpack('darwin'); context.validateAllCodesignSignatures(dir); + context.validateAgentsEntryPoint(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); } }); context.test('desktop-darwin-arm64', ['darwin', 'arm64', 'desktop'], async () => { const dir = await context.downloadAndUnpack('darwin-arm64'); context.validateAllCodesignSignatures(dir); + context.validateAgentsEntryPoint(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); } }); context.test('desktop-darwin-universal', ['darwin', 'desktop'], async () => { const dir = await context.downloadAndUnpack('darwin-universal'); context.validateAllCodesignSignatures(dir); + context.validateAgentsEntryPoint(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); } }); @@ -42,8 +48,10 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const dir = context.mountDmg(packagePath); context.validateAllCodesignSignatures(dir); + context.validateAgentsEntryPoint(dir); const entryPoint = context.getDesktopEntryPoint(dir); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); context.unmountDmg(dir); } }); @@ -54,8 +62,10 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const dir = context.mountDmg(packagePath); context.validateAllCodesignSignatures(dir); + context.validateAgentsEntryPoint(dir); const entryPoint = context.getDesktopEntryPoint(dir); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); context.unmountDmg(dir); } }); @@ -66,8 +76,10 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const dir = context.mountDmg(packagePath); context.validateAllCodesignSignatures(dir); + context.validateAgentsEntryPoint(dir); const entryPoint = context.getDesktopEntryPoint(dir); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); context.unmountDmg(dir); } }); @@ -79,6 +91,7 @@ export function setup(context: TestContext) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); await testDesktopApp(entryPoint, dataDir); + await testAgentsApp(entryPoint, dataDir); } }); @@ -89,6 +102,7 @@ export function setup(context: TestContext) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); await testDesktopApp(entryPoint, dataDir); + await testAgentsApp(entryPoint, dataDir); } }); @@ -97,6 +111,7 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const entryPoint = await context.installDeb(packagePath); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallDeb(); } }); @@ -106,6 +121,7 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const entryPoint = await context.installDeb(packagePath); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallDeb(); } }); @@ -115,6 +131,7 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const entryPoint = await context.installDeb(packagePath); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallDeb(); } }); @@ -124,6 +141,7 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallRpm(); } }); @@ -133,6 +151,7 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallRpm(); } }); @@ -142,6 +161,7 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallRpm(); } }); @@ -151,6 +171,7 @@ export function setup(context: TestContext) { if (!context.options.downloadOnly) { const entryPoint = context.installSnap(packagePath); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallSnap(); } }); @@ -162,6 +183,7 @@ export function setup(context: TestContext) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); await testDesktopApp(entryPoint, dataDir); + await testAgentsApp(entryPoint, dataDir); } }); @@ -173,7 +195,9 @@ export function setup(context: TestContext) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); context.validateAllVersionInfo(path.dirname(entryPoint)); + context.validateAgentsEntryPoint(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallWindowsApp('system'); } }); @@ -182,10 +206,12 @@ export function setup(context: TestContext) { const dir = await context.downloadAndUnpack('win32-arm64-archive'); context.validateAllAuthenticodeSignatures(dir); context.validateAllVersionInfo(dir); + context.validateAgentsEntryPoint(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); await testDesktopApp(entryPoint, dataDir); + await testAgentsApp(entryPoint, dataDir); } }); @@ -197,7 +223,9 @@ export function setup(context: TestContext) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); context.validateAllVersionInfo(path.dirname(entryPoint)); + context.validateAgentsEntryPoint(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallWindowsApp('user'); } }); @@ -210,7 +238,9 @@ export function setup(context: TestContext) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); context.validateAllVersionInfo(path.dirname(entryPoint)); + context.validateAgentsEntryPoint(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallWindowsApp('system'); } }); @@ -219,10 +249,12 @@ export function setup(context: TestContext) { const dir = await context.downloadAndUnpack('win32-x64-archive'); context.validateAllAuthenticodeSignatures(dir); context.validateAllVersionInfo(dir); + context.validateAgentsEntryPoint(dir); if (!context.options.downloadOnly) { const entryPoint = context.getDesktopEntryPoint(dir); const dataDir = context.createPortableDataDir(dir); await testDesktopApp(entryPoint, dataDir); + await testAgentsApp(entryPoint, dataDir); } }); @@ -234,7 +266,9 @@ export function setup(context: TestContext) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); context.validateAllVersionInfo(path.dirname(entryPoint)); + context.validateAgentsEntryPoint(path.dirname(entryPoint)); await testDesktopApp(entryPoint); + await testAgentsApp(entryPoint); await context.uninstallWindowsApp('user'); } }); @@ -259,4 +293,27 @@ export function setup(context: TestContext) { test.validate(); } + + async function testAgentsApp(desktopEntryPoint: string, dataDir?: string) { + const test = new UITest(context, dataDir); + const args = ['--agents']; + if (!dataDir) { + args.push('--extensions-dir', test.extensionsDir); + args.push('--user-data-dir', test.userDataDir); + } + + context.log(`Starting Agents app ${desktopEntryPoint} with args ${args.join(' ')}`); + const app = await _electron.launch({ executablePath: desktopEntryPoint, args }); + try { + const window = await context.getPage(app.firstWindow()); + await window.waitForSelector('.agent-sessions-workbench', { timeout: 60000 }); + + context.log('Clicking "Sign in with GitHub" button'); + const button = await window.waitForSelector('button.provider-github'); + await button.click(); + } finally { + context.log('Closing the Agents app'); + await app.close(); + } + } } diff --git a/test/sanity/src/devTunnel.test.ts b/test/sanity/src/devTunnel.test.ts index f99371275df0e..7de7245034219 100644 --- a/test/sanity/src/devTunnel.test.ts +++ b/test/sanity/src/devTunnel.test.ts @@ -42,14 +42,13 @@ export function setup(context: TestContext) { await testCliApp(entryPoint); }); - */ - context.test('dev-tunnel-darwin-arm64', ['darwin', 'arm64', 'browser', 'github-account'], async () => { const dir = await context.downloadAndUnpack('cli-darwin-arm64'); context.validateAllCodesignSignatures(dir); const entryPoint = context.getCliEntryPoint(dir); await testCliApp(entryPoint); }); + */ context.test('dev-tunnel-darwin-x64', ['darwin', 'x64', 'browser', 'github-account'], async () => { const dir = await context.downloadAndUnpack('cli-darwin-x64'); @@ -58,6 +57,7 @@ export function setup(context: TestContext) { await testCliApp(entryPoint); }); + /** TODO: @dmitrivMS Fix flakiness and then reenable context.test('dev-tunnel-win32-arm64', ['windows', 'arm64', 'browser', 'github-account'], async () => { const dir = await context.downloadAndUnpack('cli-win32-arm64'); context.validateAllAuthenticodeSignatures(dir); @@ -66,7 +66,6 @@ export function setup(context: TestContext) { await testCliApp(entryPoint); }); - /** TODO: @dmitrivMS Fix flakiness and then reenable context.test('dev-tunnel-win32-x64', ['windows', 'x64', 'browser', 'github-account'], async () => { const dir = await context.downloadAndUnpack('cli-win32-x64'); context.validateAllAuthenticodeSignatures(dir); diff --git a/test/sanity/src/index.ts b/test/sanity/src/index.ts index 60fc2cab5a0ed..19c193f50cb71 100644 --- a/test/sanity/src/index.ts +++ b/test/sanity/src/index.ts @@ -41,8 +41,6 @@ const mochaOptions: MochaOptions = { slow: 3 * 60 * 1000, grep: options.grep, fgrep: options.fgrep, - reporter: testResults ? 'mocha-junit-reporter' : undefined, - reporterOptions: testResults ? { mochaFile: testResults, outputs: true } : undefined, }; if (testResults) { @@ -66,6 +64,12 @@ const runner = mocha.run(failures => { }, 1000); }); +// Attach JUnit reporter for CI test result publishing (runs alongside the default spec reporter) +if (testResults) { + const JUnitReporter = (await import('mocha-junit-reporter' as string)).default; + new JUnitReporter(runner, { reporterOptions: { mochaFile: testResults, outputs: true } }); +} + if (options.verbose) { runner.on('test', (test) => console.log(`Starting: ${test.fullTitle()}`)); runner.on('pass', (test) => console.log(`Passed: ${test.fullTitle()}`)); From 938197bd708df5f6e8bf39436fb76b802e8b1469 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:17:43 +0000 Subject: [PATCH 05/15] Agents - add editor part into the layout (#310111) Co-authored-by: Benjamin Pasero --- src/vs/sessions/browser/media/style.css | 25 ++++++++- src/vs/sessions/browser/workbench.ts | 71 +++++++++++++++---------- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 3a1cdad951772..bfdd053b1138c 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -108,6 +108,20 @@ box-sizing: border-box; } +.agent-sessions-workbench .part.editor:not(.modal-editor-part) { + margin: 0 0 0 0; + height: calc(100% - 10px) !important; + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); + border-right-width: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + box-sizing: border-box; + overflow: hidden; +} + .agent-sessions-workbench:not(.nosidebar) .part.chatbar { margin-left: 0; } @@ -116,10 +130,18 @@ margin: 0 10px 0px 0; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); - border-radius: 8px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; box-sizing: border-box; } +.agent-sessions-workbench.nomaineditorarea .part.auxiliarybar { + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; +} + .agent-sessions-workbench .part.auxiliarybar > .content .pane-body { background-color: var(--vscode-sessionsAuxiliaryBar-background); } @@ -145,6 +167,7 @@ } .monaco-workbench.vs.agent-sessions-workbench .part.chatbar, +.monaco-workbench.vs.agent-sessions-workbench .part.editor:not(.modal-editor-part), .monaco-workbench.vs.agent-sessions-workbench .part.auxiliarybar, .monaco-workbench.vs.agent-sessions-workbench .part.panel { border-color: var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index c8113f41d2d76..0da6439233ac3 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -230,6 +230,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { private sideBarPartView!: ISerializableView; private panelPartView!: ISerializableView; private auxiliaryBarPartView!: ISerializableView; + private editorPartView!: ISerializableView; private chatBarPartView!: ISerializableView; @@ -539,7 +540,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Warm up font cache information before building up too many dom elements this.restoreFontInfo(storageService, configurationService); - // Create Parts (excluding editor - it will be in a modal) + // Create Parts (editor starts hidden and is shown when an editor opens) for (const { id, role, classes } of [ { id: Parts.TITLEBAR_PART, role: 'none', classes: ['titlebar'] }, { id: Parts.SIDEBAR_PART, role: 'none', classes: ['sidebar', 'left'] }, @@ -554,8 +555,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { mark(`code/didCreatePart/${id}`); } - // Create Editor Part (hidden — all editors open via MODAL_GROUP) - this.createHiddenEditorPart(); + // Create Editor Part (hidden by default) + this.createEditorPart(); // Notification Handlers this.createNotificationsHandlers(instantiationService, notificationService); @@ -601,19 +602,16 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { return part; } - private createHiddenEditorPart(): void { + private createEditorPart(): void { const editorPartContainer = document.createElement('div'); editorPartContainer.classList.add('part', 'editor'); editorPartContainer.id = Parts.EDITOR_PART; editorPartContainer.setAttribute('role', 'main'); - editorPartContainer.style.display = 'none'; mark('code/willCreatePart/workbench.parts.editor'); this.getPart(Parts.EDITOR_PART).create(editorPartContainer, { restorePreviousState: false }); mark('code/didCreatePart/workbench.parts.editor'); - this.getPart(Parts.EDITOR_PART).layout(0, 0, 0, 0); // needed to make some view methods work - this.mainContainer.appendChild(editorPartContainer); } @@ -673,8 +671,17 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Register layout listeners this.registerLayoutListeners(); - // Show editor part when an editor opens - this._register(this.editorService.onWillOpenEditor(() => { + // Keep background editor hidden for modal opens, only show for non-modal opens. + this._register(this.editorService.onWillOpenEditor(e => { + const isModalOpen = this.editorGroupService.activeModalEditorPart?.groups.some(group => group.id === e.groupId) ?? false; + + if (isModalOpen) { + if (this.partVisibility.editor) { + this.setEditorHidden(true); + } + return; + } + if (!this.partVisibility.editor) { this.setEditorHidden(false); } @@ -731,19 +738,21 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { const sideBar = this.getPart(Parts.SIDEBAR_PART); const chatBarPart = this.getPart(Parts.CHATBAR_PART); - // View references for parts in the grid (editor is NOT in grid) + // View references for parts in the grid this.titleBarPartView = titleBar; this.sideBarPartView = sideBar; this.panelPartView = panelPart; this.auxiliaryBarPartView = auxiliaryBarPart; this.chatBarPartView = chatBarPart; + this.editorPartView = editorPart; const viewMap: { [key: string]: ISerializableView } = { [Parts.TITLEBAR_PART]: this.titleBarPartView, [Parts.PANEL_PART]: this.panelPartView, [Parts.SIDEBAR_PART]: this.sideBarPartView, [Parts.AUXILIARYBAR_PART]: this.auxiliaryBarPartView, - [Parts.CHATBAR_PART]: this.chatBarPartView + [Parts.CHATBAR_PART]: this.chatBarPartView, + [Parts.EDITOR_PART]: this.editorPartView }; const fromJSON = ({ type }: { type: string }) => viewMap[type]; @@ -759,7 +768,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.workbenchGrid.edgeSnapping = this.mainWindowFullscreen; // Listen for part visibility changes (for parts in grid) - for (const part of [titleBar, panelPart, sideBar, auxiliaryBarPart, chatBarPart]) { + for (const part of [titleBar, panelPart, sideBar, auxiliaryBarPart, chatBarPart, editorPart]) { this._register(part.onDidVisibilityChange(visible => { if (part === sideBar) { this.setSideBarHidden(!visible); @@ -769,19 +778,14 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.setAuxiliaryBarHidden(!visible); } else if (part === chatBarPart) { this.setChatBarHidden(!visible); + } else if (part === editorPart) { + this.setEditorHidden(!visible); } this._onDidChangePartVisibility.fire({ partId: part.getId(), visible }); this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); })); } - - // Listen for editor part visibility changes (modal) - this._register(editorPart.onDidVisibilityChange(visible => { - this.setEditorHidden(!visible); - this._onDidChangePartVisibility.fire({ partId: editorPart.getId(), visible }); - this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); - })); } createWorkbenchManagement(_instantiationService: IInstantiationService): void { @@ -790,27 +794,27 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { /** * Creates the grid descriptor for the Agent Sessions layout. - * Editor is NOT included - it's rendered as a modal overlay. * * Structure (horizontal orientation): * - Sidebar (left, spans full height from top to bottom) * - Right section (vertical): * - Titlebar (top of right section) - * - Top right (horizontal): Chat Bar | Auxiliary Bar - * - Panel (below chat and auxiliary bar only) + * - Top right (horizontal): Chat Bar | Editor | Auxiliary Bar + * - Panel (below chat, editor, and auxiliary bar) */ private createGridDescriptor(): ISerializedGrid { const { width, height } = this._mainContainerDimension; // Default sizes const sideBarSize = 300; + const editorSize = 650; const auxiliaryBarSize = 380; const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; // Calculate right section width and chat bar width const rightSectionWidth = Math.max(0, width - sideBarSize); - const chatBarWidth = Math.max(0, rightSectionWidth - auxiliaryBarSize); + const chatBarWidth = Math.max(0, rightSectionWidth - auxiliaryBarSize - editorSize); const contentHeight = height - titleBarHeight; const topRightHeight = contentHeight - panelSize; @@ -843,6 +847,13 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { visible: this.partVisibility.chatBar }; + const editorNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.EDITOR_PART }, + size: editorSize, + visible: this.partVisibility.editor + }; + const panelNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.PANEL_PART }, @@ -850,10 +861,10 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { visible: this.partVisibility.panel }; - // Top right section: Chat Bar | Auxiliary Bar (horizontal) + // Top right section: Chat Bar | Editor | Auxiliary Bar (horizontal) const topRightSection: ISerializedNode = { type: 'branch', - data: [chatBarNode, auxiliaryBarNode], + data: [chatBarNode, editorNode, auxiliaryBarNode], size: topRightHeight }; @@ -1132,6 +1143,10 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.partVisibility.editor = !hidden; this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); + + if (this.editorPartView) { + this.workbenchGrid.setViewVisible(this.editorPartView, !hidden); + } } private setPanelHidden(hidden: boolean): void { @@ -1270,7 +1285,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { case Parts.AUXILIARYBAR_PART: return this.auxiliaryBarPartView; case Parts.EDITOR_PART: - return undefined; // Editor is not in the grid, it's a modal + return this.editorPartView; case Parts.PANEL_PART: return this.panelPartView; case Parts.CHATBAR_PART: @@ -1408,7 +1423,9 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { if (neighborView === this.auxiliaryBarPartView) { return Parts.AUXILIARYBAR_PART; } - // Editor is not in the grid - it's rendered as a modal + if (neighborView === this.editorPartView) { + return Parts.EDITOR_PART; + } if (neighborView === this.panelPartView) { return Parts.PANEL_PART; } From aa6cff9994e9625224ae54d5e08a125753a38e5b Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 15 Apr 2026 15:50:49 +0100 Subject: [PATCH 06/15] fix: update menu border color to use default fallback --- src/vs/platform/theme/browser/defaultStyles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/theme/browser/defaultStyles.ts b/src/vs/platform/theme/browser/defaultStyles.ts index bd8147af0d4ac..8887866670938 100644 --- a/src/vs/platform/theme/browser/defaultStyles.ts +++ b/src/vs/platform/theme/browser/defaultStyles.ts @@ -241,7 +241,7 @@ export function getSelectBoxStyles(override: IStyleOverride): export const defaultMenuStyles: IMenuStyles = { shadowColor: asCssVariable(widgetShadow), - borderColor: asCssVariable(menuBorder), + borderColor: asCssVariableWithDefault(menuBorder, asCssVariable(editorWidgetBorder)), foregroundColor: asCssVariable(menuForeground), backgroundColor: asCssVariable(menuBackground), selectionForegroundColor: asCssVariable(listHoverForeground), From 902c28bad7caeb475314b2dbafcf5de136014c9e Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 15 Apr 2026 16:03:42 +0100 Subject: [PATCH 07/15] Update src/vs/platform/theme/browser/defaultStyles.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/platform/theme/browser/defaultStyles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/theme/browser/defaultStyles.ts b/src/vs/platform/theme/browser/defaultStyles.ts index 8887866670938..fdc98de1cf6e9 100644 --- a/src/vs/platform/theme/browser/defaultStyles.ts +++ b/src/vs/platform/theme/browser/defaultStyles.ts @@ -241,7 +241,7 @@ export function getSelectBoxStyles(override: IStyleOverride): export const defaultMenuStyles: IMenuStyles = { shadowColor: asCssVariable(widgetShadow), - borderColor: asCssVariableWithDefault(menuBorder, asCssVariable(editorWidgetBorder)), + borderColor: asCssVariableWithDefault(menuBorder, editorWidgetBorder), foregroundColor: asCssVariable(menuForeground), backgroundColor: asCssVariable(menuBackground), selectionForegroundColor: asCssVariable(listHoverForeground), From bb93f9dc790cbfb592ee4620003a532b3a9c2371 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 15 Apr 2026 11:23:38 -0400 Subject: [PATCH 08/15] output monitor fixes (#310145) --- .../browser/tools/monitoring/outputMonitor.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index a10fcf864576f..6b1f790bd7434 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -253,9 +253,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private async _handleIdleState(token: CancellationToken): Promise<{ resources?: ILinkLocation[]; shouldContinuePolling: boolean; output?: string }> { const output = this._execution.getOutput(); - this._logService.trace(`OutputMonitor: Idle output summary: len=${output.length}, lastLine=${this._formatLastLineForLog(output)}`); - if (detectsNonInteractiveHelpPattern(output)) { + // Use only the tail of the output for logging and pattern detection. All + // detect* functions match prompts near the end of the buffer (using $ + // anchors or normalized-string includes), and the idle summary only + // needs the last line. Slicing avoids unnecessary work over potentially + // large terminal scrollback. + const outputTail = output.slice(-1000); + this._logService.trace(`OutputMonitor: Idle output summary: len=${output.length}, lastLine=${this._formatLastLineForLog(outputTail)}`); + + if (detectsNonInteractiveHelpPattern(outputTail)) { this._logService.trace('OutputMonitor: Idle -> non-interactive help pattern detected, stopping'); return { shouldContinuePolling: false, output }; } @@ -264,7 +271,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // If the execution is a task and the output contains a VS Code task finish message, // always treat it as a stop signal regardless of task active state (which can be stale). const isTask = this._execution.task !== undefined; - if (isTask && detectsVSCodeTaskFinishMessage(output)) { + if (isTask && detectsVSCodeTaskFinishMessage(outputTail)) { this._logService.trace('OutputMonitor: Idle -> VS Code task finish message detected, stopping'); // Task is finished, ignore the "press any key to close" message return { shouldContinuePolling: false, output }; @@ -272,7 +279,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Check for generic "press any key" prompts from scripts. // Only shown for non-task executions since task finish messages are handled above. - if (!isTask && detectsGenericPressAnyKeyPattern(output)) { + if (!isTask && detectsGenericPressAnyKeyPattern(outputTail)) { this._logService.trace('OutputMonitor: Idle -> generic "press any key" detected, signaling agent'); this._onDidDetectInputNeeded.fire(); this._cleanupIdleInputListener(); @@ -289,7 +296,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // In async mode, use regex-based detection for input-required patterns // (passwords, [Y/n], etc.) and signal the agent to handle via send_to_terminal. if (this._asyncMode) { - if (detectsInputRequiredPattern(output)) { + if (detectsInputRequiredPattern(outputTail)) { this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, signaling agent'); this._onDidDetectInputNeeded.fire(); } @@ -301,7 +308,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // In foreground mode, fire the event so the race in runInTerminalTool can pick it // up and return control to the agent (which uses send_to_terminal to provide input). // No elicitation UI is shown — the agent handles it autonomously. - if (detectsInputRequiredPattern(output)) { + if (detectsInputRequiredPattern(outputTail)) { this._logService.trace('OutputMonitor: Input-required pattern detected, signaling agent'); this._onDidDetectInputNeeded.fire(); this._cleanupIdleInputListener(); @@ -364,17 +371,18 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { waited += waitTime; currentInterval = Math.min(currentInterval * 2, maxInterval); const currentOutput = execution.getOutput(); + const currentTail = currentOutput.slice(-1000); - if (detectsNonInteractiveHelpPattern(currentOutput)) { + if (detectsNonInteractiveHelpPattern(currentTail)) { this._logService.trace(`OutputMonitor: waitForIdle -> non-interactive help detected (waited=${waited}ms)`); this._state = OutputMonitorState.Idle; this._setupIdleInputListener(); return this._state; } - const promptResult = detectsInputRequiredPattern(currentOutput); + const promptResult = detectsInputRequiredPattern(currentTail); if (promptResult) { - this._logService.trace(`OutputMonitor: waitForIdle -> input-required pattern detected (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentOutput)})`); + this._logService.trace(`OutputMonitor: waitForIdle -> input-required pattern detected (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentTail)})`); this._state = OutputMonitorState.Idle; this._setupIdleInputListener(); return this._state; @@ -391,7 +399,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const isActive = execution.isActive ? await execution.isActive() : undefined; this._logService.trace(`OutputMonitor: waitForIdle check: waited=${waited}ms, recentlyIdle=${recentlyIdle}, isActive=${isActive}`); if (recentlyIdle && isActive !== true) { - this._logService.trace(`OutputMonitor: waitForIdle -> recentlyIdle && !active (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentOutput)})`); + this._logService.trace(`OutputMonitor: waitForIdle -> recentlyIdle && !active (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentTail)})`); this._state = OutputMonitorState.Idle; this._setupIdleInputListener(); return this._state; @@ -472,9 +480,9 @@ export function matchTerminalPromptOption(options: readonly string[], suggestedO export function detectsInputRequiredPattern(cursorLine: string): boolean { return [ // PowerShell-style multi-option line (supports [?] Help and optional default suffix) ending - // in whitespace. The label part uses [^\[\s]+(?:\s+[^\[\s]+)* to support multi-word - // labels (e.g. "Yes to All") while avoiding overlap with \s* that caused ReDoS. - /\s*(?:\[[^\]]\]\s+[^\[\s]+(?:\s+[^\[\s]+)*\s*)+(?:\(default is\s+"[^"]+"\):)?\s+$/, + // in whitespace. Uses [^\[]* to match each label (everything up to the next bracket), + // ensuring linear-time matching with no nested quantifiers that could cause ReDoS. + /\s*(?:\[[^\]]\][^\[]*)+(?:\(default is\s+"[^"]+"\):)?\s+$/, // Bracketed/parenthesized yes/no pairs at end of line: (y/n), [Y/n], (yes/no), [no/yes] /(?:\(|\[)\s*(?:y(?:es)?\s*\/\s*n(?:o)?|n(?:o)?\s*\/\s*y(?:es)?)\s*(?:\]|\))\s+$/i, // Same as above but allows a preceding '?' or ':' and optional wrappers e.g. From ac028c6784ad6c440036f266fae091a25961bbd7 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 15 Apr 2026 17:50:23 +0200 Subject: [PATCH 09/15] remove deprecated chat customization APIs (#310139) --- .../common/extensionsApiProposals.ts | 2 +- .../api/browser/mainThreadChatAgents2.ts | 69 ++---------- .../workbench/api/common/extHost.api.impl.ts | 24 ----- .../workbench/api/common/extHost.protocol.ts | 12 +-- .../api/common/extHostChatAgents2.ts | 100 ++++++++++-------- .../vscode.proposed.chatPromptFiles.d.ts | 38 +------ 6 files changed, 68 insertions(+), 177 deletions(-) diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 5cbe6159c652f..f9402b1ef47c3 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -67,7 +67,7 @@ const _allApiProposals = { }, chatPromptFiles: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', - version: 1 + version: 2 }, chatProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatProvider.d.ts', diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index f4b4f642f65d4..58632789a3ca0 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -169,41 +169,25 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // Push the initial active session if there is already a focused widget this._acceptActiveChatSession(this._chatWidgetService.lastFocusedWidget); - // Push custom agents to ext host - void this._pushCustomAgents(); this._register(this._promptsService.onDidChangeCustomAgents(() => { - void this._pushCustomAgents(); + this._proxy.$onDidChangeCustomAgents(); })); - - // Push instructions to ext host - void this._pushInstructions(); this._register(this._promptsService.onDidChangeInstructions(() => { - void this._pushInstructions(); + this._proxy.$onDidChangeInstructions(); })); - - // Push skills to ext host - void this._pushSkills(); this._register(this._promptsService.onDidChangeSkills(() => { - void this._pushSkills(); + this._proxy.$onDidChangeSkills(); })); - - // Push slash commands to ext host - void this._pushSlashCommands(); this._register(this._promptsService.onDidChangeSlashCommands(() => { - void this._pushSlashCommands(); + this._proxy.$onDidChangeSlashCommands(); })); - - // Push hooks to ext host - void this._pushHooks(); this._register(this._promptsService.onDidChangeHooks(() => { - void this._pushHooks(); + this._proxy.$onDidChangeHooks(); })); - // Push plugins to ext host (reactive via autorun) this._register(autorun(reader => { - const plugins = this._agentPluginService.plugins.read(reader); - const dtos: IPluginDto[] = plugins.map(p => ({ uri: p.uri })); - this._proxy.$acceptPlugins(dtos); + this._agentPluginService.plugins.read(reader); + this._proxy.$onDidChangePlugins(); })); } @@ -309,45 +293,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA return plugins.map(plugin => ({ uri: plugin.uri })); } - private async _pushCustomAgents(): Promise { - try { - this._proxy.$acceptCustomAgents(await this.$provideCustomAgents(CancellationToken.None)); - } catch (error) { - this._logService.error('[chat] Failed to push custom agents to extension host', error); - } - } - - private async _pushInstructions(): Promise { - try { - this._proxy.$acceptInstructions(await this.$provideInstructions(CancellationToken.None)); - } catch (error) { - this._logService.error('[chat] Failed to push instructions to extension host', error); - } - } - - private async _pushSkills(): Promise { - try { - this._proxy.$acceptSkills(await this.$provideSkills(CancellationToken.None)); - } catch (error) { - this._logService.error('[chat] Failed to push skills to extension host', error); - } - } - - private async _pushSlashCommands(): Promise { - try { - this._proxy.$acceptSlashCommands(await this.$provideSlashCommands(CancellationToken.None)); - } catch (error) { - this._logService.error('[chat] Failed to push slash commands to extension host', error); - } - } - - private async _pushHooks(): Promise { - try { - this._proxy.$acceptHooks(await this.$provideHooks(CancellationToken.None)); - } catch (error) { - this._logService.error('[chat] Failed to push hooks to extension host', error); - } - } $unregisterAgent(handle: number): void { this._agents.deleteAndDispose(handle); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5b39d22ee90f5..c00f7cee4c9ec 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1717,10 +1717,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatDebug'); return extHostChatDebug.onDidAddCoreEvent(listener, thisArgs, disposables); }, - get customAgents() { - checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.customAgents as readonly vscode.ChatCustomAgent[]; - }, getCustomAgents(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.provideCustomAgents(token) as Thenable; @@ -1729,10 +1725,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeCustomAgents(listener, thisArgs, disposables); }, - get instructions() { - checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.instructions as readonly vscode.ChatInstruction[]; - }, getInstructions(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.provideInstructions(token) as Thenable; @@ -1741,10 +1733,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeInstructions(listener, thisArgs, disposables); }, - get skills() { - checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.skills as readonly vscode.ChatSkill[]; - }, getSkills(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.provideSkills(token) as Thenable; @@ -1753,10 +1741,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeSkills(listener, thisArgs, disposables); }, - get slashCommands() { - checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.slashCommands as readonly vscode.ChatSlashCommand[]; - }, getSlashCommands(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.provideSlashCommands(token) as Thenable; @@ -1765,10 +1749,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeSlashCommands(listener, thisArgs, disposables); }, - get hooks() { - checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.hooks as readonly vscode.ChatResource[]; - }, getHooks(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.provideHooks(token) as Thenable; @@ -1777,10 +1757,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeHooks(listener, thisArgs, disposables); }, - get plugins() { - checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.plugins as readonly vscode.ChatResource[]; - }, getPlugins(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.providePlugins(token) as Thenable; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 3397d6166d6f4..8a6055ba510c9 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1675,12 +1675,12 @@ export interface ExtHostChatAgentsShape2 { $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; - $acceptCustomAgents(agents: ICustomAgentDto[]): void; - $acceptInstructions(instructions: IInstructionDto[]): void; - $acceptSkills(skills: ISkillDto[]): void; - $acceptSlashCommands(slashCommands: ISlashCommandDto[]): void; - $acceptHooks(hooks: IHookDto[]): void; - $acceptPlugins(plugins: IPluginDto[]): void; + $onDidChangeCustomAgents(): void; + $onDidChangeInstructions(): void; + $onDidChangeSkills(): void; + $onDidChangeSlashCommands(): void; + $onDidChangeHooks(): void; + $onDidChangePlugins(): void; } export type IChatResourceSourceDto = 'local' | 'user' | 'extension' | 'plugin'; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 0a3f81a2a7ed0..0b43f3f2fdf25 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -506,12 +506,12 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _onDidChangePlugins = this._register(new Emitter()); readonly onDidChangePlugins = this._onDidChangePlugins.event; - private _customAgents: vscode.ChatCustomAgent[] = []; - private _instructions: vscode.ChatInstruction[] = []; - private _skills: vscode.ChatSkill[] = []; - private _slashCommands: vscode.ChatSlashCommand[] = []; - private _hooks: vscode.ChatHook[] = []; - private _plugins: vscode.ChatPlugin[] = []; + private readonly _customAgents = new CachedPromise(() => this._proxy.$provideCustomAgents(CancellationToken.None).then(agents => agents.map(agent => this.toCustomAgent(agent)))); + private readonly _instructions = new CachedPromise(() => this._proxy.$provideInstructions(CancellationToken.None).then(instructions => instructions.map(instruction => this.toInstruction(instruction)))); + private readonly _skills = new CachedPromise(() => this._proxy.$provideSkills(CancellationToken.None).then(skills => skills.map(skill => this.toSkill(skill)))); + private readonly _slashCommands = new CachedPromise(() => this._proxy.$provideSlashCommands(CancellationToken.None).then(slashCommands => slashCommands.map(slashCommand => this.toSlashCommand(slashCommand)))); + private readonly _hooks = new CachedPromise(() => this._proxy.$provideHooks(CancellationToken.None).then(hooks => hooks.map(hook => this.toHook(hook)))); + private readonly _plugins = new CachedPromise(() => this._proxy.$providePlugins(CancellationToken.None).then(plugins => plugins.map(plugin => this.toPlugin(plugin)))); private _activeChatPanelSessionResource: URI | undefined; @@ -522,21 +522,6 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return this._activeChatPanelSessionResource; } - get customAgents(): readonly vscode.ChatCustomAgent[] { - return this._customAgents; - } - - get instructions(): readonly vscode.ChatInstruction[] { - return this._instructions; - } - - get skills(): readonly vscode.ChatSkill[] { - return this._skills; - } - - get slashCommands(): readonly vscode.ChatSlashCommand[] { - return this._slashCommands; - } private toCustomAgent(dto: ICustomAgentDto): vscode.ChatCustomAgent { return Object.freeze({ @@ -599,65 +584,57 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return Object.freeze({ uri: URI.revive(dto.uri) }); } - get hooks(): readonly vscode.ChatHook[] { - return this._hooks; - } - - get plugins(): readonly vscode.ChatPlugin[] { - return this._plugins; - } - provideCustomAgents(token: vscode.CancellationToken): Thenable { - return this._proxy.$provideCustomAgents(token).then(agents => this._customAgents = agents.map(agent => this.toCustomAgent(agent))); + return this._customAgents.get(token); } provideInstructions(token: vscode.CancellationToken): Thenable { - return this._proxy.$provideInstructions(token).then(instructions => this._instructions = instructions.map(instruction => this.toInstruction(instruction))); + return this._instructions.get(token); } provideSkills(token: vscode.CancellationToken): Thenable { - return this._proxy.$provideSkills(token).then(skills => this._skills = skills.map(skill => this.toSkill(skill))); + return this._skills.get(token); } provideSlashCommands(token: vscode.CancellationToken): Thenable { - return this._proxy.$provideSlashCommands(token).then(slashCommands => this._slashCommands = slashCommands.map(slashCommand => this.toSlashCommand(slashCommand))); + return this._slashCommands.get(token); } provideHooks(token: vscode.CancellationToken): Thenable { - return this._proxy.$provideHooks(token).then(hooks => this._hooks = hooks.map(hook => this.toHook(hook))); + return this._hooks.get(token); } providePlugins(token: vscode.CancellationToken): Thenable { - return this._proxy.$providePlugins(token).then(plugins => this._plugins = plugins.map(plugin => this.toPlugin(plugin))); + return this._plugins.get(token); } - $acceptCustomAgents(agents: ICustomAgentDto[]): void { - this._customAgents = agents.map(agent => this.toCustomAgent(agent)); + $onDidChangeCustomAgents(): void { + this._customAgents.clear(); this._onDidChangeCustomAgents.fire(); } - $acceptInstructions(instructions: IInstructionDto[]): void { - this._instructions = instructions.map(instruction => this.toInstruction(instruction)); + $onDidChangeInstructions(): void { + this._instructions.clear(); this._onDidChangeInstructions.fire(); } - $acceptSkills(skills: ISkillDto[]): void { - this._skills = skills.map(skill => this.toSkill(skill)); + $onDidChangeSkills(): void { + this._skills.clear(); this._onDidChangeSkills.fire(); } - $acceptSlashCommands(slashCommands: ISlashCommandDto[]): void { - this._slashCommands = slashCommands.map(slashCommand => this.toSlashCommand(slashCommand)); + $onDidChangeSlashCommands(): void { + this._slashCommands.clear(); this._onDidChangeSlashCommands.fire(); } - $acceptHooks(hooks: IHookDto[]): void { - this._hooks = hooks.map(hook => this.toHook(hook)); + $onDidChangeHooks(): void { + this._hooks.clear(); this._onDidChangeHooks.fire(); } - $acceptPlugins(plugins: IPluginDto[]): void { - this._plugins = plugins.map(plugin => this.toPlugin(plugin)); + $onDidChangePlugins(): void { + this._plugins.clear(); this._onDidChangePlugins.fire(); } @@ -1510,6 +1487,35 @@ function raceCancellationWithTimeout(cancelWait: number, promise: Promise, }); } +/** + * Lazily computes and caches a promise result until explicitly cleared. + * Failed computations are not retained so later callers can retry. + */ +class CachedPromise { + + private cachedPromise: Promise | undefined; + + constructor(private readonly computeFn: () => Promise) { } + + get(token: CancellationToken): Promise { + if (!this.cachedPromise) { + const promise = this.computeFn().catch(err => { + if (this.cachedPromise === promise) { + this.cachedPromise = undefined; + } + throw err; + }); + this.cachedPromise = promise; + } + + return raceCancellation(this.cachedPromise, token, []); + } + + clear(): void { + this.cachedPromise = undefined; + } +} + function isBuiltinParticipant(agentId: string): boolean { return agentId.startsWith('github.copilot'); } diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index caa8863086096..70b992a956c5b 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 1 +// version: 2 declare module 'vscode' { // #region Resource Classes @@ -328,12 +328,6 @@ declare module 'vscode' { */ export const onDidChangeCustomAgents: Event; - /** - * The list of currently available custom agents. These are `.agent.md` files - * from all sources (workspace, user, and extension-provided). - * @deprecated Use {@link getCustomAgents provideCustomAgents} instead, which queries the current list of custom agents on demand. This property may become out of sync with the actual available custom agents. - */ - export const customAgents: readonly ChatCustomAgent[]; /** * Provide the list of currently available custom agents. These are `.agent.md` files @@ -347,12 +341,6 @@ declare module 'vscode' { */ export const onDidChangeInstructions: Event; - /** - * The list of currently available instructions. These are `.instructions.md` files - * from all sources (workspace, user, and extension-provided). - * @deprecated Use {@link getInstructions getInstructions} instead, which queries the current list of instructions on demand. This property may become out of sync with the actual available instructions. - */ - export const instructions: readonly ChatInstruction[]; /** * Provide the list of currently available instructions. These are `.instructions.md` files @@ -366,12 +354,6 @@ declare module 'vscode' { */ export const onDidChangeSkills: Event; - /** - * The list of currently available skills. These are `SKILL.md` files - * from all sources (workspace, user, and extension-provided). - * @deprecated Use {@link getSkills getSkills} instead, which queries the current list of skills on demand. This property may become out of sync with the actual available skills. - */ - export const skills: readonly ChatSkill[]; /** * Provide the list of currently available skills. These are `SKILL.md` files @@ -385,12 +367,6 @@ declare module 'vscode' { */ export const onDidChangeSlashCommands: Event; - /** - * The list of currently available slash commands. These are `.prompt.md` files and - * user-invocable `SKILL.md` files from all sources (workspace, user, and extension-provided). - * @deprecated Use {@link getSlashCommands getSlashCommands} instead, which queries the current list of slash commands on demand. This property may become out of sync with the actual available slash commands. - */ - export const slashCommands: readonly ChatSlashCommand[]; /** * Provide the list of currently available slash commands. These are `.prompt.md` files and @@ -404,13 +380,6 @@ declare module 'vscode' { */ export const onDidChangeHooks: Event; - /** - * The list of currently available hook configuration files. - * These are JSON files that define lifecycle hooks from all sources - * (workspace, user, and extension-provided). - * @deprecated Use {@link getHooks getHooks} instead, which queries the current list of hook configuration files on demand. This property may become out of sync with the actual available hook configuration files. - */ - export const hooks: readonly ChatResource[]; /** * Provide the list of currently available hook configuration files. These are JSON files that define lifecycle hooks from all sources (workspace, user, and extension-provided). @@ -423,11 +392,6 @@ declare module 'vscode' { */ export const onDidChangePlugins: Event; - /** - * The list of currently installed agent plugins. - * @deprecated Use {@link getPlugins getPlugins} instead, which queries the current list of installed agent plugins on demand. This property may become out of sync with the actual installed agent plugins. - */ - export const plugins: readonly ChatResource[]; /** * Provide the list of currently installed agent plugins. From 03dd400cf7d92658233f879fb3495c121ffc07a1 Mon Sep 17 00:00:00 2001 From: Yogeshwaran C <84272111+yogeshwaran-c@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:22:54 +0530 Subject: [PATCH 10/15] Adopt CodeAction type for built-in css server (#310055) * Adopt CodeAction type for built-in css server Remove the now-dead `_css.applyCodeAction` command handler and its activation event. The CSS server already returns proper `CodeAction` objects with `edit` via `doCodeActions2`, so the legacy command-based fallback is no longer reached. Fixes #237858 * revert package.json change --------- Co-authored-by: Martin Aeschlimann --- .../client/src/cssClient.ts | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/extensions/css-language-features/client/src/cssClient.ts b/extensions/css-language-features/client/src/cssClient.ts index 49bacd90a5c88..34337f028e8e5 100644 --- a/extensions/css-language-features/client/src/cssClient.ts +++ b/extensions/css-language-features/client/src/cssClient.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { commands, CompletionItem, CompletionItemKind, ExtensionContext, languages, Position, Range, SnippetString, TextEdit, window, TextDocument, CompletionContext, CancellationToken, ProviderResult, CompletionList, FormattingOptions, workspace, l10n } from 'vscode'; +import { CompletionItem, CompletionItemKind, ExtensionContext, languages, Position, Range, SnippetString, TextEdit, TextDocument, CompletionContext, CancellationToken, ProviderResult, CompletionList, FormattingOptions, workspace, l10n } from 'vscode'; import { Disposable, LanguageClientOptions, ProvideCompletionItemsSignature, NotificationType, BaseLanguageClient, DocumentRangeFormattingParams, DocumentRangeFormattingRequest } from 'vscode-languageclient'; import { getCustomDataSource } from './customData'; import { RequestService, serveFileSystemRequests } from './requests'; @@ -147,26 +147,6 @@ export async function startClient(context: ExtensionContext, newLanguageClient: }); } - commands.registerCommand('_css.applyCodeAction', applyCodeAction); - - function applyCodeAction(uri: string, documentVersion: number, edits: TextEdit[]) { - const textEditor = window.activeTextEditor; - if (textEditor && textEditor.document.uri.toString() === uri) { - if (textEditor.document.version !== documentVersion) { - window.showInformationMessage(l10n.t('CSS fix is outdated and can\'t be applied to the document.')); - } - textEditor.edit(mutator => { - for (const edit of edits) { - mutator.replace(client.protocol2CodeConverter.asRange(edit.range), edit.newText); - } - }).then(success => { - if (!success) { - window.showErrorMessage(l10n.t('Failed to apply CSS fix to the document. Please consider opening an issue with steps to reproduce.')); - } - }); - } - } - function updateFormatterRegistration(registration: FormatterRegistration) { const formatEnabled = workspace.getConfiguration().get(registration.settingId); if (!formatEnabled && registration.provider) { From 4c482dcb913d7a27a18dfa3fc36b37ea76d3fbe7 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 15 Apr 2026 18:11:17 +0200 Subject: [PATCH 11/15] make dependency hovers in package.json faster (#310140) * make dependency hovers in package.json faster * Update extensions/npm/src/features/packageJSONContribution.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../npm/src/features/packageJSONContribution.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/extensions/npm/src/features/packageJSONContribution.ts b/extensions/npm/src/features/packageJSONContribution.ts index 4778bd91f085b..157823ae03df7 100644 --- a/extensions/npm/src/features/packageJSONContribution.ts +++ b/extensions/npm/src/features/packageJSONContribution.ts @@ -278,13 +278,16 @@ export class PackageJSONContribution implements IJSONContribution { return undefined; // avoid unnecessary lookups } let info: ViewPackageInfo | undefined; + let installedVersion: string | undefined; if (this.npmCommandPath) { - info = await this.npmView(this.npmCommandPath, pack, resource); + ([info, installedVersion] = await Promise.all([ + this.npmView(this.npmCommandPath, pack, resource), + this.npmListInstalledVersion(this.npmCommandPath, pack, resource) + ])); } if (!info && this.onlineEnabled()) { info = await this.npmjsView(pack); } - const installedVersion = this.npmCommandPath ? await this.npmListInstalledVersion(this.npmCommandPath, pack, resource) : undefined; if (installedVersion) { info = info ?? { description: '' }; info.installedVersion = installedVersion; @@ -320,16 +323,18 @@ export class PackageJSONContribution implements IJSONContribution { } private async npmView(npmCommandPath: string, pack: string, resource: Uri | undefined): Promise { - const args = ['view', '--json', '--', pack, 'description', 'dist-tags.latest', 'homepage', 'version', 'time']; + // Request @latest to avoid fetching publish timestamps for all versions in the time field. + const args = ['view', '--json', '--', `${pack}@latest`, 'description', 'homepage', 'version', 'time']; + const stdout = await this.runNpmCommand(npmCommandPath, args, resource); if (stdout) { try { const content = JSON.parse(stdout); - const version = content['dist-tags.latest'] || content['version']; + const version = content['version']; return { description: content['description'], - version, - time: content.time?.[version], + version: content['version'], + time: version ? content['time']?.[version] : undefined, homepage: content['homepage'] }; } catch (e) { From b604f475c59b0cfa8d3015ecc0434fedf18b1638 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:11:43 +0000 Subject: [PATCH 12/15] Git - fix the default branch protection when workspace folders change (#310147) --- extensions/git/src/branchProtection.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/git/src/branchProtection.ts b/extensions/git/src/branchProtection.ts index b142a333b24a1..63abfa8eb1637 100644 --- a/extensions/git/src/branchProtection.ts +++ b/extensions/git/src/branchProtection.ts @@ -26,6 +26,10 @@ export class GitBranchProtectionProvider implements BranchProtectionProvider { constructor(private readonly repositoryRoot: Uri) { const onDidChangeBranchProtectionEvent = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.branchProtection', repositoryRoot)); onDidChangeBranchProtectionEvent(this.updateBranchProtection, this, this.disposables); + + // Update default branch protection when the workspace folders change + workspace.onDidChangeWorkspaceFolders(this.updateBranchProtection, this, this.disposables); + this.updateBranchProtection(); } From e647683a3ebaf42830e69a056de77604e55a8f5c Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 15 Apr 2026 17:12:01 +0100 Subject: [PATCH 13/15] Agents: Fix padding for collapsed customization toolbar (#310152) fix: adjust padding for collapsed customization toolbar Co-authored-by: mrleemurray --- .../contrib/sessions/browser/media/customizationsToolbar.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index 8ff9655fcdcb6..e9a4a9e0e1c5d 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -133,4 +133,5 @@ .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { max-height: 0; + padding-bottom: 0; } From 8379a047a31a9ab6dc0256ad6435ed5725a5772a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 15 Apr 2026 12:15:39 -0400 Subject: [PATCH 14/15] Fix listener leak in terminal tool progress parts, execute strategies, and detached terminals (#310157) Fix terminal leaks --- .../chatTerminalToolProgressPart.ts | 180 ++++++++---------- .../contrib/terminal/browser/terminal.ts | 15 ++ .../terminal/browser/terminalService.ts | 45 ++++- .../terminal/browser/xterm/xtermTerminal.ts | 56 ++++-- .../executeStrategy/basicExecuteStrategy.ts | 3 + .../executeStrategy/richExecuteStrategy.ts | 5 + 6 files changed, 189 insertions(+), 115 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 8b194523ef28f..cbe437ab673ff 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -24,7 +24,8 @@ import { ChatCollapsibleContentPart } from '../chatCollapsibleContentPart.js'; import { IChatRendererContent } from '../../../../common/model/chatViewModel.js'; import '../media/chatTerminalToolProgressPart.css'; import type { ICodeBlockRenderOptions } from '../codeBlockPart.js'; -import { IAction } from '../../../../../../../base/common/actions.js'; +import { Action, IAction } from '../../../../../../../base/common/actions.js'; +import { ActionBar } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; import { timeout } from '../../../../../../../base/common/async.js'; import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../terminal/browser/terminal.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; @@ -46,7 +47,6 @@ import { IContextKey, IContextKeyService } from '../../../../../../../platform/c import { AccessibilityVerbositySettingId } from '../../../../../accessibility/browser/accessibilityConfiguration.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; import { EditorPool } from '../chatContentCodePools.js'; -import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; import { DetachedTerminalCommandMirror, DetachedTerminalSnapshotMirror } from '../../../../../terminal/browser/chatTerminalCommandMirror.js'; import { TerminalLocation } from '../../../../../../../platform/terminal/common/terminal.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; @@ -57,11 +57,7 @@ import { removeAnsiEscapeCodes } from '../../../../../../../base/common/strings. import { PANEL_BACKGROUND } from '../../../../../../common/theme.js'; import { editorBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; -import { MenuWorkbenchToolBar } from '../../../../../../../platform/actions/browser/toolbar.js'; -import { MenuRegistry } from '../../../../../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../../../../../platform/commands/common/commands.js'; -import { MENU_CHAT_TERMINAL_TOOL_PROGRESS, TerminalChatContextKeys } from '../../../../../terminal/terminalContribChatExports.js'; -import { ServiceCollection } from '../../../../../../../platform/instantiation/common/serviceCollection.js'; /** * Minimum number of rows to display in the terminal output view. @@ -98,7 +94,7 @@ const MIN_DATA_EVENTS_FOR_REAL_OUTPUT = 2; */ const expandedStateByInvocation = new WeakMap(); -// --- Command and menu registrations for terminal tool progress toolbar --- +// --- Command registrations for terminal tool progress toolbar --- CommandsRegistry.registerCommand(TerminalContribCommandId.FocusChatInstanceAction, async (_accessor: unknown, progressPart?: IChatTerminalToolProgressPart) => { await progressPart?.focusTerminal(); @@ -112,48 +108,6 @@ CommandsRegistry.registerCommand(TerminalContribCommandId.ToggleChatTerminalOutp await progressPart?.toggleOutputFromAction(); }); -MenuRegistry.appendMenuItem(MENU_CHAT_TERMINAL_TOOL_PROGRESS, { - command: { - id: TerminalContribCommandId.ContinueInBackground, - title: localize('continueInBackground', 'Continue in Background'), - icon: Codicon.debugContinue, - }, - when: TerminalChatContextKeys.chatToolCanContinueInBackground, - order: 0, - group: 'navigation', -}); - -MenuRegistry.appendMenuItem(MENU_CHAT_TERMINAL_TOOL_PROGRESS, { - command: { - id: TerminalContribCommandId.FocusChatInstanceAction, - title: localize('focusTerminal', 'Focus Terminal'), - icon: Codicon.openInProduct, - toggled: { - condition: TerminalChatContextKeys.chatToolIsHiddenTerminal, - title: localize('showTerminal', 'Show and Focus Terminal'), - } - }, - when: TerminalChatContextKeys.chatToolHasInstance, - order: 1, - group: 'navigation', -}); - -MenuRegistry.appendMenuItem(MENU_CHAT_TERMINAL_TOOL_PROGRESS, { - command: { - id: TerminalContribCommandId.ToggleChatTerminalOutput, - title: localize('showTerminalOutput', 'Show Output'), - icon: Codicon.chevronRight, - toggled: { - condition: TerminalChatContextKeys.chatToolOutputExpanded, - title: localize('hideTerminalOutput', 'Hide Output'), - icon: Codicon.chevronDown, - } - }, - when: TerminalChatContextKeys.chatToolHasOutput.isEqualTo(true), - order: 2, - group: 'navigation', -}); - /** * Options for configuring a terminal command decoration. */ @@ -315,12 +269,15 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private readonly _contentIndex: number; private readonly _sessionResource: URI; - // Scoped context keys that drive toolbar action visibility - private readonly _hasInstanceKey: IContextKey; - private readonly _canContinueInBackgroundKey: IContextKey; - private readonly _hasOutputKey: IContextKey; - private readonly _isHiddenTerminalKey: IContextKey; - private readonly _outputExpandedKey: IContextKey; + // Toolbar state that drives action visibility (replaces context keys to avoid + // accumulating listeners on the shared IContextKeyService when many parts exist) + private _toolbarHasInstance = false; + private _toolbarCanContinueInBackground = false; + private _toolbarHasOutput = false; + private _toolbarIsHiddenTerminal = false; + private _toolbarOutputExpanded = false; + private _actionBar: ActionBar | undefined; + private readonly _actionBarActions = new DisposableStore(); private readonly _terminalData: IChatTerminalToolInvocationData; private _terminalCommandUri: URI | undefined; @@ -359,7 +316,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart @ITerminalService private readonly _terminalService: ITerminalService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @IKeybindingService private readonly _keybindingService: IKeybindingService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @@ -430,38 +386,13 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._register(this._outputView.onDidBlur(e => this._handleOutputBlur(e))); this._register(toDisposable(() => this._handleDispose())); - // Create a scoped context key service for this toolbar so each progress part's - // context keys are independent from other parts. + // Use a lightweight ActionBar instead of MenuWorkbenchToolBar to avoid + // accumulating listeners on the shared IContextKeyService when many + // terminal tool progress parts exist concurrently (fixes listener LEAK). const actionBarEl = h('.chat-terminal-action-bar@actionBar'); elements.title.append(actionBarEl.root); - const toolbarContextKeyService = this._register(this._contextKeyService.createScoped(actionBarEl.actionBar)); - this._hasInstanceKey = TerminalChatContextKeys.chatToolHasInstance.bindTo(toolbarContextKeyService); - this._canContinueInBackgroundKey = TerminalChatContextKeys.chatToolCanContinueInBackground.bindTo(toolbarContextKeyService); - this._hasOutputKey = TerminalChatContextKeys.chatToolHasOutput.bindTo(toolbarContextKeyService); - this._isHiddenTerminalKey = TerminalChatContextKeys.chatToolIsHiddenTerminal.bindTo(toolbarContextKeyService); - this._outputExpandedKey = TerminalChatContextKeys.chatToolOutputExpanded.bindTo(toolbarContextKeyService); - const usesCollapsibleKey = TerminalChatContextKeys.chatToolUsesCollapsible.bindTo(toolbarContextKeyService); - - const scopedInstantiationService = this._register(this._instantiationService.createChild( - new ServiceCollection([IContextKeyService, toolbarContextKeyService]) - )); - this._register(scopedInstantiationService.createInstance( - MenuWorkbenchToolBar, - actionBarEl.actionBar, - MENU_CHAT_TERMINAL_TOOL_PROGRESS, - { - menuOptions: { arg: this, shouldForwardArgs: true }, - getKeyBinding: (action: IAction) => { - if (action.id === TerminalContribCommandId.FocusChatInstanceAction) { - return this._keybindingService.lookupKeybinding(TerminalContribCommandId.FocusMostRecentChatTerminal); - } - if (action.id === TerminalContribCommandId.ToggleChatTerminalOutput) { - return this._keybindingService.lookupKeybinding(TerminalContribCommandId.FocusMostRecentChatTerminalOutput); - } - return undefined; - }, - } - )); + this._actionBar = this._register(new ActionBar(actionBarEl.actionBar)); + this._register(this._actionBarActions); let didInitializeTerminalActions = false; const initializeTerminalActionsOnce = () => { if (didInitializeTerminalActions || this._store.isDisposed) { @@ -475,13 +406,14 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart initializeTerminalActionsOnce(); }); - // Listen for continue in background — sets context key so toolbar auto-hides the action + // Listen for continue in background — updates toolbar to auto-hide the action const terminalToolSessionId = this._terminalData.terminalToolSessionId; if (terminalToolSessionId) { this._register(this._terminalChatService.onDidContinueInBackground(sessionId => { if (sessionId === terminalToolSessionId) { this._terminalData.didContinueInBackground = true; - this._canContinueInBackgroundKey.set(false); + this._toolbarCanContinueInBackground = false; + this._updateToolbarActions(); } })); } @@ -526,7 +458,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const requiresConfirmation = toolInvocation.kind === 'toolInvocation' && IChatToolInvocation.getConfirmationMessages(toolInvocation); this._isInThinkingContainer = terminalToolsInThinking && !requiresConfirmation; this._usesCollapsibleWrapper = this._isInThinkingContainer || isSimpleTerminal; - usesCollapsibleKey.set(this._usesCollapsibleWrapper); if (this._usesCollapsibleWrapper) { this.domNode = this._createCollapsibleWrapper(progressPart.domNode, displayCommand, toolInvocation, context); @@ -696,7 +627,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart /** * Updates the scoped context keys that drive toolbar action visibility. - * The `MenuWorkbenchToolBar` automatically shows/hides actions based on these keys. + * The ActionBar is rebuilt with the correct set of visible actions. */ private _updateToolbarContextKeys(terminalInstance?: ITerminalInstance, terminalToolSessionId?: string): void { if (this._store.isDisposed) { @@ -705,26 +636,26 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const resolvedCommand = this._getResolvedCommand(terminalInstance); // Focus terminal action - this._hasInstanceKey.set(!!terminalInstance); + this._toolbarHasInstance = !!terminalInstance; if (terminalInstance && terminalToolSessionId) { - this._isHiddenTerminalKey.set(this._terminalChatService.isBackgroundTerminal(terminalToolSessionId)); + this._toolbarIsHiddenTerminal = this._terminalChatService.isBackgroundTerminal(terminalToolSessionId); } else { - this._isHiddenTerminalKey.set(false); + this._toolbarIsHiddenTerminal = false; } // Continue in background action if (terminalInstance && terminalToolSessionId && !this._terminalData.isBackground && !this._terminalData.didContinueInBackground) { const isStillRunning = resolvedCommand?.exitCode === undefined && this._terminalData.terminalCommandState?.exitCode === undefined; - this._canContinueInBackgroundKey.set(isStillRunning); + this._toolbarCanContinueInBackground = isStillRunning; } else { - this._canContinueInBackgroundKey.set(false); + this._toolbarCanContinueInBackground = false; } // Show output action (only when NOT using collapsible wrapper) if (!this._usesCollapsibleWrapper) { const hasSnapshot = !!this._terminalData.terminalCommandOutput; const hasOutput = !!resolvedCommand || hasSnapshot; - this._hasOutputKey.set(hasOutput); + this._toolbarHasOutput = hasOutput; // Auto-expand on first detection of failed output if (hasOutput && !this._outputView.isExpanded) { @@ -736,9 +667,63 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } } + this._updateToolbarActions(); this._decoration.update(resolvedCommand); } + /** + * Rebuilds the ActionBar actions based on current toolbar state. + */ + private _updateToolbarActions(): void { + if (!this._actionBar || this._store.isDisposed) { + return; + } + this._actionBar.clear(); + this._actionBarActions.clear(); + const actions: IAction[] = []; + if (this._toolbarCanContinueInBackground) { + const action = new Action( + TerminalContribCommandId.ContinueInBackground, + localize('continueInBackground', 'Continue in Background'), + ThemeIcon.asClassName(Codicon.debugContinue), + true, + () => this.continueInBackground() + ); + this._actionBarActions.add(action); + actions.push(action); + } + if (this._toolbarHasInstance) { + const focusLabel = this._toolbarIsHiddenTerminal + ? localize('showTerminal', 'Show and Focus Terminal') + : localize('focusTerminal', 'Focus Terminal'); + const action = new Action( + TerminalContribCommandId.FocusChatInstanceAction, + focusLabel, + ThemeIcon.asClassName(Codicon.openInProduct), + true, + () => this.focusTerminal() + ); + this._actionBarActions.add(action); + actions.push(action); + } + if (this._toolbarHasOutput && !this._usesCollapsibleWrapper) { + const toggleIcon = this._toolbarOutputExpanded ? Codicon.chevronDown : Codicon.chevronRight; + const toggleLabel = this._toolbarOutputExpanded + ? localize('hideTerminalOutput', 'Hide Output') + : localize('showTerminalOutput', 'Show Output'); + const action = new Action( + TerminalContribCommandId.ToggleChatTerminalOutput, + toggleLabel, + ThemeIcon.asClassName(toggleIcon), + true, + () => this.toggleOutputFromAction() + ); + this._actionBarActions.add(action); + actions.push(action); + } + this._actionBar.push(actions, { icon: true, label: false }); + } + private _getResolvedCommand(instance?: ITerminalInstance): ITerminalCommand | undefined { const target = instance ?? this._terminalInstance; if (!target) { @@ -908,7 +893,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const didChange = await this._outputView.toggle(expanded); const isExpanded = this._outputView.isExpanded; this._titleElement.classList.toggle('chat-terminal-content-title-no-bottom-radius', isExpanded); - this._outputExpandedKey.set(isExpanded); + this._toolbarOutputExpanded = isExpanded; + this._updateToolbarActions(); if (didChange) { expandedStateByInvocation.set(this.toolInvocation, isExpanded); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 56318e6db4a9d..d9e376596ef97 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1509,6 +1509,21 @@ export interface IDetachedXtermTerminal extends IXtermTerminal { */ reset(): void; + /** + * Updates the terminal configuration from current settings. + */ + updateConfig(): void; + + /** + * Updates the terminal theme from the current color theme. + */ + updateTheme(): void; + + /** + * Updates the xterm log level to match the given VS Code log level. + */ + updateLogLevel(): void; + /** * Access to the terminal buffer for reading cursor position and content. */ diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 5eaceee04d043..55fa8dc86152e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -20,7 +20,7 @@ import { IContextKey, IContextKeyService } from '../../../../platform/contextkey import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { ICreateContributedTerminalProfileOptions, IExtensionTerminalProfile, IPtyHostAttachTarget, IRawTerminalInstanceLayoutInfo, IRawTerminalTabLayoutInfo, IShellLaunchConfig, ITerminalBackend, ITerminalLaunchError, ITerminalLogService, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, TerminalExitReason, TerminalLocation, TitleEventSource } from '../../../../platform/terminal/common/terminal.js'; +import { ICreateContributedTerminalProfileOptions, IExtensionTerminalProfile, IPtyHostAttachTarget, IRawTerminalInstanceLayoutInfo, IRawTerminalTabLayoutInfo, IShellLaunchConfig, ITerminalBackend, ITerminalLaunchError, ITerminalLogService, ITerminalsLayoutInfo, ITerminalsLayoutInfoById, TerminalExitReason, TerminalLocation, TerminalSettingId, TitleEventSource } from '../../../../platform/terminal/common/terminal.js'; import { formatMessageForTerminal } from '../../../../platform/terminal/common/terminalStrings.js'; import { iconForeground } from '../../../../platform/theme/common/colorRegistry.js'; import { getIconRegistry } from '../../../../platform/theme/common/iconRegistry.js'; @@ -69,6 +69,7 @@ export class TerminalService extends Disposable implements ITerminalService { private _hostActiveTerminals: Map = new Map(); private _detachedXterms = new Set(); + private _detachedListenersRegistered = false; private readonly _terminalShellTypeContextKey: IContextKey; private _isShuttingDown: boolean = false; @@ -185,7 +186,8 @@ export class TerminalService extends Disposable implements ITerminalService { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ICommandService private readonly _commandService: ICommandService, @IKeybindingService private readonly _keybindingService: IKeybindingService, - @ITimerService private readonly _timerService: ITimerService + @ITimerService private readonly _timerService: ITimerService, + @IThemeService private readonly _themeService: IThemeService ) { super(); @@ -1101,6 +1103,7 @@ export class TerminalService extends Disposable implements ITerminalService { xtermColorProvider: options.colorProvider, capabilities, disableOverviewRuler: options.disableOverviewRuler, + detached: true, }, undefined); if (options.readonly) { @@ -1109,6 +1112,8 @@ export class TerminalService extends Disposable implements ITerminalService { const instance = new DetachedTerminal(xterm, { ...options, capabilities }, this._instantiationService); this._detachedXterms.add(instance); + // Ensure centralized theme/config listeners update this detached terminal + this._ensureDetachedTerminalListeners(); const l = xterm.onDidDispose(() => { this._detachedXterms.delete(instance); l.dispose(); @@ -1117,6 +1122,42 @@ export class TerminalService extends Disposable implements ITerminalService { return instance; } + /** + * Registers a single set of global service listeners (theme/config/log-level + * changes) that forward updates to all detached xterm instances. This avoids + * each detached terminal registering its own listener on global singletons. + */ + private _ensureDetachedTerminalListeners(): void { + if (this._detachedListenersRegistered) { + return; + } + this._detachedListenersRegistered = true; + this._register(this._themeService.onDidColorThemeChange(() => { + for (const instance of this._detachedXterms) { + instance.xterm.updateTheme(); + } + })); + this._register(this._configurationService.onDidChangeConfiguration(e => { + const shouldUpdateConfig = e.affectsConfiguration('terminal.integrated') || e.affectsConfiguration('editor.fastScrollSensitivity') || e.affectsConfiguration('editor.mouseWheelScrollSensitivity') || e.affectsConfiguration('editor.multiCursorModifier'); + const shouldUpdateTheme = e.affectsConfiguration(TerminalSettingId.ShellIntegrationDecorationsEnabled); + if (shouldUpdateConfig || shouldUpdateTheme) { + for (const instance of this._detachedXterms) { + if (shouldUpdateConfig) { + instance.xterm.updateConfig(); + } + if (shouldUpdateTheme) { + instance.xterm.updateTheme(); + } + } + } + })); + this._register(this._logService.onDidChangeLogLevel(() => { + for (const instance of this._detachedXterms) { + instance.xterm.updateLogLevel(); + } + })); + } + private async _resolveCwd(shellLaunchConfig: IShellLaunchConfig, splitActiveTerminal: boolean, options?: ICreateTerminalOptions): Promise { const cwd = shellLaunchConfig.cwd; if (!cwd) { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index b043728acf3dc..34bae81f28ee8 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -91,6 +91,14 @@ export interface IXtermTerminalOptions { xtermAddonImporter?: XtermAddonImporter; /** Whether to disable the overview ruler. */ disableOverviewRuler?: boolean; + /** + * When true, skips registering listeners on global singleton services + * (configuration, theme, log level) to avoid accumulating listeners when + * many detached terminals are created concurrently. The caller should use + * {@link XtermTerminal.updateConfig}, {@link XtermTerminal.updateTheme}, + * and {@link XtermTerminal.updateLogLevel} to apply those changes externally. + */ + detached?: boolean; } /** @@ -271,23 +279,27 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } this._core = (this.raw as ITerminalWithCore)._core as IXtermCore; - this._register(this._configurationService.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(TerminalSettingId.GpuAcceleration)) { - XtermTerminal._suggestedRendererType = undefined; - } - if (e.affectsConfiguration('terminal.integrated') || e.affectsConfiguration('editor.fastScrollSensitivity') || e.affectsConfiguration('editor.mouseWheelScrollSensitivity') || e.affectsConfiguration('editor.multiCursorModifier')) { - this.updateConfig(); - } - if (e.affectsConfiguration(TerminalSettingId.UnicodeVersion)) { - this._updateUnicodeVersion(); - } - if (e.affectsConfiguration(TerminalSettingId.ShellIntegrationDecorationsEnabled)) { - this._updateTheme(); - } - })); + // Skip global service listeners for detached terminals to avoid + // accumulating listeners when many detached instances exist concurrently. + if (!options.detached) { + this._register(this._configurationService.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(TerminalSettingId.GpuAcceleration)) { + XtermTerminal._suggestedRendererType = undefined; + } + if (e.affectsConfiguration('terminal.integrated') || e.affectsConfiguration('editor.fastScrollSensitivity') || e.affectsConfiguration('editor.mouseWheelScrollSensitivity') || e.affectsConfiguration('editor.multiCursorModifier')) { + this.updateConfig(); + } + if (e.affectsConfiguration(TerminalSettingId.UnicodeVersion)) { + this._updateUnicodeVersion(); + } + if (e.affectsConfiguration(TerminalSettingId.ShellIntegrationDecorationsEnabled)) { + this._updateTheme(); + } + })); - this._register(this._themeService.onDidColorThemeChange(theme => this._updateTheme(theme))); - this._register(this._logService.onDidChangeLogLevel(e => this.raw.options.logLevel = vscodeToXtermLogLevel(e))); + this._register(this._themeService.onDidColorThemeChange(theme => this._updateTheme(theme))); + this._register(this._logService.onDidChangeLogLevel(e => this.raw.options.logLevel = vscodeToXtermLogLevel(e))); + } // Refire events this._register(this.raw.onSelectionChange(() => { @@ -532,6 +544,10 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this.raw.resize(columns, rows); } + updateLogLevel(): void { + this.raw.options.logLevel = vscodeToXtermLogLevel(this._logService.getLevel()); + } + updateConfig(): void { const config = this._terminalConfigurationService.config; this.raw.options.altClickMovesCursor = config.altClickMovesCursor; @@ -1037,6 +1053,14 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this.raw.options.theme = this.getXtermTheme(theme); } + /** + * Updates the terminal theme. Use this to externally trigger a theme + * refresh for detached terminals that skip global service listeners. + */ + updateTheme(): void { + this._updateTheme(); + } + refresh() { this._updateTheme(); this._decorationAddon.refreshLayouts(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index 6fef09cce909a..33dc12554a6fa 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -60,6 +60,9 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute async execute(commandLine: string, token: CancellationToken, commandId?: string, _commandLineForMetadata?: string): Promise { const store = new DisposableStore(); + // Register with strategy lifetime so listeners are cleaned up if + // the strategy is disposed while execute() is still running. + this._register(store); try { const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index 50c27944c782e..648466e9bcd28 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -43,6 +43,11 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS async execute(commandLine: string, token: CancellationToken, commandId?: string, commandLineForMetadata?: string): Promise { const store = new DisposableStore(); + // Register the store with this strategy's disposable chain so that if + // the strategy is disposed while execute() is still running (e.g. the + // session is torn down), accumulated Event.toPromise listeners on + // shared emitters like onCommandFinished are cleaned up immediately. + this._register(store); try { // Ensure xterm is available this._log('Waiting for xterm'); From 4b5765fe58bc8f2f4338283acd76961768b9184a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 15 Apr 2026 12:15:43 -0400 Subject: [PATCH 15/15] Include terminal output in sendToTerminal result and improve notification labels (#309952) fix terminal approval UI missing backslashes on Windows paths (#309796)