Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/vs/platform/native/common/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export interface IOpenAgentsWindowOptions {
readonly folderUri?: UriComponents;
readonly sessionResource?: UriComponents;
readonly source?: AgentsWindowOpenSource;
readonly preferDevContainer?: boolean;
}

export interface ICPUProperties {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
context: OpenContext.API,
contextWindowId: windowId,
cli: this.environmentMainService.args,
}, options?.folderUri ? URI.revive(options.folderUri) : undefined, options?.sessionResource ? URI.revive(options.sessionResource) : undefined, options?.source);
}, options?.folderUri ? URI.revive(options.folderUri) : undefined, options?.sessionResource ? URI.revive(options.sessionResource) : undefined, options?.source, options?.preferDevContainer);
if (windows.length > 0) {
windows[0].focus();
}
Expand Down
2 changes: 1 addition & 1 deletion src/vs/platform/windows/electron-main/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface IWindowsMainService {
openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise<ICodeWindow[]>;
openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void;

openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource): Promise<ICodeWindow[]>;
openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource, preferDevContainer?: boolean): Promise<ICodeWindow[]>;

sendToFocused(channel: string, ...args: unknown[]): void;
sendToOpeningWindow(channel: string, ...args: unknown[]): void;
Expand Down
4 changes: 2 additions & 2 deletions src/vs/platform/windows/electron-main/windowsMainService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic
this.handleChatRequest(openConfig, [window]);
}

async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource): Promise<ICodeWindow[]> {
async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource, preferDevContainer?: boolean): Promise<ICodeWindow[]> {
this.logService.trace('windowsManager#openAgentsWindow');

// Open in a new browser window with the agent sessions workspace
Expand All @@ -304,7 +304,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic
// folder-resolve.
if (windows.length > 0) {
const openSource = source ?? (openConfig.cli.agents ? AgentsWindowOpenSource.CommandLine : AgentsWindowOpenSource.Unknown);
windows[0].sendWhenReady('vscode:selectAgentsFolder', CancellationToken.None, folderUri?.toJSON(), sessionResource?.toJSON(), openSource);
windows[0].sendWhenReady('vscode:selectAgentsFolder', CancellationToken.None, folderUri?.toJSON(), sessionResource?.toJSON(), openSource, preferDevContainer);
}

return windows;
Expand Down
4 changes: 2 additions & 2 deletions src/vs/sessions/browser/parts/chatGroupView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,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, providerId?: string, options?: { readonly preferDevContainer?: boolean }): void {
this._currentView.value?.selectWorkspace(folderUri, providerId, options);
}

prefillInput(text: string): void {
Expand Down
4 changes: 2 additions & 2 deletions src/vs/sessions/browser/parts/chatGroupsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,8 +792,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, providerId?: string, options?: { readonly preferDevContainer?: boolean }): void {
this._activeGroup?.view.selectWorkspace(folderUri, providerId, options);
}

prefillInput(text: string): void {
Expand Down
2 changes: 1 addition & 1 deletion src/vs/sessions/browser/parts/chatView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,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, _providerId?: string, _options?: { readonly preferDevContainer?: boolean }): void {
// no-op by default
}

Expand Down
4 changes: 2 additions & 2 deletions src/vs/sessions/browser/parts/sessionView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,9 @@ export class SessionView extends Disposable implements ISerializableView {
return this._isVisible && this._header.startTitleEditing();
}

