diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 1c62eb7cfef77..a0747e00c3ad4 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -82,6 +82,8 @@ export interface INativeZipOptions { export interface IOpenAgentsWindowOptions { readonly folderUri?: UriComponents; + /** Use the invoking editor's folder only for a fresh composer, without replacing an existing session or user choice. */ + readonly folderUriIsDefault?: boolean; readonly sessionResource?: UriComponents; readonly source?: AgentsWindowOpenSource; } diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index b1fb89e25a3ac..9a7a0b80d78a2 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -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?.folderUriIsDefault); if (windows.length > 0) { windows[0].focus(); } diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 7c4f02efdfa37..e82d7c24c6a09 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -41,7 +41,7 @@ export interface IWindowsMainService { openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise; openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void; - openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource): Promise; + openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource, folderUriIsDefault?: boolean): Promise; sendToFocused(channel: string, ...args: unknown[]): void; sendToOpeningWindow(channel: string, ...args: unknown[]): void; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index d8428b529b26f..a4f2528a4ad92 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -292,19 +292,16 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.handleChatRequest(openConfig, [window]); } - async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource): Promise { + async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource, folderUriIsDefault = false): Promise { this.logService.trace('windowsManager#openAgentsWindow'); // Open in a new browser window with the agent sessions workspace const windows = await this.open(await this.ensureAgentsWindow(openConfig)); - // Single IPC carrying the folder to pre-select and an optional existing- - // session resource to open. The handler in the agents window sequences - // them (folder → open session) so the session-open doesn't race the - // folder-resolve. + // Existing-session intent takes precedence over explicit or inferred workspace selection. 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, folderUriIsDefault); } return windows; diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index 46eecd46948e1..389ca9db6edeb 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, ISelectWorkspaceOptions } from './chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions, ISelectWorkspaceOptions, WorkspaceSelectionResult } from './chatView.js'; /** * The data + callbacks a {@link ChatGroupView} needs from its owning @@ -358,8 +358,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { return this._currentView.value?.submitInput() ?? Promise.resolve(false); } - selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { - this._currentView.value?.selectWorkspace(folderUri, options); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { + return this._currentView.value?.selectWorkspace(folderUri, options) ?? 'notReady'; } selectNoWorkspace(): void { diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index 0a64df9d5beaf..a4b5f0fb6daee 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, ISelectWorkspaceOptions } from './chatView.js'; +import { IChatViewOptions, ISelectWorkspaceOptions, WorkspaceSelectionResult } from './chatView.js'; import { ChatGroupView, IChatGroupContext } from './chatGroupView.js'; import { ChatDropZone, ChatGroupDropTarget, IChatGroupDropTargetDelegate } from './chatGroupDropTarget.js'; import { IDraggedSessionChat, isSessionChatDrag } from '../dnd.js'; @@ -799,8 +799,8 @@ export class ChatGroupsView extends Themable { return this._activeGroup?.view.submitInput() ?? Promise.resolve(false); } - selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { - this._activeGroup?.view.selectWorkspace(folderUri, options); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { + return this._activeGroup?.view.selectWorkspace(folderUri, options) ?? 'notReady'; } selectNoWorkspace(): void { diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 75c02ac4875ab..24c5d69903a69 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -13,6 +13,7 @@ import { URI } from '../../../base/common/uri.js'; import { defaultProgressBarStyles } from '../../../platform/theme/browser/defaultStyles.js'; import { IProgressScope, ScopedProgressIndicator } from '../../../workbench/services/progress/browser/progressIndicator.js'; import { IChat, ISession } from '../../services/sessions/common/session.js'; +import { WorkspaceSelectionOrigin } from '../../common/workspaceSelection.js'; /** * Discriminates between concrete {@link AbstractChatView} subclasses without @@ -29,8 +30,13 @@ export interface IChatViewOptions { export interface ISelectWorkspaceOptions { readonly providerId?: string; readonly preferDevContainer?: boolean; + readonly selectionOrigin?: WorkspaceSelectionOrigin; + /** Only replace an automatic default in a fresh, empty composer. */ + readonly isDefault?: boolean; } +export type WorkspaceSelectionResult = 'applied' | 'notReady' | 'preserved'; + /** * Base class for a view that lives inside the {@link SessionsPart} internal grid. * Each instance occupies a single grid leaf. Subclasses populate {@link element} @@ -90,12 +96,11 @@ export abstract class AbstractChatView extends Disposable implements ISerializab } /** - * Select a workspace folder in this view's workspace picker. The default - * implementation is a no-op; subclasses that host a workspace picker - * (e.g. `NewChatView`) override this to forward the selection. + * Select a workspace folder, acknowledging application or preservation of an existing choice. + * Views without a ready workspace picker return notReady. */ - selectWorkspace(_folderUri: URI, _options?: ISelectWorkspaceOptions): void { - // no-op by default + selectWorkspace(_folderUri: URI, _options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { + return 'notReady'; } selectNoWorkspace(): void { diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index d053e006de71b..7a197604473fc 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, ISelectWorkspaceOptions } from './chatView.js'; +import { AbstractChatView, IChatViewOptions, ISelectWorkspaceOptions, WorkspaceSelectionResult } from './chatView.js'; import { ChatGroupsView } from './chatGroupsView.js'; import { SessionHeader, SessionViewFloatingToolbar } from './sessionHeader.js'; import { ISessionContext, SessionContext } from '../../services/sessions/browser/sessionContext.js'; @@ -282,9 +282,9 @@ export class SessionView extends Disposable implements ISerializableView { return this._currentSession; } - selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { const standaloneView = this._standaloneView.value; - standaloneView ? standaloneView.selectWorkspace(folderUri, options) : this._groupsView.selectWorkspace(folderUri, options); + return standaloneView ? standaloneView.selectWorkspace(folderUri, options) : this._groupsView.selectWorkspace(folderUri, options); } selectNoWorkspace(): void { diff --git a/src/vs/sessions/common/workspaceSelection.ts b/src/vs/sessions/common/workspaceSelection.ts new file mode 100644 index 0000000000000..7e9106ccb2f59 --- /dev/null +++ b/src/vs/sessions/common/workspaceSelection.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../base/common/uri.js'; + +export const enum WorkspaceSelectionOrigin { + None = 'none', + CheckedWorkspace = 'checkedWorkspace', + AgentsRecent = 'agentsRecent', + VSCodeRecent = 'vscodeRecent', + VSCodeWorkspace = 'vscodeWorkspace', + ExistingSessions = 'existingSessions', + WindowOpen = 'windowOpen', + WindowContext = 'windowContext', + RestoredDraft = 'restoredDraft', + SessionSync = 'sessionSync', + Programmatic = 'programmatic', + User = 'user', +} + +export type WorkspaceHistoryLoadState = 'loading' | 'loaded' | 'error'; +export type WorkspaceSessionFallbackState = 'idle' | 'pending' | 'completed' | 'error' | 'disabled'; +export type WorkspaceArgumentKind = 'none' | 'local' | 'devContainer' | 'remote' | 'other'; + +/** Selection and lookup state at the instant it is read, not a guarantee that a session can run. */ +export interface IWorkspaceSelectionSnapshot { + /** For local comparisons only; never include this URI in telemetry. */ + readonly folderUri: URI | undefined; + readonly origin: WorkspaceSelectionOrigin; + readonly state: 'none' | 'noWorkspace' | 'selected' | 'unresolved'; + readonly historyState: WorkspaceHistoryLoadState; + readonly sessionFallbackState: WorkspaceSessionFallbackState; + readonly registeredProviderCount: number; +} diff --git a/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts b/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts index bdb64ba2424e4..c2cd31b1d51ba 100644 --- a/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts +++ b/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts @@ -8,6 +8,7 @@ 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'; +import { WorkspaceArgumentKind } from '../../../common/workspaceSelection.js'; const DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX = 'dev-container+'; @@ -16,6 +17,20 @@ export interface IAgentsWindowFolderIntent { readonly preferDevContainer: boolean; } +/** Classifies the original argument without exposing its path or remote authority. */ +export function getAgentsWindowWorkspaceArgumentKind(workspaceUri: URI | undefined): WorkspaceArgumentKind { + if (!workspaceUri) { + return 'none'; + } + if (workspaceUri.scheme === Schemas.file) { + return 'local'; + } + if (workspaceUri.scheme === Schemas.vscodeRemote) { + return workspaceUri.authority.startsWith(DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX) ? 'devContainer' : 'remote'; + } + return 'other'; +} + export function resolveAgentsWindowFolderIntent(workspaceUri: URI | undefined, configurationService: IConfigurationService): IAgentsWindowFolderIntent { if (workspaceUri?.scheme === Schemas.file) { return { folderUri: workspaceUri, preferDevContainer: false }; diff --git a/src/vs/sessions/contrib/chat/browser/agentsWindowWorkspaceHandoff.ts b/src/vs/sessions/contrib/chat/browser/agentsWindowWorkspaceHandoff.ts new file mode 100644 index 0000000000000..5d43b2fb34543 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentsWindowWorkspaceHandoff.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceCancellationError, timeout } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, waitForState } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ISessionsSetUpService } from '../../../browser/sessionsSetUpService.js'; +import { WorkspaceSelectionOrigin } from '../../../common/workspaceSelection.js'; +import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { WorkspaceHandoffState } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; +import { SessionsView, SessionsViewId } from '../../sessions/browser/views/sessionsView.js'; +import { INewSessionComposerService } from './newSessionComposerService.js'; + +export const WORKSPACE_HANDOFF_TIMEOUT_MS = 15_000; + +export interface IAgentsWindowWorkspaceHandoff { + readonly folderUri: URI; + readonly preferDevContainer: boolean; + readonly isDefault: boolean; +} + +/** Keeps one opening intent alive until the target composer applies it or a newer user intent wins. */ +export class AgentsWindowWorkspaceHandoff extends Disposable { + private readonly _pending = this._register(new MutableDisposable()); + private readonly _notification = this._register(new MutableDisposable()); + private _cancelPending: ((state: WorkspaceHandoffState) => void) | undefined; + + constructor( + @ISessionsService private readonly sessionsService: ISessionsService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsPartService private readonly sessionsPartService: ISessionsPartService, + @ISessionsSetUpService private readonly sessionsSetUpService: ISessionsSetUpService, + @INewSessionComposerService private readonly composerService: INewSessionComposerService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IViewsService private readonly viewsService: IViewsService, + @INotificationService private readonly notificationService: INotificationService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this._register(lifecycleService.onWillShutdown(() => this.cancel())); + } + + cancel(): void { + this._cancelPending?.('superseded'); + this._pending.clear(); + this._notification.clear(); + } + + async selectWorkspace(intent: IAgentsWindowWorkspaceHandoff, onState: (state: WorkspaceHandoffState) => void): Promise { + this.cancel(); + const store = new DisposableStore(); + this._pending.value = store; + const source = new CancellationTokenSource(); + let finished = false; + const cancel = (state: WorkspaceHandoffState) => { + if (!finished && !source.token.isCancellationRequested) { + onState(state); + source.cancel(); + } + }; + this._cancelPending = cancel; + store.add(toDisposable(() => { + cancel('cancelled'); + source.dispose(true); + if (this._cancelPending === cancel) { + this._cancelPending = undefined; + } + })); + const selectionVersion = this.composerService.userWorkspaceSelectionVersion.get(); + const navigationVersion = this.composerService.userNavigationVersion.get(); + const navigationRequest = this.sessionsService.navigationRequest.get(); + store.add(autorun(reader => { + const currentNavigation = this.sessionsService.navigationRequest.read(reader); + if (this.composerService.userWorkspaceSelectionVersion.read(reader) !== selectionVersion + || this.composerService.userNavigationVersion.read(reader) !== navigationVersion + || (currentNavigation !== navigationRequest && currentNavigation?.token !== source.token)) { + cancel('userChanged'); + } + })); + store.add(Event.once(this.sessionsManagementService.onWillSendRequest)(() => cancel('sessionAlreadyCreated'))); + store.add(Event.once(this.composerService.onWillSendRequest)(() => cancel('sessionAlreadyCreated'))); + let restoreComplete = this.sessionsService.initialRestoreComplete.get(); + let previousSession = this.sessionsService.activeSession.get(); + let previousCreated = previousSession?.isCreated.get() ?? false; + store.add(autorun(reader => { + const restored = this.sessionsService.initialRestoreComplete.read(reader); + const session = this.sessionsService.activeSession.read(reader); + const created = session?.isCreated.read(reader) ?? false; + if (restoreComplete && created && (session !== previousSession || !previousCreated)) { + cancel('sessionAlreadyCreated'); + } + restoreComplete = restored; + previousSession = session; + previousCreated = created; + })); + try { + onState('waitingForSetup'); + await raceCancellationError(this.sessionsSetUpService.whenWelcomeDone(), source.token); + if (source.token.isCancellationRequested) { + return; + } + onState('waitingForSessionView'); + if (intent.isDefault) { + await waitForState(this.sessionsService.initialRestoreComplete, complete => complete, undefined, source.token); + } else { + await raceCancellationError(this.lifecycleService.when(LifecyclePhase.Restored), source.token); + if (source.token.isCancellationRequested) { + return; + } + await this.sessionsService.openNewSession({ cancelRestore: true }, source.token); + } + + const deadline = Date.now() + WORKSPACE_HANDOFF_TIMEOUT_MS; + while (!source.token.isCancellationRequested) { + const session = this.sessionsService.activeSession.get(); + if (session?.isCreated.get() || session?.isQuickChat?.get()) { + onState('preservedSession'); + return; + } + + const resolved = this.sessionsManagementService.resolveWorkspace(intent.folderUri); + const view = this.sessionsPartService.getSessionView(session?.sessionId); + const result = resolved ? view?.selectWorkspace(intent.folderUri, { + providerId: resolved.providerId, + preferDevContainer: intent.preferDevContainer, + selectionOrigin: intent.isDefault ? WorkspaceSelectionOrigin.WindowContext : WorkspaceSelectionOrigin.WindowOpen, + isDefault: intent.isDefault, + }) : undefined; + if (result === 'preserved') { + onState('preservedSession'); + return; + } + if (result === 'applied') { + onState('applied'); + this.viewsService.getViewWithId(SessionsViewId)?.sessionsControl?.setOpenWindowSourceFolder(intent.folderUri); + return; + } + const state = !resolved ? 'waitingForProvider' : !view ? 'waitingForSessionView' : 'selectionRequested'; + onState(state); + if (Date.now() >= deadline) { + onState(!resolved ? 'providerUnavailable' : !view ? 'viewUnavailable' : 'selectionNotApplied'); + this.logService.warn(`[AgentsHandoff] Workspace selection timed out: ${state}`); + if (!intent.isDefault) { + this._showRecovery(intent, onState); + } + return; + } + // View construction and provider readiness can finish independently of lifecycle phases. + await timeout(100, source.token); + } + } catch (error) { + if (!source.token.isCancellationRequested && !isCancellationError(error)) { + onState('error'); + this.logService.error('[AgentsHandoff] Workspace selection failed', error); + if (!intent.isDefault) { + this._showRecovery(intent, onState); + } + } + } finally { + finished = true; + if (this._pending.value === store) { + this._cancelPending = undefined; + this._pending.clear(); + } + } + } + + private _showRecovery(intent: IAgentsWindowWorkspaceHandoff, onState: (state: WorkspaceHandoffState) => void): void { + const notification = this.notificationService.prompt(Severity.Warning, + localize('agentsHandoff.workspaceUnavailable', "The workspace could not be selected. Try again or choose a workspace to continue."), + [ + { label: localize('agentsHandoff.retry', "Retry"), run: () => this.selectWorkspace(intent, onState) }, + { label: localize('agentsHandoff.chooseWorkspace', "Choose Workspace"), run: () => this.commandService.executeCommand('workbench.action.sessions.newSession.pickFolderQuickPick') }, + ]); + this._notification.value = toDisposable(() => notification.close()); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index bd461c0bd1954..5c5c060c64168 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -59,6 +59,7 @@ import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; import { LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, unifiedWorkspacePickerConfigurationMigration } from './unifiedWorkspacePickerConfiguration.js'; import { ISessionArchiveNudgeService, SESSION_ARCHIVE_NUDGE_SETTING, SessionArchiveNudgeContribution, SessionArchiveNudgeService } from './sessionArchiveNudge.js'; +import { INewSessionComposerService } from './newSessionComposerService.js'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackground'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackgroundLayout'; @@ -191,6 +192,7 @@ class NewChatInSessionsWindowAction extends Action2 { } override async run(accessor: ServicesAccessor, options?: { toSide?: boolean }): Promise { + accessor.get(INewSessionComposerService).notifyUserNavigation(); const sessionsService = accessor.get(ISessionsService); const sessionsManagementService = accessor.get(ISessionsManagementService); const activeSession = sessionsService.activeSession.get(); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 91f4c62928bb7..2a85fdc29f4e6 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, ISelectWorkspaceOptions } from '../../../browser/parts/chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions, ISelectWorkspaceOptions, WorkspaceSelectionResult } 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'; @@ -117,10 +117,8 @@ export class NewChatView extends AbstractChatView { this._widget.focusInput(); } - override selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { - if (this._widget instanceof NewChatWidget) { - this._widget.selectWorkspace(folderUri, options); - } + override selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { + return this._widget instanceof NewChatWidget ? this._widget.selectWorkspace(folderUri, options) : 'notReady'; } override selectNoWorkspace(): void { diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index c2793b07eca1e..2b75918d80615 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -130,6 +130,7 @@ import { AGENTS_VOICE_ENABLED } from '../../../../workbench/contrib/agentsVoice/ import { animatePromptTyping, IPromptTypingAnimation } from './promptTypingAnimation.js'; import { PromptTemplatePlaceholderController } from './promptTemplatePlaceholder.js'; import { INewSessionComposer, INewSessionPromptOptionsController, NEW_SESSION_PROMPT_TYPING_DURATION_MS, NewSessionPromptOptionsState, NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; +import { IWorkspaceSelectionSnapshot } from '../../../common/workspaceSelection.js'; import { NewSessionPromptOptionsWidget } from './newSessionPromptOptions.js'; import { isInputGitHubContext, toInputGitHubContextMetadata } from '../common/newChatContextIds.js'; @@ -435,6 +436,22 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation return this.options.getWorkspacePreselectionSource?.(); } + get workspaceSelection(): IWorkspaceSelectionSnapshot | undefined { + return this.options.getWorkspaceSelection?.(); + } + + get onDidChangeWorkspaceSelection(): Event { + return this.options.onDidChangeWorkspaceSelection ?? Event.None; + } + + get hasInput(): boolean { + return !!this._editor?.getValue() || this._contextAttachments.attachments.length > 0; + } + + get canApplyWorkspaceDefault(): boolean { + return !this.hasInput && (this.options.canApplyWorkspaceDefault?.() ?? false); + } + /** Opens the model picker dropdown. */ openModelPicker(): void { this._newChatModelPickerService.openModelPicker(); } @@ -498,6 +515,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation getContextFolderUri: () => URI | undefined; getContextPickerActions?: () => readonly IWorkspacePickerContextAction[]; getWorkspacePreselectionSource?: () => NewSessionWorkspacePreselectionSource; + getWorkspaceSelection?: () => IWorkspaceSelectionSnapshot; + onDidChangeWorkspaceSelection?: Event; + canApplyWorkspaceDefault?: () => boolean; sendRequest: (request: INewChatInputSendRequest) => Promise; canSendRequest: IObservable; canSubmitWithoutSession?: IObservable; diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 0155fcc9d698a..c1b9def8e22c4 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -38,7 +38,8 @@ 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, ISelectWorkspaceOptions } from '../../../browser/parts/chatView.js'; +import { IChatViewOptions, ISelectWorkspaceOptions, WorkspaceSelectionResult } from '../../../browser/parts/chatView.js'; +import { WorkspaceSelectionOrigin } from '../../../common/workspaceSelection.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'; @@ -75,6 +76,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 _createdSessionId: string | undefined; private _preferredDevContainerFolderUri: URI | undefined; /** @@ -129,7 +131,7 @@ export class NewChatWidget extends Disposable { @IOpenerService private readonly openerService: IOpenerService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IStorageService private readonly storageService: IStorageService, - @INewSessionComposerService newSessionComposerService: INewSessionComposerService, + @INewSessionComposerService private readonly newSessionComposerService: INewSessionComposerService, @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -170,6 +172,7 @@ export class NewChatWidget extends Disposable { const PickerCtor = isWeb ? WebWorkspacePicker : WorkspacePicker; this._workspacePicker = this._register(this.instantiationService.createInstance(PickerCtor, { canRestoreWorkspace: () => !this._isQuickChatComposer.get(), + onUserSelection: () => newSessionComposerService.notifyUserWorkspaceSelection(), getWorkspaceGroupAction: group => { if (group === SESSION_WORKSPACE_GROUP_GITHUB && shouldShowGitHubWorkspaceGroupSignIn( this.defaultAccountService.currentDefaultAccount !== null, @@ -200,7 +203,7 @@ export class NewChatWidget extends Disposable { reader.store.add(Event.filter( provider.onDidChangeSessionConfig, sessionId => sessionId === activeSession.sessionId, - )(() => this._syncWorkspacePickerDevContainerMode(activeSession, false))); + )(() => this._syncWorkspacePickerDevContainerMode(activeSession, false, WorkspaceSelectionOrigin.SessionSync))); })); const feedbackChanged = observableSignalFromEvent(this, this.agentFeedbackService.onDidChangeFeedback); @@ -240,6 +243,11 @@ export class NewChatWidget extends Disposable { getWorkspacePreselectionSource: () => this._isQuickChatComposer.get() ? NewSessionWorkspacePreselectionSource.None : this._workspacePicker.preselectionSource, + getWorkspaceSelection: () => this._isQuickChatComposer.get() + ? { ...this._workspacePicker.selectionSnapshot, folderUri: undefined, origin: WorkspaceSelectionOrigin.None, state: 'noWorkspace' } + : this._workspacePicker.selectionSnapshot, + onDidChangeWorkspaceSelection: Event.any(this._workspacePicker.onDidChangeSelection, Event.fromObservableLight(this._isQuickChatComposer)), + canApplyWorkspaceDefault: () => this._canApplyWorkspaceDefault(), sendRequest: async ({ query, attachments, background }) => this._send(query, attachments, background), canSendRequest, canSubmitWithoutSession, @@ -341,6 +349,7 @@ export class NewChatWidget extends Disposable { syncAttachedContext(); })); this._register(this._newChatInput.sessionTypePicker.onDidSelectSessionType(async pick => { + this.newSessionComposerService.notifyUserWorkspaceSelection(); // A quick chat has no folder: re-create the draft with the picked // type via openQuickChat (mirrors the folder path's draft recreation). if (this._isQuickChatComposer.get()) { @@ -398,7 +407,7 @@ export class NewChatWidget extends Disposable { private _syncWorkspacePickerFromSessionWorkspace(workspace: ISessionWorkspace | undefined): void { const folderUri = workspace?.folders[0]?.root; if (folderUri && !this._workspacePicker.matchesSelectedWorkspace(workspace)) { - this._workspacePicker.setSelectedWorkspace(folderUri, { fireEvent: false }); + this._workspacePicker.setSelectedWorkspace(folderUri, { fireEvent: false, origin: WorkspaceSelectionOrigin.SessionSync }); } } @@ -646,7 +655,7 @@ export class NewChatWidget extends Disposable { return false; } - const folderUri = this._syncWorkspacePickerDevContainerMode(activeSession, true); + const folderUri = this._syncWorkspacePickerDevContainerMode(activeSession, true, WorkspaceSelectionOrigin.RestoredDraft); if (folderUri) { this._replaceDraftOnUnservableHarness(folderUri, activeSession); } @@ -654,14 +663,14 @@ export class NewChatWidget extends Disposable { return true; } - private _syncWorkspacePickerDevContainerMode(activeSession: IActiveSession, persist: boolean): URI | undefined { + private _syncWorkspacePickerDevContainerMode(activeSession: IActiveSession, persist: boolean, origin: WorkspaceSelectionOrigin): URI | undefined { const folderUri = activeSession.workspace.get()?.folders[0]?.root; if (!folderUri) { return undefined; } const provider = this.sessionsProvidersService.getProvider(activeSession.providerId); const preferDevContainer = !!provider && isAgentHostProvider(provider) && provider.isDevContainerEnabled?.(activeSession.sessionId) === true; - this._workspacePicker.setSelectedWorkspace(folderUri, { fireEvent: false, providerId: activeSession.providerId, persist, preferDevContainer }); + this._workspacePicker.setSelectedWorkspace(folderUri, { fireEvent: false, providerId: activeSession.providerId, persist, preferDevContainer, origin }); return folderUri; } @@ -715,6 +724,9 @@ export class NewChatWidget extends Disposable { } const isCurrentCreation = this._newSessionCreation.value === creationLifecycle; if (isCurrentCreation) { + if (result.session) { + this._createdSessionId = result.session.sessionId; + } this._newSessionCreation.clear(); } else { return result; @@ -766,6 +778,7 @@ export class NewChatWidget extends Disposable { try { return await this.sessionsService.openNewSession({ folderUri, + preserveNavigation: true, ...(preferredPick ? { providerId: preferredPick.providerId, sessionTypeId: preferredPick.sessionTypeId } : fallbackProviderId @@ -1015,6 +1028,7 @@ export class NewChatWidget extends Disposable { } try { + this.newSessionComposerService.notifyWillSendRequest(sendOptions, wasQuickChat ? undefined : this._workspacePicker.selectionSnapshot); await this.sessionsManagementService.sendNewChatRequest(session, sendOptions); } catch (e) { this._pendingBackgroundSends.deleteAndDispose(sendOptions); @@ -1175,9 +1189,31 @@ export class NewChatWidget extends Disposable { this._newChatInput.attach(uris); } - selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + private _canApplyWorkspaceDefault(): boolean { + const session = this._session.get(); + return !session || session.sessionId === this._createdSessionId; + } + + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { + if (options?.isDefault) { + if (this._newSessionCreation.value) { + return 'notReady'; + } + const selection = this._workspacePicker.selectionSnapshot; + if (!this._newChatInput.canApplyWorkspaceDefault || this._isQuickChatComposer.get() + || selection.state === 'noWorkspace' + || selection.origin === WorkspaceSelectionOrigin.User + || selection.origin === WorkspaceSelectionOrigin.WindowOpen + || selection.origin === WorkspaceSelectionOrigin.RestoredDraft + || selection.origin === WorkspaceSelectionOrigin.SessionSync + || selection.origin === WorkspaceSelectionOrigin.Programmatic) { + return 'preserved'; + } + } this._preferredDevContainerFolderUri = options?.preferDevContainer ? folderUri : undefined; - this._workspacePicker.setSelectedWorkspace(folderUri, { providerId: options?.providerId, preferDevContainer: options?.preferDevContainer }); + this._workspacePicker.setSelectedWorkspace(folderUri, { providerId: options?.providerId, preferDevContainer: options?.preferDevContainer, origin: options?.selectionOrigin }); + const selection = this._workspacePicker.selectionSnapshot; + return selection.state === 'selected' && this.uriIdentityService.extUri.isEqual(selection.folderUri, folderUri) ? 'applied' : 'notReady'; } } diff --git a/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts b/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts index 122d5e9c4d4d8..a4793827b2b93 100644 --- a/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts +++ b/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts @@ -5,10 +5,13 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { derived, IObservable, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceSelectionSnapshot } from '../../../common/workspaceSelection.js'; +import { ISendRequestOptions } from '../../../services/sessions/common/sessionsProvider.js'; export const NEW_SESSION_PROMPT_TYPING_DURATION_MS = 2_500; @@ -59,6 +62,10 @@ export interface INewSessionPromptOptionsController { export interface INewSessionComposer { readonly workspacePreselectionSource?: NewSessionWorkspacePreselectionSource; + readonly workspaceSelection?: IWorkspaceSelectionSnapshot; + readonly onDidChangeWorkspaceSelection?: Event; + readonly hasInput?: boolean; + readonly canApplyWorkspaceDefault?: boolean; animatePrompt(text: string, durationMs: number, placeholder: string, token: CancellationToken): Promise; showPromptOptions(state: NewSessionPromptOptionsState | undefined): boolean; setPromptOptionsController?(controller: INewSessionPromptOptionsController): void; @@ -70,6 +77,13 @@ export const INewSessionComposerService = createDecorator; + readonly workspaceSelection: IObservable; + readonly userWorkspaceSelectionVersion: IObservable; + notifyUserWorkspaceSelection(): void; + readonly userNavigationVersion: IObservable; + notifyUserNavigation(): void; + readonly onWillSendRequest: Event<{ readonly options: ISendRequestOptions; readonly selection: IWorkspaceSelectionSnapshot | undefined }>; + notifyWillSendRequest(options: ISendRequestOptions, selection: IWorkspaceSelectionSnapshot | undefined): void; registerComposer(composer: INewSessionComposer): IDisposable; } @@ -79,6 +93,29 @@ export class NewSessionComposerService extends Disposable implements INewSession private readonly _composers = new Set(); private readonly _activeComposer = observableValue(this, undefined); readonly activeComposer: IObservable = this._activeComposer; + private readonly _userWorkspaceSelectionVersion = observableValue(this, 0); + readonly userWorkspaceSelectionVersion: IObservable = this._userWorkspaceSelectionVersion; + private readonly _userNavigationVersion = observableValue(this, 0); + readonly userNavigationVersion: IObservable = this._userNavigationVersion; + private readonly _selectionChanged = derived(this, reader => observableSignalFromEvent(this, this.activeComposer.read(reader)?.onDidChangeWorkspaceSelection ?? Event.None)); + readonly workspaceSelection = derived(this, reader => { + this._selectionChanged.read(reader).read(reader); + return this.activeComposer.read(reader)?.workspaceSelection; + }); + private readonly _onWillSendRequest = this._register(new Emitter<{ readonly options: ISendRequestOptions; readonly selection: IWorkspaceSelectionSnapshot | undefined }>()); + readonly onWillSendRequest = this._onWillSendRequest.event; + + notifyUserWorkspaceSelection(): void { + this._userWorkspaceSelectionVersion.set(this._userWorkspaceSelectionVersion.get() + 1, undefined); + } + + notifyUserNavigation(): void { + this._userNavigationVersion.set(this._userNavigationVersion.get() + 1, undefined); + } + + notifyWillSendRequest(options: ISendRequestOptions, selection: IWorkspaceSelectionSnapshot | undefined): void { + this._onWillSendRequest.fire({ options, selection }); + } registerComposer(composer: INewSessionComposer): IDisposable { this._composers.add(composer); diff --git a/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts b/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts index 5bb8b72cd5fd8..9186127c41ba8 100644 --- a/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts +++ b/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts @@ -17,6 +17,7 @@ import { IsSessionsWindowContext } from '../../../../workbench/common/contextkey import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ISessionsRecentWorkspacesService } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { INewSessionComposerService } from './newSessionComposerService.js'; export interface IFolderQuickPickItem extends IQuickPickItem { readonly folderUri?: URI; @@ -82,6 +83,7 @@ class NewSessionPickFolderQuickPickAction extends Action2 { const recentWorkspacesService = accessor.get(ISessionsRecentWorkspacesService); const labelService = accessor.get(ILabelService); const fileDialogService = accessor.get(IFileDialogService); + const composerService = accessor.get(INewSessionComposerService); const items = buildFolderQuickPickItems(recentWorkspacesService, labelService); @@ -108,6 +110,7 @@ class NewSessionPickFolderQuickPickAction extends Action2 { return; } + composerService.notifyUserWorkspaceSelection(); await sessionsService.openNewSession({ folderUri, providerId }); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index e345dd04d752c..fc099714a6a9e 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -48,6 +48,7 @@ import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; +import { IWorkspaceSelectionSnapshot, WorkspaceSelectionOrigin, WorkspaceSessionFallbackState } from '../../../common/workspaceSelection.js'; import { type IResolvedFolderWorkspace, SessionWorkspaceFallback } from './sessionWorkspaceFallback.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX, ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX, getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../common/newChatContextIds.js'; @@ -93,6 +94,7 @@ export interface IWorkspacePickerItem { } export interface IWorkspacePickerOptions { + readonly onUserSelection?: () => void; readonly canSelectWorkspace?: (folderUri: URI, providerId: string | undefined) => Promise; readonly canRestoreWorkspace?: () => boolean; readonly restoreFromSessions?: boolean; @@ -152,6 +154,7 @@ interface IAttachedRepositorySelection extends IResolvedBrowseSelection { interface IRestoredWorkspaceSelection { readonly resolved: IResolvedFolderWorkspace; readonly source: NewSessionWorkspacePreselectionSource; + readonly origin: WorkspaceSelectionOrigin; } interface IWorkspacePickerTriggerElements { @@ -210,8 +213,10 @@ export class WorkspacePicker extends Disposable { private _selectedResolved: IResolvedFolderWorkspace | undefined; private _selectedDevContainerFolderUri: URI | undefined; private _preselectionSource = NewSessionWorkspacePreselectionSource.None; + private _selectionOrigin = WorkspaceSelectionOrigin.None; private _selectionGeneration = 0; private _sessionRestoreGeneration = 0; + private _sessionRestoreState: { readonly generation: number; readonly selectionGeneration: number; state: WorkspaceSessionFallbackState } | undefined; private readonly _sessionWorkspaceFallback: SessionWorkspaceFallback | undefined; /** @@ -382,6 +387,20 @@ export class WorkspacePicker extends Disposable { return this._preselectionSource; } + get selectionSnapshot(): IWorkspaceSelectionSnapshot { + const restoreState = this._sessionRestoreState; + return { + folderUri: this._selectedFolderUri, + origin: this._selectionOrigin, + state: this.isNoWorkspaceSelected() ? 'noWorkspace' : !this._selectedFolderUri ? 'none' : this._selectedResolved ? 'selected' : 'unresolved', + historyState: this.recentWorkspacesService.historyLoadState.get(), + sessionFallbackState: !this._sessionWorkspaceFallback ? 'disabled' + : restoreState?.generation === this._sessionRestoreGeneration && restoreState.selectionGeneration === this._selectionGeneration && !this._userHasPicked && this._canRestoreWorkspace() + ? restoreState.state : 'idle', + registeredProviderCount: this.sessionsProvidersService.getProviders().length, + }; + } + matchesSelectedWorkspace(workspace: ISessionWorkspace): boolean { const folderUri = workspace.folders[0]?.root; if (folderUri && this._selectedFolderUri && this.uriIdentityService.extUri.isEqual(folderUri, this._selectedFolderUri)) { @@ -434,7 +453,7 @@ export class WorkspacePicker extends Disposable { // Restore selected workspace from storage const restored = this._restoreSelectedWorkspace(); - this._applySelection(restored?.resolved, restored?.source); + this._applySelection(restored?.resolved, restored?.source, restored?.origin); if (this._selectedResolved) { this._watchForConnectionFailure(this._selectedResolved); } else { @@ -457,6 +476,7 @@ export class WorkspacePicker extends Disposable { this._selectedResolved = undefined; this._selectedDevContainerFolderUri = undefined; this._preselectionSource = NewSessionWorkspacePreselectionSource.None; + this._selectionOrigin = WorkspaceSelectionOrigin.None; this._connectionStatusWatch.clear(); this._gitHubInfoWatch.clear(); this._updateTriggerLabel(); @@ -1068,14 +1088,18 @@ export class WorkspacePicker extends Disposable { * workspace were created by a specific provider). * @param options.persist Whether to persist the selection as a recent workspace. Defaults to true. */ - setSelectedWorkspace(folderUri: URI, options?: { fireEvent?: boolean; providerId?: string; persist?: boolean; preferDevContainer?: boolean }): void { + setSelectedWorkspace(folderUri: URI, options?: { fireEvent?: boolean; providerId?: string; persist?: boolean; preferDevContainer?: boolean; origin?: WorkspaceSelectionOrigin }): void { this._selectedDevContainerFolderUri = options?.preferDevContainer ? folderUri : undefined; + const origin = options?.origin ?? WorkspaceSelectionOrigin.Programmatic; + const preserveOrigin = (origin === WorkspaceSelectionOrigin.RestoredDraft || origin === WorkspaceSelectionOrigin.SessionSync) + && this._isSelectedFolder(folderUri); this._selectFolder( folderUri, options?.fireEvent ?? true, options?.providerId, options?.persist ?? true, NewSessionWorkspacePreselectionSource.ProvidedWorkspace, + preserveOrigin ? this._selectionOrigin : origin, ); } @@ -1099,6 +1123,7 @@ export class WorkspacePicker extends Disposable { * Clears the selected project. */ clearSelection(): void { + this.options.onUserSelection?.(); this._selectionGeneration++; this._hidePicker(); this._userHasPicked = true; @@ -1106,6 +1131,7 @@ export class WorkspacePicker extends Disposable { this._selectedFolderUri = undefined; this._selectedResolved = undefined; this._preselectionSource = NewSessionWorkspacePreselectionSource.None; + this._selectionOrigin = WorkspaceSelectionOrigin.None; if (this._shouldPersistSelection()) { this.recentWorkspacesService.clearCheckedWorkspace(); } @@ -1119,6 +1145,7 @@ export class WorkspacePicker extends Disposable { } selectNoWorkspace(): void { + this.options.onUserSelection?.(); this._selectionGeneration++; this._sessionRestoreGeneration++; this._hidePicker(); @@ -1145,7 +1172,11 @@ export class WorkspacePicker extends Disposable { providerIdHint?: string, persist = true, source = NewSessionWorkspacePreselectionSource.User, + origin = WorkspaceSelectionOrigin.User, ): void { + if (source === NewSessionWorkspacePreselectionSource.User) { + this.options.onUserSelection?.(); + } this._selectionGeneration++; this._userHasPicked = true; this._connectionStatusWatch.clear(); @@ -1173,6 +1204,7 @@ export class WorkspacePicker extends Disposable { this._selectedResolved = resolved; this._watchSelectedGitHubInfo(); this._preselectionSource = source; + this._selectionOrigin = origin; if (persist && this._shouldPersistSelection()) { this.recentWorkspacesService.addRecentWorkspace(folderUri, resolved?.providerId, true); } @@ -1191,11 +1223,12 @@ export class WorkspacePicker extends Disposable { * Apply a restored selection without firing events or persisting. Used * during construction and after provider list changes. */ - private _applySelection(resolved: IResolvedFolderWorkspace | undefined, source = NewSessionWorkspacePreselectionSource.None): void { + private _applySelection(resolved: IResolvedFolderWorkspace | undefined, source = NewSessionWorkspacePreselectionSource.None, origin = WorkspaceSelectionOrigin.None): void { this._selectedResolved = resolved; this._selectedFolderUri = resolved?.workspace.folders[0]?.root; this._watchSelectedGitHubInfo(); this._preselectionSource = resolved ? source : NewSessionWorkspacePreselectionSource.None; + this._selectionOrigin = resolved ? origin : WorkspaceSelectionOrigin.None; } private _watchSelectedGitHubInfo(): void { @@ -2042,6 +2075,7 @@ export class WorkspacePicker extends Disposable { return { resolved: checked, source: NewSessionWorkspacePreselectionSource.CheckedWorkspace, + origin: WorkspaceSelectionOrigin.CheckedWorkspace, }; } @@ -2055,6 +2089,8 @@ export class WorkspacePicker extends Disposable { return { resolved: recent, source: NewSessionWorkspacePreselectionSource.RecentWorkspace, + origin: recent.source === 'agents' ? WorkspaceSelectionOrigin.AgentsRecent + : recent.source === 'vscodeWorkspace' ? WorkspaceSelectionOrigin.VSCodeWorkspace : WorkspaceSelectionOrigin.VSCodeRecent, }; } return undefined; @@ -2098,7 +2134,7 @@ export class WorkspacePicker extends Disposable { this._preselectionSource = restored.source; return false; } - this._applySelection(restored.resolved, restored.source); + this._applySelection(restored.resolved, restored.source, restored.origin); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); this._onDidSelectWorkspace.fire(this._selectedFolderUri); @@ -2112,7 +2148,10 @@ export class WorkspacePicker extends Disposable { } const restoreGeneration = ++this._sessionRestoreGeneration; const selectionGeneration = this._selectionGeneration; + this._sessionRestoreState = { generation: restoreGeneration, selectionGeneration, state: 'pending' }; + const restoreState = this._sessionRestoreState; void this._sessionWorkspaceFallback.findWorkspace().then(restored => { + restoreState.state = 'completed'; if (restoreGeneration !== this._sessionRestoreGeneration || selectionGeneration !== this._selectionGeneration || this._userHasPicked @@ -2138,12 +2177,15 @@ export class WorkspacePicker extends Disposable { this._preselectionSource = NewSessionWorkspacePreselectionSource.ExistingSessions; return; } - this._applySelection(restored, NewSessionWorkspacePreselectionSource.ExistingSessions); + this._applySelection(restored, NewSessionWorkspacePreselectionSource.ExistingSessions, WorkspaceSelectionOrigin.ExistingSessions); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); this._onDidSelectWorkspace.fire(this._selectedFolderUri); this._watchForConnectionFailure(restored); - }).catch(onUnexpectedError); + }).catch(error => { + restoreState.state = 'error'; + onUnexpectedError(error); + }); } private _canRestoreProviderWorkspace(providerId: string): boolean { @@ -2212,6 +2254,7 @@ export class WorkspacePicker extends Disposable { this._selectedFolderUri = undefined; this._selectedResolved = undefined; this._preselectionSource = NewSessionWorkspacePreselectionSource.None; + this._selectionOrigin = WorkspaceSelectionOrigin.None; this._updateTriggerLabel(); this._onDidChangeSelection.fire(); this._onDidSelectWorkspace.fire(undefined); @@ -2252,10 +2295,12 @@ export class WorkspacePicker extends Disposable { // Clear current selection if it was the removed workspace if (this._isSelectedFolder(folderUri)) { + this.options.onUserSelection?.(); this._hidePicker(); this._selectedFolderUri = undefined; this._selectedResolved = undefined; this._preselectionSource = NewSessionWorkspacePreselectionSource.None; + this._selectionOrigin = WorkspaceSelectionOrigin.None; this._updateTriggerLabel(); this._onDidSelectWorkspace.fire(undefined); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index dc02e7994b876..b690bf3c4b33a 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -57,6 +57,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session. When consolidated remote workspaces are enabled, opening the picker focuses its search input so you can immediately type to filter workspaces. If quick chats are available, you can also choose No workspace to start a workspace-less chat.")); + content.push(localize('sessionsChat.workspaceHandoff', "Opening Agents from an editor can suggest that editor's folder for a fresh session. Choosing a workspace yourself or starting another session cancels a pending suggestion. If an explicitly requested workspace cannot be selected, a notification offers Retry and Choose Workspace actions.")); content.push(localize('sessionsChat.syncChanges', "When available, Sync Changes appears as a separate sync icon with incoming and outgoing commit counts beside the branch picker below the input. Use Tab and the arrow keys to reach it, then Enter or Space to synchronize the session's repository. The action is disabled while synchronization is running.")); content.push(localize('sessionsChat.githubContext', "Use Add Context to attach files, images, and, when available, GitHub issues or pull requests.")); content.push(localize('sessionsChat.devContainer', "When Dev Container Agent Host sessions are enabled, Docker is available, and a local folder contains a Dev Container configuration, open the folder's submenu and choose Use Dev Container. The workspace picker then identifies the folder with a Dev Container suffix. Choose Use Local to switch back.")); diff --git a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts index c5011ea2a1ecf..7edb6980dc8ce 100644 --- a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts @@ -18,6 +18,7 @@ import { IVoiceModelSelectionResult, resolveVoiceModel } from '../../../../workb import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, inheritableSessionTarget, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { INewChatVoiceComposer, INewChatVoiceTargetService, NEW_CHAT_VOICE_SENTINEL } from './newChatVoice.js'; +import { INewSessionComposerService } from './newSessionComposerService.js'; export async function prepareNewVoiceSession( text: string, @@ -91,6 +92,7 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @INewChatVoiceTargetService private readonly newChatVoiceTargetService: INewChatVoiceTargetService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, + @INewSessionComposerService private readonly newSessionComposerService: INewSessionComposerService, @ILogService private readonly logService: ILogService, ) { super(); @@ -149,8 +151,9 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo return this.chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource?.toString(); })); - this._commandDisposables.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', (_accessor, text: string) => - prepareNewVoiceSession( + this._commandDisposables.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', (_accessor, text: string) => { + this.newSessionComposerService.notifyUserNavigation(); + return prepareNewVoiceSession( text, this.sessionsService, this.sessionsManagementService, @@ -158,8 +161,8 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo () => !!this._activeComposerTarget(), () => this.newChatVoiceTargetService.beginVoiceTransition(), this.logService, - ) - )); + ); + })); this._commandDisposables.add(CommandsRegistry.registerCommand('_chat.voice.selectModel', (_accessor, requestedModel: string): IVoiceModelSelectionResult => { const composer = this._activeComposerTarget(); 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 3b19033ab0fd8..3e4c26837c711 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -5,7 +5,9 @@ import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { raceCancellation } from '../../../../base/common/async.js'; import { localize } from '../../../../nls.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; @@ -17,37 +19,39 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { SessionsView, SessionsViewId as SessionsListViewId } from '../../sessions/browser/views/sessionsView.js'; import { ISessionsSetUpService } from '../../../browser/sessionsSetUpService.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; -import { SessionStatus } from '../../../services/sessions/common/session.js'; import { SessionsCopilotConfigSlashSubmitHandlerContribution } from '../browser/copilotConfigSlashSubmitHandler.js'; import { AgentsWindowOpenSource, isAgentsWindowOpenSource } from '../../../../platform/window/common/window.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; -import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; +import { ISessionsWindowOpenContext, ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from '../browser/newSessionComposerService.js'; -import { resolveAgentsWindowFolderIntent } from '../browser/agentsWindowOpenIntent.js'; +import { getAgentsWindowWorkspaceArgumentKind, resolveAgentsWindowFolderIntent } from '../browser/agentsWindowOpenIntent.js'; import { findSessionForOpenSessionLink } from '../browser/openSessionLinkOpener.contribution.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { AgentsWindowWorkspaceHandoff } from '../browser/agentsWindowWorkspaceHandoff.js'; +import { SessionsWorkspaceSelectionTelemetry } from '../../sessions/browser/sessionsWorkspaceSelectionTelemetry.js'; -class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { +export class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.selectAgentsFolder'; private readonly _windowOpenTelemetry = this._register(new MutableDisposable()); + private readonly _workspaceSelectionTelemetry = this._register(new MutableDisposable()); + private readonly _openIntent = this._register(new MutableDisposable()); + private readonly _workspaceHandoff: AgentsWindowWorkspaceHandoff; private _didHandleInitialWindowOpen = false; constructor( @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, - @IViewsService private readonly viewsService: IViewsService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @ISessionsSetUpService private readonly sessionsSetUpService: ISessionsSetUpService, @ILogService private readonly logService: ILogService, @@ -59,24 +63,34 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, @INotificationService private readonly notificationService: INotificationService, @IProductService private readonly productService: IProductService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); + this._workspaceHandoff = this._register(instantiationService.createInstance(AgentsWindowWorkspaceHandoff)); const handleSelectAgentsFolder = (_: unknown, ...args: unknown[]) => { + this._workspaceHandoff.cancel(); + const cancellation = new CancellationTokenSource(); + this._openIntent.value = toDisposable(() => cancellation.dispose(true)); 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; + const workspaceArgumentIsDefault = args[3] === true; this.logService.info(`[AgentsHandoff] IPC received: folderUri=${folderUri?.toString() ?? '(none)'} sessionResource=${sessionResource?.toString() ?? '(none)'}`); - this._startWindowOpenTelemetry(source); + const telemetry = this._startWindowOpenTelemetry(source, { + workspaceArgumentKind: getAgentsWindowWorkspaceArgumentKind(workspaceUri), + hasSessionArgument: sessionResource !== undefined, + workspaceArgumentIsDefault, + }); - this._handleOpenIntentAndCaptureInitialState(folderUri, sessionResource, preferDevContainer) + this._handleOpenIntentAndCaptureInitialState(folderUri, sessionResource, preferDevContainer, workspaceArgumentIsDefault, cancellation.token, telemetry) .catch(err => this.logService.error('[AgentsHandoff] handleOpenIntent failed', err)); }; ipcRenderer.on('vscode:selectAgentsFolder', handleSelectAgentsFolder); this._register({ dispose: () => ipcRenderer.removeListener('vscode:selectAgentsFolder', handleSelectAgentsFolder) }); } - private _startWindowOpenTelemetry(source: AgentsWindowOpenSource): void { + private _startWindowOpenTelemetry(source: AgentsWindowOpenSource, context: ISessionsWindowOpenContext): SessionsWindowOpenTelemetry | undefined { if (this._didHandleInitialWindowOpen) { return; } @@ -89,23 +103,31 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon this._windowOpenTelemetry.value = new SessionsWindowOpenTelemetry( source, + context, () => this.sessionsSetUpService.initialSignInDialogShown, () => this._getWindowOpenViewState(), this.telemetryService, this.lifecycleService, ); + if (!context.hasSessionArgument) { + this._workspaceSelectionTelemetry.value = this.instantiationService.createInstance(SessionsWorkspaceSelectionTelemetry, source, context); + } + return this._windowOpenTelemetry.value; } - private async _captureInitialWindowViewState(): Promise { + private async _captureInitialWindowViewState(telemetry: SessionsWindowOpenTelemetry | undefined): Promise { await this.lifecycleService.when(LifecyclePhase.Eventually); - this._windowOpenTelemetry.value?.captureInitialViewState(); + telemetry?.captureInitialViewState(); } - private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise { + private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean, isDefault: boolean, token: CancellationToken, telemetry: SessionsWindowOpenTelemetry | undefined): Promise { try { - await this.handleOpenIntent(folderUri, sessionResource, preferDevContainer); + await this.handleOpenIntent(folderUri, sessionResource, preferDevContainer, isDefault, token, telemetry); + } catch (error) { + telemetry?.recordWorkspaceHandoffState('error'); + throw error; } finally { - await this._captureInitialWindowViewState(); + await this._captureInitialWindowViewState(telemetry); } } @@ -116,42 +138,49 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon return { workspacePreselected: undefined, workspacePreselectionSource: undefined, + viewKind: 'createdSession', }; } - const composerSource = this.newSessionComposerService.activeComposer.get()?.workspacePreselectionSource; + const composer = this.newSessionComposerService.activeComposer.get(); + const composerSource = composer?.workspacePreselectionSource; const workspacePreselected = activeSession?.workspace.get() !== undefined || (composerSource !== undefined && composerSource !== NewSessionWorkspacePreselectionSource.None); return { workspacePreselected, workspacePreselectionSource: composerSource ?? (workspacePreselected ? NewSessionWorkspacePreselectionSource.Unknown : NewSessionWorkspacePreselectionSource.None), + viewKind: composer ? 'newSession' : 'noComposer', + workspaceSelection: composer?.workspaceSelection, }; } - private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise { + private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean, isDefault: boolean, token: CancellationToken, telemetry: SessionsWindowOpenTelemetry | undefined): 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). if (sessionResource) { - await this.openExistingSession(sessionResource); + await this.openExistingSession(sessionResource, token); return; } if (folderUri) { - await this.selectFolder(folderUri, preferDevContainer); + await this._workspaceHandoff.selectWorkspace({ folderUri, preferDevContainer, isDefault }, state => telemetry?.recordWorkspaceHandoffState(state)); } } - private async openExistingSession(sessionResource: URI): Promise { + private async openExistingSession(sessionResource: URI, token: CancellationToken): Promise { this.logService.info(`[AgentsHandoff] openExistingSession: target=${sessionResource.toString()}`); // Wait until initial restore has started so opening the target can cancel it, // without delaying the handoff until the intentionally deferred Eventually phase. - await this.lifecycleService.when(LifecyclePhase.Restored); + await raceCancellation(this.lifecycleService.when(LifecyclePhase.Restored), token); + if (token.isCancellationRequested) { + return; + } this.logService.info('[AgentsHandoff] reached LifecyclePhase.Restored'); const backendSession = parseOpenSessionLinkUri(sessionResource); if (backendSession) { - await this.sessionsPartService.getProgressIndicator().showWhile(this.resolveAndOpenSessionLink(sessionResource, backendSession)); + await this.sessionsPartService.getProgressIndicator().showWhile(this.resolveAndOpenSessionLink(sessionResource, backendSession, token)); return; } @@ -165,11 +194,14 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon // Show the sessions part's progress bar while we wait for the session to // appear in the providers and open it, so the window doesn't just sit on // its restored state until the target session pops in. - await this.sessionsPartService.getProgressIndicator().showWhile(this.resolveAndOpenSession(sessionResource)); + await this.sessionsPartService.getProgressIndicator().showWhile(this.resolveAndOpenSession(sessionResource, token)); } - private async resolveAndOpenSessionLink(sessionLink: URI, backendSession: URI): Promise { - const session = await this.waitForSessionLinkAvailable(backendSession); + private async resolveAndOpenSessionLink(sessionLink: URI, backendSession: URI, token: CancellationToken): Promise { + const session = await this.waitForSessionLinkAvailable(backendSession, token); + if (token.isCancellationRequested) { + return; + } if (!session) { this.logService.warn('[AgentsHandoff] linked session never appeared in providers; aborting'); const externalLink = buildExternalOpenSessionLinkUri( @@ -194,11 +226,17 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon const chatId = parseOpenSessionLinkChatId(sessionLink); const chatResource = chatId ? session.resource.with({ fragment: chatId }) : session.mainChat.get().resource; + if (token.isCancellationRequested) { + return; + } this.logService.info(`[AgentsHandoff] linked session available; opening ${chatResource.toString()}`); await this.sessionsService.openChat(session, chatResource, { source: 'link' }); } - private waitForSessionLinkAvailable(backendSession: URI, timeoutMs = 15_000): Promise> { + private waitForSessionLinkAvailable(backendSession: URI, token: CancellationToken, timeoutMs = 15_000): Promise> { + if (token.isCancellationRequested) { + return Promise.resolve(undefined); + } const findSession = () => findSessionForOpenSessionLink(backendSession, this.sessionsManagementService, this.agentHostConnectionsService); const existing = findSession(); if (existing) { @@ -221,15 +259,19 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon store.add({ dispose: () => clearTimeout(timer) }); store.add(this.sessionsManagementService.onDidChangeSessions(tryFind)); store.add(this.agentHostConnectionsService.onDidChangeSessionResolution(tryFind)); + store.add(token.onCancellationRequested(() => done(undefined))); tryFind(); }); } - private async resolveAndOpenSession(sessionResource: URI): Promise { + private async resolveAndOpenSession(sessionResource: URI, token: CancellationToken): Promise { // The Copilot Chat Sessions Provider lists sessions asynchronously // via an RPC; the target session may not yet be in the providers' // `getSessions()` map. Poll until it shows up. - const found = await this.waitForSessionAvailable(sessionResource); + const found = await this.waitForSessionAvailable(sessionResource, token); + if (token.isCancellationRequested) { + return; + } if (!found) { this.logService.warn(`[AgentsHandoff] target session never appeared in providers; aborting`); return; @@ -241,7 +283,10 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon await this.sessionsService.openSession(sessionResource, { source: 'chat' }); } - private async waitForSessionAvailable(sessionResource: URI, timeoutMs = 15_000): Promise { + private async waitForSessionAvailable(sessionResource: URI, token: CancellationToken, timeoutMs = 15_000): Promise { + if (token.isCancellationRequested) { + return false; + } if (this.sessionsManagementService.getSession(sessionResource)) { return true; } @@ -260,48 +305,10 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon done(true); } })); + store.add(token.onCancellationRequested(() => done(false))); }); } - private async selectFolder(folderUri: URI, preferDevContainer: boolean): Promise { - // Wait for the welcome/setup flow to complete before selecting the folder - await this.sessionsSetUpService.whenWelcomeDone(); - - 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(SessionsListViewId); - sessionsView?.sessionsControl?.setOpenWindowSourceFolder(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, preferDevContainer)) { - disposable.dispose(); - } - }); - this.lifecycleService.when(LifecyclePhase.Eventually).then(() => disposable.dispose()); - } - - 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, { - providerId: resolved.providerId, - preferDevContainer, - }); - } - return true; - } } registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts index cb9c4254ab1bf..8d0e3dd2aaef1 100644 --- a/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts @@ -10,11 +10,21 @@ 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'; +import { getAgentsWindowWorkspaceArgumentKind, resolveAgentsWindowFolderIntent } from '../../browser/agentsWindowOpenIntent.js'; suite('Agents Window open intent', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('classifies the original workspace argument without exposing resource identifiers', () => { + assert.deepStrictEqual([ + undefined, + URI.file('/private/project'), + URI.parse('vscode-remote://dev-container+invalid/private/project'), + URI.parse('vscode-remote://ssh-remote+private-host/private/project'), + URI.parse('vscode-vfs://github/private/repository'), + ].map(getAgentsWindowWorkspaceArgumentKind), ['none', 'local', 'devContainer', 'remote', 'other']); + }); + test('resolves local and Dev Container editor workspaces', () => { const configurationService = (enabled: boolean) => new TestConfigurationService({ [DevContainerAgentHostEnabledSettingId]: enabled, diff --git a/src/vs/sessions/contrib/chat/test/browser/agentsWindowWorkspaceHandoff.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentsWindowWorkspaceHandoff.test.ts new file mode 100644 index 0000000000000..aa3487f345764 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/agentsWindowWorkspaceHandoff.test.ts @@ -0,0 +1,352 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INotificationService, IPromptChoice, IPromptChoiceWithMenu, NoOpNotification } from '../../../../../platform/notification/common/notification.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { ISelectWorkspaceOptions } from '../../../../browser/parts/chatView.js'; +import { SessionView } from '../../../../browser/parts/sessionView.js'; +import { ISessionsSetUpService } from '../../../../browser/sessionsSetUpService.js'; +import { WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionNavigationRequest, ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; +import { WorkspaceHandoffState } from '../../../sessions/browser/sessionsWindowOpenTelemetry.js'; +import { AgentsWindowWorkspaceHandoff, WORKSPACE_HANDOFF_TIMEOUT_MS } from '../../browser/agentsWindowWorkspaceHandoff.js'; +import { INewSessionComposerService, NewSessionComposerService } from '../../browser/newSessionComposerService.js'; + +suite('Agents Window workspace handoff', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const folderUri = URI.file('/private/from-editor'); + + function createHarness() { + const instantiationService = disposables.add(new TestInstantiationService()); + const composerService = disposables.add(new NewSessionComposerService()); + const activeSession = observableValue('activeSession', undefined); + const initialRestoreComplete = observableValue('restored', true); + const navigationRequest = observableValue('navigationRequest', undefined); + const onWillSend = disposables.add(new Emitter()); + const selections: { folder: URI; options?: ISelectWorkspaceOptions }[] = []; + const notifications: (IPromptChoice | IPromptChoiceWithMenu)[][] = []; + const states: WorkspaceHandoffState[] = []; + const openingOptions: boolean[] = []; + let providerReady = true; + let viewReady = true; + let applies = true; + let defaultAllowed = true; + let welcome = Promise.resolve(); + let resolutionError: Error | undefined; + const sessionsService = upcastPartial({ + activeSession, + initialRestoreComplete, + navigationRequest, + openNewSession: async (options, token = CancellationToken.None) => { + if (!options?.preserveNavigation) { + navigationRequest.set({ token }, undefined); + } + openingOptions.push(!!options?.cancelRestore); + activeSession.set(undefined, undefined); + return { session: undefined, trustDeclined: false }; + }, + }); + instantiationService.stub(ISessionsService, sessionsService); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + onWillSendRequest: onWillSend.event, + resolveWorkspace: () => { + if (resolutionError) { + throw resolutionError; + } + return providerReady ? { providerId: 'local', workspace: upcastPartial({}) } : undefined; + }, + })); + const sessionsPartService = upcastPartial({ + getSessionView: () => viewReady ? upcastPartial({ + selectWorkspace: (folder, options) => { + if (options?.isDefault && !defaultAllowed) { + return 'preserved'; + } + selections.push({ folder, options }); + return applies ? 'applied' : 'notReady'; + }, + }) : undefined, + }); + instantiationService.stub(ISessionsPartService, sessionsPartService); + instantiationService.stub(ISessionsSetUpService, upcastPartial({ whenWelcomeDone: () => welcome })); + instantiationService.stub(INewSessionComposerService, composerService); + disposables.add(composerService.registerComposer({ + get canApplyWorkspaceDefault() { return defaultAllowed; }, + animatePrompt: async () => false, + showPromptOptions: () => false, + })); + instantiationService.stub(ILifecycleService, upcastPartial({ when: async () => { }, phase: LifecyclePhase.Eventually, onWillShutdown: Event.None })); + instantiationService.stub(IViewsService, upcastPartial({ getViewWithId: () => null })); + instantiationService.stub(INotificationService, upcastPartial({ + prompt: (_severity, _message, choices) => { + notifications.push(choices); + return new NoOpNotification(); + }, + })); + instantiationService.stub(ICommandService, upcastPartial({ executeCommand: async () => undefined })); + instantiationService.stub(ILogService, upcastPartial({ warn: () => { }, error: () => { } })); + const handoff = disposables.add(instantiationService.createInstance(AgentsWindowWorkspaceHandoff)); + return { + handoff, composerService, sessionsService, sessionsPartService, activeSession, initialRestoreComplete, onWillSend, states, selections, notifications, openingOptions, + set providerReady(value: boolean) { providerReady = value; }, + set viewReady(value: boolean) { viewReady = value; }, + set applies(value: boolean) { applies = value; }, + set defaultAllowed(value: boolean) { defaultAllowed = value; }, + set welcome(value: Promise) { welcome = value; }, + set resolutionError(value: Error) { resolutionError = value; }, + open: (isDefault = false, folder = folderUri) => handoff.selectWorkspace({ folderUri: folder, preferDevContainer: true, isDefault }, state => states.push(state)), + }; + } + + test('retains the requested folder through setup and late provider and view readiness, past Eventually', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + const welcome = new DeferredPromise(); + harness.welcome = welcome.p; + harness.providerReady = false; + harness.viewReady = false; + const opening = harness.open(); + await timeout(500); + await welcome.complete(); + await timeout(500); + harness.providerReady = true; + await timeout(500); + harness.viewReady = true; + await opening; + assert.deepStrictEqual({ + stages: [...new Set(harness.states)], + openingOptions: harness.openingOptions, + selections: harness.selections.map(entry => ({ folder: entry.folder.toString(), options: entry.options })), + notifications: harness.notifications.length, + }, { + stages: ['waitingForSetup', 'waitingForSessionView', 'waitingForProvider', 'applied'], + openingOptions: [true], + selections: [{ folder: folderUri.toString(), options: { providerId: 'local', preferDevContainer: true, selectionOrigin: WorkspaceSelectionOrigin.WindowOpen, isDefault: false } }], + notifications: 0, + }); + }); + }); + + for (const action of ['select', 'newComposer', 'send', 'createdSession', 'supersede', 'dispose'] as const) { + test(`${action} cancels a workspace handoff waiting for setup`, async () => { + const harness = createHarness(); + const welcome = new DeferredPromise(); + harness.welcome = welcome.p; + const opening = harness.open(); + if (action === 'select') { + harness.composerService.notifyUserWorkspaceSelection(); + } else if (action === 'newComposer') { + harness.composerService.notifyUserNavigation(); + } else if (action === 'send') { + harness.composerService.notifyWillSendRequest({ query: 'test' }, undefined); + } else if (action === 'createdSession') { + harness.activeSession.set(upcastPartial({ isCreated: observableValue('created', true) }), undefined); + } else if (action === 'supersede') { + harness.handoff.cancel(); + } else { + harness.handoff.dispose(); + } + await opening; + await welcome.complete(); + assert.deepStrictEqual({ + state: harness.states.at(-1), + openings: harness.openingOptions.length, + selections: harness.selections.length, + notifications: harness.notifications.length, + }, { + state: action === 'select' || action === 'newComposer' ? 'userChanged' : action === 'send' || action === 'createdSession' ? 'sessionAlreadyCreated' : action === 'supersede' ? 'superseded' : 'cancelled', + openings: 0, + selections: 0, + notifications: 0, + }); + }); + } + + for (const waitingFor of ['setup', 'provider'] as const) { + test(`direct remote workspace navigation cancels a handoff waiting for ${waitingFor}`, async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + const welcome = new DeferredPromise(); + harness.welcome = waitingFor === 'setup' ? welcome.p : Promise.resolve(); + harness.providerReady = waitingFor !== 'provider'; + const opening = harness.open(); + await timeout(100); + + const remoteFolder = URI.parse('vscode-remote://ssh-remote+host/project'); + await harness.sessionsService.openNewSession(); + harness.sessionsPartService.getSessionView(harness.activeSession.get()?.sessionId)?.selectWorkspace(remoteFolder); + + harness.providerReady = true; + await welcome.complete(); + await opening; + assert.deepStrictEqual({ + state: harness.states.at(-1), + folders: harness.selections.map(selection => selection.folder.toString()), + workspaceChoices: harness.composerService.userWorkspaceSelectionVersion.get(), + navigationChoices: harness.composerService.userNavigationVersion.get(), + }, { + state: 'userChanged', + folders: [remoteFolder.toString()], + workspaceChoices: 0, + navigationChoices: 0, + }); + }); + }); + } + + test('a newer explicit folder wins when the older request resumes', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + harness.providerReady = false; + const oldOpening = harness.open(); + await timeout(100); + harness.providerReady = true; + const latestFolder = URI.file('/private/latest'); + await harness.open(false, latestFolder); + await oldOpening; + assert.deepStrictEqual(harness.selections.map(entry => entry.folder.toString()), [latestFolder.toString()]); + }); + }); + + test('a later explicit user selection cancels provider retries', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + harness.providerReady = false; + const opening = harness.open(); + await timeout(500); + harness.composerService.notifyUserWorkspaceSelection(); + harness.providerReady = true; + await opening; + assert.deepStrictEqual({ state: harness.states.at(-1), selections: harness.selections }, { state: 'userChanged', selections: [] }); + }); + }); + + for (const unavailable of ['provider', 'view', 'acknowledgement'] as const) { + test(`offers recovery after the ${unavailable} deadline instead of claiming success`, async () => { + await runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 200 }, async () => { + const harness = createHarness(); + harness.providerReady = unavailable !== 'provider'; + harness.viewReady = unavailable !== 'view'; + harness.applies = unavailable !== 'acknowledgement'; + const startedAt = Date.now(); + await harness.open(); + const timedOut = { state: harness.states.at(-1), duration: Date.now() - startedAt, actions: harness.notifications[0].map(choice => choice.label) }; + harness.providerReady = harness.viewReady = harness.applies = true; + await harness.notifications[0][0].run(); + assert.deepStrictEqual({ timedOut, retried: harness.states.at(-1) }, { + timedOut: { + state: unavailable === 'provider' ? 'providerUnavailable' : unavailable === 'view' ? 'viewUnavailable' : 'selectionNotApplied', + duration: WORKSPACE_HANDOFF_TIMEOUT_MS, + actions: ['Retry', 'Choose Workspace'], + }, + retried: 'applied', + }); + }); + }); + } + + test('reports provider errors with recovery rather than silently abandoning the request', async () => { + const harness = createHarness(); + harness.resolutionError = new Error('unavailable'); + await harness.open(); + assert.deepStrictEqual({ state: harness.states.at(-1), notifications: harness.notifications.length }, { state: 'error', notifications: 1 }); + }); + + test('an inferred default waits for restore without opening a new session', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + harness.initialRestoreComplete.set(false, undefined); + const opening = harness.open(true); + await timeout(100); + harness.initialRestoreComplete.set(true, undefined); + await opening; + assert.deepStrictEqual({ openings: harness.openingOptions, origin: harness.selections[0]?.options?.selectionOrigin }, { + openings: [], + origin: WorkspaceSelectionOrigin.WindowContext, + }); + }); + }); + + test('cancels an inferred default while waiting for initial restore', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + harness.initialRestoreComplete.set(false, undefined); + const opening = harness.open(true); + await timeout(100); + harness.handoff.cancel(); + await opening; + harness.initialRestoreComplete.set(true, undefined); + assert.deepStrictEqual({ state: harness.states.at(-1), selections: harness.selections }, { state: 'superseded', selections: [] }); + }); + }); + + test('does not show a warning when an inferred default times out', async () => { + await runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 200 }, async () => { + const harness = createHarness(); + harness.providerReady = false; + await harness.open(true); + assert.deepStrictEqual({ state: harness.states.at(-1), notifications: harness.notifications }, { state: 'providerUnavailable', notifications: [] }); + }); + }); + + test('navigating to a created session cancels pending provider retries', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + harness.providerReady = false; + const opening = harness.open(); + await timeout(100); + harness.activeSession.set(upcastPartial({ isCreated: observableValue('created', true) }), undefined); + harness.providerReady = true; + await opening; + assert.deepStrictEqual({ state: harness.states.at(-1), selections: harness.selections }, { state: 'sessionAlreadyCreated', selections: [] }); + }); + }); + + test('asks the target view rather than the most recently mounted composer whether a default is safe', async () => { + const harness = createHarness(); + disposables.add(harness.composerService.registerComposer({ + canApplyWorkspaceDefault: false, + animatePrompt: async () => false, + showPromptOptions: () => false, + })); + await harness.open(true); + assert.deepStrictEqual({ state: harness.states.at(-1), selections: harness.selections.length }, { state: 'applied', selections: 1 }); + }); + + for (const protectedState of ['restoredSession', 'quickChat', 'composer'] as const) { + test(`an inferred default preserves ${protectedState}`, async () => { + const harness = createHarness(); + if (protectedState === 'restoredSession') { + harness.activeSession.set(upcastPartial({ isCreated: observableValue('created', true) }), undefined); + } else if (protectedState === 'quickChat') { + harness.activeSession.set(upcastPartial({ + isCreated: observableValue('created', false), isQuickChat: observableValue('quickChat', true), + }), undefined); + } else { + harness.defaultAllowed = false; + } + await harness.open(true); + assert.deepStrictEqual({ state: harness.states.at(-1), selections: harness.selections, openings: harness.openingOptions }, { + state: 'preservedSession', selections: [], openings: [], + }); + }); + } +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index dc8868c543820..ca4f7300dc0c7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -29,10 +29,29 @@ import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js' import { NewChatInputWidget } from '../../browser/newChatInput.js'; import { NewChatWidget } from '../../browser/newChatWidget.js'; import '../../../../../workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css'; +import { ISelectWorkspaceOptions } from '../../../../browser/parts/chatView.js'; suite('Sessions - Chat View', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('forwards workspace acknowledgement only from a new-session widget', () => { + const calls: { folder: URI; options?: ISelectWorkspaceOptions }[] = []; + const widget: NewChatWidget = Object.assign(Object.create(NewChatWidget.prototype), { + selectWorkspace: (folder: URI, options?: ISelectWorkspaceOptions) => { + calls.push({ folder, options }); + return 'applied'; + }, + }); + const results = [undefined, Object.create(NewChatInSessionWidget.prototype), widget].map(_widget => { + const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { _widget }); + return view.selectWorkspace(URI.file('/requested'), { isDefault: true }); + }); + assert.deepStrictEqual({ results, calls }, { + results: ['notReady', 'notReady', 'applied'], + calls: [{ folder: URI.file('/requested'), options: { isDefault: true } }], + }); + }); + /** Reaches the banner without standing up the widget's whole service graph. */ interface ISubSessionTipRenderer { _renderSubSessionTip(): void; diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index 8b515752389d5..f75de832850da 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -46,7 +46,7 @@ import { AgentHostFilterConnectionStatus, IAgentHostFilterService } from '../../ import { ISessionsChatBackgroundService } from '../../../../services/chatBackground/browser/chatBackgroundService.js'; import { SessionsChatBackgroundRenderer } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; +import { IRecentWorkspace, ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ChatModelSource, IChat, ISession, ISessionWorkspace, ISessionType, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE, SessionStatus, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; @@ -242,7 +242,8 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN }()); reg.defineInstance(ISessionsRecentWorkspacesService, new class extends mock() { override readonly onDidChangeRecentWorkspaces = Event.None; - override getRecentWorkspaces() { return activeSession ? [{ workspace, providerId: provider.id, checked: true }] : []; } + override readonly historyLoadState = constObservable('loaded' as const); + override getRecentWorkspaces(): IRecentWorkspace[] { return activeSession ? [{ workspace, providerId: provider.id, checked: true, source: 'agents' }] : []; } override addRecentWorkspace(): void { } override removeRecentWorkspace(): void { } override clearCheckedWorkspace(): void { } 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 bff51858b8e12..b41c1a4104275 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -24,7 +24,10 @@ import { IChatRequestVariableEntry, toFileVariableEntry, toPasteVariableEntry } 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'; +import { IWorkspacePickerNoWorkspaceOption, WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; +import { IWorkspaceSelectionSnapshot, WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; +import { ISelectWorkspaceOptions } from '../../../../browser/parts/chatView.js'; +import { NewChatInputWidget } from '../../browser/newChatInput.js'; /** The part of the active session `_recreateOnProviderChange` actually reads. */ interface IActiveDraft { @@ -61,6 +64,7 @@ interface ICreateSessionNowHarness { interface INewChatWidgetHarness extends IRecreateHarness { readonly _newSessionCreation: MutableDisposable; + _createdSessionId: string | undefined; readonly sessionsManagementService: { readonly onDidChangeSessionTypes: Event }; readonly _newChatInput: { readonly sessionTypePicker: { @@ -85,6 +89,7 @@ const createSessionNow = Reflect.get(NewChatWidget.prototype, '_createSessionNow userPick: IPreferredSessionType | undefined, token: CancellationToken, ) => Promise; +const canApplyWorkspaceDefault = Reflect.get(NewChatWidget.prototype, '_canApplyWorkspaceDefault') as (this: NewChatWidget) => boolean; const prepareSessionTypeSelection = Reflect.get(NewChatWidget.prototype, '_prepareSessionTypeSelection') as ( this: { readonly _workspacePicker: { @@ -115,11 +120,12 @@ const syncWorkspacePickerDevContainerMode = Reflect.get(NewChatWidget.prototype, getProvider(providerId: string): { readonly id: string; isDevContainerEnabled?(sessionId: string): boolean } | undefined; }; readonly _workspacePicker: { - setSelectedWorkspace(folderUri: URI, options: { fireEvent: boolean; providerId: string; persist: boolean; preferDevContainer: boolean }): void; + setSelectedWorkspace(folderUri: URI, options: { fireEvent: boolean; providerId: string; persist: boolean; preferDevContainer: boolean; origin: WorkspaceSelectionOrigin }): void; }; }, activeSession: IActiveSession, persist: boolean, + origin: WorkspaceSelectionOrigin, ) => URI | undefined; const scheduleRecreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_scheduleRecreateOnProviderChange') as INewChatWidgetHarness['_scheduleRecreateOnProviderChange']; const recreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_recreateOnProviderChange') as ( @@ -142,7 +148,7 @@ interface IPromptOptionsWorkspaceHarness { interface ISyncWorkspacePickerHarness { readonly _workspacePicker: { matchesSelectedWorkspace(workspace: ISessionWorkspace): boolean; - setSelectedWorkspace(folderUri: URI, options: { fireEvent: false }): void; + setSelectedWorkspace(folderUri: URI, options: { fireEvent: false; origin: WorkspaceSelectionOrigin }): void; }; } @@ -151,10 +157,12 @@ interface ISessionCountHarness { } interface ISendHarness { + readonly newSessionComposerService: { notifyWillSendRequest(options: ISendRequestOptions, selection: IWorkspaceSelectionSnapshot | undefined): void }; readonly _session: IObservable; readonly _feedbackItems: IObservable; readonly _workspacePicker: { readonly selectedFolderUri: URI | undefined; + readonly selectionSnapshot?: IWorkspaceSelectionSnapshot; clearAttachedContext(): void; showPicker(): void; }; @@ -226,6 +234,7 @@ function createHarness( const harness: INewChatWidgetHarness = { _pendingPreferredUpgrade: pendingPreferredUpgrade, _newSessionCreation: newSessionCreation, + _createdSessionId: undefined, sessionsManagementService: { onDidChangeSessionTypes }, _session: observableValue('session', undefined), _newChatInput: { @@ -574,10 +583,10 @@ suite('NewChatWidget', () => { }); }); - test('resynchronizes the workspace picker when the active draft Dev Container mode changes', () => { + test('resynchronizes Dev Container mode with the selection origin when updating and restoring a draft', () => { const folder = URI.file('/project'); let enabled = true; - const selections: Array<{ readonly folderUri: string; readonly providerId: string; readonly persist: boolean; readonly preferDevContainer: boolean }> = []; + const selections: Array<{ readonly folderUri: string; readonly providerId: string; readonly persist: boolean; readonly preferDevContainer: boolean; readonly origin: WorkspaceSelectionOrigin }> = []; const harness = { sessionsProvidersService: { getProvider: () => ({ @@ -586,11 +595,12 @@ suite('NewChatWidget', () => { }), }, _workspacePicker: { - setSelectedWorkspace: (folderUri: URI, options: { providerId: string; persist: boolean; preferDevContainer: boolean }) => selections.push({ + setSelectedWorkspace: (folderUri: URI, options: { providerId: string; persist: boolean; preferDevContainer: boolean; origin: WorkspaceSelectionOrigin }) => selections.push({ folderUri: folderUri.toString(), providerId: options.providerId, persist: options.persist, preferDevContainer: options.preferDevContainer, + origin: options.origin, }), }, }; @@ -604,17 +614,19 @@ suite('NewChatWidget', () => { })), }); - syncWorkspacePickerDevContainerMode.call(harness, activeSession, false); + syncWorkspacePickerDevContainerMode.call(harness, activeSession, false, WorkspaceSelectionOrigin.SessionSync); enabled = false; - syncWorkspacePickerDevContainerMode.call(harness, activeSession, false); + syncWorkspacePickerDevContainerMode.call(harness, activeSession, false, WorkspaceSelectionOrigin.SessionSync); + syncWorkspacePickerDevContainerMode.call(harness, activeSession, true, WorkspaceSelectionOrigin.RestoredDraft); assert.deepStrictEqual(selections, [ - { folderUri: folder.toString(), providerId: LOCAL_AGENT_HOST_PROVIDER_ID, persist: false, preferDevContainer: true }, - { folderUri: folder.toString(), providerId: LOCAL_AGENT_HOST_PROVIDER_ID, persist: false, preferDevContainer: false }, + { folderUri: folder.toString(), providerId: LOCAL_AGENT_HOST_PROVIDER_ID, persist: false, preferDevContainer: true, origin: WorkspaceSelectionOrigin.SessionSync }, + { folderUri: folder.toString(), providerId: LOCAL_AGENT_HOST_PROVIDER_ID, persist: false, preferDevContainer: false, origin: WorkspaceSelectionOrigin.SessionSync }, + { folderUri: folder.toString(), providerId: LOCAL_AGENT_HOST_PROVIDER_ID, persist: true, preferDevContainer: false, origin: WorkspaceSelectionOrigin.RestoredDraft }, ]); }); - test('cancels an in-flight creation when a newer one starts', async () => { + test('cancels an in-flight creation and keeps the newer draft ownership', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); const newSessionCreation = disposables.add(new MutableDisposable()); @@ -624,16 +636,20 @@ suite('NewChatWidget', () => { tokens.push(token); return tokens.length === 1 ? firstCreation.p - : Promise.resolve({ session: undefined, trustDeclined: true }); + : Promise.resolve({ session: upcastPartial({ sessionId: 'second' }), trustDeclined: false }); }); const first = harness._createNewSession(URI.file('/first')); const second = harness._createNewSession(URI.file('/second')); const firstCancelledWhenSecondStarted = tokens[0].isCancellationRequested; - firstCreation.complete({ session: undefined, trustDeclined: false }); + firstCreation.complete({ session: upcastPartial({ sessionId: 'first' }), trustDeclined: false }); await Promise.all([first, second]); - assert.deepStrictEqual({ tokenCount: tokens.length, firstCancelledWhenSecondStarted }, { tokenCount: 2, firstCancelledWhenSecondStarted: true }); + assert.deepStrictEqual({ + tokenCount: tokens.length, firstCancelledWhenSecondStarted, createdSessionId: harness._createdSessionId, + }, { + tokenCount: 2, firstCancelledWhenSecondStarted: true, createdSessionId: 'second', + }); }); test('sends the user pick to openNewSession, falling back to the preferred type', async () => { @@ -661,14 +677,14 @@ suite('NewChatWidget', () => { logService: { error: () => { } }, _isPreferredServable: () => servable, }, folder, pick, CancellationToken.None); - return { providerId: options?.providerId, sessionTypeId: options?.sessionTypeId }; + return { providerId: options?.providerId, sessionTypeId: options?.sessionTypeId, preserveNavigation: options?.preserveNavigation }; })); assert.deepStrictEqual(requested, [ - { providerId: 'agent-host', sessionTypeId: 'claude' }, - { providerId: 'copilot', sessionTypeId: 'copilot-cli' }, - { providerId: 'copilot', sessionTypeId: 'copilot-cli' }, - { providerId: 'workspace-provider', sessionTypeId: undefined }, + { providerId: 'agent-host', sessionTypeId: 'claude', preserveNavigation: true }, + { providerId: 'copilot', sessionTypeId: 'copilot-cli', preserveNavigation: true }, + { providerId: 'copilot', sessionTypeId: 'copilot-cli', preserveNavigation: true }, + { providerId: 'workspace-provider', sessionTypeId: undefined, preserveNavigation: true }, ]); }); @@ -820,11 +836,11 @@ suite('NewChatWidget', () => { const localFolder = URI.file('/project'); const cloudFolder = URI.parse('github-remote-file://github/owner/project/HEAD'); const otherCloudFolder = URI.parse('github-remote-file://github/owner/other/HEAD'); - const selected: string[] = []; + const selected: { folder: string; origin: WorkspaceSelectionOrigin }[] = []; const harness: ISyncWorkspacePickerHarness = { _workspacePicker: { matchesSelectedWorkspace: workspace => workspace.folders[0].root.toString() === cloudFolder.toString(), - setSelectedWorkspace: folderUri => selected.push(folderUri.toString()), + setSelectedWorkspace: (folderUri, options) => selected.push({ folder: folderUri.toString(), origin: options.origin }), }, }; const workspace = (root: URI): ISessionWorkspace => upcastPartial({ @@ -841,7 +857,10 @@ suite('NewChatWidget', () => { syncWorkspacePickerFromSessionWorkspace.call(harness, workspace(otherCloudFolder)); syncWorkspacePickerFromSessionWorkspace.call(harness, workspace(localFolder)); - assert.deepStrictEqual(selected, [otherCloudFolder.toString(), localFolder.toString()]); + assert.deepStrictEqual(selected, [ + { folder: otherCloudFolder.toString(), origin: WorkspaceSelectionOrigin.SessionSync }, + { folder: localFolder.toString(), origin: WorkspaceSelectionOrigin.SessionSync }, + ]); }); test('only allows first-run notices once the session count threshold is reached', () => { @@ -913,6 +932,13 @@ suite('NewChatWidget', () => { { id: `github-context:${issueContext.uri.toString()}`, icon: issueContext.icon }, ); let sentOptions: ISendRequestOptions | undefined; + let preparedOptions: ISendRequestOptions | undefined; + let preparedSelection: IWorkspaceSelectionSnapshot | undefined; + const stages: string[] = []; + const selectionSnapshot: IWorkspaceSelectionSnapshot = { + folderUri: primaryFolder, state: 'selected', origin: WorkspaceSelectionOrigin.WindowOpen, + historyState: 'loaded', sessionFallbackState: 'idle', registeredProviderCount: 1, + }; let clearAttachedContextCount = 0; const result = await send.call({ @@ -920,14 +946,23 @@ suite('NewChatWidget', () => { _feedbackItems: constObservable([]), _workspacePicker: { selectedFolderUri: primaryFolder, + selectionSnapshot, clearAttachedContext: () => clearAttachedContextCount++, showPicker: () => { }, }, _isQuickChatComposer: constObservable(false), agentFeedbackService: { removeFeedback: () => { } }, + newSessionComposerService: { + notifyWillSendRequest: (options, selection) => { + stages.push('prepare'); + preparedOptions = options; + preparedSelection = selection; + } + }, sessionsManagementService: { sendNewChatRequest: async (_session, options) => { sentOptions = options; + stages.push('send'); }, }, logService: { error: () => { } }, @@ -942,6 +977,9 @@ suite('NewChatWidget', () => { assert.deepStrictEqual({ result, + stages, + preparedExactOptions: preparedOptions === sentOptions, + preparedExactSelection: preparedSelection === selectionSnapshot, clearAttachedContextCount, attachments: sentOptions?.attachedContext?.map(attachment => ({ kind: attachment.kind, @@ -950,6 +988,9 @@ suite('NewChatWidget', () => { })), }, { result: true, + stages: ['prepare', 'send'], + preparedExactOptions: true, + preparedExactSelection: true, clearAttachedContextCount: 1, attachments: [ { kind: 'file', id: composerAttachment.id, value: URI.file('/explicit-file').toString() }, @@ -975,6 +1016,7 @@ suite('NewChatWidget', () => { }, _isQuickChatComposer: constObservable(false), agentFeedbackService: { removeFeedback: () => { } }, + newSessionComposerService: { notifyWillSendRequest: () => { } }, sessionsManagementService: { sendNewChatRequest: async () => { sendCount++; @@ -991,4 +1033,129 @@ suite('NewChatWidget', () => { }); }); + for (const origin of [ + WorkspaceSelectionOrigin.None, WorkspaceSelectionOrigin.CheckedWorkspace, WorkspaceSelectionOrigin.AgentsRecent, + WorkspaceSelectionOrigin.VSCodeRecent, WorkspaceSelectionOrigin.VSCodeWorkspace, WorkspaceSelectionOrigin.ExistingSessions, WorkspaceSelectionOrigin.WindowContext, + WorkspaceSelectionOrigin.User, WorkspaceSelectionOrigin.WindowOpen, WorkspaceSelectionOrigin.RestoredDraft, + WorkspaceSelectionOrigin.SessionSync, WorkspaceSelectionOrigin.Programmatic, + ]) { + test(`the target composer handles an inferred default over ${origin}`, () => { + const folderUri = URI.file('/from-editor'); + let selection: IWorkspaceSelectionSnapshot = { + folderUri: URI.file('/previous'), state: 'selected', origin, + historyState: 'loaded', sessionFallbackState: 'idle', registeredProviderCount: 1, + }; + const selected: URI[] = []; + const widget: NewChatWidget = Object.assign(Object.create(NewChatWidget.prototype), { + _newSessionCreation: disposables.add(new MutableDisposable()), + _newChatInput: { canApplyWorkspaceDefault: true }, + _isQuickChatComposer: constObservable(false), + uriIdentityService: { extUri }, + _workspacePicker: { + get selectionSnapshot() { return selection; }, + setSelectedWorkspace: (folder: URI) => { + selected.push(folder); + selection = { ...selection, folderUri: folder }; + }, + }, + }); + const preserved = [ + WorkspaceSelectionOrigin.User, WorkspaceSelectionOrigin.WindowOpen, WorkspaceSelectionOrigin.RestoredDraft, + WorkspaceSelectionOrigin.SessionSync, WorkspaceSelectionOrigin.Programmatic, + ].includes(origin); + assert.deepStrictEqual({ result: widget.selectWorkspace(folderUri, { isDefault: true }), selected }, { + result: preserved ? 'preserved' : 'applied', selected: preserved ? [] : [folderUri], + }); + }); + } + + for (const protectedState of ['input', 'attachments', 'restoredDraft', 'noWorkspace', 'quickChat'] as const) { + test(`an inferred workspace preserves the target composer's ${protectedState}`, () => { + const input: NewChatInputWidget = Object.assign(Object.create(NewChatInputWidget.prototype), { + _editor: { getValue: () => protectedState === 'input' ? 'unsent input' : '' }, + _contextAttachments: { attachments: protectedState === 'attachments' ? [toFileVariableEntry(URI.file('/attached'))] : [] }, + options: { canApplyWorkspaceDefault: () => canApplyWorkspaceDefault.call(widget) }, + }); + const widget: NewChatWidget = Object.assign(Object.create(NewChatWidget.prototype), { + _session: constObservable(protectedState === 'restoredDraft' ? upcastPartial({ sessionId: 'restored' }) : undefined), + _newSessionCreation: disposables.add(new MutableDisposable()), + _newChatInput: input, + _isQuickChatComposer: constObservable(protectedState === 'quickChat'), + _workspacePicker: { + selectionSnapshot: { state: protectedState === 'noWorkspace' ? 'noWorkspace' : 'none', origin: WorkspaceSelectionOrigin.None }, + setSelectedWorkspace: () => assert.fail('must preserve the composer'), + }, + }); + assert.strictEqual(widget.selectWorkspace(URI.file('/default'), { isDefault: true }), 'preserved'); + }); + } + + for (const createdSessionId of [undefined, 'late-draft', 'another-draft']) { + test(`checks late draft ownership before applying a default (created: ${createdSessionId})`, () => { + const session = observableValue('session', undefined); + const creation = disposables.add(new MutableDisposable()); + const selected: URI[] = []; + let selection: IWorkspaceSelectionSnapshot = { + folderUri: undefined, state: 'none', origin: WorkspaceSelectionOrigin.None, + historyState: 'loaded', sessionFallbackState: 'idle', registeredProviderCount: 1, + }; + const input: NewChatInputWidget = Object.assign(Object.create(NewChatInputWidget.prototype), { + _editor: { getValue: () => '' }, + _contextAttachments: { attachments: [] }, + options: { canApplyWorkspaceDefault: () => canApplyWorkspaceDefault.call(widget) }, + }); + const widget: NewChatWidget = Object.assign(Object.create(NewChatWidget.prototype), { + _session: session, + _createdSessionId: createdSessionId, + _newSessionCreation: creation, + _newChatInput: input, + _isQuickChatComposer: constObservable(false), + uriIdentityService: { extUri }, + _workspacePicker: { + get selectionSnapshot() { return selection; }, + setSelectedWorkspace: (folderUri: URI) => { + selected.push(folderUri); + selection = { ...selection, state: 'selected', folderUri }; + }, + }, + }); + const initiallyEligible = input.canApplyWorkspaceDefault; + session.set(upcastPartial({ + sessionId: 'late-draft', workspace: constObservable(undefined), + }), undefined); + creation.value = toDisposable(() => { }); + const folderUri = URI.file('/from-editor'); + const whileCreating = widget.selectWorkspace(folderUri, { isDefault: true }); + creation.clear(); + const result = widget.selectWorkspace(folderUri, { isDefault: true }); + const ownsDraft = createdSessionId === 'late-draft'; + assert.deepStrictEqual({ initiallyEligible, whileCreating, result, selected }, { + initiallyEligible: true, whileCreating: 'notReady', + result: ownsDraft ? 'applied' : 'preserved', selected: ownsDraft ? [folderUri] : [], + }); + }); + } + + test('forwards Dev Container mode and selection origin while acknowledging only the resolved target folder', () => { + const folder = URI.file('/requested'); + let selection: Pick = { folderUri: folder, state: 'unresolved' }; + const forwarded: Parameters[1][] = []; + const widget: NewChatWidget = Object.assign(Object.create(NewChatWidget.prototype), { + uriIdentityService: { extUri }, + _workspacePicker: { + get selectionSnapshot() { return selection; }, + setSelectedWorkspace: (_folder: URI, options: Parameters[1]) => forwarded.push(options), + }, + }); + const options: ISelectWorkspaceOptions = { providerId: 'provider', preferDevContainer: true, selectionOrigin: WorkspaceSelectionOrigin.WindowOpen }; + const results = [widget.selectWorkspace(folder, options)]; + selection = { folderUri: URI.file('/unrelated'), state: 'selected' }; + results.push(widget.selectWorkspace(folder, options)); + selection = { folderUri: folder, state: 'selected' }; + results.push(widget.selectWorkspace(folder, options)); + assert.deepStrictEqual({ results, forwarded }, { + results: ['notReady', 'notReady', 'applied'], + forwarded: Array.from({ length: 3 }, () => ({ providerId: 'provider', preferDevContainer: true, origin: WorkspaceSelectionOrigin.WindowOpen })), + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newSessionComposerService.test.ts b/src/vs/sessions/contrib/chat/test/browser/newSessionComposerService.test.ts index dedaf8beda406..1316dc96fe099 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newSessionComposerService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newSessionComposerService.test.ts @@ -8,6 +8,10 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { INewSessionComposer, NewSessionComposerService } from '../../browser/newSessionComposerService.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { IWorkspaceSelectionSnapshot, WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; +import { URI } from '../../../../../base/common/uri.js'; suite('NewSessionComposerService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -32,4 +36,29 @@ suite('NewSessionComposerService', () => { assert.deepStrictEqual({ newest, fallback: service.activeComposer.get() === first }, { newest: true, fallback: true }); }); + + test('observes workspace changes and releases replaced composer listeners', () => { + const service = disposables.add(new NewSessionComposerService()); + const changed = disposables.add(new Emitter()); + const selection: IWorkspaceSelectionSnapshot = { + folderUri: URI.file('/private/workspace'), state: 'selected', origin: WorkspaceSelectionOrigin.VSCodeRecent, + historyState: 'loaded', sessionFallbackState: 'idle', registeredProviderCount: 1, + }; + let currentSelection: IWorkspaceSelectionSnapshot | undefined; + const first = { ...composer(), get workspaceSelection() { return currentSelection; }, onDidChangeWorkspaceSelection: changed.event }; + disposables.add(service.registerComposer(first)); + const snapshots: (WorkspaceSelectionOrigin | undefined)[] = []; + disposables.add(autorun(reader => snapshots.push(service.workspaceSelection.read(reader)?.origin))); + currentSelection = selection; + changed.fire(); + const replacement = disposables.add(service.registerComposer(composer())); + currentSelection = { ...selection, origin: WorkspaceSelectionOrigin.WindowOpen }; + changed.fire(); + replacement.dispose(); + service.notifyUserWorkspaceSelection(); + assert.deepStrictEqual({ snapshots, userVersion: service.userWorkspaceSelectionVersion.get() }, { + snapshots: [undefined, WorkspaceSelectionOrigin.VSCodeRecent, undefined, WorkspaceSelectionOrigin.WindowOpen], + userVersion: 1, + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts b/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts index 50282eb7cc5f1..df7c06480841e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts @@ -29,7 +29,7 @@ function createResolvedRecent(uri: URI, providerId = 'local-1', checked = false) requiresWorkspaceTrust: false, isVirtualWorkspace: false, }; - return { workspace, providerId, checked }; + return { workspace, providerId, checked, source: 'agents' }; } function createRecentWorkspacesService(recent: IRecentWorkspace[]): ISessionsRecentWorkspacesService { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 7bf4c4f77a494..93acebf1eb9d8 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -9,6 +9,7 @@ import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { errorHandler } from '../../../../../base/common/errors.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -39,6 +40,7 @@ import { IWorkspacePickerItem, IWorkspacePickerOptions, WorkspacePicker } from ' import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; import { WebWorkspacePicker } from '../../browser/webWorkspacePicker.js'; import { NewSessionWorkspacePreselectionSource } from '../../browser/newSessionComposerService.js'; +import { WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; import { ISessionsRecentWorkspacesService, SessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { AutomationsWorkspacePicker } from '../../../automations/browser/automationDialog.js'; import { AutomationIsolationModel } from '../../../automations/common/isolationGroupModel.js'; @@ -51,7 +53,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { TestDialogService } from '../../../../../platform/dialogs/test/common/testDialogService.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IFileContent, IFileService } from '../../../../../platform/files/common/files.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IMenuService } from '../../../../../platform/actions/common/actions.js'; @@ -62,6 +64,8 @@ import { IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../ import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; // ---- Storage key (must match the one in sessionWorkspacePicker.ts) ---------- const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; @@ -303,7 +307,7 @@ function seedStorage(storageService: IStorageService, entries: { uri: URI; provi } function createTestPicker( - disposables: DisposableStore, + disposables: Pick, providersService: MockSessionsProvidersService, storageService?: IStorageService, notificationService: INotificationService = new TestNotificationService(), @@ -349,6 +353,7 @@ function createTestPicker( }); instantiationService.stub(INotificationService, notificationService); instantiationService.stub(IWorkspacesService, workspacesService); + instantiationService.stub(ILogService, disposables.add(new NullLogService())); instantiationService.stub(ISessionsRecentWorkspacesService, recentWorkspacesService ?? disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IHoverService, NullHoverService); @@ -393,16 +398,19 @@ function createMockSession( * builds its own service inline). */ async function createResolvedRecentWorkspacesService( - disposables: DisposableStore, + disposables: Pick, storageService: IStorageService, providersService: MockSessionsProvidersService, workspacesService: IWorkspacesService, + fileService: IFileService = upcastPartial({ onDidChangeFileSystemProviderRegistrations: Event.None, hasProvider: () => false }), ): Promise { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, storageService); instantiationService.stub(IUriIdentityService, { extUri }); instantiationService.stub(IWorkspacesService, workspacesService); instantiationService.stub(ISessionsProvidersService, providersService); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(ILogService, disposables.add(new NullLogService())); const recentWorkspacesService = disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService)); await new Promise(resolve => { const listener = recentWorkspacesService.onDidChangeRecentWorkspaces(() => { @@ -740,9 +748,11 @@ suite('WorkspacePicker - Connection Status', () => { assert.deepStrictEqual({ providerId: picker.selectedResolved?.providerId, source: picker.preselectionSource, + origin: picker.selectionSnapshot.origin, }, { providerId: 'agenthost-remote-1', source: NewSessionWorkspacePreselectionSource.CheckedWorkspace, + origin: WorkspaceSelectionOrigin.CheckedWorkspace, }); }); @@ -775,9 +785,13 @@ suite('WorkspacePicker - Connection Status', () => { assert.deepStrictEqual({ folderUri: picker.selectedFolderUri?.toString(), source: picker.preselectionSource, + origin: picker.selectionSnapshot.origin, + recentSources: recentWorkspacesService.getRecentWorkspaces().map(recent => recent.source), }, { folderUri: ownUri.toString(), source: NewSessionWorkspacePreselectionSource.RecentWorkspace, + origin: WorkspaceSelectionOrigin.AgentsRecent, + recentSources: ['agents', 'vscode'], }); }); @@ -800,7 +814,13 @@ suite('WorkspacePicker - Connection Status', () => { const picker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recentWorkspacesService); - assert.strictEqual(picker.selectedFolderUri?.toString(), mostRecentGlobalUri.toString()); + assert.deepStrictEqual({ + folderUri: picker.selectedFolderUri?.toString(), + origin: picker.selectionSnapshot.origin, + }, { + folderUri: mostRecentGlobalUri.toString(), + origin: WorkspaceSelectionOrigin.VSCodeRecent, + }); }); test('restore selects a VS Code recent that finishes loading after picker creation', async () => { @@ -815,11 +835,17 @@ suite('WorkspacePicker - Connection Status', () => { } as unknown as IWorkspacesService; const picker = createTestPicker(disposables, providersService, undefined, undefined, undefined, undefined, workspacesService); - const initialSelection = picker.selectedFolderUri; - assert.strictEqual(initialSelection, undefined); + const initialSelection = picker.selectionSnapshot; await recentlyOpened.complete({ workspaces: [{ folderUri: globalUri }], files: [] }); + await timeout(0); - assert.strictEqual(picker.selectedFolderUri?.toString(), globalUri.toString()); + assert.deepStrictEqual({ + initial: [initialSelection.folderUri, initialSelection.state, initialSelection.historyState], + loaded: [picker.selectedFolderUri?.toString(), picker.selectionSnapshot.state, picker.selectionSnapshot.historyState, picker.selectionSnapshot.origin], + }, { + initial: [undefined, 'none', 'loading'], + loaded: [globalUri.toString(), 'selected', 'loaded', WorkspaceSelectionOrigin.VSCodeRecent], + }); }); test('late VS Code recents do not override an explicit workspace selection', async () => { @@ -834,11 +860,20 @@ suite('WorkspacePicker - Connection Status', () => { onDidChangeRecentlyOpened: Event.None, } as unknown as IWorkspacesService; const picker = createTestPicker(disposables, providersService, undefined, undefined, undefined, undefined, workspacesService); - picker.setSelectedWorkspace(selectedUri, { fireEvent: false }); + picker.setSelectedWorkspace(selectedUri, { fireEvent: false, origin: WorkspaceSelectionOrigin.WindowOpen }); await recentlyOpened.complete({ workspaces: [{ folderUri: globalUri }], files: [] }); + await timeout(0); - assert.strictEqual(picker.selectedFolderUri?.toString(), selectedUri.toString()); + assert.deepStrictEqual({ + folderUri: picker.selectedFolderUri?.toString(), + origin: picker.selectionSnapshot.origin, + history: picker.selectionSnapshot.historyState, + }, { + folderUri: selectedUri.toString(), + origin: WorkspaceSelectionOrigin.WindowOpen, + history: 'loaded', + }); }); test('restore chooses the most frequent workspace among the 15 most recent sessions', async () => { @@ -967,6 +1002,7 @@ suite('WorkspacePicker - Connection Status', () => { const firstExists = new DeferredPromise(); let existsCallCount = 0; const fileService = upcastPartial({ + onDidChangeFileSystemProviderRegistrations: Event.None, hasProvider: () => true, exists: async () => ++existsCallCount === 1 ? firstExists.p : true, }); @@ -989,15 +1025,18 @@ suite('WorkspacePicker - Connection Status', () => { const disabledSelection = picker.selectedFolderUri; canRestoreWorkspace = true; + const completedLookup = picker.selectionSnapshot.sessionFallbackState; picker.refreshAutomaticSelection(); await timeout(0); assert.deepStrictEqual({ disabledSelection, + completedLookup, folderUri: picker.selectedFolderUri?.toString(), source: picker.preselectionSource, }, { disabledSelection: undefined, + completedLookup: 'completed', folderUri: folderUri.toString(), source: NewSessionWorkspacePreselectionSource.ExistingSessions, }); @@ -1529,6 +1568,251 @@ suite('WorkspacePicker - Connection Status', () => { }); }); +suite('WorkspacePicker - Selection diagnostics', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('selects a multi-root history folder with distinct provenance and preserves No workspace', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + providersService.setProviders([createMockProvider('local-1')]); + const storage = disposables.add(new TestStorageService()); + const workspacesService = upcastPartial({ + getRecentlyOpened: async () => ({ + workspaces: [{ workspace: { id: 'multi-root', configPath: URI.file('/workspaces/recent.code-workspace') } }], + files: [], + }), + onDidChangeRecentlyOpened: Event.None, + }); + const fileService = upcastPartial({ + onDidChangeFileSystemProviderRegistrations: Event.None, + hasProvider: () => true, + readFile: async () => upcastPartial({ value: VSBuffer.fromString(JSON.stringify({ folders: [{ uri: URI.file('/local/project').toString() }] })) }), + }); + const recents = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService, fileService); + const picker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recents); + const selected = { folder: picker.selectedFolderUri?.path, origin: picker.selectionSnapshot.origin }; + recents.checkNoWorkspace(); + const noWorkspacePicker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recents, { + getNoWorkspaceOption: () => ({ description: 'No workspace', isSelected: true, select: () => { } }), + }); + assert.deepStrictEqual({ selected, noWorkspace: noWorkspacePicker.selectionSnapshot.state }, { + selected: { folder: '/local/project', origin: WorkspaceSelectionOrigin.VSCodeWorkspace }, noWorkspace: 'noWorkspace', + }); + }); + + test('same-folder draft synchronization preserves the origin while updating Dev Container mode', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + providersService.setProviders([createMockProvider('local-1')]); + const folderUri = URI.file('/local/recent'); + const storage = disposables.add(new TestStorageService()); + const workspacesService = upcastPartial({ + getRecentlyOpened: async () => ({ workspaces: [{ folderUri }], files: [] }), + onDidChangeRecentlyOpened: Event.None, + }); + const recents = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + const picker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recents); + const container = document.createElement('div'); + picker.render(container); + const origins = [picker.selectionSnapshot.origin]; + + picker.setSelectedWorkspace(folderUri, { fireEvent: false, preferDevContainer: true, origin: WorkspaceSelectionOrigin.RestoredDraft }); + origins.push(picker.selectionSnapshot.origin); + const devContainerLabel = container.querySelector('.sessions-chat-dropdown-label')?.textContent; + picker.setSelectedWorkspace(folderUri, { fireEvent: false, origin: WorkspaceSelectionOrigin.SessionSync }); + origins.push(picker.selectionSnapshot.origin); + const localLabel = container.querySelector('.sessions-chat-dropdown-label')?.textContent; + picker.setSelectedWorkspace(URI.file('/local/restored'), { fireEvent: false, origin: WorkspaceSelectionOrigin.RestoredDraft }); + origins.push(picker.selectionSnapshot.origin); + + assert.deepStrictEqual({ + origins, + devContainerLabel, + localLabel, + legacySource: picker.preselectionSource, + selected: picker.selectedFolderUri?.path, + }, { + origins: [WorkspaceSelectionOrigin.VSCodeRecent, WorkspaceSelectionOrigin.VSCodeRecent, WorkspaceSelectionOrigin.VSCodeRecent, WorkspaceSelectionOrigin.RestoredDraft], + devContainerLabel: 'local/recent - Dev Container', + localLabel: 'local/recent', + legacySource: NewSessionWorkspacePreselectionSource.ProvidedWorkspace, + selected: '/local/restored', + }); + }); + + test('window handoff, user selection and clearing retain distinct origins', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + providersService.setProviders([createMockProvider('local-1')]); + let userSelections = 0; + const picker = createTestPicker(disposables, providersService, undefined, undefined, DispatchingWorkspacePicker, undefined, undefined, undefined, { + onUserSelection: () => userSelections++, + }) as DispatchingWorkspacePicker; + const handedOff = URI.file('/local/from-window'); + const userPicked = URI.file('/local/user-picked'); + + picker.setSelectedWorkspace(handedOff, { fireEvent: false, origin: WorkspaceSelectionOrigin.WindowOpen }); + const handoff = picker.selectionSnapshot; + await picker.dispatchFolder(userPicked, 'local-1'); + const user = picker.selectionSnapshot; + picker.setSelectedWorkspace(userPicked, { fireEvent: false, origin: WorkspaceSelectionOrigin.SessionSync }); + const synchronized = picker.selectionSnapshot; + const userSelectionsAfterSync = userSelections; + picker.clearSelection(); + + assert.deepStrictEqual({ + origins: [handoff.origin, user.origin, synchronized.origin, picker.selectionSnapshot.origin], + state: picker.selectionSnapshot.state, + folderUri: picker.selectedFolderUri, + userSelectionsAfterSync, + userSelections, + }, { + origins: [WorkspaceSelectionOrigin.WindowOpen, WorkspaceSelectionOrigin.User, WorkspaceSelectionOrigin.User, WorkspaceSelectionOrigin.None], + state: 'none', + folderUri: undefined, + userSelectionsAfterSync: 1, + userSelections: 2, + }); + }); + + test('reports a pending existing-session lookup and its completion', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const folderUri = URI.file('/local/from-session'); + let sessions: ISession[] = []; + const provider = createMockProvider('local-1', { getSessions: () => sessions }); + sessions = [createMockSession(provider, folderUri, 1)]; + providersService.setProviders([provider]); + const storage = disposables.add(new TestStorageService()); + const workspacesService = upcastPartial({ + getRecentlyOpened: async () => ({ workspaces: [], files: [] }), + onDidChangeRecentlyOpened: Event.None, + }); + const recents = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + const exists = new DeferredPromise(); + const fileService = upcastPartial({ hasProvider: () => true, exists: () => exists.p }); + const picker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recents, undefined, fileService); + const pending = picker.selectionSnapshot; + + await exists.complete(true); + await timeout(0); + const completed = picker.selectionSnapshot; + + assert.deepStrictEqual({ + pending: [pending.state, pending.historyState, pending.sessionFallbackState, pending.registeredProviderCount], + completed: [completed.state, completed.origin, completed.sessionFallbackState, completed.folderUri?.toString()], + }, { + pending: ['none', 'loaded', 'pending', 1], + completed: ['selected', WorkspaceSelectionOrigin.ExistingSessions, 'completed', folderUri.toString()], + }); + }); + + test('superseded lookups neither overwrite a selection nor remain pending', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + let sessions: ISession[] = []; + const provider = createMockProvider('local-1', { getSessions: () => sessions }); + sessions = [createMockSession(provider, URI.file('/local/from-session'), 1)]; + providersService.setProviders([provider]); + const exists = new DeferredPromise(); + const picker = createTestPicker( + disposables, providersService, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + upcastPartial({ onDidChangeFileSystemProviderRegistrations: Event.None, hasProvider: () => true, exists: () => exists.p }), + ); + picker.setSelectedWorkspace(URI.file('/local/from-window'), { fireEvent: false, origin: WorkspaceSelectionOrigin.WindowOpen }); + await exists.complete(true); + await timeout(0); + + assert.deepStrictEqual({ + origin: picker.selectionSnapshot.origin, + fallback: picker.selectionSnapshot.sessionFallbackState, + folder: picker.selectedFolderUri?.path, + }, { + origin: WorkspaceSelectionOrigin.WindowOpen, + fallback: 'idle', + folder: '/local/from-window', + }); + }); + + test('distinguishes no registered providers, unresolved selection and explicit no workspace', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const picker = createTestPicker(disposables, providersService, undefined, undefined, undefined, undefined, undefined, undefined, { + getNoWorkspaceOption: () => ({ description: 'No workspace', isSelected: false, select: () => { } }), + }); + await timeout(0); + const noProviders = picker.selectionSnapshot; + picker.setSelectedWorkspace(URI.file('/local/unresolved'), { fireEvent: false }); + const unresolved = picker.selectionSnapshot; + picker.selectNoWorkspace(); + + assert.deepStrictEqual({ + noProviders: [noProviders.state, noProviders.historyState, noProviders.sessionFallbackState, noProviders.registeredProviderCount], + unresolved: [unresolved.state, unresolved.origin], + noWorkspace: [picker.selectionSnapshot.state, picker.selectionSnapshot.origin], + }, { + noProviders: ['none', 'loaded', 'completed', 0], + unresolved: ['unresolved', WorkspaceSelectionOrigin.Programmatic], + noWorkspace: ['noWorkspace', WorkspaceSelectionOrigin.None], + }); + }); + + test('reports history lookup errors instead of an empty successful history', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const expectedError = new Error('history lookup failed'); + const errors: Error[] = []; + const previousHandler = errorHandler.getUnexpectedErrorHandler(); + errorHandler.setUnexpectedErrorHandler(error => errors.push(error)); + try { + const workspacesService = upcastPartial({ + getRecentlyOpened: async () => { throw expectedError; }, + onDidChangeRecentlyOpened: Event.None, + }); + const picker = createTestPicker(disposables, providersService, undefined, undefined, undefined, undefined, workspacesService); + await timeout(0); + + assert.deepStrictEqual({ + history: picker.selectionSnapshot.historyState, + state: picker.selectionSnapshot.state, + errors, + }, { + history: 'error', + state: 'none', + errors: [expectedError], + }); + } finally { + errorHandler.setUnexpectedErrorHandler(previousHandler); + } + }); + + test('reports existing-session lookup errors instead of no matching workspace', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const expectedError = new Error('session lookup failed'); + providersService.setProviders([createMockProvider('local-1', { getSessions: () => { throw expectedError; } })]); + const storage = disposables.add(new TestStorageService()); + const workspacesService = upcastPartial({ + getRecentlyOpened: async () => ({ workspaces: [], files: [] }), + onDidChangeRecentlyOpened: Event.None, + }); + const recents = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + const errors: Error[] = []; + const previousHandler = errorHandler.getUnexpectedErrorHandler(); + errorHandler.setUnexpectedErrorHandler(error => errors.push(error)); + try { + const picker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recents); + await timeout(0); + + assert.deepStrictEqual({ + history: picker.selectionSnapshot.historyState, + fallback: picker.selectionSnapshot.sessionFallbackState, + state: picker.selectionSnapshot.state, + errors, + }, { + history: 'loaded', + fallback: 'error', + state: 'none', + errors: [expectedError], + }); + } finally { + errorHandler.setUnexpectedErrorHandler(previousHandler); + } + }); +}); + suite('WorkspacePicker - Path validation', () => { const disposables = new DisposableStore(); teardown(() => disposables.clear()); @@ -3621,6 +3905,7 @@ function createTestablePicker( getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None, }); + instantiationService.stub(ILogService, disposables.add(new NullLogService())); instantiationService.stub(ISessionsRecentWorkspacesService, disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); return disposables.add(instantiationService.createInstance(TestablePicker, options)); @@ -4554,6 +4839,7 @@ suite('WorkspacePicker - Tab discovery', () => { getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None, }); + instantiationService.stub(ILogService, disposables.add(new NullLogService())); instantiationService.stub(ISessionsRecentWorkspacesService, disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); const picker = disposables.add(instantiationService.createInstance(TestablePicker, {})); diff --git a/src/vs/sessions/contrib/chat/test/electron-browser/agentsWindowWorkspaceHandoff.test.ts b/src/vs/sessions/contrib/chat/test/electron-browser/agentsWindowWorkspaceHandoff.test.ts new file mode 100644 index 0000000000000..c0049ee3462df --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/electron-browser/agentsWindowWorkspaceHandoff.test.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.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 { ITelemetryData, ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { AgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; +import { ShutdownReason } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { TestLifecycleService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { ISessionsWindowOpenContext, SessionsWindowOpenTelemetry } from '../../../sessions/browser/sessionsWindowOpenTelemetry.js'; +import { SelectAgentsFolderContribution } from '../../electron-browser/chat.contribution.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; + +const startWindowOpenTelemetry = Reflect.get(SelectAgentsFolderContribution.prototype, '_startWindowOpenTelemetry') as ( + source: AgentsWindowOpenSource, + context: ISessionsWindowOpenContext, +) => SessionsWindowOpenTelemetry | undefined; + +suite('Agents Window workspace handoff telemetry', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('later opening requests cannot change the initial opening context or handoff tracker', () => { + const lifecycleService = disposables.add(new TestLifecycleService()); + const events: { name: string; data?: ITelemetryData }[] = []; + const harness = { + startWindowOpenTelemetry, + _didHandleInitialWindowOpen: false, + _windowOpenTelemetry: disposables.add(new MutableDisposable()), + _workspaceSelectionTelemetry: disposables.add(new MutableDisposable()), + instantiationService: { createInstance: () => Disposable.None }, + storageService: { getNumber: () => 0 }, + telemetryService: upcastPartial({ publicLog2: (name, data) => { events.push({ name, data }); } }), + sessionsSetUpService: { initialSignInDialogShown: false }, + _getWindowOpenViewState: () => ({ workspacePreselected: false, workspacePreselectionSource: 'none', viewKind: 'noComposer' }), + lifecycleService, + }; + const initialTracker = harness.startWindowOpenTelemetry(AgentsWindowOpenSource.TitleBar, { workspaceArgumentKind: 'local', hasSessionArgument: false, workspaceArgumentIsDefault: true }); + initialTracker?.recordWorkspaceHandoffState('waitingForProvider'); + const subsequentTracker = harness.startWindowOpenTelemetry(AgentsWindowOpenSource.CommandPalette, { workspaceArgumentKind: 'none', hasSessionArgument: true }); + subsequentTracker?.recordWorkspaceHandoffState('applied'); + lifecycleService.fireShutdown(ShutdownReason.CLOSE); + + const data = events.find(event => event.name === 'agents/firstTimeWindowOpen')?.data; + assert.deepStrictEqual({ + eventNames: events.map(event => event.name), + hasInitialTracker: initialTracker !== undefined, + hasSubsequentTracker: subsequentTracker !== undefined, + source: data?.source, + argument: data?.workspaceArgumentKind, + isDefault: data?.workspaceArgumentIsDefault, + hasSessionArgument: data?.hasSessionArgument, + handoff: data?.workspaceHandoffStateAtEmission, + }, { + eventNames: ['agents/windowSessionStart', 'agents/firstTimeWindowOpen'], + hasInitialTracker: true, + hasSubsequentTracker: false, + source: 'titleBar', + argument: 'local', + isDefault: true, + hasSessionArgument: false, + handoff: 'waitingForProvider', + }); + }); + + test('a superseded existing-session lookup cannot navigate after a newer opening', async () => { + const found = new DeferredPromise(); + const cancellation = disposables.add(new CancellationTokenSource()); + let opened = false; + const harness = { + resolveAndOpenSession: Reflect.get(SelectAgentsFolderContribution.prototype, 'resolveAndOpenSession') as (resource: URI, token: CancellationTokenSource['token']) => Promise, + waitForSessionAvailable: () => found.p, + sessionsService: { openSession: async () => { opened = true; } }, + logService: { info: () => { }, warn: () => { } }, + }; + const opening = harness.resolveAndOpenSession(URI.file('/private/session'), cancellation.token); + cancellation.cancel(); + await found.complete(true); + await opening; + assert.strictEqual(opened, false); + }); + + for (const kind of ['session', 'link'] as const) { + for (const alreadyCancelled of [false, true]) { + test(`disposes ${kind} lookup listeners on ${alreadyCancelled ? 'immediate' : 'pending'} cancellation`, async () => { + const changed = disposables.add(new Emitter()); + const resolved = disposables.add(new Emitter()); + const cancellation = disposables.add(new CancellationTokenSource()); + const harness = { + waitForSessionAvailable: Reflect.get(SelectAgentsFolderContribution.prototype, 'waitForSessionAvailable') as (resource: URI, token: CancellationToken) => Promise, + waitForSessionLinkAvailable: Reflect.get(SelectAgentsFolderContribution.prototype, 'waitForSessionLinkAvailable') as (resource: URI, token: CancellationToken) => Promise, + sessionsManagementService: { getSession: () => undefined, getSessions: () => [], onDidChangeSessions: changed.event }, + agentHostConnectionsService: { onDidChangeSessionResolution: resolved.event }, + }; + if (alreadyCancelled) { + cancellation.cancel(); + } + const resource = URI.parse('agent-host-copilot:/session'); + const opening = kind === 'link' + ? harness.waitForSessionLinkAvailable(resource, cancellation.token) + : harness.waitForSessionAvailable(resource, cancellation.token); + const listening = changed.hasListeners(); + cancellation.cancel(); + const result = await opening; + assert.deepStrictEqual({ listening, result, listenersRemain: changed.hasListeners() || resolved.hasListeners() }, { + listening: !alreadyCancelled, result: kind === 'link' ? undefined : false, listenersRemain: false, + }); + }); + } + } +}); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index f998a726e0e9e..140bdc8896d42 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -65,6 +65,7 @@ import { getSessionConversationActionId, isSessionConversationSideChat, SESSION_ import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext, SessionsList, SessionsListFocusedChatItemContext } from './views/sessionsList.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './media/newSessionActionViewItem.css'; +import { INewSessionComposerService } from '../../chat/browser/newSessionComposerService.js'; export const NEW_SESSION_BUTTON_STYLE_SETTING = 'sessions.newSessionButton.style'; export const NEW_SESSION_BUTTON_STYLE_TREATMENT = 'agentSessionsNewSessionButtonStyle'; @@ -97,6 +98,7 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { const sessionsListModelService = accessor.get(ISessionsListModelService); const sessionsManagementService = accessor.get(ISessionsManagementService); const contextKeyService = accessor.get(IContextKeyService); + const composerService = accessor.get(INewSessionComposerService); const activeSessionId = sessionsService.activeSession.get()?.sessionId; @@ -195,6 +197,7 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { disposables.add(toDisposable(() => pickerVisibleContext.reset())); const openSelected = (selected: ISessionPickItem, inBackground: boolean, toSide: boolean): void => { + composerService.notifyUserNavigation(); if (!selected.session) { sessionsService.openNewSession(); sessionsPartService.focusSession(sessionsService.activeSession.get()); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsWindowOpenTelemetry.ts b/src/vs/sessions/contrib/sessions/browser/sessionsWindowOpenTelemetry.ts index 984383458a41a..483fdb8ee51bf 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsWindowOpenTelemetry.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsWindowOpenTelemetry.ts @@ -8,14 +8,28 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { AgentsWindowOpenSource } from '../../../../platform/window/common/window.js'; import { ILifecycleService, ShutdownReason } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { IWorkspaceSelectionSnapshot, WorkspaceArgumentKind } from '../../../common/workspaceSelection.js'; export const FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS = 3 * 60 * 1000; export interface ISessionsWindowOpenViewState { readonly workspacePreselected: boolean | undefined; readonly workspacePreselectionSource: string | undefined; + readonly viewKind: 'newSession' | 'createdSession' | 'noComposer'; + readonly workspaceSelection?: IWorkspaceSelectionSnapshot; } +export interface ISessionsWindowOpenContext { + readonly workspaceArgumentKind: WorkspaceArgumentKind; + readonly hasSessionArgument: boolean; + readonly workspaceArgumentIsDefault?: boolean; +} + +export type WorkspaceHandoffState = 'notRequested' | 'notApplicable' | 'unsupportedWorkspace' + | 'waitingForSetup' | 'waitingForSessionView' | 'waitingForProvider' | 'providerUnavailable' + | 'viewUnavailable' | 'sessionAlreadyCreated' | 'selectionRequested' | 'selectionNotApplied' | 'applied' | 'error' + | 'userChanged' | 'superseded' | 'cancelled' | 'preservedSession'; + type SessionsWindowSessionStartEvent = { sessionStart: boolean; source: string; @@ -42,12 +56,37 @@ export class SessionsWindowSessionStartTelemetry { } type FirstTimeWindowOpenEmissionReason = 'timer' | 'close' | 'quit' | 'reload' | 'otherShutdown'; +type FirstTimeWindowOpenCaptureReason = 'initialization' | FirstTimeWindowOpenEmissionReason; + +interface ISessionsWindowOpenSnapshot extends ISessionsWindowOpenViewState { + readonly captureReason: FirstTimeWindowOpenCaptureReason; + readonly captureDurationMs: number; + readonly workspaceHandoffState: WorkspaceHandoffState; +} type FirstTimeWindowOpenEvent = { source: string; signInDialogShown: boolean; workspacePreselected: boolean | undefined; workspacePreselectionSource: string | undefined; + workspaceArgumentKind: WorkspaceArgumentKind; + hasSessionArgument: boolean; + workspaceArgumentIsDefault: boolean; + initialViewKind: ISessionsWindowOpenViewState['viewKind']; + initialStateCaptureReason: FirstTimeWindowOpenCaptureReason; + initialStateCaptureDurationMs: number; + workspaceSelectionOrigin: IWorkspaceSelectionSnapshot['origin'] | undefined; + workspaceSelectionState: IWorkspaceSelectionSnapshot['state'] | undefined; + workspaceHistoryState: IWorkspaceSelectionSnapshot['historyState'] | undefined; + workspaceSessionFallbackState: IWorkspaceSelectionSnapshot['sessionFallbackState'] | undefined; + workspaceProviderCount: number | undefined; + workspaceHandoffState: WorkspaceHandoffState; + workspaceHandoffStateAtEmission: WorkspaceHandoffState; + workspaceHandoffDurationMs: number | undefined; + viewKindAtEmission: ISessionsWindowOpenViewState['viewKind']; + workspacePreselectedAtEmission: boolean | undefined; + workspaceSelectionOriginAtEmission: IWorkspaceSelectionSnapshot['origin'] | undefined; + workspaceSelectionStateAtEmission: IWorkspaceSelectionSnapshot['state'] | undefined; windowCloseDurationMs: number | undefined; emissionReason: FirstTimeWindowOpenEmissionReason; }; @@ -57,6 +96,24 @@ type FirstTimeWindowOpenClassification = { signInDialogShown: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the initial Agents setup flow showed a sign-in dialog.' }; workspacePreselected: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the initial new-session view had a workspace selected. Undefined when a created session was visible.' }; workspacePreselectionSource: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the initial new-session workspace was selected: checked workspace, recent workspace, existing sessions, provided workspace, user selection, none, or unknown. Undefined when a created session was visible.' }; + workspaceArgumentKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Kind of workspace argument received by the initial window open: none, local, devContainer, remote, or other. Contains no URI or authority.' }; + hasSessionArgument: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the initial window open requested an existing session, which takes precedence over a workspace argument.' }; + workspaceArgumentIsDefault: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the initial workspace argument was inferred from the invoking editor and must not replace an existing session or user choice.' }; + initialViewKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the initial snapshot saw a new-session composer, a created session, or no composer.' }; + initialStateCaptureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Why the initial snapshot was captured: initialization, timer, close, quit, reload, or otherShutdown. Distinct from the later emission reason.' }; + initialStateCaptureDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time from initial open IPC to the initial snapshot, capped at three minutes.' }; + workspaceSelectionOrigin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Origin of the initially selected folder: none, checkedWorkspace, agentsRecent, vscodeRecent, vscodeWorkspace, existingSessions, windowOpen, windowContext, restoredDraft, sessionSync, programmatic, or user. Same-folder synchronization preserves the original origin.' }; + workspaceSelectionState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Initial picker state: none, noWorkspace, selected, or unresolved. Selected means provider-resolved, not that a session is ready to run.' }; + workspaceHistoryState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'VS Code recent-folder history lookup state at capture: loading, loaded, or error. Loaded means a lookup completed, not that every provider is ready.' }; + workspaceSessionFallbackState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Existing-session workspace lookup state at capture: idle, pending, completed, error, or disabled. Completed may have found no candidate.' }; + workspaceProviderCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of registered session providers at capture, capped at 100. Registration does not imply readiness.' }; + workspaceHandoffState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Initial workspace handoff state at capture: notRequested, notApplicable, unsupportedWorkspace, waitingForSetup, waitingForSessionView, waitingForProvider, providerUnavailable, viewUnavailable, sessionAlreadyCreated, selectionRequested, selectionNotApplied, applied, error, userChanged, superseded, cancelled, or preservedSession. Applied requires the target view to acknowledge its provider-resolved selection.' }; + workspaceHandoffStateAtEmission: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Latest state of the initial workspace handoff when the event is emitted, using the same states as workspaceHandoffState.' }; + workspaceHandoffDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time from initial open IPC to observing the handed-off folder in the composer after selection, capped at three minutes. Undefined if not observed.' }; + viewKindAtEmission: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'View kind at delayed emission: newSession, createdSession, or noComposer. Not a selection-settled signal.' }; + workspacePreselectedAtEmission: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the new-session view has a workspace when this event is emitted. May include later user actions; undefined for a created session.' }; + workspaceSelectionOriginAtEmission: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Detailed workspace origin at delayed emission, using the same values as workspaceSelectionOrigin. Allows later user selection to be distinguished from automatic selection.' }; + workspaceSelectionStateAtEmission: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Picker state at delayed emission: none, noWorkspace, selected, or unresolved. Not a selection-settled signal.' }; windowCloseDurationMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Milliseconds before the Agents window closed, capped at three minutes.' }; emissionReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Why the delayed first-time window event was emitted: timer, close, quit, reload, or otherShutdown.' }; owner: 'benibenj'; @@ -65,18 +122,23 @@ type FirstTimeWindowOpenClassification = { export class SessionsWindowOpenTelemetry extends Disposable { - private _viewState: ISessionsWindowOpenViewState | undefined; + private _viewState: ISessionsWindowOpenSnapshot | undefined; + private _workspaceHandoffState: WorkspaceHandoffState; + private _workspaceHandoffDurationMs: number | undefined; private _didSend = false; private readonly _openedAt = Date.now(); constructor( private readonly _source: AgentsWindowOpenSource, + private readonly _context: ISessionsWindowOpenContext, private readonly _getSignInDialogShown: () => boolean, private readonly _getViewState: () => ISessionsWindowOpenViewState, private readonly _telemetryService: ITelemetryService, lifecycleService: ILifecycleService, ) { super(); + this._workspaceHandoffState = _context.hasSessionArgument ? 'notApplicable' + : _context.workspaceArgumentKind === 'none' ? 'notRequested' : 'unsupportedWorkspace'; const remainingDuration = Math.max(0, FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS - this._elapsed()); this._register(disposableTimeout(() => this._send('timer', undefined), remainingDuration)); @@ -89,7 +151,32 @@ export class SessionsWindowOpenTelemetry extends Disposable { } captureInitialViewState(): void { - this._viewState ??= this._getViewState(); + if (!this._viewState) { + this._captureInitialViewState('initialization', this._getViewState()); + } + } + + recordWorkspaceHandoffState(state: WorkspaceHandoffState): void { + if (this._didSend) { + return; + } + this._workspaceHandoffState = state; + if (state === 'applied') { + this._workspaceHandoffDurationMs ??= Math.min(this._elapsed(), FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS); + } + } + + private _captureInitialViewState(reason: FirstTimeWindowOpenCaptureReason, state: ISessionsWindowOpenViewState): ISessionsWindowOpenSnapshot { + if (this._viewState) { + return this._viewState; + } + return this._viewState = { + ...state, + workspaceSelection: state.workspaceSelection ? { ...state.workspaceSelection } : undefined, + captureReason: reason, + captureDurationMs: Math.min(this._elapsed(), FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS), + workspaceHandoffState: this._workspaceHandoffState, + }; } private _elapsed(): number { @@ -119,13 +206,33 @@ export class SessionsWindowOpenTelemetry extends Disposable { return; } this._didSend = true; - this.captureInitialViewState(); + const emissionState = this._getViewState(); + const initialState = this._captureInitialViewState(emissionReason, emissionState); + const selection = initialState.workspaceSelection; this._telemetryService.publicLog2('agents/firstTimeWindowOpen', { source: this._source, signInDialogShown: this._getSignInDialogShown(), - workspacePreselected: this._viewState?.workspacePreselected, - workspacePreselectionSource: this._viewState?.workspacePreselectionSource, + workspacePreselected: initialState.workspacePreselected, + workspacePreselectionSource: initialState.workspacePreselectionSource, + workspaceArgumentKind: this._context.workspaceArgumentKind, + hasSessionArgument: this._context.hasSessionArgument, + workspaceArgumentIsDefault: this._context.workspaceArgumentIsDefault ?? false, + initialViewKind: initialState.viewKind, + initialStateCaptureReason: initialState.captureReason, + initialStateCaptureDurationMs: initialState.captureDurationMs, + workspaceSelectionOrigin: selection?.origin, + workspaceSelectionState: selection?.state, + workspaceHistoryState: selection?.historyState, + workspaceSessionFallbackState: selection?.sessionFallbackState, + workspaceProviderCount: selection ? Math.min(selection.registeredProviderCount, 100) : undefined, + workspaceHandoffState: initialState.workspaceHandoffState, + workspaceHandoffStateAtEmission: this._workspaceHandoffState, + workspaceHandoffDurationMs: this._workspaceHandoffDurationMs, + viewKindAtEmission: emissionState.viewKind, + workspacePreselectedAtEmission: emissionState.workspacePreselected, + workspaceSelectionOriginAtEmission: emissionState.workspaceSelection?.origin, + workspaceSelectionStateAtEmission: emissionState.workspaceSelection?.state, windowCloseDurationMs, emissionReason, }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsWorkspaceSelectionTelemetry.ts b/src/vs/sessions/contrib/sessions/browser/sessionsWorkspaceSelectionTelemetry.ts new file mode 100644 index 0000000000000..845be74b0266b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsWorkspaceSelectionTelemetry.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../base/common/async.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { AgentsWindowOpenSource } from '../../../../platform/window/common/window.js'; +import { ILifecycleService, ShutdownReason } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { IWorkspaceSelectionSnapshot, WorkspaceSelectionOrigin } from '../../../common/workspaceSelection.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISendRequestOptions } from '../../../services/sessions/common/sessionsProvider.js'; +import { INewSessionComposerService } from '../../chat/browser/newSessionComposerService.js'; +import { FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS, ISessionsWindowOpenContext } from './sessionsWindowOpenTelemetry.js'; + +type ObservationReason = 'firstRequest' | 'timer' | 'close' | 'quit' | 'reload' | 'otherShutdown'; + +interface IDefaultWorkspace { + readonly folderUri: URI; + readonly origin: WorkspaceSelectionOrigin; + readonly durationMs: number; + readyDurationMs?: number; +} + +interface IRequestSelection { + readonly defaultWorkspace: IDefaultWorkspace | undefined; + readonly selection: IWorkspaceSelectionSnapshot | undefined; + readonly userSelectedWorkspace: boolean; +} + +type WorkspaceSelectionOutcomeEvent = { + source: AgentsWindowOpenSource; + workspaceArgumentKind: ISessionsWindowOpenContext['workspaceArgumentKind']; + workspaceArgumentIsDefault: boolean; + observationReason: ObservationReason; + observationDurationMs: number; + firstRequestSent: boolean; + defaultAvailable: boolean; + defaultOrigin: WorkspaceSelectionOrigin | undefined; + timeToDefaultMs: number | undefined; + timeToUsableDefaultMs: number | undefined; + userSelectedWorkspace: boolean; + defaultRetainedAtFirstRequest: boolean | undefined; + workspaceSelectedAtFirstRequest: boolean | undefined; +}; + +type WorkspaceSelectionOutcomeClassification = { + owner: 'benibenj'; + comment: 'Reports workspace-default acceptance and first-request conversion for first-time Agents window openings within three minutes. No request in that interval is not proof of permanent abandonment.'; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Entry point of the initial Agents window opening.' }; + workspaceArgumentKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Original workspace argument category; never a resource identifier.' }; + workspaceArgumentIsDefault: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the workspace argument was inferred from the invoking editor.' }; + observationReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Observation ended at firstRequest, timer, close, quit, reload, or otherShutdown.' }; + observationDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Observation duration, capped at three minutes.' }; + firstRequestSent: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether a request was successfully sent in this window within the observation interval.' }; + defaultAvailable: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether an automatic provider-resolved default was observed before the first user workspace choice.' }; + defaultOrigin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Origin of the last automatic default before the first user workspace choice or send, using WorkspaceSelectionOrigin categories.' }; + timeToDefaultMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time until that provider-resolved default was selected. Does not imply a runnable session.' }; + timeToUsableDefaultMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time until a non-loading draft with the same folder was observed for that default. Undefined if not observed.' }; + userSelectedWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the user explicitly changed, cleared, or reselected the workspace or harness during observation.' }; + defaultRetainedAtFirstRequest: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Local URI comparison between the default and the workspace captured before the successfully sent composer request. Undefined without a default or correlated composer request.' }; + workspaceSelectedAtFirstRequest: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the correlated composer request had a provider-resolved workspace. Undefined for a request not sent from that composer.' }; +}; + +/** Observes a bounded first-use funnel without persisting or emitting workspace identifiers. */ +export class SessionsWorkspaceSelectionTelemetry extends Disposable { + private readonly _observation = this._register(new DisposableStore()); + private readonly _startedAt = Date.now(); + private _didSend = false; + private _defaultWorkspace: IDefaultWorkspace | undefined; + private _requests = new WeakMap(); + private readonly _initialSelectionVersion: number; + + constructor( + private readonly source: AgentsWindowOpenSource, + private readonly context: ISessionsWindowOpenContext, + @INewSessionComposerService private readonly composerService: INewSessionComposerService, + @ISessionsService sessionsService: ISessionsService, + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILifecycleService lifecycleService: ILifecycleService, + ) { + super(); + this._initialSelectionVersion = composerService.userWorkspaceSelectionVersion.get(); + this._observation.add(autorun(reader => { + const version = composerService.userWorkspaceSelectionVersion.read(reader); + const selection = composerService.workspaceSelection.read(reader); + const session = sessionsService.activeSession.read(reader); + const created = session?.isCreated.read(reader); + const loading = session?.loading.read(reader); + const folderUri = session?.workspace.read(reader)?.folders[0]?.root; + if (version !== this._initialSelectionVersion || created || selection?.state !== 'selected' + || !selection.folderUri || selection.origin === WorkspaceSelectionOrigin.User || selection.origin === WorkspaceSelectionOrigin.None) { + return; + } + if (!this._defaultWorkspace || !this.uriIdentityService.extUri.isEqual(this._defaultWorkspace.folderUri, selection.folderUri) + || this._defaultWorkspace.origin !== selection.origin) { + this._defaultWorkspace = { folderUri: selection.folderUri, origin: selection.origin, durationMs: this._elapsed() }; + } + if (loading === false && this.uriIdentityService.extUri.isEqual(folderUri, selection.folderUri)) { + this._defaultWorkspace.readyDurationMs ??= this._elapsed(); + } + })); + this._observation.add(composerService.onWillSendRequest(({ options, selection }) => { + this._requests.set(options, { + defaultWorkspace: this._defaultWorkspace ? { ...this._defaultWorkspace } : undefined, + selection, + userSelectedWorkspace: this._userSelectedWorkspace(), + }); + })); + this._observation.add(sessionsManagementService.onDidSendRequest(event => this._send('firstRequest', this._requests.get(event.options)))); + this._observation.add(disposableTimeout(() => this._send('timer'), FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS)); + this._observation.add(lifecycleService.onWillShutdown(event => this._send( + event.reason === ShutdownReason.CLOSE ? 'close' + : event.reason === ShutdownReason.QUIT ? 'quit' + : event.reason === ShutdownReason.RELOAD ? 'reload' : 'otherShutdown', + ))); + } + + private _userSelectedWorkspace(): boolean { + return this.composerService.userWorkspaceSelectionVersion.get() !== this._initialSelectionVersion; + } + + private _elapsed(): number { + return Math.min(Math.max(0, Date.now() - this._startedAt), FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS); + } + + private _send(reason: ObservationReason, request?: IRequestSelection): void { + if (this._didSend) { + return; + } + this._didSend = true; + const defaultWorkspace = request ? request.defaultWorkspace : this._defaultWorkspace; + this.telemetryService.publicLog2('agents/workspaceSelectionOutcome', { + source: this.source, + workspaceArgumentKind: this.context.workspaceArgumentKind, + workspaceArgumentIsDefault: this.context.workspaceArgumentIsDefault ?? false, + observationReason: reason, + observationDurationMs: this._elapsed(), + firstRequestSent: reason === 'firstRequest', + defaultAvailable: defaultWorkspace !== undefined, + defaultOrigin: defaultWorkspace?.origin, + timeToDefaultMs: defaultWorkspace?.durationMs, + timeToUsableDefaultMs: defaultWorkspace?.readyDurationMs, + userSelectedWorkspace: request?.userSelectedWorkspace ?? this._userSelectedWorkspace(), + defaultRetainedAtFirstRequest: request && defaultWorkspace + ? request.selection?.state === 'selected' && this.uriIdentityService.extUri.isEqual(defaultWorkspace.folderUri, request.selection.folderUri) + : undefined, + workspaceSelectedAtFirstRequest: request ? request.selection?.state === 'selected' : undefined, + }); + this._observation.clear(); + this._defaultWorkspace = undefined; + this._requests = new WeakMap(); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 461a4f550a608..80a1d80441832 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -45,6 +45,8 @@ import { IAutomationService } from '../../../../../workbench/contrib/chat/common import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; +import { INewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; +import { WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; const CLOSE_SESSION_COMMAND_ID = 'sessionsViewPane.closeSession'; registerAction2(class CloseSessionAction extends Action2 { @@ -58,6 +60,7 @@ registerAction2(class CloseSessionAction extends Action2 { }); } override async run(accessor: ServicesAccessor) { + accessor.get(INewSessionComposerService).notifyUserNavigation(); const sessionsService = accessor.get(ISessionsService); sessionsService.openNewSession(); } @@ -483,7 +486,8 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { const sessionsPartService = accessor.get(ISessionsPartService); const commandService = accessor.get(ICommandService); - sessionsService.openNewSession(); + accessor.get(INewSessionComposerService).notifyUserWorkspaceSelection(); + await sessionsService.openNewSession(); const session = context.sessions[0]; const workspace = session.workspace.get(); @@ -492,7 +496,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, selectionOrigin: WorkspaceSelectionOrigin.User }); } // On mobile web, the sidebar drawer covers the viewport; close it so @@ -545,6 +549,8 @@ registerAction2(class NewQuickChatAction extends Action2 { override run(accessor: ServicesAccessor): void { const sessionsService = accessor.get(ISessionsService); const sessionsPartService = accessor.get(ISessionsPartService); + const composerService = accessor.get(INewSessionComposerService); + composerService.notifyUserNavigation(); let activeSession; if (accessor.get(IConfigurationService).getValue(UNIFIED_WORKSPACE_PICKER_SETTING)) { if (accessor.get(ISessionsManagementService).isQuickChatTargetAvailable()) { @@ -553,6 +559,7 @@ registerAction2(class NewQuickChatAction extends Action2 { } activeSession = sessionsService.activeSession.get(); } else { + composerService.notifyUserWorkspaceSelection(); activeSession = sessionsService.openQuickChat(); } @@ -787,6 +794,7 @@ registerAction2(class NewSessionInGroupAction extends Action2 { const sessionGroupsService = accessor.get(ISessionGroupsService); const commandService = accessor.get(ICommandService); + accessor.get(INewSessionComposerService).notifyUserNavigation(); sessionsService.openNewSession(); sessionGroupsService.setPendingNewSessionGroup(context.group.id); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index 3e0ae0206121e..5b726e24e5938 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -29,6 +29,11 @@ import '../../../chat/browser/chat.contribution.js'; import { NEW_SESSION_ACTION_ID, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import '../../browser/views/sessionsViewActions.js'; import { createTestSession, TestCommandService } from './sessionsListTestUtils.js'; +import { INewSessionComposerService, NewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { ISessionSection, NEW_SESSION_FOR_WORKSPACE_ACTION_ID } from '../../browser/views/sessionsList.js'; +import { ISelectWorkspaceOptions } from '../../../../browser/parts/chatView.js'; +import { WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; suite('Sessions - Actions', () => { @@ -148,6 +153,7 @@ suite('Sessions - Actions', () => { const run = async (consolidatedRemoteWorkspaces: boolean, quickChatAvailable = true) => { const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(INewSessionComposerService, disposables.add(new NewSessionComposerService())); const quickChat = upcastPartial({ sessionId: 'quick-chat' }); const existingSession = upcastPartial({ sessionId: 'existing-session' }); const activeSession = observableValue('activeSession', existingSession); @@ -359,6 +365,8 @@ suite('Sessions - Actions', () => { ]) { test(`New Session preserves ${scenario.name} inheritance with toSide=${toSide}`, async () => { const instantiationService = disposables.add(new TestInstantiationService()); + const composerService = disposables.add(new NewSessionComposerService()); + instantiationService.stub(INewSessionComposerService, composerService); const { session } = createTestSession('active'); const activeSession = upcastPartial({ ...session, @@ -380,21 +388,64 @@ suite('Sessions - Actions', () => { assert.ok(command); await command.handler(instantiationService, toSide ? { toSide } : undefined); - assert.deepStrictEqual(requests, [{ - folderUri: scenario.isQuickChat ? undefined : session.workspace.get()?.uri, - toSide, - ...(!scenario.isQuickChat && scenario.targetAvailable ? { - providerId: session.providerId, - sessionTypeId: session.sessionType, - } : {}), - }]); + assert.deepStrictEqual({ navigationVersion: composerService.userNavigationVersion.get(), requests }, { + navigationVersion: 1, requests: [{ + folderUri: scenario.isQuickChat ? undefined : session.workspace.get()?.uri, + toSide, + ...(!scenario.isQuickChat && scenario.targetAvailable ? { + providerId: session.providerId, + sessionTypeId: session.sessionType, + } : {}), + }] + }); }); } } + test('choosing a workspace section cancels older defaults before waiting for its composer', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const composerService = disposables.add(new NewSessionComposerService()); + const opening = new DeferredPromise(); + const { session } = createTestSession('workspace-section'); + const workspace = session.workspace.get(); + assert.ok(workspace); + const folder = URI.file('/workspace-section'); + workspace.folders.push({ root: folder, workingDirectory: folder, name: 'workspace-section', description: undefined }); + const selections: { folder: URI; options?: ISelectWorkspaceOptions }[] = []; + instantiationService.stub(INewSessionComposerService, composerService); + instantiationService.stub(ISessionsService, upcastPartial({ + activeSession: constObservable(undefined), + openNewSession: () => opening.p, + })); + instantiationService.stub(ISessionsPartService, upcastPartial({ + getSessionView: () => upcastPartial({ + selectWorkspace: (folder, options) => { + selections.push({ folder, options }); + return 'applied'; + }, + }), + focusSession: () => { }, + })); + instantiationService.stub(ICommandService, new TestCommandService()); + const command = CommandsRegistry.getCommand(NEW_SESSION_FOR_WORKSPACE_ACTION_ID); + assert.ok(command); + const request = command.handler(instantiationService, upcastPartial({ sessions: [session] })); + const beforeOpening = { userSelections: composerService.userWorkspaceSelectionVersion.get(), selections: selections.length }; + await opening.complete({ session: undefined, trustDeclined: false }); + await request; + assert.deepStrictEqual({ beforeOpening, selections }, { + beforeOpening: { userSelections: 1, selections: 0 }, + selections: [{ + folder, + options: { providerId: session.providerId, selectionOrigin: WorkspaceSelectionOrigin.User }, + }], + }); + }); + test('New Session propagates opening failures', async () => { const instantiationService = disposables.add(new TestInstantiationService()); const error = new Error('Opening failed'); + instantiationService.stub(INewSessionComposerService, disposables.add(new NewSessionComposerService())); instantiationService.stub(ISessionsService, new class extends mock() { override readonly activeSession = constObservable(undefined); override async openNewSession(): Promise { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowOpenTelemetry.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowOpenTelemetry.test.ts index c1a4fbd95c8db..b95c6fe85f3f2 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowOpenTelemetry.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowOpenTelemetry.test.ts @@ -11,7 +11,9 @@ import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/com import { AgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; import { TestLifecycleService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { ShutdownReason } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; -import { FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../browser/sessionsWindowOpenTelemetry.js'; +import { FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS, ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../browser/sessionsWindowOpenTelemetry.js'; +import { IWorkspaceSelectionSnapshot, WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; +import { URI } from '../../../../../base/common/uri.js'; function isTelemetryData(data: unknown): data is Record { return typeof data === 'object' && data !== null; @@ -47,10 +49,19 @@ suite('SessionsWindowOpenTelemetry', () => { const telemetryService = new TestTelemetryService(); let workspacePreselected = true; let workspacePreselectionSource = 'existingSessions'; + const selection = { + folderUri: URI.file('/private/project'), + origin: WorkspaceSelectionOrigin.ExistingSessions, + state: 'selected', + historyState: 'loaded', + sessionFallbackState: 'completed', + registeredProviderCount: 2, + } satisfies IWorkspaceSelectionSnapshot; const tracker = disposables.add(new SessionsWindowOpenTelemetry( AgentsWindowOpenSource.TitleBar, + { workspaceArgumentKind: 'none', hasSessionArgument: false }, () => true, - () => ({ workspacePreselected, workspacePreselectionSource }), + () => ({ workspacePreselected, workspacePreselectionSource, viewKind: 'newSession', workspaceSelection: selection }), telemetryService, lifecycleService, )); @@ -58,6 +69,7 @@ suite('SessionsWindowOpenTelemetry', () => { tracker.captureInitialViewState(); workspacePreselected = false; workspacePreselectionSource = 'none'; + selection.registeredProviderCount = 3; await timeout(4_000); lifecycleService.fireShutdown(ShutdownReason.CLOSE); @@ -68,6 +80,24 @@ suite('SessionsWindowOpenTelemetry', () => { signInDialogShown: true, workspacePreselected: true, workspacePreselectionSource: 'existingSessions', + workspaceArgumentKind: 'none', + hasSessionArgument: false, + workspaceArgumentIsDefault: false, + initialViewKind: 'newSession', + initialStateCaptureReason: 'initialization', + initialStateCaptureDurationMs: 0, + workspaceSelectionOrigin: 'existingSessions', + workspaceSelectionState: 'selected', + workspaceHistoryState: 'loaded', + workspaceSessionFallbackState: 'completed', + workspaceProviderCount: 2, + workspaceHandoffState: 'notRequested', + workspaceHandoffStateAtEmission: 'notRequested', + workspaceHandoffDurationMs: undefined, + viewKindAtEmission: 'newSession', + workspacePreselectedAtEmission: false, + workspaceSelectionOriginAtEmission: 'existingSessions', + workspaceSelectionStateAtEmission: 'selected', windowCloseDurationMs: 4_000, emissionReason: 'close', }, @@ -83,8 +113,9 @@ suite('SessionsWindowOpenTelemetry', () => { const telemetryService = new TestTelemetryService(); const tracker = disposables.add(new SessionsWindowOpenTelemetry( AgentsWindowOpenSource.CommandPalette, + { workspaceArgumentKind: 'local', hasSessionArgument: true }, () => false, - () => ({ workspacePreselected: undefined, workspacePreselectionSource: undefined }), + () => ({ workspacePreselected: undefined, workspacePreselectionSource: undefined, viewKind: 'createdSession' }), telemetryService, lifecycleService, )); @@ -99,6 +130,24 @@ suite('SessionsWindowOpenTelemetry', () => { signInDialogShown: false, workspacePreselected: undefined, workspacePreselectionSource: undefined, + workspaceArgumentKind: 'local', + hasSessionArgument: true, + workspaceArgumentIsDefault: false, + initialViewKind: 'createdSession', + initialStateCaptureReason: 'timer', + initialStateCaptureDurationMs: FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS, + workspaceSelectionOrigin: undefined, + workspaceSelectionState: undefined, + workspaceHistoryState: undefined, + workspaceSessionFallbackState: undefined, + workspaceProviderCount: undefined, + workspaceHandoffState: 'notApplicable', + workspaceHandoffStateAtEmission: 'notApplicable', + workspaceHandoffDurationMs: undefined, + viewKindAtEmission: 'createdSession', + workspacePreselectedAtEmission: undefined, + workspaceSelectionOriginAtEmission: undefined, + workspaceSelectionStateAtEmission: undefined, windowCloseDurationMs: undefined, emissionReason: 'timer', }, @@ -120,8 +169,9 @@ suite('SessionsWindowOpenTelemetry', () => { const telemetryService = new TestTelemetryService(); const tracker = disposables.add(new SessionsWindowOpenTelemetry( AgentsWindowOpenSource.CommandPalette, + { workspaceArgumentKind: 'none', hasSessionArgument: false }, () => false, - () => ({ workspacePreselected: undefined, workspacePreselectionSource: undefined }), + () => ({ workspacePreselected: undefined, workspacePreselectionSource: undefined, viewKind: 'createdSession' }), telemetryService, lifecycleService, )); @@ -138,6 +188,7 @@ suite('SessionsWindowOpenTelemetry', () => { workspacePreselected: Reflect.get(event.data, 'workspacePreselected'), workspacePreselectionSource: Reflect.get(event.data, 'workspacePreselectionSource'), emissionReason: Reflect.get(event.data, 'emissionReason'), + captureReason: event.data.initialStateCaptureReason, }, { name: 'agents/firstTimeWindowOpen', source: 'commandPalette', @@ -145,6 +196,7 @@ suite('SessionsWindowOpenTelemetry', () => { workspacePreselected: undefined, workspacePreselectionSource: undefined, emissionReason, + captureReason: emissionReason, }); assert.strictEqual( typeof Reflect.get(event.data, 'windowCloseDurationMs'), @@ -154,4 +206,144 @@ suite('SessionsWindowOpenTelemetry', () => { lifecycleService.dispose(); } }); + + test('distinguishes an early close while setup is pending from no eligible workspace', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const lifecycleService = disposables.add(new TestLifecycleService()); + const telemetryService = new TestTelemetryService(); + const tracker = disposables.add(new SessionsWindowOpenTelemetry( + AgentsWindowOpenSource.Banner, + { workspaceArgumentKind: 'local', hasSessionArgument: false }, + () => true, + () => ({ workspacePreselected: false, workspacePreselectionSource: 'none', viewKind: 'noComposer' }), + telemetryService, + lifecycleService, + )); + tracker.recordWorkspaceHandoffState('waitingForSetup'); + await timeout(1_000); + lifecycleService.fireShutdown(ShutdownReason.CLOSE); + + const data = telemetryService.events[0].data; + assert.deepStrictEqual({ + captureReason: data.initialStateCaptureReason, + captureDurationMs: data.initialStateCaptureDurationMs, + viewKind: data.initialViewKind, + handoff: data.workspaceHandoffState, + selected: data.workspacePreselected, + selectionState: data.workspaceSelectionState, + }, { + captureReason: 'close', + captureDurationMs: 1_000, + viewKind: 'noComposer', + handoff: 'waitingForSetup', + selected: false, + selectionState: undefined, + }); + tracker.dispose(); + }); + }); + + test('keeps the initial pending snapshot when a workspace is selected before emission', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const lifecycleService = disposables.add(new TestLifecycleService()); + const telemetryService = new TestTelemetryService(); + let selection: IWorkspaceSelectionSnapshot = { + folderUri: undefined, + origin: WorkspaceSelectionOrigin.None, + state: 'none', + historyState: 'loading', + sessionFallbackState: 'pending', + registeredProviderCount: 0, + }; + const getViewState = (): ISessionsWindowOpenViewState => ({ + workspacePreselected: selection.state === 'selected', + workspacePreselectionSource: selection.state === 'selected' ? 'providedWorkspace' : 'none', + viewKind: 'newSession', + workspaceSelection: selection, + }); + const tracker = disposables.add(new SessionsWindowOpenTelemetry( + AgentsWindowOpenSource.TitleBar, + { workspaceArgumentKind: 'local', hasSessionArgument: false }, + () => false, + getViewState, + telemetryService, + lifecycleService, + )); + tracker.recordWorkspaceHandoffState('waitingForProvider'); + await timeout(100); + tracker.captureInitialViewState(); + await timeout(1_000); + selection = { + folderUri: URI.file('/private/handed-off-project'), + origin: WorkspaceSelectionOrigin.WindowOpen, + state: 'selected', + historyState: 'loaded', + sessionFallbackState: 'idle', + registeredProviderCount: 1, + }; + tracker.recordWorkspaceHandoffState('applied'); + tracker.captureInitialViewState(); + lifecycleService.fireShutdown(ShutdownReason.CLOSE); + + const data = telemetryService.events[0].data; + assert.deepStrictEqual({ + captureReason: data.initialStateCaptureReason, + captureDurationMs: data.initialStateCaptureDurationMs, + initial: [data.workspacePreselected, data.workspaceSelectionOrigin, data.workspaceHistoryState, data.workspaceSessionFallbackState, data.workspaceProviderCount, data.workspaceHandoffState], + atEmission: [data.workspacePreselectedAtEmission, data.workspaceSelectionOriginAtEmission, data.workspaceSelectionStateAtEmission, data.workspaceHandoffStateAtEmission], + handoffDurationMs: data.workspaceHandoffDurationMs, + }, { + captureReason: 'initialization', + captureDurationMs: 100, + initial: [false, 'none', 'loading', 'pending', 0, 'waitingForProvider'], + atEmission: [true, 'windowOpen', 'selected', 'applied'], + handoffDurationMs: 1_100, + }); + tracker.dispose(); + }); + }); + + test('records unsupported arguments separately from history selection and caps provider counts', () => { + const lifecycleService = disposables.add(new TestLifecycleService()); + const telemetryService = new TestTelemetryService(); + const tracker = disposables.add(new SessionsWindowOpenTelemetry( + AgentsWindowOpenSource.CommandPalette, + { workspaceArgumentKind: 'remote', hasSessionArgument: false }, + () => false, + () => ({ + workspacePreselected: true, + workspacePreselectionSource: 'recentWorkspace', + viewKind: 'newSession', + workspaceSelection: { + folderUri: URI.file('/private/fallback'), + origin: WorkspaceSelectionOrigin.VSCodeRecent, + state: 'selected', + historyState: 'loaded', + sessionFallbackState: 'idle', + registeredProviderCount: 120, + }, + }), + telemetryService, + lifecycleService, + )); + lifecycleService.fireShutdown(ShutdownReason.CLOSE); + + const data = telemetryService.events[0].data; + assert.deepStrictEqual({ + argument: data.workspaceArgumentKind, + handoff: data.workspaceHandoffState, + selected: data.workspacePreselected, + origin: data.workspaceSelectionOrigin, + providers: data.workspaceProviderCount, + containsPath: JSON.stringify(data).includes('/private/'), + }, { + argument: 'remote', + handoff: 'unsupportedWorkspace', + selected: true, + origin: 'vscodeRecent', + providers: 100, + containsPath: false, + }); + tracker.dispose(); + }); }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsWorkspaceSelectionTelemetry.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsWorkspaceSelectionTelemetry.test.ts new file mode 100644 index 0000000000000..37fe2869e669b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsWorkspaceSelectionTelemetry.test.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITelemetryData, ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { AgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; +import { ILifecycleService, ShutdownReason } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { TestLifecycleService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { IWorkspaceSelectionSnapshot, WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISendRequestSentEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISendRequestOptions } from '../../../../services/sessions/common/sessionsProvider.js'; +import { INewSessionComposerService, NewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; +import { FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS } from '../../browser/sessionsWindowOpenTelemetry.js'; +import { SessionsWorkspaceSelectionTelemetry } from '../../browser/sessionsWorkspaceSelectionTelemetry.js'; + +suite('SessionsWorkspaceSelectionTelemetry', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const folder = URI.file('/private/suggested-project'); + + function createHarness() { + const instantiationService = disposables.add(new TestInstantiationService()); + const composerService = disposables.add(new NewSessionComposerService()); + const lifecycleService = disposables.add(new TestLifecycleService()); + const selectionChanged = disposables.add(new Emitter()); + const requestSent = disposables.add(new Emitter()); + const activeSession = observableValue('activeSession', undefined); + const events: { name: string; data?: ITelemetryData }[] = []; + let selection: IWorkspaceSelectionSnapshot | undefined; + disposables.add(composerService.registerComposer({ + get workspaceSelection() { return selection; }, + onDidChangeWorkspaceSelection: selectionChanged.event, + animatePrompt: async () => false, + showPromptOptions: () => false, + })); + instantiationService.stub(INewSessionComposerService, composerService); + instantiationService.stub(ISessionsService, upcastPartial({ activeSession })); + instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidSendRequest: requestSent.event })); + instantiationService.stub(IUriIdentityService, upcastPartial({ extUri })); + instantiationService.stub(ITelemetryService, upcastPartial({ publicLog2: (name, data) => { events.push({ name, data }); } })); + instantiationService.stub(ILifecycleService, lifecycleService); + disposables.add(instantiationService.createInstance(SessionsWorkspaceSelectionTelemetry, AgentsWindowOpenSource.KeyboardShortcut, { + workspaceArgumentKind: 'local', workspaceArgumentIsDefault: true, hasSessionArgument: false, + })); + return { + composerService, lifecycleService, events, + setSelection(folderUri: URI | undefined, origin = WorkspaceSelectionOrigin.WindowContext) { + selection = { + folderUri, origin, state: folderUri ? 'selected' : 'noWorkspace', + historyState: 'loaded', sessionFallbackState: 'idle', registeredProviderCount: 1, + }; + selectionChanged.fire(); + }, + setReady(folderUri: URI) { + activeSession.set(upcastPartial({ + isCreated: observableValue('created', false), + loading: observableValue('loading', false), + workspace: observableValue('workspace', { + uri: folderUri, label: 'workspace', icon: Codicon.folder, + folders: [{ root: folderUri, workingDirectory: folderUri, name: 'workspace', description: undefined }], + requiresWorkspaceTrust: true, isVirtualWorkspace: false, + }), + }), undefined); + }, + startRequest(options: ISendRequestOptions) { + composerService.notifyWillSendRequest(options, selection); + }, + succeedRequest(options: ISendRequestOptions) { + requestSent.fire(upcastPartial({ options, isNewSession: true })); + }, + }; + } + + test('records a retained, usable default once without uploading identifiers or request content', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + await timeout(100); + harness.setSelection(folder); + await timeout(200); + harness.setReady(folder); + await timeout(100); + const options = { query: 'private request text' }; + harness.startRequest(options); + harness.succeedRequest(options); + harness.lifecycleService.fireShutdown(ShutdownReason.CLOSE); + assert.deepStrictEqual(harness.events, [{ + name: 'agents/workspaceSelectionOutcome', + data: { + source: 'keyboardShortcut', + workspaceArgumentKind: 'local', + workspaceArgumentIsDefault: true, + observationReason: 'firstRequest', + observationDurationMs: 400, + firstRequestSent: true, + defaultAvailable: true, + defaultOrigin: 'windowContext', + timeToDefaultMs: 100, + timeToUsableDefaultMs: 300, + userSelectedWorkspace: false, + defaultRetainedAtFirstRequest: true, + workspaceSelectedAtFirstRequest: true, + }, + }]); + }); + }); + + for (const choice of ['different', 'same', 'noWorkspace'] as const) { + test(`records ${choice} user selection separately from default retention`, () => { + const harness = createHarness(); + harness.setSelection(folder); + harness.composerService.notifyUserWorkspaceSelection(); + harness.setSelection(choice === 'different' ? URI.file('/private/user-choice') : choice === 'same' ? folder : undefined, WorkspaceSelectionOrigin.User); + const options = { query: 'test' }; + harness.startRequest(options); + harness.succeedRequest(options); + const data = harness.events[0].data; + assert.deepStrictEqual({ + defaultOrigin: data?.defaultOrigin, acted: data?.userSelectedWorkspace, + retained: data?.defaultRetainedAtFirstRequest, selected: data?.workspaceSelectedAtFirstRequest, + }, { defaultOrigin: 'windowContext', acted: true, retained: choice === 'same', selected: choice !== 'noWorkspace' }); + }); + } + + test('uses the actual successful request snapshot when background sends complete out of order', () => { + const harness = createHarness(); + harness.setSelection(folder); + const first = { query: 'first' }; + const second = { query: 'second' }; + harness.startRequest(first); + harness.composerService.notifyUserWorkspaceSelection(); + harness.setSelection(URI.file('/private/other'), WorkspaceSelectionOrigin.User); + harness.startRequest(second); + harness.setSelection(undefined); + harness.succeedRequest(second); + harness.succeedRequest(first); + assert.deepStrictEqual({ + count: harness.events.length, + selected: harness.events[0].data?.workspaceSelectedAtFirstRequest, + retained: harness.events[0].data?.defaultRetainedAtFirstRequest, + }, { count: 1, selected: true, retained: false }); + }); + + test('allows automatic priority upgrades before the user chooses', () => { + const harness = createHarness(); + harness.setSelection(URI.file('/private/history'), WorkspaceSelectionOrigin.VSCodeRecent); + harness.setSelection(folder, WorkspaceSelectionOrigin.WindowContext); + const options = { query: 'test' }; + harness.startRequest(options); + harness.succeedRequest(options); + assert.deepStrictEqual({ + origin: harness.events[0].data?.defaultOrigin, retained: harness.events[0].data?.defaultRetainedAtFirstRequest, + }, { origin: 'windowContext', retained: true }); + }); + + for (const end of ['timer', 'close', 'quit', 'reload'] as const) { + test(`a failed send followed by ${end} does not count as conversion`, async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + harness.setSelection(folder); + harness.startRequest({ query: 'unsent' }); + if (end === 'timer') { + await timeout(FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS); + } else { + harness.lifecycleService.fireShutdown(end === 'quit' ? ShutdownReason.QUIT : end === 'reload' ? ShutdownReason.RELOAD : ShutdownReason.CLOSE); + } + assert.deepStrictEqual({ + reason: harness.events[0].data?.observationReason, + sent: harness.events[0].data?.firstRequestSent, + retained: harness.events[0].data?.defaultRetainedAtFirstRequest, + usable: harness.events[0].data?.timeToUsableDefaultMs, + }, { reason: end, sent: false, retained: undefined, usable: undefined }); + }); + }); + } + + test('does not call the first manual workspace a default', () => { + const harness = createHarness(); + harness.composerService.notifyUserWorkspaceSelection(); + harness.setSelection(folder, WorkspaceSelectionOrigin.User); + const options = { query: 'test' }; + harness.startRequest(options); + harness.succeedRequest(options); + assert.deepStrictEqual({ + defaultAvailable: harness.events[0].data?.defaultAvailable, + selected: harness.events[0].data?.workspaceSelectedAtFirstRequest, + retained: harness.events[0].data?.defaultRetainedAtFirstRequest, + }, { defaultAvailable: false, selected: true, retained: undefined }); + }); + + test('records conversion without inferring acceptance for an uncorrelated send', () => { + const harness = createHarness(); + harness.setSelection(folder); + harness.succeedRequest({ query: 'request outside the composer' }); + assert.deepStrictEqual({ + sent: harness.events[0].data?.firstRequestSent, + available: harness.events[0].data?.defaultAvailable, + retained: harness.events[0].data?.defaultRetainedAtFirstRequest, + selected: harness.events[0].data?.workspaceSelectedAtFirstRequest, + }, { sent: true, available: true, retained: undefined, selected: undefined }); + }); + + test('records a bounded no-default observation and stops listening after emission', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const harness = createHarness(); + await timeout(FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS + 100); + harness.setSelection(folder); + const options = { query: 'outside the observation interval' }; + harness.startRequest(options); + harness.succeedRequest(options); + assert.deepStrictEqual({ + count: harness.events.length, reason: harness.events[0].data?.observationReason, + duration: harness.events[0].data?.observationDurationMs, + available: harness.events[0].data?.defaultAvailable, + selected: harness.events[0].data?.workspaceSelectedAtFirstRequest, + }, { count: 1, reason: 'timer', duration: FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS, available: false, selected: undefined }); + }); + }); +}); diff --git a/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts index 15c6bbb17502c..44ae7abbdd14e 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts @@ -4,21 +4,35 @@ *--------------------------------------------------------------------------------------------*/ import { basename } from '../../../../base/common/resources.js'; +import { disposableTimeout, raceCancellationError } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { parse, ParseError } from '../../../../base/common/json.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { hasKey, isObject } from '../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { isRecentFolder, IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; +import { isRecentFolder, IRecentWorkspace as IRecentWorkspaceFile, isStoredWorkspaceFolder, IWorkspacesService, toWorkspaceFolders } from '../../../../platform/workspaces/common/workspaces.js'; import { ISessionWorkspace } from '../common/session.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; +import { WorkspaceHistoryLoadState } from '../../../common/workspaceSelection.js'; const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; const STORAGE_KEY_NO_WORKSPACE_CHECKED = 'sessions.noWorkspaceChecked'; +const STORAGE_KEY_EXCLUDED_VSCODE_FOLDERS = 'sessions.excludedVSCodeRecentFolders'; const MAX_RECENT_WORKSPACES = 10; const MAX_VSCODE_RECENT_WORKSPACES = 10; +const MAX_RECENT_WORKSPACE_FILES = 10; +const MAX_WORKSPACE_FILE_SIZE = 1024 * 1024; +const HISTORY_LOAD_TIMEOUT_MS = 5_000; export function isWorktreeWorkspaceUri(uri: URI): boolean { return uri.path.split('/').some(segment => { @@ -43,6 +57,7 @@ export interface IRecentWorkspace { readonly workspace: ISessionWorkspace; readonly providerId: string; readonly checked: boolean; + readonly source: 'agents' | 'vscode' | 'vscodeWorkspace'; } interface IStoredRecentWorkspace { @@ -51,6 +66,11 @@ interface IStoredRecentWorkspace { readonly checked: boolean; } +interface IVSCodeRecentFolder { + readonly folderUri: URI; + readonly source: 'vscode' | 'vscodeWorkspace'; +} + export const ISessionsRecentWorkspacesService = createDecorator('sessionsRecentWorkspacesService'); /** Single source of truth for the sessions' own "recently used" workspace folders, shared by every folder-selection surface. */ @@ -58,11 +78,13 @@ export interface ISessionsRecentWorkspacesService { readonly _serviceBrand: undefined; readonly onDidChangeRecentWorkspaces: Event; + /** Whether VS Code's recent-folder and workspace-file history has loaded; Agents-owned history is synchronous. */ + readonly historyLoadState: IObservable; /** * The recently used folders, resolved and most recent first: own history * first, then (when `includeVSCodeRecents` is `true`, the default) VS - * Code's own recently opened folders (deduplicated against own history). + * Code's recently opened folders and workspace-file folders (deduplicated against own history). * * Pass `false` to restrict to the sessions' own recently-picked history * only. The new-session workspace picker checks this history before @@ -94,29 +116,35 @@ export class SessionsRecentWorkspacesService extends Disposable implements ISess private readonly _onDidChangeRecentWorkspaces = this._register(new Emitter()); readonly onDidChangeRecentWorkspaces: Event = this._onDidChangeRecentWorkspaces.event; - private _vsCodeRecentFolderUris: URI[] = []; + private _vsCodeRecentFolders: IVSCodeRecentFolder[] = []; + private readonly _historyRefresh = this._register(new MutableDisposable()); + private readonly _historyLoadState = observableValue(this, 'loading'); + readonly historyLoadState: IObservable = this._historyLoadState; constructor( @IStorageService private readonly storageService: IStorageService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IWorkspacesService private readonly workspacesService: IWorkspacesService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, ) { super(); this._refreshVSCodeRecentWorkspaces(); this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._refreshVSCodeRecentWorkspaces())); + this._register(this.fileService.onDidChangeFileSystemProviderRegistrations(() => this._refreshVSCodeRecentWorkspaces())); } getRecentWorkspaces(includeVSCodeRecents = true, collapseWorktrees = false): IRecentWorkspace[] { const storedOwn = this._getStoredRecentWorkspaces(); if (!includeVSCodeRecents) { - return this._resolveStored(storedOwn); + return this._resolveStored(storedOwn, 'agents'); } const availableUris = new Set([ ...storedOwn.map(entry => URI.revive(entry.uri)), - ...this._vsCodeRecentFolderUris, + ...this._vsCodeRecentFolders.map(entry => entry.folderUri), ].map(uri => this.uriIdentityService.extUri.getComparisonKey(uri))); const seenOwnUris = new Set(); const own = storedOwn.flatMap(entry => { @@ -133,26 +161,27 @@ export class SessionsRecentWorkspacesService extends Disposable implements ISess return [{ ...entry, uri: displayUri.toJSON() }]; }); const ownUris = new Set(own.map(o => this.uriIdentityService.extUri.getComparisonKey(URI.revive(o.uri)))); - const vsCode = this._vsCodeRecentFolderUris - .filter(uri => !ownUris.has(this.uriIdentityService.extUri.getComparisonKey(uri))) - .map(uri => ({ uri: uri.toJSON(), providerId: undefined, checked: false }) satisfies IStoredRecentWorkspace); + const vsCode = this._vsCodeRecentFolders + .filter(entry => !ownUris.has(this.uriIdentityService.extUri.getComparisonKey(entry.folderUri))) + .flatMap(entry => this._resolveStored([{ uri: entry.folderUri.toJSON(), checked: false }], entry.source)); - return this._resolveStored([...own, ...vsCode]); + return [...this._resolveStored(own, 'agents'), ...vsCode]; } - private _resolveStored(stored: readonly IStoredRecentWorkspace[]): IRecentWorkspace[] { + private _resolveStored(stored: readonly IStoredRecentWorkspace[], source: IRecentWorkspace['source']): IRecentWorkspace[] { const recents: IRecentWorkspace[] = []; for (const entry of stored) { const folderUri = URI.revive(entry.uri); const resolved = this._resolveWorkspace(folderUri, entry.providerId); if (resolved) { - recents.push({ workspace: resolved.workspace, providerId: resolved.providerId, checked: entry.checked }); + recents.push({ workspace: resolved.workspace, providerId: resolved.providerId, checked: entry.checked, source }); } } return recents; } addRecentWorkspace(folderUri: URI, providerId: string | undefined, checked: boolean): void { + this._updateExcludedVSCodeFolders([folderUri], false); const recents = this._getStoredRecentWorkspaces(); const filtered = recents.map(p => { // Remove the entry being re-added (it will go to the front) @@ -184,10 +213,14 @@ export class SessionsRecentWorkspacesService extends Disposable implements ISess return !!repositoryUri && this.uriIdentityService.extUri.isEqual(repositoryUri, folderUri); }; const updated = recents.filter(p => !matchesRemovedWorkspace(URI.revive(p.uri))); + const vsCodeUris = this._vsCodeRecentFolders.map(entry => entry.folderUri).filter(matchesRemovedWorkspace); + this._updateExcludedVSCodeFolders([folderUri, ...vsCodeUris], true); + this._vsCodeRecentFolders = this._vsCodeRecentFolders.filter(entry => !matchesRemovedWorkspace(entry.folderUri)); if (updated.length !== recents.length) { this._persistRecentWorkspaces(updated); + } else { + this._onDidChangeRecentWorkspaces.fire(); } - const vsCodeUris = this._vsCodeRecentFolderUris.filter(matchesRemovedWorkspace); this.workspacesService.removeRecentlyOpened([folderUri, ...vsCodeUris]); } @@ -228,14 +261,135 @@ export class SessionsRecentWorkspacesService extends Disposable implements ISess } private async _refreshVSCodeRecentWorkspaces(): Promise { - const recentlyOpened = await this.workspacesService.getRecentlyOpened(); - this._vsCodeRecentFolderUris = recentlyOpened.workspaces - .filter(isRecentFolder) - .map(f => f.folderUri) - .filter(uri => !basename(uri).startsWith('copilot-')) - .filter(uri => !isWorktreeWorkspaceUri(uri)) - .slice(0, MAX_VSCODE_RECENT_WORKSPACES); - this._onDidChangeRecentWorkspaces.fire(); + const refresh = new DisposableStore(); + this._historyRefresh.value = refresh; + const cancellation = new CancellationTokenSource(); + refresh.add(toDisposable(() => cancellation.dispose(true))); + let timedOut = false; + refresh.add(disposableTimeout(() => { + timedOut = true; + cancellation.cancel(); + }, HISTORY_LOAD_TIMEOUT_MS)); + this._historyLoadState.set('loading', undefined); + try { + const recentlyOpened = await raceCancellationError(this.workspacesService.getRecentlyOpened(), cancellation.token); + if (refresh.isDisposed) { + return; + } + const results: Promise<{ folders: IVSCodeRecentFolder[]; hasErrors: boolean }>[] = []; + const directFolders = new Set(); + let workspaceFiles = 0; + for (const entry of recentlyOpened.workspaces) { + if (isRecentFolder(entry)) { + results.push(Promise.resolve({ folders: [{ folderUri: entry.folderUri, source: 'vscode' }], hasErrors: false })); + if (this._includeVSCodeFolder(entry.folderUri)) { + directFolders.add(this.uriIdentityService.extUri.getComparisonKey(entry.folderUri)); + } + if (directFolders.size >= MAX_VSCODE_RECENT_WORKSPACES) { + break; + } + } else if (workspaceFiles++ < MAX_RECENT_WORKSPACE_FILES) { + results.push((async () => { + try { + const result = await this._readWorkspaceFolders(entry, cancellation.token); + return { folders: result.folders.map(folderUri => ({ folderUri, source: 'vscodeWorkspace' as const })), hasErrors: result.hasErrors }; + } catch (error) { + if (!cancellation.token.isCancellationRequested) { + this.logService.warn('[SessionsRecentWorkspaces] Could not read recent workspace folders', error); + } + return { folders: [], hasErrors: true }; + } + })()); + } + } + const loaded = await Promise.all(results); + if (refresh.isDisposed) { + return; + } + const excluded = new Set(this.storageService.getObject(STORAGE_KEY_EXCLUDED_VSCODE_FOLDERS, StorageScope.PROFILE, [])); + const seen = new Set(); + this._vsCodeRecentFolders = loaded.flatMap(result => result.folders).filter(entry => { + const key = this.uriIdentityService.extUri.getComparisonKey(entry.folderUri); + if (seen.has(key) || (entry.source === 'vscodeWorkspace' && excluded.has(key)) || !this._includeVSCodeFolder(entry.folderUri)) { + return false; + } + seen.add(key); + return true; + }).slice(0, MAX_VSCODE_RECENT_WORKSPACES); + if (timedOut) { + this.logService.warn('[SessionsRecentWorkspaces] Timed out reading recent workspace folders'); + } + this._historyLoadState.set(timedOut || loaded.some(result => result.hasErrors) ? 'error' : 'loaded', undefined); + this._onDidChangeRecentWorkspaces.fire(); + } catch (error) { + if (!refresh.isDisposed) { + this._historyLoadState.set('error', undefined); + if (timedOut) { + this.logService.warn('[SessionsRecentWorkspaces] Timed out loading workspace history'); + } else { + onUnexpectedError(error); + } + } + } finally { + if (this._historyRefresh.value === refresh) { + this._historyRefresh.clear(); + } + } + } + + private _includeVSCodeFolder(uri: URI): boolean { + return !basename(uri).startsWith('copilot-') && !isWorktreeWorkspaceUri(uri); + } + + private async _readWorkspaceFolders(recent: IRecentWorkspaceFile, token: CancellationToken): Promise<{ folders: URI[]; hasErrors: boolean }> { + const configPath = recent.workspace.configPath; + if (!this.fileService.hasProvider(configPath)) { + throw new Error('No file system provider for the recent workspace file'); + } + const content = await raceCancellationError(this.fileService.readFile(configPath, { limits: { size: MAX_WORKSPACE_FILE_SIZE } }, token), token); + const errors: ParseError[] = []; + const stored: { folders?: unknown; remoteAuthority?: unknown } | undefined = parse(content.value.toString(), errors); + if (errors.length || !isObject(stored) || !Array.isArray(stored.folders)) { + throw new Error('Invalid recent workspace file'); + } + const configuredFolders = stored.folders.filter(isStoredWorkspaceFolder); + let hasErrors = configuredFolders.length !== stored.folders.length; + const remoteAuthority = recent.remoteAuthority || (typeof stored.remoteAuthority === 'string' ? stored.remoteAuthority : undefined); + const folders = configuredFolders.filter(folder => { + if (configPath.scheme === Schemas.file && remoteAuthority && hasKey(folder, { path: true })) { + hasErrors = true; + return false; + } + if (hasKey(folder, { uri: true })) { + URI.parse(folder.uri, true); + } + return true; + }); + if (hasErrors) { + this.logService.warn('[SessionsRecentWorkspaces] Skipped invalid folders or ambiguous remote paths in a recent workspace file'); + } + return { folders: toWorkspaceFolders(folders, configPath, this.uriIdentityService.extUri).map(folder => folder.uri), hasErrors }; + } + + private _updateExcludedVSCodeFolders(folders: readonly URI[], excluded: boolean): void { + const keys = new Set(this.storageService.getObject(STORAGE_KEY_EXCLUDED_VSCODE_FOLDERS, StorageScope.PROFILE, [])); + let changed = false; + for (const folder of folders) { + const key = this.uriIdentityService.extUri.getComparisonKey(folder); + if (excluded && !keys.has(key)) { + keys.add(key); + changed = true; + } else if (!excluded && keys.delete(key)) { + changed = true; + } + } + if (changed) { + if (keys.size) { + this.storageService.store(STORAGE_KEY_EXCLUDED_VSCODE_FOLDERS, JSON.stringify([...keys]), StorageScope.PROFILE, StorageTarget.MACHINE); + } else { + this.storageService.remove(STORAGE_KEY_EXCLUDED_VSCODE_FOLDERS, StorageScope.PROFILE); + } + } } private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] { diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index f6c6331a3070c..3c328beb373ff 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -52,6 +52,11 @@ const MAX_RECENTLY_OPENED_SESSIONS = 10; type SessionNavigationIntent = 'explicit' | 'automatic'; +export interface ISessionNavigationRequest { + /** The caller's token, so deferred handoffs can recognize their own navigation. */ + readonly token: CancellationToken; +} + /** * Options for {@link ISessionsService.openNewSession}. */ @@ -64,6 +69,8 @@ export interface IOpenNewSessionOptions extends ICreateNewSessionOptions { readonly folderUri?: URI; /** Cancel startup session restoration so this new-session navigation wins. */ readonly cancelRestore?: boolean; + /** Keep the current navigation intent when the composer creates or updates its own draft. */ + readonly preserveNavigation?: boolean; /** * When `true`, opens the new session (or empty composer slot) to the side @@ -171,6 +178,9 @@ export interface ISessionsService { /** Whether the initial persisted visible-session restore has settled. */ readonly initialRestoreComplete: IObservable; + /** Latest explicit navigation, including requests to an already-active empty composer. */ + readonly navigationRequest: IObservable; + /** Fires after a session's stickiness was toggled via {@link toggleSessionStickiness}. */ readonly onDidToggleSessionStickiness: Event; @@ -348,6 +358,8 @@ export class SessionsService extends Disposable implements ISessionsService { readonly activeSession: IObservable; private readonly _initialRestoreComplete = observableValue(this, false); readonly initialRestoreComplete: IObservable = this._initialRestoreComplete; + private readonly _navigationRequest = observableValue(this, undefined); + readonly navigationRequest: IObservable = this._navigationRequest; private readonly _isNewChatSessionContext: IContextKey; @@ -725,12 +737,19 @@ export class SessionsService extends Disposable implements ISessionsService { return cts.token; } - private _dismissCustomViewForNavigation(intent: SessionNavigationIntent): void { + private _beginNavigation(intent: SessionNavigationIntent, token: CancellationToken = CancellationToken.None, preserveNavigation = false): void { if (intent === 'explicit') { + if (!preserveNavigation) { + this._recordNavigation(token); + } this.customViewService.hideCustomView(); } } + private _recordNavigation(token: CancellationToken = CancellationToken.None): void { + this._navigationRequest.set({ token }, undefined); + } + /** * Cancel an in-flight {@link restoreVisibleSessions}. Called when the user * explicitly navigates to a session, including a new-session handoff that @@ -761,7 +780,7 @@ export class SessionsService extends Disposable implements ISessionsService { private async _openChatSession(session: ISession, chatUri: URI, options: IOpenSessionOptions | undefined, intent: SessionNavigationIntent): Promise { const t0 = Date.now(); this._cancelRestore(); - this._dismissCustomViewForNavigation(intent); + this._beginNavigation(intent); const token = this._startOpenSession(); // Redirect a superseded resource (e.g. a legacy session adopted into another // provider) before activating, the same way `openSession` does for a URI, so @@ -900,7 +919,7 @@ export class SessionsService extends Disposable implements ISessionsService { // Copilot CLI resource, and a newer open must win regardless of which // resolution finishes first. this._cancelRestore(); - this._dismissCustomViewForNavigation(intent); + this._beginNavigation(intent); const token = this._startOpenSession(); await this.sessionOpenTelemetryService.withOpenRequest(options?.source ?? 'unknown', token, async telemetryAttempt => { // Redirect a superseded resource (legacy session adopted into another @@ -931,7 +950,7 @@ export class SessionsService extends Disposable implements ISessionsService { showSession(sessionResource: URI, options?: { preserveFocus?: boolean }): void { this._cancelRestore(); - this._dismissCustomViewForNavigation('explicit'); + this._beginNavigation('explicit'); this._startOpenSession(); this._showSession(this._getSession(sessionResource), options); } @@ -980,7 +999,7 @@ export class SessionsService extends Disposable implements ISessionsService { } async openSessionToSide(session: ISession, options?: IOpenSessionOptions & { chatResource?: URI }): Promise { - this._dismissCustomViewForNavigation('explicit'); + this._beginNavigation('explicit'); const token = this._startOpenSession(); // Redirect a superseded resource before inserting a slot, so the side-by-side // view/terminal never briefly binds to the old facade. @@ -1011,7 +1030,7 @@ export class SessionsService extends Disposable implements ISessionsService { * `resolveSessionResource` hook, not a scheme check. */ async openChatToSide(session: ISession, chatResource: URI, options?: { preserveFocus?: boolean }): Promise { - this._dismissCustomViewForNavigation('explicit'); + this._beginNavigation('explicit'); const token = this._startOpenSession(); const resolved = await this._resolveSessionForOpen(session, chatResource); if (token.isCancellationRequested) { @@ -1118,7 +1137,7 @@ export class SessionsService extends Disposable implements ISessionsService { if (token.isCancellationRequested) { return { session: undefined, trustDeclined: false }; } - this._dismissCustomViewForNavigation(intent); + this._beginNavigation(intent, token, options?.preserveNavigation); this._startOpenSession(); try { const session = this.sessionsManagementService.createNewSession(folderUri, options); @@ -1136,7 +1155,7 @@ export class SessionsService extends Disposable implements ISessionsService { // the new-session composer view. // No-op when the empty new-session placeholder is active, unless opening to the side. if (!folderUri) { - this._dismissCustomViewForNavigation(intent); + this._beginNavigation(intent, token, options?.preserveNavigation); } if (this._visibility.activeSession.get() === undefined && !options?.toSide) { return { session: undefined, trustDeclined: false }; @@ -1176,7 +1195,7 @@ export class SessionsService extends Disposable implements ISessionsService { } private _openQuickChat(options: ICreateNewSessionOptions | undefined, intent: SessionNavigationIntent): IActiveSession | undefined { - this._dismissCustomViewForNavigation(intent); + this._beginNavigation(intent); this._startOpenSession(); try { const session = this.sessionsManagementService.createQuickChat(options); @@ -1191,7 +1210,7 @@ export class SessionsService extends Disposable implements ISessionsService { async openNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise { this._cancelRestore(); - this._dismissCustomViewForNavigation('explicit'); + this._beginNavigation('explicit'); this._startOpenSession(); const chat = await this.sessionsManagementService.createNewChatInSession(session, options); if (!chat) { @@ -1205,6 +1224,9 @@ export class SessionsService extends Disposable implements ISessionsService { } setActive(session: IActiveSession | undefined): void { + if (session?.sessionId !== this.activeSession.get()?.sessionId) { + this._recordNavigation(); + } this._activate(session); } 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 37f907a66929b..f99511aeac2c4 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -728,6 +728,58 @@ suite('SessionsManagementService', () => { }); }); + test('publishes navigation requests even when the empty composer is already active', async () => { + const session = stubSession({ sessionId: 'session', providerId: 'test' }); + const { view } = createSessionsManagementService(session, disposables); + const request = disposables.add(new CancellationTokenSource()); + const tokens: CancellationToken[] = []; + disposables.add(autorun(reader => { + const navigation = view.navigationRequest.read(reader); + if (navigation) { + tokens.push(navigation.token); + } + })); + + await view.openNewSession(undefined, request.token); + await view.openNewSession(); + await view.openNewSession(); + + assert.deepStrictEqual({ tokens, activeSession: view.activeSession.get() }, { + tokens: [request.token, CancellationToken.None, CancellationToken.None], + activeSession: undefined, + }); + }); + + test('creating a composer draft preserves navigation while an explicit opening replaces it', async () => { + const folderUri = URI.file('/test/workspace'); + const workspace: ISessionWorkspace = { + uri: folderUri, label: 'workspace', icon: Codicon.vm, + folders: [{ root: folderUri, workingDirectory: folderUri, name: 'workspace', description: undefined }], + requiresWorkspaceTrust: false, isVirtualWorkspace: false, + }; + const session = stubSession({ + sessionId: 'draft', providerId: 'test', workspace: constObservable(workspace), status: constObservable(SessionStatus.Untitled), + }); + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(): ISessionWorkspace { return workspace; } + }(session); + const { view } = createSessionsManagementService(session, disposables, provider); + await view.openNewSession(); + const initialNavigation = view.navigationRequest.get(); + + await view.openNewSession({ folderUri, preserveNavigation: true }); + const draftCreation = { + sessionId: view.activeSession.get()?.sessionId, + preservedNavigation: view.navigationRequest.get() === initialNavigation, + }; + await view.openNewSession(); + + assert.deepStrictEqual({ draftCreation, explicitNavigation: view.navigationRequest.get() !== initialNavigation }, { + draftCreation: { sessionId: 'draft', preservedNavigation: true }, + explicitNavigation: true, + }); + }); + test('does not change active session when added session is not displayed in any widget', async () => { const originalSession = stubSession({ sessionId: 'original', providerId: 'test' }); const onDidChangeSessions = disposables.add(new Emitter()); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsRecentWorkspacesService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsRecentWorkspacesService.test.ts new file mode 100644 index 0000000000000..d4bdf76c9c826 --- /dev/null +++ b/src/vs/sessions/services/sessions/test/browser/sessionsRecentWorkspacesService.test.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { waitForState } from '../../../../../base/common/observable.js'; +import { isWindows } from '../../../../../base/common/platform.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IFileContent, IFileService, IFileSystemProviderRegistrationEvent } from '../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IRecentlyOpened, isRecentFolder, IWorkspacesService } from '../../../../../platform/workspaces/common/workspaces.js'; +import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; +import { SessionsRecentWorkspacesService } from '../../browser/sessionsRecentWorkspacesService.js'; +import { ISessionsProvider } from '../../common/sessionsProvider.js'; + +suite('SessionsRecentWorkspacesService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const workspaceFile = URI.file(isWindows ? 'c:\\workspaces\\project.code-workspace' : '/workspaces/project.code-workspace'); + const firstFolder = URI.file(isWindows ? 'c:\\first' : '/first'); + const secondFolder = URI.file(isWindows ? 'c:\\second' : '/second'); + + function recentWorkspace(configPath = workspaceFile, remoteAuthority?: string) { + return { workspace: { id: extUri.getComparisonKey(configPath), configPath }, remoteAuthority }; + } + + function createHarness( + initialRecents: IRecentlyOpened['workspaces'], + files: ReadonlyMap> = new Map(), + storage: IStorageService = disposables.add(new TestStorageService()), + initialLookup?: () => Promise, + ) { + const instantiationService = disposables.add(new TestInstantiationService()); + const changed = disposables.add(new Emitter()); + const fileProvidersChanged = disposables.add(new Emitter()); + const reads: { uri: URI; sizeLimit?: number; token?: CancellationToken }[] = []; + const removed: URI[][] = []; + const warnings: string[] = []; + let recents = initialRecents; + let lookup = initialLookup; + let fileProviderAvailable = true; + const provider = upcastPartial({ + id: 'provider', + resolveWorkspace: uri => ({ + uri, label: 'workspace', icon: Codicon.folder, + folders: [{ root: uri, workingDirectory: uri, name: 'folder', description: undefined }], + requiresWorkspaceTrust: true, isVirtualWorkspace: false, + }), + }); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(IUriIdentityService, upcastPartial({ extUri })); + instantiationService.stub(ISessionsProvidersService, upcastPartial({ + getProvider: () => undefined, + getProviders: () => [provider], + })); + instantiationService.stub(ILogService, upcastPartial({ warn: message => warnings.push(message) })); + instantiationService.stub(IFileService, upcastPartial({ + onDidChangeFileSystemProviderRegistrations: fileProvidersChanged.event, + hasProvider: () => fileProviderAvailable, + readFile: async (uri, options, token) => { + reads.push({ uri, sizeLimit: options?.limits?.size, token }); + const content = files.get(extUri.getComparisonKey(uri)); + if (content === undefined) { + throw new Error('Workspace file not found'); + } + return upcastPartial({ value: VSBuffer.fromString(typeof content === 'string' ? content : await content.p) }); + }, + })); + instantiationService.stub(IWorkspacesService, upcastPartial({ + onDidChangeRecentlyOpened: changed.event, + getRecentlyOpened: () => lookup ? lookup() : Promise.resolve({ workspaces: recents, files: [] }), + removeRecentlyOpened: async uris => { + removed.push(uris); + recents = recents.filter(entry => !isRecentFolder(entry) || !uris.some(uri => extUri.isEqual(uri, entry.folderUri))); + changed.fire(); + }, + })); + const service = disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService)); + return { + service, storage, reads, removed, warnings, changed, fileProvidersChanged, + ready: () => waitForState(service.historyLoadState, state => state !== 'loading'), + refresh(entries = recents) { + recents = entries; + lookup = undefined; + changed.fire(); + }, + set fileProviderAvailable(value: boolean) { fileProviderAvailable = value; }, + }; + } + + function snapshot(service: SessionsRecentWorkspacesService) { + return service.getRecentWorkspaces().map(entry => ({ + uri: entry.workspace.uri.toString(), source: entry.source, checked: entry.checked, + })); + } + + test('expands JSONC multi-root history in order while preserving Agents history, filtering, and deduplication', async () => { + const harness = createHarness([ + recentWorkspace(), + { folderUri: firstFolder }, + { folderUri: URI.file('/third') }, + ], new Map([[extUri.getComparisonKey(workspaceFile), `{ + // Relative paths use the workspace file's directory. + "folders": [ + { "path": "../first" }, + { "uri": "${secondFolder.toString()}" }, + { "path": "../first" }, + { "path": "../copilot-temporary" }, + { "path": "../repo.worktrees/feature" }, + ], + }`]])); + harness.service.addRecentWorkspace(secondFolder, 'provider', true); + await harness.ready(); + assert.deepStrictEqual({ + entries: snapshot(harness.service), + ownOnly: harness.service.getRecentWorkspaces(false).map(entry => entry.workspace.uri), + state: harness.service.historyLoadState.get(), + }, { + entries: [ + { uri: secondFolder.toString(), source: 'agents', checked: true }, + { uri: firstFolder.toString(), source: 'vscodeWorkspace', checked: false }, + { uri: URI.file('/third').toString(), source: 'vscode', checked: false }, + ], + ownOnly: [secondFolder], + state: 'loaded', + }); + }); + + test('bounds workspace-file reads and file size while retaining later standalone folders', async () => { + const workspaceFiles = Array.from({ length: 15 }, (_, index) => URI.file(`/workspace-${index}.code-workspace`)); + const harness = createHarness([ + ...workspaceFiles.map(uri => recentWorkspace(uri)), + { folderUri: firstFolder }, + ], new Map(workspaceFiles.map(uri => [extUri.getComparisonKey(uri), '{"folders":[]}']))); + await harness.ready(); + assert.deepStrictEqual({ + readCount: harness.reads.length, + sizeLimits: [...new Set(harness.reads.map(read => read.sizeLimit))], + entries: snapshot(harness.service), + }, { + readCount: 10, sizeLimits: [1024 * 1024], + entries: [{ uri: firstFolder.toString(), source: 'vscode', checked: false }], + }); + }); + + test('keeps the ten-folder limit and does not read older workspace files unnecessarily', async () => { + const folders = Array.from({ length: 12 }, (_, index) => URI.file(`/folder-${index}`)); + const harness = createHarness([...folders.map(folderUri => ({ folderUri })), recentWorkspace()]); + await harness.ready(); + assert.deepStrictEqual({ + folders: harness.service.getRecentWorkspaces().map(entry => entry.workspace.uri), reads: harness.reads, + }, { folders: folders.slice(0, 10), reads: [] }); + }); + + test('never interprets a remote workspace file path relative to the local filesystem', async () => { + const remoteConfig = URI.parse('vscode-remote://ssh-remote+test/home/config/project.code-workspace'); + const remoteFolder = URI.parse('vscode-remote://ssh-remote+test/home/explicit'); + const harness = createHarness([recentWorkspace(), recentWorkspace(remoteConfig)], new Map([ + [extUri.getComparisonKey(workspaceFile), JSON.stringify({ + remoteAuthority: 'ssh-remote+test', + folders: [{ path: '../local-but-ambiguous' }, { path: '/remote-but-ambiguous' }, { uri: remoteFolder.toString() }], + })], + [extUri.getComparisonKey(remoteConfig), '{"folders":[{"path":"../repository"}]}'], + ])); + await harness.ready(); + assert.deepStrictEqual({ + folders: harness.service.getRecentWorkspaces().map(entry => entry.workspace.uri.toString()), + state: harness.service.historyLoadState.get(), warningCount: harness.warnings.length, + }, { + folders: [remoteFolder.toString(), URI.parse('vscode-remote://ssh-remote+test/home/repository').toString()], + state: 'error', warningCount: 1, + }); + }); + + test('reports malformed and missing workspace files but still supplies valid history', async () => { + const missing = URI.file('/missing.code-workspace'); + const harness = createHarness([recentWorkspace(), recentWorkspace(missing), { folderUri: firstFolder }], + new Map([[extUri.getComparisonKey(workspaceFile), '{"folders": [}']])); + await harness.ready(); + assert.deepStrictEqual({ + entries: snapshot(harness.service), state: harness.service.historyLoadState.get(), warningCount: harness.warnings.length, + }, { + entries: [{ uri: firstFolder.toString(), source: 'vscode', checked: false }], state: 'error', warningCount: 2, + }); + }); + + test('retries workspace files when a filesystem provider becomes available without activating one itself', async () => { + const harness = createHarness([recentWorkspace()], new Map([[extUri.getComparisonKey(workspaceFile), '{"folders":[{"path":"../first"}]}']])); + harness.fileProviderAvailable = false; + await harness.ready(); + const unavailable = { readCount: harness.reads.length, state: harness.service.historyLoadState.get() }; + harness.fileProviderAvailable = true; + harness.fileProvidersChanged.fire(upcastPartial({ added: true })); + await harness.ready(); + assert.deepStrictEqual({ unavailable, entries: snapshot(harness.service) }, { + unavailable: { readCount: 0, state: 'error' }, + entries: [{ uri: firstFolder.toString(), source: 'vscodeWorkspace', checked: false }], + }); + }); + + test('a superseded history refresh cannot overwrite newer entries', async () => { + const old = new DeferredPromise(); + const harness = createHarness([], undefined, undefined, () => old.p); + harness.refresh([{ folderUri: secondFolder }]); + await harness.ready(); + await old.complete({ workspaces: [{ folderUri: firstFolder }], files: [] }); + await timeout(0); + assert.deepStrictEqual(snapshot(harness.service), [{ uri: secondFolder.toString(), source: 'vscode', checked: false }]); + }); + + test('disposal cancels in-flight file reads without publishing stale history', async () => { + const pending = new DeferredPromise(); + const harness = createHarness([recentWorkspace()], new Map([[extUri.getComparisonKey(workspaceFile), pending]])); + let changed = 0; + disposables.add(harness.service.onDidChangeRecentWorkspaces(() => changed++)); + await timeout(0); + harness.service.dispose(); + await pending.complete('{"folders":[{"path":"../first"}]}'); + await timeout(0); + assert.deepStrictEqual({ + changed, cancelled: harness.reads[0].token?.isCancellationRequested, entries: snapshot(harness.service), + }, { changed: 0, cancelled: true, entries: [] }); + }); + + test('times out slow workspace files at five seconds and keeps standalone-folder fallback', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const pending = new DeferredPromise(); + const startedAt = Date.now(); + const harness = createHarness([recentWorkspace(), { folderUri: firstFolder }], + new Map([[extUri.getComparisonKey(workspaceFile), pending]])); + await harness.ready(); + const elapsed = Date.now() - startedAt; + await pending.complete('{"folders":[{"path":"../second"}]}'); + assert.deepStrictEqual({ + elapsed, entries: snapshot(harness.service), state: harness.service.historyLoadState.get(), + cancelled: harness.reads[0].token?.isCancellationRequested, warningCount: harness.warnings.length, + }, { + elapsed: 5_000, entries: [{ uri: firstFolder.toString(), source: 'vscode', checked: false }], + state: 'error', cancelled: true, warningCount: 1, + }); + }); + }); + + test('removing an expanded folder persists without deleting its parent workspace or the no-workspace choice', async () => { + const files = new Map([[extUri.getComparisonKey(workspaceFile), '{"folders":[{"path":"../first"},{"path":"../second"}]}']]); + const harness = createHarness([recentWorkspace()], files); + await harness.ready(); + harness.service.checkNoWorkspace(); + harness.service.removeRecentWorkspace(firstFolder); + await harness.ready(); + harness.service.dispose(); + const restored = createHarness([recentWorkspace()], files, harness.storage); + await restored.ready(); + const afterRestore = { entries: snapshot(restored.service), noWorkspace: restored.service.isNoWorkspaceChecked() }; + restored.service.addRecentWorkspace(firstFolder, 'provider', true); + restored.refresh(); + await restored.ready(); + assert.deepStrictEqual({ + removed: harness.removed, afterRestore, + afterRepick: snapshot(restored.service), noWorkspaceAfterRepick: restored.service.isNoWorkspaceChecked(), + }, { + removed: [[firstFolder, firstFolder]], + afterRestore: { entries: [{ uri: secondFolder.toString(), source: 'vscodeWorkspace', checked: false }], noWorkspace: true }, + afterRepick: [ + { uri: firstFolder.toString(), source: 'agents', checked: true }, + { uri: secondFolder.toString(), source: 'vscodeWorkspace', checked: false }, + ], + noWorkspaceAfterRepick: false, + }); + }); + + test('a subsequently reopened standalone folder remains eligible after its workspace-file entry was removed', async () => { + const harness = createHarness([recentWorkspace()], new Map([[extUri.getComparisonKey(workspaceFile), '{"folders":[{"path":"../first"}]}']])); + await harness.ready(); + harness.service.removeRecentWorkspace(firstFolder); + await harness.ready(); + harness.refresh([recentWorkspace(), { folderUri: firstFolder }]); + await harness.ready(); + assert.deepStrictEqual(snapshot(harness.service), [{ uri: firstFolder.toString(), source: 'vscode', checked: false }]); + }); +}); diff --git a/src/vs/sessions/test/browser/sessionView.test.ts b/src/vs/sessions/test/browser/sessionView.test.ts index 42121345791e8..7397e031b28c2 100644 --- a/src/vs/sessions/test/browser/sessionView.test.ts +++ b/src/vs/sessions/test/browser/sessionView.test.ts @@ -10,8 +10,11 @@ import { DisposableStore, MutableDisposable } from '../../../base/common/lifecyc import { observableValue } from '../../../base/common/observable.js'; import { mock } from '../../../base/test/common/mock.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; -import { AbstractChatView, IChatViewOptions } from '../../browser/parts/chatView.js'; +import { AbstractChatView, IChatViewOptions, ISelectWorkspaceOptions, WorkspaceSelectionResult } from '../../browser/parts/chatView.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { ChatGroupView } from '../../browser/parts/chatGroupView.js'; +import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; +import { URI } from '../../../base/common/uri.js'; suite('Sessions - Session View', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -29,6 +32,36 @@ suite('Sessions - Session View', () => { } } + test('forwards workspace selection to the actual standalone or active group view', () => { + const folder = URI.file('/requested'); + const options: ISelectWorkspaceOptions = { providerId: 'provider', isDefault: true }; + const calls: { folder: URI; options?: ISelectWorkspaceOptions }[] = []; + const target = disposables.add(new class extends TestNewSessionView { + override selectWorkspace(folder: URI, options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { + calls.push({ folder, options }); + return 'preserved'; + } + }()); + const missingPicker = disposables.add(new TestNewSessionView()); + const currentView = { value: undefined as AbstractChatView | undefined }; + const group: ChatGroupView = Object.assign(Object.create(ChatGroupView.prototype), { _currentView: currentView }); + const groups: ChatGroupsView = Object.assign(Object.create(ChatGroupsView.prototype), { _activeGroup: { view: group } }); + const standalone = { value: undefined as AbstractChatView | undefined }; + const sessionView: SessionView = Object.assign(Object.create(SessionView.prototype), { _standaloneView: standalone, _groupsView: groups }); + const results = [sessionView.selectWorkspace(folder, options)]; + currentView.value = missingPicker; + results.push(sessionView.selectWorkspace(folder, options)); + currentView.value = target; + results.push(sessionView.selectWorkspace(folder, options)); + currentView.value = missingPicker; + standalone.value = target; + results.push(sessionView.selectWorkspace(folder, options)); + assert.deepStrictEqual({ results, calls }, { + results: ['notReady', 'notReady', 'preserved', 'preserved'], + calls: [{ folder, options }, { folder, options }], + }); + }); + test('forwards effective visibility (part and grid leaf) to the hosted chat view', () => { const forwarded: boolean[] = []; // Created from the prototype so the internal visibility helpers are present. 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 5a41c3fa41237..90665a138f74d 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -19,10 +19,10 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { INativeHostService } from '../../../../../platform/native/common/native.js'; +import { INativeHostService, IOpenAgentsWindowOptions } from '../../../../../platform/native/common/native.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { URI, UriComponents } from '../../../../../base/common/uri.js'; +import { URI } from '../../../../../base/common/uri.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; @@ -39,16 +39,28 @@ import { CommandsRegistry, ICommandService } from '../../../../../platform/comma import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { AgentsWindowOpenSource, isAgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; const OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE = localize2('openWorkspaceInAgentsWindow', "Open in Agents"); const OPEN_WORKSPACE_IN_AGENTS_WINDOW_CHAT_TITLE_COMMAND_ID = 'workbench.action.chat.openWorkspaceInAgentsWindow.chatTitle'; const OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE_BAR_COMMAND_ID = 'workbench.action.chat.openWorkspaceInAgentsWindow.titleBar'; +function getInvokingWorkspaceFolder(accessor: ServicesAccessor): URI | undefined { + const workspaceContextService = accessor.get(IWorkspaceContextService); + const folders = workspaceContextService.getWorkspace().folders; + if (folders.length <= 1) { + return folders[0]?.uri; + } + const resource = EditorResourceAccessor.getOriginalUri(accessor.get(IEditorService).activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); + return resource ? workspaceContextService.getWorkspaceFolder(resource)?.uri : undefined; +} + async function openCurrentWorkspaceInAgentsWindow(accessor: ServicesAccessor, source: AgentsWindowOpenSource): Promise { const nativeHostService = accessor.get(INativeHostService); const workspaceContextService = accessor.get(IWorkspaceContextService); await nativeHostService.openAgentsWindow({ - folderUri: workspaceContextService.getWorkspace().folders[0]?.uri, + folderUri: getInvokingWorkspaceFolder(accessor) ?? workspaceContextService.getWorkspace().folders[0]?.uri, source, }); } @@ -169,9 +181,14 @@ export class OpenAgentsWindowAction extends Action2 { }); } - async run(accessor: ServicesAccessor, args?: { folderUri?: UriComponents; sessionResource?: UriComponents; source?: AgentsWindowOpenSource }) { + async run(accessor: ServicesAccessor, args?: IOpenAgentsWindowOptions): Promise { const nativeHostService = accessor.get(INativeHostService); - await nativeHostService.openAgentsWindow({ ...args, source: args?.source ?? AgentsWindowOpenSource.CommandPalette }); + const folderUri = !args?.folderUri && !args?.sessionResource ? getInvokingWorkspaceFolder(accessor) : undefined; + await nativeHostService.openAgentsWindow({ + ...args, + ...(folderUri ? { folderUri, folderUriIsDefault: true } : undefined), + source: args?.source ?? AgentsWindowOpenSource.CommandPalette, + }); } } @@ -233,7 +250,7 @@ export class OpenChatSessionInAgentsWindowAction extends Action2 { // back to forwarding the workspace folder so the agents window scopes its // new-session composer to it. const hasRealSession = sessionResource && !isUntitledChatSession(sessionResource); - const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; + const folderUri = getInvokingWorkspaceFolder(accessor) ?? workspaceContextService.getWorkspace().folders[0]?.uri; await nativeHostService.openAgentsWindow({ folderUri: !hasRealSession && folderUri?.scheme === Schemas.file ? folderUri.toJSON() : undefined, sessionResource: hasRealSession ? sessionResource?.toJSON() : undefined, 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 index 1ca5c950660bc..936c3f703869f 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts @@ -16,11 +16,14 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ 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 { IWorkspaceContextService, WorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatWidget, IChatWidgetService } from '../../browser/chat.js'; import { ChatConfiguration, OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID } from '../../common/constants.js'; import { IChatViewModel } from '../../common/model/chatViewModel.js'; -import { OpenChatSessionInAgentsWindowAction, OpenWorkspaceInAgentsWindowAction, OpenWorkspaceInAgentsWindowTitleBarAction } from '../../electron-browser/agentSessions/agentSessionsActions.js'; +import { OpenAgentsWindowAction, OpenChatSessionInAgentsWindowAction, OpenWorkspaceInAgentsWindowAction, OpenWorkspaceInAgentsWindowTitleBarAction } from '../../electron-browser/agentSessions/agentSessionsActions.js'; class TestCommandService extends mock() { readonly calls: { readonly commandId: string; readonly args: readonly unknown[] }[] = []; @@ -34,6 +37,28 @@ class TestCommandService extends mock() { suite('OpenWorkspaceInAgentsWindowAction', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + for (const activeFile of ['/second/file.ts', undefined]) { + test(`explicit Open in Agents prefers the active root with a first-root fallback (${activeFile ?? 'no editor'})`, async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const folders = ['/first', '/second'].map((path, index) => new WorkspaceFolder({ uri: URI.file(path), name: path, index })); + const calls: IOpenAgentsWindowOptions[] = []; + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => ({ id: 'multi-root', folders }), + getWorkspaceFolder: uri => folders.find(folder => extUri.isEqualOrParent(uri, folder.uri)) ?? null, + })); + instantiationService.stub(IEditorService, upcastPartial({ + activeEditor: activeFile ? upcastPartial({ resource: URI.file(activeFile) }) : undefined, + })); + instantiationService.stub(INativeHostService, upcastPartial({ + openAgentsWindow: async options => { calls.push(options ?? {}); }, + })); + await instantiationService.invokeFunction(accessor => new OpenWorkspaceInAgentsWindowAction().run(accessor)); + assert.deepStrictEqual(calls.map(call => ({ folder: URI.revive(call.folderUri)?.path, isDefault: call.folderUriIsDefault })), [{ + folder: activeFile ? '/second' : '/first', isDefault: undefined, + }]); + }); + } + 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()); @@ -80,6 +105,55 @@ suite('OpenWorkspaceInAgentsWindowAction', () => { }); }); +suite('OpenAgentsWindowAction workspace defaults', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const scenario of [ + { name: 'single folder', folders: ['/one'], activeFile: undefined, expected: '/one' }, + { name: 'active file in a multi-root workspace', folders: ['/one', '/two'], activeFile: '/two/file.ts', expected: '/two' }, + { name: 'no active file in a multi-root workspace', folders: ['/one', '/two'], activeFile: undefined, expected: undefined }, + { name: 'active file outside the workspace', folders: ['/one', '/two'], activeFile: '/elsewhere/file.ts', expected: undefined }, + { name: 'empty editor window', folders: [], activeFile: '/elsewhere/file.ts', expected: undefined }, + ]) { + test(`infers ${scenario.name} without turning it into an explicit selection`, async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const folders = scenario.folders.map((path, index) => new WorkspaceFolder({ uri: URI.file(path), name: path, index })); + const calls: IOpenAgentsWindowOptions[] = []; + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => ({ id: 'workspace', folders }), + getWorkspaceFolder: resource => folders.find(folder => extUri.isEqualOrParent(resource, folder.uri)) ?? null, + })); + instantiationService.stub(IEditorService, upcastPartial({ + activeEditor: scenario.activeFile ? upcastPartial({ resource: URI.file(scenario.activeFile) }) : undefined, + })); + instantiationService.stub(INativeHostService, upcastPartial({ + openAgentsWindow: async options => { calls.push(options ?? {}); }, + })); + + await instantiationService.invokeFunction(accessor => new OpenAgentsWindowAction().run(accessor, { source: AgentsWindowOpenSource.KeyboardShortcut })); + assert.deepStrictEqual(calls.map(call => ({ + folder: URI.revive(call.folderUri)?.path, + isDefault: call.folderUriIsDefault, + source: call.source, + })), [{ folder: scenario.expected, isDefault: scenario.expected ? true : undefined, source: AgentsWindowOpenSource.KeyboardShortcut }]); + }); + } + + test('preserves explicit folder and existing-session arguments without consulting editor context', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const calls: IOpenAgentsWindowOptions[] = []; + instantiationService.stub(INativeHostService, upcastPartial({ openAgentsWindow: async options => { calls.push(options ?? {}); } })); + const explicit = { folderUri: URI.file('/explicit') }; + const existing = { sessionResource: URI.parse('agent-host-copilot:/session') }; + await instantiationService.invokeFunction(accessor => new OpenAgentsWindowAction().run(accessor, explicit)); + await instantiationService.invokeFunction(accessor => new OpenAgentsWindowAction().run(accessor, existing)); + assert.deepStrictEqual(calls, [ + { ...explicit, source: AgentsWindowOpenSource.CommandPalette }, + { ...existing, source: AgentsWindowOpenSource.CommandPalette }, + ]); + }); +}); + suite('OpenWorkspaceInAgentsWindowTitleBarAction', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite();