diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index 1843789366c4ae..548207bcddec9a 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -25,7 +25,7 @@ import { ChatCompositeBar, IChatCompositeBarDelegate } from './chatCompositeBar. import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from './remoteHostUnavailableEmptyState.js'; import { SessionRemoteConnection } from './sessionRemoteConnection.js'; import { ISessionReadOnlyBannerContent, SessionReadOnlyBanner } from './sessionReadOnlyBanner.js'; -import { AbstractChatView, ChatViewKind, IChatViewOptions } from './chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions, ISelectWorkspaceOptions } from './chatView.js'; /** * The data + callbacks a {@link ChatGroupView} needs from its owning @@ -347,8 +347,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { return this._currentView.value?.submitInput() ?? Promise.resolve(false); } - selectWorkspace(folderUri: URI, providerId?: string): void { - this._currentView.value?.selectWorkspace(folderUri, providerId); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + this._currentView.value?.selectWorkspace(folderUri, options); } prefillInput(text: string): void { diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index 5298c64ae5d3e3..a5e7cb15321682 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -19,7 +19,7 @@ import { agentsPanelBorder } from '../../common/theme.js'; import { IChat } from '../../services/sessions/common/session.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; -import { IChatViewOptions } from './chatView.js'; +import { IChatViewOptions, ISelectWorkspaceOptions } from './chatView.js'; import { ChatGroupView, IChatGroupContext } from './chatGroupView.js'; import { ChatDropZone, ChatGroupDropTarget, IChatGroupDropTargetDelegate } from './chatGroupDropTarget.js'; import { IDraggedSessionChat, isSessionChatDrag } from '../dnd.js'; @@ -805,8 +805,8 @@ export class ChatGroupsView extends Themable { return this._activeGroup?.view.submitInput() ?? Promise.resolve(false); } - selectWorkspace(folderUri: URI, providerId?: string): void { - this._activeGroup?.view.selectWorkspace(folderUri, providerId); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + this._activeGroup?.view.selectWorkspace(folderUri, options); } prefillInput(text: string): void { diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 7ad49b776284ec..f1638f696d91c3 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -26,6 +26,11 @@ export type ChatViewKind = 'newSession' | 'newChatInSession' | 'chat'; export interface IChatViewOptions { } +export interface ISelectWorkspaceOptions { + readonly providerId?: string; + readonly preferDevContainer?: boolean; +} + /** * Base class for a view that lives inside the {@link SessionsPart} internal grid. * Each instance occupies a single grid leaf. Subclasses populate {@link element} @@ -81,7 +86,7 @@ export abstract class AbstractChatView extends Disposable implements ISerializab * implementation is a no-op; subclasses that host a workspace picker * (e.g. `NewChatView`) override this to forward the selection. */ - selectWorkspace(_folderUri: URI, _providerId?: string): void { + selectWorkspace(_folderUri: URI, _options?: ISelectWorkspaceOptions): void { // no-op by default } diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 5513c53b22ffdc..637668bf2ea6a5 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -15,7 +15,7 @@ import { IContextKey, IContextKeyService } from '../../../platform/contextkey/co import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { IChat } from '../../services/sessions/common/session.js'; -import { AbstractChatView, IChatViewOptions } from './chatView.js'; +import { AbstractChatView, IChatViewOptions, ISelectWorkspaceOptions } from './chatView.js'; import { ChatGroupsView } from './chatGroupsView.js'; import { SessionHeader, SessionViewFloatingToolbar } from './sessionHeader.js'; import { ISessionContext, SessionContext } from '../../services/sessions/browser/sessionContext.js'; @@ -293,9 +293,9 @@ export class SessionView extends Disposable implements ISerializableView { return this._currentSession; } - selectWorkspace(folderUri: URI, providerId?: string): void { + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { const standaloneView = this._standaloneView.value; - standaloneView ? standaloneView.selectWorkspace(folderUri, providerId) : this._groupsView.selectWorkspace(folderUri, providerId); + standaloneView ? standaloneView.selectWorkspace(folderUri, options) : this._groupsView.selectWorkspace(folderUri, options); } /** Opens the given chat in a group beside the active one ("open to the side"). */ diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 234ef7dce0b486..fa99397f2e590e 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -171,6 +171,8 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { isDevContainerEnabled?(sessionId: string): boolean; /** Set whether this draft should run on a Dev Container Agent Host. */ setDevContainerEnabled?(sessionId: string, enabled: boolean): void; + /** Enable Dev Container execution once availability resolves for this draft. */ + preferDevContainer?(sessionId: string): void; // -- Dynamic Session Config -- diff --git a/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts b/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts new file mode 100644 index 00000000000000..bdb64ba2424e4f --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { decodeHex } from '../../../../base/common/buffer.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { DevContainerAgentHostEnabledSettingId } from '../../../common/devContainerAgentHostService.js'; + +const DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX = 'dev-container+'; + +export interface IAgentsWindowFolderIntent { + readonly folderUri: URI | undefined; + readonly preferDevContainer: boolean; +} + +export function resolveAgentsWindowFolderIntent(workspaceUri: URI | undefined, configurationService: IConfigurationService): IAgentsWindowFolderIntent { + if (workspaceUri?.scheme === Schemas.file) { + return { folderUri: workspaceUri, preferDevContainer: false }; + } + if (workspaceUri?.scheme !== Schemas.vscodeRemote || !workspaceUri.authority.startsWith(DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX)) { + return { folderUri: undefined, preferDevContainer: false }; + } + try { + return { + folderUri: URI.file(decodeHex(workspaceUri.authority.slice(DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX.length)).toString()), + preferDevContainer: configurationService.getValue(DevContainerAgentHostEnabledSettingId) === true, + }; + } catch (error) { + if (error instanceof SyntaxError) { + return { folderUri: undefined, preferDevContainer: false }; + } + throw error; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 93c5cd32fb595c..1dae88cd3a7fa2 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -36,7 +36,7 @@ import { IChatModel } from '../../../../workbench/contrib/chat/common/model/chat import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IChatSessionsService, localChatSessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { AbstractChatView, ChatViewKind, IChatViewOptions } from '../../../browser/parts/chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions, ISelectWorkspaceOptions } from '../../../browser/parts/chatView.js'; import { ChatInteractivity, getSessionStatusMessage, IChat, isActiveSessionStatus, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; import { NewChatWidget } from './newChatWidget.js'; @@ -104,9 +104,9 @@ export class NewChatView extends AbstractChatView { this._widget.focusInput(); } - override selectWorkspace(folderUri: URI, providerId?: string): void { + override selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { if (this._widget instanceof NewChatWidget) { - this._widget.selectWorkspace(folderUri, providerId); + this._widget.selectWorkspace(folderUri, options); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 74aae8f54b12bd..429fd585751acc 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -25,8 +25,10 @@ import { localize } from '../../../../nls.js'; import { IActiveSession, ICreateNewSessionOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../services/sessions/common/session.js'; import { IOpenNewSessionResult, ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { isAllowSignedOutWhenUsableEnabled, shouldShowGitHubWorkspaceGroupSignIn } from '../../../browser/sessionsAuthGate.js'; import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; import { IWorkspacePickerNoWorkspaceOption, IWorkspacePickerTrigger, WorkspacePicker } from './sessionWorkspacePicker.js'; import { WebWorkspacePicker } from './webWorkspacePicker.js'; @@ -35,7 +37,7 @@ import { NewChatInputWidget } from './newChatInput.js'; import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; -import { IChatViewOptions } from '../../../browser/parts/chatView.js'; +import { IChatViewOptions, ISelectWorkspaceOptions } from '../../../browser/parts/chatView.js'; import { SessionWorkspacePickerVisibleContext } from '../../../common/contextkeys.js'; import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; import { buildNewSessionPrompt } from '../../agentFeedback/browser/agentFeedbackAttachmentEntry.js'; @@ -70,6 +72,7 @@ export class NewChatWidget extends Disposable { /** Recreates the draft once a better/late-registering provider can serve the folder (see {@link _createNewSession}). */ private readonly _pendingPreferredUpgrade = new MutableDisposable(); private readonly _newSessionCreation = new MutableDisposable(); + private _preferredDevContainerFolderUri: URI | undefined; /** * The currently mounted no-agent-host empty state, if any. Set by @@ -114,6 +117,7 @@ export class NewChatWidget extends Disposable { @ILogService private readonly logService: ILogService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IAquariumService private readonly aquariumService: IAquariumService, @IAgentHostFilterService private readonly agentHostFilterService: IAgentHostFilterService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @@ -618,13 +622,16 @@ export class NewChatWidget extends Disposable { } else { return result; } + this._applyPreferredDevContainer(result.session, folderUri); if (result.trustDeclined) { + this._preferredDevContainerFolderUri = undefined; // The user explicitly declined trust: don't schedule a retry, which // would silently recreate (and possibly re-prompt) the draft once a // provider registers/changes without any further user action. this._pendingPreferredUpgrade.clear(); return result; } + // Keep the draft in sync with late-registering providers. Agent hosts // connect lazily, so there is no timeout — the listener lives until the // draft is sent or replaced. We watch when: @@ -640,6 +647,18 @@ export class NewChatWidget extends Disposable { return result; } + private _applyPreferredDevContainer(session: ISession | undefined, folderUri: URI): void { + if (!session || !this._preferredDevContainerFolderUri || !this.uriIdentityService.extUri.isEqual(this._preferredDevContainerFolderUri, folderUri)) { + return; + } + const provider = this.sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider) || !provider.preferDevContainer) { + return; + } + provider.preferDevContainer(session.sessionId); + this._preferredDevContainerFolderUri = undefined; + } + private async _createSessionNow(folderUri: URI, userPick: IPreferredSessionType | undefined, token: CancellationToken): Promise { // Prefer the user's explicit pick when its provider can serve the // folder; otherwise fall back to the preferred (first) session type. @@ -997,6 +1016,9 @@ export class NewChatWidget extends Disposable { private async _onWorkspaceSelected(folderUri: URI | undefined): Promise { // Cancel any in-flight upgrade for a previous selection. this._pendingPreferredUpgrade.clear(); + if (!folderUri || !this._preferredDevContainerFolderUri || !this.uriIdentityService.extUri.isEqual(this._preferredDevContainerFolderUri, folderUri)) { + this._preferredDevContainerFolderUri = undefined; + } const currentFolderUri = this._session.get()?.workspace.get()?.folders[0]?.root; const refreshingPromptOptions = !!currentFolderUri && (!folderUri || !this.uriIdentityService.extUri.isEqual(currentFolderUri, folderUri)) @@ -1054,8 +1076,9 @@ export class NewChatWidget extends Disposable { this._newChatInput.attach(uris); } - selectWorkspace(folderUri: URI, providerId?: string): void { - this._workspacePicker.setSelectedWorkspace(folderUri, { providerId }); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + this._preferredDevContainerFolderUri = options?.preferDevContainer ? folderUri : undefined; + this._workspacePicker.setSelectedWorkspace(folderUri, { providerId: options?.providerId }); } } diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index e8371d55cece66..4a4f1ab768ed9a 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -8,6 +8,7 @@ import { URI, UriComponents } from '../../../../base/common/uri.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { AgentHostByokLmHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -27,6 +28,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from '../browser/newSessionComposerService.js'; +import { resolveAgentsWindowFolderIntent } from '../browser/agentsWindowOpenIntent.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { @@ -46,16 +48,18 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INewSessionComposerService private readonly newSessionComposerService: INewSessionComposerService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); const handleSelectAgentsFolder = (_: unknown, ...args: unknown[]) => { - const folderUri = args[0] ? URI.revive(args[0] as UriComponents) : undefined; + const workspaceUri = args[0] ? URI.revive(args[0] as UriComponents) : undefined; + const { folderUri, preferDevContainer } = resolveAgentsWindowFolderIntent(workspaceUri, this.configurationService); const sessionResource = args[1] ? URI.revive(args[1] as UriComponents) : undefined; const source = isAgentsWindowOpenSource(args[2]) ? args[2] : AgentsWindowOpenSource.Unknown; this.logService.info(`[AgentsHandoff] IPC received: folderUri=${folderUri?.toString() ?? '(none)'} sessionResource=${sessionResource?.toString() ?? '(none)'}`); this._startWindowOpenTelemetry(source); - this._handleOpenIntentAndCaptureInitialState(folderUri, sessionResource) + this._handleOpenIntentAndCaptureInitialState(folderUri, sessionResource, preferDevContainer) .catch(err => this.logService.error('[AgentsHandoff] handleOpenIntent failed', err)); }; ipcRenderer.on('vscode:selectAgentsFolder', handleSelectAgentsFolder); @@ -87,9 +91,9 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon this._windowOpenTelemetry.value?.captureInitialViewState(); } - private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined): Promise { + private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise { try { - await this.handleOpenIntent(folderUri, sessionResource); + await this.handleOpenIntent(folderUri, sessionResource, preferDevContainer); } finally { await this._captureInitialWindowViewState(); } @@ -114,7 +118,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon }; } - private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined): Promise { + private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise { // Opening an existing session establishes its own workspace context, so // the folder selection is only needed for the folder-only handoff (no // session to restore). @@ -123,7 +127,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon return; } if (folderUri) { - await this.selectFolder(folderUri); + await this.selectFolder(folderUri, preferDevContainer); } } @@ -186,11 +190,11 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon }); } - private async selectFolder(folderUri: URI): Promise { + private async selectFolder(folderUri: URI, preferDevContainer: boolean): Promise { // Wait for the welcome/setup flow to complete before selecting the folder await this.sessionsSetUpService.whenWelcomeDone(); - this.sessionsService.openNewSession(); + await this.sessionsService.openNewSession({ cancelRestore: true }); // Tell the sessions list this folder is the open-window source folder // so it ranks the matching folder section first. Get the view if it @@ -198,27 +202,30 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon const sessionsView = this.viewsService.getViewWithId(SessionsListViewId); sessionsView?.sessionsControl?.setOpenWindowSourceFolder(folderUri); - if (this.tryResolveAndSelect(folderUri)) { + if (this.tryResolveAndSelect(folderUri, preferDevContainer)) { return; } // Provider not registered yet — wait for it, but give up at Eventually phase const disposable = this.sessionsProvidersService.onDidChangeProviders(() => { - if (this.tryResolveAndSelect(folderUri)) { + if (this.tryResolveAndSelect(folderUri, preferDevContainer)) { disposable.dispose(); } }); this.lifecycleService.when(LifecyclePhase.Eventually).then(() => disposable.dispose()); } - private tryResolveAndSelect(folderUri: URI): boolean { + private tryResolveAndSelect(folderUri: URI, preferDevContainer: boolean): boolean { const resolved = this.sessionsManagementService.resolveWorkspace(folderUri); if (!resolved) { return false; } const activeSession = this.sessionsService.activeSession.get(); if (activeSession === undefined || activeSession.status.get() === SessionStatus.Untitled) { - this.sessionsPartService.getSessionView(activeSession?.sessionId)?.selectWorkspace(folderUri, resolved.providerId); + this.sessionsPartService.getSessionView(activeSession?.sessionId)?.selectWorkspace(folderUri, { + providerId: resolved.providerId, + preferDevContainer, + }); } return true; } diff --git a/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts new file mode 100644 index 00000000000000..cb9c4254ab1bf4 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { encodeHex, VSBuffer } from '../../../../../base/common/buffer.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { DevContainerAgentHostEnabledSettingId } from '../../../../common/devContainerAgentHostService.js'; +import { resolveAgentsWindowFolderIntent } from '../../browser/agentsWindowOpenIntent.js'; + +suite('Agents Window open intent', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('resolves local and Dev Container editor workspaces', () => { + const configurationService = (enabled: boolean) => new TestConfigurationService({ + [DevContainerAgentHostEnabledSettingId]: enabled, + }); + const localFolder = URI.file('/workspace'); + const hostFolder = URI.file('/host/workspace'); + const devContainerFolder = URI.from({ + scheme: Schemas.vscodeRemote, + authority: `dev-container+${encodeHex(VSBuffer.fromString(hostFolder.fsPath))}`, + path: '/workspaces/project', + }); + const resolve = (uri: URI | undefined, enabled: boolean) => { + const result = resolveAgentsWindowFolderIntent(uri, configurationService(enabled)); + return { + folderUri: result.folderUri?.toString(), + preferDevContainer: result.preferDevContainer, + }; + }; + + assert.deepStrictEqual({ + local: resolve(localFolder, true), + devContainerEnabled: resolve(devContainerFolder, true), + devContainerDisabled: resolve(devContainerFolder, false), + otherRemote: resolve(URI.parse('vscode-remote://ssh-remote+host/workspace'), true), + invalidDevContainer: resolve(URI.parse('vscode-remote://dev-container+invalid/workspace'), true), + }, { + local: { folderUri: localFolder.toString(), preferDevContainer: false }, + devContainerEnabled: { folderUri: hostFolder.toString(), preferDevContainer: true }, + devContainerDisabled: { folderUri: hostFolder.toString(), preferDevContainer: false }, + otherRemote: { folderUri: undefined, preferDevContainer: false }, + invalidDevContainer: { folderUri: undefined, preferDevContainer: false }, + }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index 650379ac503032..af7c9b19282792 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise } from '../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -22,6 +22,7 @@ import { NewChatWidget } from '../../browser/newChatWidget.js'; import { IChatRequestVariableEntry, toFileVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { IWorkspacePickerNoWorkspaceOption } from '../../browser/sessionWorkspacePicker.js'; /** The part of the active session `_recreateOnProviderChange` actually reads. */ @@ -67,6 +68,7 @@ interface INewChatWidgetHarness extends IRecreateHarness { }; }; _createSessionNow(folderUri: URI, userPick: IPreferredSessionType | undefined, token: CancellationToken): Promise; + _applyPreferredDevContainer(session: ISession | undefined, folderUri: URI): void; _scheduleRecreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined, replayMissedChange: boolean): void; _recreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined): void; } @@ -81,6 +83,17 @@ const createSessionNow = Reflect.get(NewChatWidget.prototype, '_createSessionNow userPick: IPreferredSessionType | undefined, token: CancellationToken, ) => Promise; +const applyPreferredDevContainer = Reflect.get(NewChatWidget.prototype, '_applyPreferredDevContainer') as ( + this: { + _preferredDevContainerFolderUri: URI | undefined; + readonly uriIdentityService: { readonly extUri: typeof extUri }; + readonly sessionsProvidersService: { + getProvider(providerId: string): { readonly id: string; preferDevContainer?(sessionId: string): void } | undefined; + }; + }, + session: ISession | undefined, + folderUri: URI, +) => void; const scheduleRecreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_scheduleRecreateOnProviderChange') as INewChatWidgetHarness['_scheduleRecreateOnProviderChange']; const recreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_recreateOnProviderChange') as ( this: IRecreateHarness, @@ -181,6 +194,7 @@ function createHarness( }, _isPreferredServable: () => false, _createSessionNow: (_folderUri, _userPick, token) => stubCreateSessionNow(token), + _applyPreferredDevContainer: () => { }, _createNewSession: folderUri => createNewSession.call(harness, folderUri), _scheduleRecreateOnProviderChange: (folderUri, userPick, created, replayMissedChange) => scheduleRecreateOnProviderChange.call(harness, folderUri, userPick, created, replayMissedChange), _recreateOnProviderChange: (folderUri, userPick, created) => recreateOnProviderChange.call(harness, folderUri, userPick, created), @@ -412,21 +426,102 @@ suite('NewChatWidget', () => { assert.strictEqual(createCount, 2); }); - test('waits for another provider change after creation fails', async () => { + test('applies the Dev Container preference when a late provider creates the draft', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); const newSessionCreation = disposables.add(new MutableDisposable()); let createCount = 0; + const applied: string[] = []; + const session = upcastPartial({ sessionId: 'draft', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }); const harness = createHarness(pendingPreferredUpgrade, newSessionCreation, sessionTypesChanged.event, async () => { createCount++; - return { session: undefined, trustDeclined: false }; + return { session: createCount === 1 ? undefined : session, trustDeclined: false }; }); + harness._applyPreferredDevContainer = created => { + if (created) { + applied.push(created.sessionId); + } + }; await harness._createNewSession(URI.file('/project')); const countBeforeChange = createCount; sessionTypesChanged.fire(); + await timeout(0); + + assert.deepStrictEqual({ + countBeforeChange, + countAfterChange: createCount, + applied, + }, { + countBeforeChange: 1, + countAfterChange: 2, + applied: ['draft'], + }); + }); + + test('applies the Dev Container preference after the composer creates the requested draft', async () => { + const sessionTypesChanged = disposables.add(new Emitter()); + const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); + const newSessionCreation = disposables.add(new MutableDisposable()); + const folder = URI.file('/project'); + const session = upcastPartial({ sessionId: 'draft', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }); + const applied: Array<{ sessionId: string; folder: string }> = []; + const harness = createHarness(pendingPreferredUpgrade, newSessionCreation, sessionTypesChanged.event, async () => ({ + session, + trustDeclined: false, + })); + harness._applyPreferredDevContainer = (created, createdFolder) => { + if (created) { + applied.push({ sessionId: created.sessionId, folder: createdFolder.toString() }); + } + }; + + await harness._createNewSession(folder); + + assert.deepStrictEqual(applied, [{ sessionId: 'draft', folder: folder.toString() }]); + }); - assert.deepStrictEqual({ countBeforeChange, countAfterChange: createCount }, { countBeforeChange: 1, countAfterChange: 2 }); + test('applies a pending Dev Container preference only to a matching Agent Host draft', () => { + const folder = URI.file('/project'); + const preferred: string[] = []; + const harness = { + _preferredDevContainerFolderUri: folder, + uriIdentityService: { extUri }, + sessionsProvidersService: { + getProvider: (providerId: string) => providerId === LOCAL_AGENT_HOST_PROVIDER_ID + ? { id: providerId, preferDevContainer: (sessionId: string) => preferred.push(sessionId) } + : { id: providerId }, + }, + }; + applyPreferredDevContainer.call( + harness, + upcastPartial({ sessionId: 'other', providerId: 'other-provider' }), + folder, + ); + const pendingAfterOtherProvider = harness._preferredDevContainerFolderUri?.toString(); + applyPreferredDevContainer.call( + harness, + upcastPartial({ sessionId: 'local', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }), + URI.file('/other-project'), + ); + const pendingAfterOtherFolder = harness._preferredDevContainerFolderUri?.toString(); + applyPreferredDevContainer.call( + harness, + upcastPartial({ sessionId: 'local', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }), + folder, + ); + + assert.deepStrictEqual({ + preferred, + pendingAfterOtherProvider, + pendingAfterOtherFolder, + pendingAfterMatch: harness._preferredDevContainerFolderUri, + }, { + preferred: ['local'], + pendingAfterOtherProvider: folder.toString(), + pendingAfterOtherFolder: folder.toString(), + pendingAfterMatch: undefined, + }); }); test('cancels an in-flight creation when a newer one starts', async () => { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 8631d5c598acbe..6f746ec0c4c3bc 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -123,6 +123,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide private _automationSessionResources = new ResourceSet(); private readonly _devContainerAvailableDrafts = new Set(); private readonly _devContainerDrafts = new Set(); + private readonly _pendingDevContainerEnablement = new Set(); override get order(): number { return -1; } @@ -308,11 +309,16 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide try { const available = await this._devContainerAgentHostService.isAvailable(workspaceUri); if (!available || !this._getNewSession(sessionId)) { + this._pendingDevContainerEnablement.delete(sessionId); return; } this._devContainerAvailableDrafts.add(sessionId); + if (this._pendingDevContainerEnablement.delete(sessionId)) { + this._devContainerDrafts.add(sessionId); + } this._onDidChangeSessionConfig.fire(sessionId); } catch (error) { + this._pendingDevContainerEnablement.delete(sessionId); this._logService.warn(`[${this.id}] Failed to resolve Dev Container availability for ${workspaceUri.toString()}`, error); } } @@ -325,6 +331,18 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide return this._devContainerDrafts.has(sessionId); } + preferDevContainer(sessionId: string): void { + if (!this._getNewSession(sessionId)) { + throw new Error(`Cannot configure unknown new session '${sessionId}'.`); + } + if (this._devContainerAvailableDrafts.has(sessionId)) { + this._devContainerDrafts.add(sessionId); + this._onDidChangeSessionConfig.fire(sessionId); + } else { + this._pendingDevContainerEnablement.add(sessionId); + } + } + setDevContainerEnabled(sessionId: string, enabled: boolean): void { if (!this._getNewSession(sessionId)) { throw new Error(`Cannot configure unknown new session '${sessionId}'.`); @@ -333,9 +351,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide throw new Error(`Cannot enable Dev Container execution for unavailable session '${sessionId}'.`); } if (enabled) { + this._pendingDevContainerEnablement.delete(sessionId); this._devContainerDrafts.add(sessionId); } else { this._devContainerDrafts.delete(sessionId); + this._pendingDevContainerEnablement.delete(sessionId); } this._onDidChangeSessionConfig.fire(sessionId); } @@ -524,12 +544,14 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide override deleteNewSession(sessionId: string): void { this._devContainerAvailableDrafts.delete(sessionId); this._devContainerDrafts.delete(sessionId); + this._pendingDevContainerEnablement.delete(sessionId); super.deleteNewSession(sessionId); } protected override _disposeAllNewSessions(): void { this._devContainerAvailableDrafts.clear(); this._devContainerDrafts.clear(); + this._pendingDevContainerEnablement.clear(); super._disposeAllNewSessions(); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index c8018bc4f46a29..546230dd8a6645 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -3348,6 +3348,94 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(provider.getSessionConfig(session.sessionId), undefined); }); + test('enables a preferred Dev Container after asynchronous availability resolves', async () => { + const availability = new DeferredPromise(); + const provider = createProvider(disposables, agentHost, undefined, { + devContainerAgentHostService: new class extends mock() { + override async isAvailable(): Promise { + return availability.p; + } + }(), + }); + const session = provider.createNewSession( + URI.file('/home/user/project'), + provider.sessionTypes[0].id, + ); + provider.preferDevContainer(session.sessionId); + const beforeResolution = provider.isDevContainerEnabled(session.sessionId); + availability.complete(true); + await timeout(0); + + assert.deepStrictEqual({ + beforeResolution, + available: provider.isDevContainerAvailable(session.sessionId), + enabled: provider.isDevContainerEnabled(session.sessionId), + }, { + beforeResolution: false, + available: true, + enabled: true, + }); + }); + + test('enables a preferred Dev Container immediately after availability resolved', async () => { + const provider = createProvider(disposables, agentHost, undefined, { + devContainerAgentHostService: new class extends mock() { + override async isAvailable(): Promise { + return true; + } + }(), + }); + const session = provider.createNewSession( + URI.file('/home/user/project'), + provider.sessionTypes[0].id, + ); + await timeout(0); + provider.preferDevContainer(session.sessionId); + + assert.deepStrictEqual({ + available: provider.isDevContainerAvailable(session.sessionId), + enabled: provider.isDevContainerEnabled(session.sessionId), + }, { + available: true, + enabled: true, + }); + }); + + test('does not enable a preferred Dev Container when unavailable or canceled', async () => { + const unavailable = new DeferredPromise(); + const canceled = new DeferredPromise(); + let call = 0; + const provider = createProvider(disposables, agentHost, undefined, { + devContainerAgentHostService: new class extends mock() { + override async isAvailable(): Promise { + return ++call === 1 ? unavailable.p : canceled.p; + } + }(), + }); + const unavailableSession = provider.createNewSession(URI.file('/unavailable'), provider.sessionTypes[0].id); + provider.preferDevContainer(unavailableSession.sessionId); + unavailable.complete(false); + await timeout(0); + + const canceledSession = provider.createNewSession(URI.file('/canceled'), provider.sessionTypes[0].id); + provider.preferDevContainer(canceledSession.sessionId); + provider.setDevContainerEnabled(canceledSession.sessionId, false); + canceled.complete(true); + await timeout(0); + + assert.deepStrictEqual({ + unavailableAvailable: provider.isDevContainerAvailable(unavailableSession.sessionId), + unavailableEnabled: provider.isDevContainerEnabled(unavailableSession.sessionId), + canceledAvailable: provider.isDevContainerAvailable(canceledSession.sessionId), + canceledEnabled: provider.isDevContainerEnabled(canceledSession.sessionId), + }, { + unavailableAvailable: false, + unavailableEnabled: false, + canceledAvailable: true, + canceledEnabled: false, + }); + }); + test('prepareNewSession does not start a Dev Container when workspace trust is denied', async () => { let connectCalls = 0; const devContainerAgentHostService = new class extends mock() { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts index 3f8652cb77324d..e44b688959b30a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -1099,7 +1099,7 @@ async function promptForTunnelFolder( } sessionsService.openNewSession(); - sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, provider.id); + sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, { providerId: provider.id }); } registerAction2(class extends Action2 { @@ -1290,7 +1290,7 @@ async function promptForWSLFolder( } sessionsService.openNewSession(); - sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, provider.id); + sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, { providerId: provider.id }); } registerAction2(class extends Action2 { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 4fcb2e70c0d845..88cbe99e0b0bbe 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -491,7 +491,7 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { const newSession = sessionsService.activeSession.get(); if (folderUri) { - sessionsPartService.getSessionView(newSession?.sessionId)?.selectWorkspace(folderUri, providerId); + sessionsPartService.getSessionView(newSession?.sessionId)?.selectWorkspace(folderUri, { providerId }); } // On mobile web, the sidebar drawer covers the viewport; close it so diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 72b52483b43239..fce727cd2d9bdf 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -62,6 +62,8 @@ export interface IOpenNewSessionOptions extends ICreateNewSessionOptions { * (restoring any pending draft). */ readonly folderUri?: URI; + /** Cancel startup session restoration so this new-session navigation wins. */ + readonly cancelRestore?: boolean; } /** @@ -343,8 +345,8 @@ export class SessionsService extends Disposable implements ISessionsService { * Cancellation for the in-flight {@link restoreVisibleSessions}. Kept * separate from {@link _openSessionCts} so that additive new-session * operations (the new-chat composer eagerly creating a draft on startup) - * do not abort restoring the previously visible grid. Only an explicit - * navigation to a specific session cancels a restore. + * do not abort restoring the previously visible grid. Explicit navigation + * to a session, or a new-session handoff with `cancelRestore`, cancels it. */ private readonly _restoreCts = this._register(new MutableDisposable()); @@ -718,8 +720,8 @@ export class SessionsService extends Disposable implements ISessionsService { /** * Cancel an in-flight {@link restoreVisibleSessions}. Called when the user - * explicitly navigates to a specific session, so restore stops fighting - * the user's choice. Additive new-session operations do NOT call this. + * explicitly navigates to a session, including a new-session handoff that + * sets `cancelRestore`, so restore stops fighting the user's choice. */ private _cancelRestore(): void { // `cancel()` (not just `clear()`/dispose) so the in-flight restore's @@ -1050,6 +1052,9 @@ export class SessionsService extends Disposable implements ISessionsService { } private async _openNewSession(options: IOpenNewSessionOptions | undefined, token: CancellationToken, intent: SessionNavigationIntent): Promise { + if (options?.cancelRestore) { + this._cancelRestore(); + } const folderUri = options?.folderUri; if (folderUri) { // Single trust gate for every path that creates a concrete session for diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 05d954673f50d1..d912e48affe8d7 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -817,6 +817,57 @@ suite('SessionsManagementService', () => { }); }); + test('RACE: an explicit new-session handoff cancels startup restoration', async () => { + const targetSession = stubSession({ sessionId: 'target', providerId: 'test' }); + const newSession = stubSession({ sessionId: 'fresh', providerId: 'test' }); + const onDidChangeSessions = disposables.add(new Emitter()); + let sessions: ISession[] = []; + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeSessions = onDidChangeSessions.event; + constructor() { super(targetSession); } + override getSessions(): ISession[] { return sessions; } + override createNewSession(): ISession { return newSession; } + override resolveWorkspace(): ISessionWorkspace { + return { folders: [], requiresWorkspaceTrust: false, isVirtualWorkspace: false } as unknown as ISessionWorkspace; + } + }; + const storage = disposables.add(new InMemoryStorageService()); + storage.store( + 'agentSessions.activeSessionStates', + JSON.stringify([{ sessionResource: targetSession.resource.toString(), visibleOrder: 0, isActive: true }]), + 1 /* StorageScope.WORKSPACE */, + 1 /* StorageTarget.MACHINE */, + ); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService([provider])); + instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); + instantiationService.stub(IChatWidgetService, new TestChatWidgetService()); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IChatService, new class extends mock() { + override readonly onDidSubmitRequest = Event.None; + }); + const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); + const restorePromise = view.restoreVisibleSessions(); + await Promise.resolve(); + + await view.openNewSession({ folderUri: URI.file('/folder'), cancelRestore: true }); + sessions = [targetSession]; + onDidChangeSessions.fire({ added: [targetSession], removed: [], changed: [] }); + await restorePromise; + + assert.deepStrictEqual({ + hasTarget: view.visibleSessions.get().some(session => session?.sessionId === 'target'), + active: view.activeSession.get()?.sessionId, + }, { + hasTarget: false, + active: 'fresh', + }); + }); + test.skip('openNewSession inherits the active session workspace when requested', async () => { const makeWorkspace = (uri: URI): ISessionWorkspace => ({ uri, diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 5b455ab42cd75a..d3c7f201ffdaed 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -47,8 +47,10 @@ const OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE_BAR_COMMAND_ID = 'workbench.action.c async function openCurrentWorkspaceInAgentsWindow(accessor: ServicesAccessor, source: AgentsWindowOpenSource): Promise { const nativeHostService = accessor.get(INativeHostService); const workspaceContextService = accessor.get(IWorkspaceContextService); - const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; - await nativeHostService.openAgentsWindow({ folderUri: folderUri?.scheme === Schemas.file ? folderUri : undefined, source }); + await nativeHostService.openAgentsWindow({ + folderUri: workspaceContextService.getWorkspace().folders[0]?.uri, + source, + }); } function isOpenChatSessionInAgentsWindowOptions(value: unknown): value is { readonly agentsWindowOpenSource: AgentsWindowOpenSource } { diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts new file mode 100644 index 00000000000000..ffaea1eb0df2ff --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { encodeHex, VSBuffer } from '../../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INativeHostService, IOpenAgentsWindowOptions } from '../../../../../platform/native/common/native.js'; +import { AgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { OpenWorkspaceInAgentsWindowAction } from '../../electron-browser/agentSessions/agentSessionsActions.js'; + +suite('OpenWorkspaceInAgentsWindowAction', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('opens the Agents Window with the local folder and Dev Container preference', async () => { + const store = disposables.add(new DisposableStore()); + const instantiationService = store.add(new TestInstantiationService()); + let workspaceFolderUri = URI.file('/workspace'); + const calls: IOpenAgentsWindowOptions[] = []; + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => ({ + id: 'workspace', + folders: [{ + uri: workspaceFolderUri, + name: 'workspace', + index: 0, + toResource: relativePath => URI.joinPath(workspaceFolderUri, relativePath), + }], + }), + })); + instantiationService.stub(INativeHostService, upcastPartial({ + openAgentsWindow: async options => { calls.push(options ?? {}); }, + })); + + await instantiationService.invokeFunction(accessor => new OpenWorkspaceInAgentsWindowAction().run(accessor, { + source: AgentsWindowOpenSource.TitleBar, + })); + const hostFolderUri = URI.file('/host/workspace'); + workspaceFolderUri = URI.from({ + scheme: Schemas.vscodeRemote, + authority: `dev-container+${encodeHex(VSBuffer.fromString(hostFolderUri.fsPath))}`, + path: '/workspaces/project', + }); + await instantiationService.invokeFunction(accessor => new OpenWorkspaceInAgentsWindowAction().run(accessor, { + source: AgentsWindowOpenSource.ChatTitleBar, + })); + + assert.deepStrictEqual(calls.map(call => ({ + folderUri: URI.revive(call.folderUri)?.toString(), + source: call.source, + })), [{ + folderUri: URI.file('/workspace').toString(), + source: AgentsWindowOpenSource.TitleBar, + }, { + folderUri: workspaceFolderUri.toString(), + source: AgentsWindowOpenSource.ChatTitleBar, + }]); + }); +});