selectWorkspace(folderUri: URI, providerId?: string): void {
selectWorkspace(folderUri: URI, providerId?: string, options?: { readonly preferDevContainer?: boolean }): void {
const standaloneView = this._standaloneView.value;
standaloneView ? standaloneView.selectWorkspace(folderUri, providerId) : this._groupsView.selectWorkspace(folderUri, providerId);
standaloneView ? standaloneView.selectWorkspace(folderUri, providerId, options) : this._groupsView.selectWorkspace(folderUri, providerId, options);
}

/** Opens the given chat in a group beside the active one ("open to the side"). */
Expand Down
2 changes: 2 additions & 0 deletions src/vs/sessions/common/agentHostSessionsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 --

Expand Down
12 changes: 12 additions & 0 deletions src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { DevContainerAgentHostEnabledSettingId } from '../../../common/devContainerAgentHostService.js';

export function shouldPreferDevContainer(requested: unknown, configurationService: IConfigurationService): boolean {
return requested === true
&& configurationService.getValue<boolean>(DevContainerAgentHostEnabledSettingId) === true;
}
4 changes: 2 additions & 2 deletions src/vs/sessions/contrib/chat/browser/chatView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ export class NewChatView extends AbstractChatView {
this._widget.focusInput();
}

override selectWorkspace(folderUri: URI, providerId?: string): void {
override selectWorkspace(folderUri: URI, providerId?: string, options?: { readonly preferDevContainer?: boolean }): void {
if (this._widget instanceof NewChatWidget) {
this._widget.selectWorkspace(folderUri, providerId);
this._widget.selectWorkspace(folderUri, providerId, options);
}
}

Expand Down
25 changes: 24 additions & 1 deletion src/vs/sessions/contrib/chat/browser/newChatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import { localize } from '../../../../nls.js';
import { IActiveSession, 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 { IWorkspacePickerTrigger, WorkspacePicker } from './sessionWorkspacePicker.js';
import { WebWorkspacePicker } from './webWorkspacePicker.js';
Expand Down Expand Up @@ -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<IDisposable>();
private readonly _newSessionCreation = new MutableDisposable<IDisposable>();
private _preferredDevContainerFolderUri: URI | undefined;

/**
* The currently mounted no-agent-host empty state, if any. Set by
Expand Down Expand Up @@ -111,6 +114,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,
Expand Down Expand Up @@ -602,13 +606,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:
Expand All @@ -624,6 +631,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<IOpenNewSessionResult> {
// Prefer the user's explicit pick when its provider can serve the
// folder; otherwise fall back to the preferred (first) session type.
Expand Down Expand Up @@ -947,6 +966,9 @@ export class NewChatWidget extends Disposable {
private async _onWorkspaceSelected(folderUri: URI | undefined): Promise<void> {
// 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))
Expand Down Expand Up @@ -1004,7 +1026,8 @@ export class NewChatWidget extends Disposable {
this._newChatInput.attach(uris);
}

selectWorkspace(folderUri: URI, providerId?: string): void {
selectWorkspace(folderUri: URI, providerId?: string, options?: { readonly preferDevContainer?: boolean }): void {
this._preferredDevContainerFolderUri = options?.preferDevContainer ? folderUri : undefined;
this._workspacePicker.setSelectedWorkspace(folderUri, { providerId });
}
}
Expand Down
26 changes: 15 additions & 11 deletions src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 { shouldPreferDevContainer } from '../browser/agentsWindowOpenIntent.js';

class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution {

Expand All @@ -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 sessionResource = args[1] ? URI.revive(args[1] as UriComponents) : undefined;
const source = isAgentsWindowOpenSource(args[2]) ? args[2] : AgentsWindowOpenSource.Unknown;
const preferDevContainer = shouldPreferDevContainer(args[3], this.configurationService);
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);
Expand Down Expand Up @@ -87,9 +91,9 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon
this._windowOpenTelemetry.value?.captureInitialViewState();
}

private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined): Promise<void> {
private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise<void> {
try {
await this.handleOpenIntent(folderUri, sessionResource);
await this.handleOpenIntent(folderUri, sessionResource, preferDevContainer);
} finally {
await this._captureInitialWindowViewState();
}
Expand All @@ -114,7 +118,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon
};
}

private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined): Promise<void> {
private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise<void> {
// 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).
Expand All @@ -123,7 +127,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon
return;
}
if (folderUri) {
await this.selectFolder(folderUri);
await this.selectFolder(folderUri, preferDevContainer);
}
}

Expand Down Expand Up @@ -186,39 +190,39 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon
});
}

private async selectFolder(folderUri: URI): Promise<void> {
private async selectFolder(folderUri: URI, preferDevContainer: boolean): Promise<void> {
// 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
// already exists — do not open it just for this side-effect.
const sessionsView = this.viewsService.getViewWithId<SessionsView>(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, resolved.providerId, { preferDevContainer });
}
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
import { DevContainerAgentHostEnabledSettingId } from '../../../../common/devContainerAgentHostService.js';
import { shouldPreferDevContainer } from '../../browser/agentsWindowOpenIntent.js';

suite('Agents Window open intent', () => {
ensureNoDisposablesAreLeakedInTestSuite();

test('gates the Dev Container preference on the Agents Window setting', () => {
const configurationService = (enabled: boolean) => new TestConfigurationService({
[DevContainerAgentHostEnabledSettingId]: enabled,
});

assert.deepStrictEqual({
requestedAndEnabled: shouldPreferDevContainer(true, configurationService(true)),
requestedAndDisabled: shouldPreferDevContainer(true, configurationService(false)),
notRequestedAndEnabled: shouldPreferDevContainer(false, configurationService(true)),
invalidRequestAndEnabled: shouldPreferDevContainer('true', configurationService(true)),
}, {
requestedAndEnabled: true,
requestedAndDisabled: false,
notRequestedAndEnabled: false,
invalidRequestAndEnabled: false,
});
});
});
Loading