From 530a1348d742a25e60c4d9bc791cfebfbefbb1b7 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Thu, 7 May 2026 09:32:38 -0500 Subject: [PATCH 01/41] Add right click visibility toggle for sign in button Co-authored-by: Copilot --- .../contrib/chat/browser/chat.contribution.ts | 5 +++ .../chatSetup/chatSetupContributions.ts | 21 +++++++++- .../browser/chatStatus/chatStatusEntry.ts | 40 +++++++++++++++++-- .../contrib/chat/common/constants.ts | 1 + 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 10afb3603743c3..384cfa985c2747 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1560,6 +1560,11 @@ configurationRegistry.registerConfiguration({ default: false, scope: ConfigurationScope.WINDOW, }, + [ChatConfiguration.TitleBarSignInEnabled]: { + type: 'boolean', + description: nls.localize('chat.titleBar.signIn.enabled', "Controls whether the Copilot Sign In button is shown in the title bar when signed out. When disabled, the Sign In affordance falls back to the status bar."), + default: true, + }, 'chat.approvedAccountOrganizations': { type: 'array', items: { type: 'string' }, diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 34449705f93747..9806133f4f4d3e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -33,6 +33,7 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' import product from '../../../../../platform/product/common/product.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementRequests, ChatEntitlementService, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; @@ -400,10 +401,10 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr order: 0, // same position as the update button when: ContextKeyExpr.and( IsWebContext.negate(), - ChatContextKeys.Entitlement.signedOut, ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), + ContextKeyExpr.equals(`config.${ChatConfiguration.TitleBarSignInEnabled}`, true), ContextKeyExpr.has('updateTitleBar').negate(), InEditorZenModeContext.negate(), ), @@ -421,6 +422,23 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr } } + class ToggleSignInTitleBarAction extends ToggleTitleBarConfigAction { + constructor() { + super( + ChatConfiguration.TitleBarSignInEnabled, + localize('toggle.chatSignIn', 'Copilot Sign In'), + localize('toggle.chatSignInDescription', "Toggle visibility of the Copilot Sign In button in title bar"), + 3, + ContextKeyExpr.and( + IsWebContext.negate(), + ChatContextKeys.Entitlement.signedOut, + ChatContextKeys.Setup.hidden.negate(), + ChatContextKeys.Setup.disabledInWorkspace.negate(), + ) + ); + } + } + const windowFocusListener = this._register(new MutableDisposable()); class UpgradePlanAction extends Action2 { constructor() { @@ -530,6 +548,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr registerAction2(ChatSetupTriggerForceSignInDialogAction); registerAction2(ChatSetupFromAccountsAction); registerAction2(ChatSetupSignInTitleBarAction); + registerAction2(ToggleSignInTitleBarAction); registerAction2(ChatSetupTriggerAnonymousWithoutDialogAction); registerAction2(ChatSetupTriggerSupportAnonymousAction); registerAction2(UpgradePlanAction); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index 3e1185bd00e8d4..f5c2868613bbf9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -25,6 +25,10 @@ import { isCompletionsEnabled } from '../../../../../editor/common/services/comp import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { CHAT_SETUP_ACTION_ID } from '../actions/chatActions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { isWeb } from '../../../../../base/common/platform.js'; +import { InEditorZenModeContext } from '../../../../common/contextkeys.js'; +import { ChatConfiguration } from '../../common/constants.js'; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { @@ -46,6 +50,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu @IInlineCompletionsService private readonly completionsService: IInlineCompletionsService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IHoverService private readonly hoverService: IHoverService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); @@ -101,6 +106,11 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.update())); this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.update())); this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.update())); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(new Set(['updateTitleBar', InEditorZenModeContext.key]))) { + this.update(); + } + })); this._register(this.completionsService.onDidChangeIsSnoozing(() => this.update())); @@ -115,7 +125,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this._register(this.editorService.onDidActiveEditorChange(() => this.onDidActiveEditorChange())); this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(product.defaultChatAgent?.completionsEnablementSetting)) { + if (e.affectsConfiguration(product.defaultChatAgent?.completionsEnablementSetting) || e.affectsConfiguration(ChatConfiguration.TitleBarSignInEnabled)) { this.update(); } })); @@ -238,17 +248,41 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu } private getSetupEntryProps(): IStatusbarEntry { + const showSignInLabel = !this.isSignInTitleBarAffordanceVisible(); const signInLabel = localize('signIn', "Sign In"); return { name: localize('chatStatus', "Copilot Status"), - text: `$(copilot) ${signInLabel}`, - ariaLabel: signInLabel, + text: showSignInLabel ? `$(copilot) ${signInLabel}` : '$(copilot)', + ariaLabel: showSignInLabel ? signInLabel : localize('chatStatusAria', "Copilot status"), command: CHAT_SETUP_ACTION_ID, showInAllWindows: true, kind: undefined, }; } + private isSignInTitleBarAffordanceVisible(): boolean { + if (isWeb) { + return false; + } + + if (this.chatEntitlementService.sentiment.hidden || this.chatEntitlementService.sentiment.disabledInWorkspace) { + return false; + } + + const hasTitleBarUpdate = Boolean(this.contextKeyService.getContextKeyValue('updateTitleBar')); + if (hasTitleBarUpdate) { + return false; + } + + const inZenMode = Boolean(this.contextKeyService.getContextKeyValue(InEditorZenModeContext.key)); + if (inZenMode) { + return false; + } + + const signInTitleBarEnabled = this.configurationService.getValue(ChatConfiguration.TitleBarSignInEnabled) !== false; + return signInTitleBarEnabled; + } + override dispose(): void { super.dispose(); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 9cd4b2e6e8995f..e04ca534b6bb75 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -64,6 +64,7 @@ export enum ChatConfiguration { ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', RevealNextChangeOnResolve = 'chat.editing.revealNextChangeOnResolve', GrowthNotificationEnabled = 'chat.growthNotification.enabled', + TitleBarSignInEnabled = 'chat.titleBar.signIn.enabled', ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', ChatCustomizationsStructuredPreviewEnabled = 'chat.customizations.structuredPreview.enabled', From b8a2c96646061b5973bdf43080aa2df2820fb19a Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 09:32:32 +0200 Subject: [PATCH 02/41] local session type for agents window Add a new 'local' session type that runs in-process VS Code chat sessions directly in the Agents window, without requiring a background agent or worktree. - Add `LocalSessionType`, `LocalNewSession`, and the `sessions.chat.localAgent.enabled` experimental setting - Wire local sessions into menus, context keys, and model selection (use general-purpose models without a `targetChatSessionType`) - Introduce `IChatSessionItemMetadata` interface to replace the untyped `{ [key: string]: unknown }` metadata shape - Add `workingDirectory` to `IChatModel` and propagate it through `IChatDetail` and `LocalChatSessionItem` - Split `chatSessionStore` metadata into sync/async paths; add `updateAndFlushIndexSync` so the session index is persisted before the storage service flushes in `onWillSaveState` --- .../changes/browser/changesViewModel.ts | 10 +- .../browser/customizationHarnessService.ts | 21 +- .../copilotChatSessions.contribution.ts | 8 +- .../browser/copilotChatSessionsActions.ts | 24 +- .../browser/copilotChatSessionsProvider.ts | 303 +++++++++++++++++- .../services/sessions/common/session.ts | 12 +- .../localAgentSessionsController.ts | 6 +- .../chat/common/chatService/chatService.ts | 5 + .../common/chatService/chatServiceImpl.ts | 14 +- .../chat/common/chatSessionsService.ts | 26 +- .../contrib/chat/common/model/chatModel.ts | 15 + .../chat/common/model/chatSessionStore.ts | 102 +++--- .../common/chatService/chatService.test.ts | 30 +- .../chat/test/common/model/mockChatModel.ts | 2 + 14 files changed, 505 insertions(+), 73 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index 296ee00b0c0ed9..28e5e1654fcc35 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -13,7 +13,7 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionFileChange2, IChatSessionItemMetadata } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { GitDiffChange, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { COPILOT_CLOUD_SESSION_TYPE, ISessionFileChange } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -88,7 +88,7 @@ export class ChangesViewModel extends Disposable { readonly activeSessionStateObs: IObservable; readonly activeSessionIsLoadingObs: IObservable; - private _activeSessionMetadataObs!: IObservable<{ readonly [key: string]: unknown } | undefined>; + private _activeSessionMetadataObs!: IObservable; private _activeSessionAllChangesPromiseObs!: IObservableWithChange>; private _activeSessionLastTurnChangesPromiseObs!: IObservableWithChange>; private _activeSessionUncommittedChangesPromiseObs!: IObservableWithChange>; @@ -230,11 +230,11 @@ export class ChangesViewModel extends Disposable { this.viewModeObs = observableValue(this, initialMode); } - private _getActiveSessionMetadata(): IObservable<{ readonly [key: string]: unknown } | undefined> { + private _getActiveSessionMetadata(): IObservable { const sessionsChangedSignal = observableSignalFromEvent(this, this.sessionManagementService.onDidChangeSessions); - const sessionMetadata = derivedObservableWithCache<{ readonly [key: string]: unknown } | undefined>(this, (reader, lastValue) => { + const sessionMetadata = derivedObservableWithCache(this, (reader, lastValue) => { const sessionResource = this.activeSessionResourceObs.read(reader); if (!sessionResource) { return undefined; @@ -252,7 +252,7 @@ export class ChangesViewModel extends Disposable { return model.metadata; }); - return derivedOpts<{ readonly [key: string]: unknown } | undefined>({ equalsFn: structuralEquals }, reader => { + return derivedOpts({ equalsFn: structuralEquals }, reader => { return sessionMetadata.read(reader); }); } diff --git a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts index a6ec791d253a7f..c7f3f51f469447 100644 --- a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts +++ b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts @@ -3,20 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CustomizationHarnessServiceBase } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../common/builtinPromptsStorage.js'; +import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; /** * Sessions-window override of the customization harness service. * - * No static harnesses are registered. The Copilot CLI extension provides - * its harness (with `itemProvider`) via `registerChatSessionCustomizationProvider()`, - * and AHP remote servers register directly via `registerExternalHarness()`. + * The Local harness is registered statically so that local customizations + * (instructions, skills, agents, etc.) are available in the Agents window. + * The Copilot CLI extension provides its harness (with `itemProvider`) via + * `registerChatSessionCustomizationProvider()`, and AHP remote servers + * register directly via `registerExternalHarness()`. */ export class SessionsCustomizationHarnessService extends CustomizationHarnessServiceBase { constructor( @IPromptsService promptsService: IPromptsService ) { - super([], '', promptsService); + const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; + super( + [createVSCodeHarnessDescriptor(localExtras)], + SessionType.Local, + promptsService, + ); } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts index 48f4c4cf073bc5..08f7470ea87184 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -6,7 +6,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING, LOCAL_SESSION_ENABLED_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import '../../copilotChatSessions/browser/copilotChatSessionsActions.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -28,6 +28,12 @@ Registry.as(ConfigurationExtensions.Configuration).regis experiment: { mode: 'startup' }, description: localize('sessions.chat.claudeAgent.enabled', "Enable Claude Agent sessions in the Agents app. Start and resume agentic coding sessions powered by Anthropic's Claude Agent SDK directly. Uses your existing Copilot subscription."), }, + [LOCAL_SESSION_ENABLED_SETTING]: { + type: 'boolean', + default: false, + tags: ['experimental'], + description: localize('sessions.chat.localAgent.enabled', "Enable Local VS Code chat sessions in the Agents app. Start in-process chat sessions directly, without a background agent or worktree."), + }, }, }); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 5496c9ec73594b..ea0e6b4743da3c 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -24,7 +24,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; -import { CLAUDE_CODE_SESSION_TYPE, COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE, ISession } from '../../../services/sessions/common/session.js'; +import { CLAUDE_CODE_SESSION_TYPE, COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE, LOCAL_SESSION_TYPE, ISession } from '../../../services/sessions/common/session.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; @@ -34,14 +34,17 @@ import { ModePicker } from './modePicker.js'; import { CloudModelPicker } from './modelPicker.js'; import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js'; import { ClaudePermissionModePicker } from './claudePermissionModePicker.js'; +import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLI_SESSION_TYPE); const IsActiveSessionCopilotCloud = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLOUD_SESSION_TYPE); +const IsActiveSessionLocal = ContextKeyExpr.equals(ActiveSessionTypeContext.key, LOCAL_SESSION_TYPE); const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, COPILOT_PROVIDER_ID); const IsActiveSessionCopilotChatCLI = ContextKeyExpr.and(IsActiveSessionCopilotCLI, IsActiveCopilotChatSessionProvider); const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(IsActiveSessionCopilotCloud, IsActiveCopilotChatSessionProvider); const IsActiveSessionClaudeCode = ContextKeyExpr.equals(ActiveSessionTypeContext.key, CLAUDE_CODE_SESSION_TYPE); const IsActiveSessionCopilotChatClaudeCode = ContextKeyExpr.and(IsActiveSessionClaudeCode, IsActiveCopilotChatSessionProvider); +const IsActiveSessionCopilotChatLocal = ContextKeyExpr.and(IsActiveSessionLocal, IsActiveCopilotChatSessionProvider); // -- Actions -- @@ -94,7 +97,7 @@ registerAction2(class extends Action2 { id: Menus.NewSessionConfig, group: 'navigation', order: 0, - when: IsActiveSessionCopilotChatCLI, + when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatLocal), }], }); } @@ -111,7 +114,7 @@ registerAction2(class extends Action2 { id: Menus.NewSessionConfig, group: 'navigation', order: 1, - when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatClaudeCode), + when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatClaudeCode, IsActiveSessionCopilotChatLocal), }], }); } @@ -145,7 +148,7 @@ registerAction2(class extends Action2 { id: Menus.NewSessionControl, group: 'navigation', order: 1, - when: IsActiveSessionCopilotChatCLI, + when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatLocal), }], }); } @@ -397,12 +400,21 @@ export function getAvailableModels( if (!session) { return []; } - return languageModelsService.getLanguageModelIds() + const allModels = languageModelsService.getLanguageModelIds() .map(id => { const metadata = languageModelsService.lookupLanguageModel(id); return metadata ? { metadata, identifier: id } : undefined; }) - .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && m.metadata.targetChatSessionType === session.sessionType); + .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m); + + // For 'local' sessions (in-process VS Code chat), use general-purpose + // models (those without a targetChatSessionType) since no extension + // registers models specifically targeting the 'local' session type. + if (session.sessionType === SessionType.Local) { + return allModels.filter(m => !m.metadata.targetChatSessionType && m.metadata.isUserSelectable); + } + + return allModels.filter(m => m.metadata.targetChatSessionType === session.sessionType); } // -- Context Key Contribution -- diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index ee94a2676fba40..13a64a83d7e934 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -22,8 +22,8 @@ import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browse import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; -import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, ISessionChangeset } from '../../../services/sessions/common/session.js'; +import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType, IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, LocalSessionType, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, ISessionChangeset } from '../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../base/common/resources.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; @@ -56,8 +56,8 @@ export interface ICopilotChatSession { readonly resource: URI; /** ID of the provider that owns this session. */ readonly providerId: string; - /** Session type ID (e.g., 'copilot-cli', 'copilot-cloud'). */ - readonly sessionType: string; + /** Session type ID (e.g., 'copilot-cli', 'copilot-cloud', 'local'). */ + readonly sessionType: typeof SessionType[keyof typeof SessionType] | string; /** Icon for this session. */ readonly icon: ThemeIcon; /** When the session was created. */ @@ -122,16 +122,19 @@ export const COPILOT_MULTI_CHAT_SETTING = 'sessions.github.copilot.multiChatSess /** Setting key controlling whether Claude agent sessions are available. */ export const CLAUDE_CODE_ENABLED_SETTING = 'sessions.chat.claudeAgent.enabled'; +/** Setting key controlling whether Local VS Code chat sessions are available in the Agents app. */ +export const LOCAL_SESSION_ENABLED_SETTING = 'sessions.chat.localAgent.enabled'; + const REPOSITORY_OPTION_ID = 'repository'; const PARENT_SESSION_OPTION_ID = 'parentSessionId'; const BRANCH_OPTION_ID = 'branch'; const ISOLATION_OPTION_ID = 'isolation'; const AGENT_OPTION_ID = 'agent'; -type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession; +type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession | LocalNewSession; function isNewSession(session: ICopilotChatSession): session is NewSession { - return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession; + return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession || session instanceof LocalNewSession; } /** @@ -147,7 +150,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { readonly id: string; readonly providerId: string; - readonly sessionType: string; + readonly sessionType: typeof SessionType.CopilotCLI; readonly icon: ThemeIcon; readonly createdAt: Date; @@ -690,6 +693,199 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession update(_session: IAgentSession): void { } } +/** + * New session for local (in-process VS Code chat) sessions. + * Implements {@link ICopilotChatSession} (session facade) for local sessions + * that run in-process without worktrees or remote agents. + * Keeps the underlying chat model reference alive via {@link attachKeepAlive}. + */ +class LocalNewSession extends Disposable implements ICopilotChatSession { + + // -- ISessionData fields -- + + readonly resource: URI; + readonly id: string; + readonly providerId: string; + readonly sessionType: typeof SessionType.Local; + readonly icon: ThemeIcon; + readonly createdAt: Date; + + private readonly _title = observableValue(this, ''); + readonly title: IObservable = this._title; + + private readonly _updatedAt = observableValue(this, new Date()); + readonly updatedAt: IObservable = this._updatedAt; + + private readonly _status = observableValue(this, SessionStatus.Untitled); + readonly status: IObservable = this._status; + + private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default); + readonly permissionLevel: IObservable = this._permissionLevel; + + private readonly _workspaceData = observableValue(this, undefined); + readonly workspace: IObservable = this._workspaceData; + + readonly changesets: IObservable = observableValue(this, []); + private readonly _changes = observableValue(this, []); + readonly changes: IObservable = this._changes; + + private readonly _modelIdObservable = observableValue(this, undefined); + readonly modelId: IObservable = this._modelIdObservable; + + private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable; + + readonly loading: IObservable = observableValue(this, false); + + private readonly _isArchived = observableValue(this, false); + readonly isArchived: IObservable = this._isArchived; + readonly isRead: IObservable = observableValue(this, true); + readonly description: IObservable = constObservable(undefined); + readonly lastTurnEnd: IObservable = constObservable(undefined); + readonly gitHubInfo: IObservable = constObservable(undefined); + readonly branch: IObservable = constObservable(undefined); + readonly isolationMode: IObservable = constObservable(undefined); + readonly branches: IObservable = constObservable([]); + readonly gitRepository?: IGitRepository | undefined; + + // -- New session configuration fields -- + + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + + readonly target = AgentSessionProviders.Local; + readonly selectedOptions = new Map(); + + get selectedModelId(): string | undefined { return this._modelId; } + get chatMode(): IChatMode | undefined { return this._mode; } + get query(): string | undefined { return undefined; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return undefined; } + get disabled(): boolean { return false; } + + constructor( + // readonly resource: URI, + readonly sessionWorkspace: ISessionWorkspace, + providerId: string, + @IGitService private readonly gitService: IGitService, + @IChatService private readonly chatService: IChatService, + ) { + super(); + + // Create a real local chat model upfront so the chat service has + // a model registered for our resource. This avoids the + // contributed-session path (which would require a content + // provider for the 'local' chat session type). + const modelRef = this._register(this.chatService.startNewLocalSession( + ChatAgentLocation.Chat, + { debugOwner: 'CopilotChatSessionsProvider#createNewSession.local' }, + )); + if (sessionWorkspace.repositories.length > 0) { + modelRef.object.setWorkingDirectory(sessionWorkspace.repositories[0]?.uri); + } + this.resource = modelRef.object.sessionResource; + + this.id = toSessionId(providerId, this.resource); + this.providerId = providerId; + this.sessionType = AgentSessionProviders.Local; + this.icon = LocalSessionType.icon; + this.createdAt = new Date(); + + this._workspaceData.set(sessionWorkspace, undefined); + + // Resolve git state asynchronously so the Changes view has + // branch names, uncommitted counts, etc. without needing + // an agent session in agentSessionsService. + this._resolveGitState(); + } + + private async _resolveGitState(): Promise { + const repoUri = this.sessionWorkspace.repositories[0]?.uri; + if (!repoUri) { + return; + } + + try { + const repo = await this.gitService.openRepository(repoUri); + if (!repo) { + return; + } + + this._register(autorun((reader) => { + const state = repo.state.read(reader); + const head = state.HEAD; + const branchName = head?.commit ? head.name : undefined; + + this._workspaceData.set({ + ...this.sessionWorkspace, + repositories: [{ + ...this.sessionWorkspace.repositories[0], + branchName, + }], + }, undefined); + + this._changes.set(state.workingTreeChanges.concat(state.untrackedChanges).map(el => { + return { + uri: el.uri, + insertions: 0, + deletions: 0, + }; + }), undefined); + })); + + } catch { + // No git repository available — workspace stays as-is + } + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + } + + setPermissionLevel(level: ChatPermissionLevel): void { + this._permissionLevel.set(level, undefined); + } + + setIsolationMode(_mode: IsolationMode): void { + // No-op — local sessions do not use isolation + } + + setBranch(_branch: string | undefined): void { + // No-op — local sessions do not manage branches + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + this._modelIdObservable.set(modelId, undefined); + } + + setTitle(title: string): void { + this._title.set(title, undefined); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, undefined); + } + + setArchived(archived: boolean): void { + this._isArchived.set(archived, undefined); + } + + setMode(mode: IChatMode | undefined): void { + this._mode = mode; + if (mode) { + this._modeObservable.set({ id: mode.id, kind: mode.kind }, undefined); + } else { + this._modeObservable.set(undefined, undefined); + } + } + + update(_session: IAgentSession): void { } +} + /** * New session for Claude agent sessions. * Implements {@link ICopilotChatSession} (session facade) and provides @@ -703,7 +899,7 @@ class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { readonly id: string; readonly providerId: string; - readonly sessionType: string; + readonly sessionType: typeof SessionType.ClaudeCode; readonly icon: ThemeIcon; readonly createdAt: Date; @@ -1206,7 +1402,7 @@ class AgentSessionAdapter implements ICopilotChatSession { } /** - * Default sessions provider for Copilot CLI and Cloud session types. + * Default sessions provider for Copilot CLI, Cloud, Claude, and Local session types. * Wraps the existing session infrastructure into the extensible provider model. */ export class CopilotChatSessionsProvider extends Disposable implements ISessionsProvider { @@ -1216,6 +1412,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly icon = Codicon.copilot; get sessionTypes(): readonly ISessionType[] { const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; + if (this._localSessionEnabled) { + types.push(LocalSessionType); + } if (this._claudeEnabled) { types.push(ClaudeCodeSessionType); } @@ -1232,7 +1431,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; /** Cache of adapted sessions, keyed by resource URI string. */ - private readonly _sessionCache = new Map(); + private readonly _sessionCache = new Map(); /** Cache of ISession wrappers, keyed by session group ID. */ private readonly _sessionGroupCache = new Map(); @@ -1254,6 +1453,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private readonly _multiChatEnabled: boolean; private _claudeEnabled: boolean; + private _localSessionEnabled: boolean; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; @@ -1277,6 +1477,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._multiChatEnabled = this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; this._claudeEnabled = this.configurationService.getValue(CLAUDE_CODE_ENABLED_SETTING); + this._localSessionEnabled = this.configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING)) { @@ -1287,6 +1488,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._refreshSessionCache(); } } + if (e.affectsConfiguration(LOCAL_SESSION_ENABLED_SETTING)) { + const localSessionEnabled = this.configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING); + if (this._localSessionEnabled !== localSessionEnabled) { + this._localSessionEnabled = localSessionEnabled; + this._onDidChangeSessionTypes.fire(); + this._refreshSessionCache(); + } + } })); this.browseActions = [ @@ -1312,6 +1521,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return [CopilotCloudSessionType]; } const types: ISessionType[] = [CopilotCLISessionType]; + if (this._localSessionEnabled) { + types.push(LocalSessionType); + } if (this._claudeEnabled) { types.push(ClaudeCodeSessionType); } @@ -1380,6 +1592,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._chatToSession(session); } + if (sessionTypeId === LocalSessionType.id) { + const session = this.instantiationService.createInstance(LocalNewSession, workspace, this.id); + this._currentNewSession = session; + return this._chatToSession(session); + } + if (sessionTypeId !== CopilotCLISessionType.id) { throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`); } @@ -1617,7 +1835,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * Sends the first chat for a newly created session. * Adds the temp session to the cache, waits for commit, then replaces it. */ - private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession, options: ISendRequestOptions): Promise { + private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession | LocalNewSession, options: ISendRequestOptions): Promise { const { query, attachedContext } = options; @@ -1659,6 +1877,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._sendFirstChatViaController(session, query, sendOptions); } + // Local sessions run in-process and do not go through the + // untitled→commit→swap flow (chatServiceImpl explicitly skips + // commit for localChatSessionType). Send the request and keep + // the session on its original URI. + if (session instanceof LocalNewSession) { + return this._sendFirstChatLocal(session, query, sendOptions, permissionLevel); + } + await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); const disposable = await this._applySessionModelState(session.resource, session, permissionLevel); const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); @@ -1735,6 +1961,58 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } + /** + * Sends the first chat for a local (in-process) session. + * + * Local sessions do not create worktrees and the chat service explicitly + * skips the untitled→commit flow for {@link localChatSessionType}. + * Instead of waiting for a commit event that will never arrive, this + * method keeps the session on its original untitled URI. + */ + private async _sendFirstChatLocal( + session: LocalNewSession, + query: string, + sendOptions: IChatSendRequestOptions, + permissionLevel: ChatPermissionLevel, + ): Promise { + // The chat model was already created in createNewSession via + // startNewLocalSession, so we skip getOrCreateChatSession here + // (which would otherwise try to resolve a content provider). + const disposable = await this._applySessionModelState(session.resource, session, permissionLevel); + const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); + disposable.dispose(); + if (!chatWidget) { + throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget for local session'); + } + + this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for local session ${session.id}`); + const result = await this.chatService.sendRequest(session.resource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[CopilotChatSessionsProvider] Local sendRequest rejected: ${result.reason}`); + } + + // Local sessions stay on their original URI — no commit swap needed. + session.setTitle(query.split('\n')[0].substring(0, 100) || localize('new session', "New Session")); + session.setStatus(SessionStatus.InProgress); + const key = session.resource.toString(); + this._sessionCache.set(key, session); + this._invalidateGroupingCaches(); + this._currentNewSession = undefined; + const newSession = this._chatToSession(session); + this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); + + // Listen for the response to complete so we can flip the status + // from InProgress → Completed and unblock the input box. + if (result.kind === 'sent') { + result.data.responseCompletePromise.then(() => { + session.setStatus(SessionStatus.Completed); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); + }); + } + + return newSession; + } + /** * Sends the first chat for a Claude session using the controller API. * @@ -2354,7 +2632,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions for (const session of this.agentSessionsService.model.sessions) { if (session.providerType !== AgentSessionProviders.Background && session.providerType !== AgentSessionProviders.Cloud - && session.providerType !== AgentSessionProviders.Claude) { + && session.providerType !== AgentSessionProviders.Claude + && session.providerType !== AgentSessionProviders.Local) { continue; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 1ee6d62fb2abc8..d9f75133685432 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -51,6 +51,16 @@ export const ClaudeCodeSessionType: ISessionType = { icon: Codicon.claude, }; +/** Session type ID for local VS Code chat sessions (in-process, no worktree). */ +export const LOCAL_SESSION_TYPE = 'local'; + +/** Local session type — in-process VS Code chat, no background agent or worktree. */ +export const LocalSessionType: ISessionType = { + id: LOCAL_SESSION_TYPE, + label: localize('localSession', "Local"), + icon: Codicon.vm, +}; + /** * Returns whether the given session type represents a workspace-backed * agent (e.g. Copilot CLI, Claude Code) that operates on a worktree or @@ -210,7 +220,7 @@ export interface ISession { readonly resource: URI; /** ID of the provider that owns this session. */ readonly providerId: string; - /** Session type ID (e.g., 'copilot-cli', 'copilot-cloud'). */ + /** Session type ID (e.g., 'copilot-cli', 'copilot-cloud', 'local'). */ readonly sessionType: string; /** Icon for this session. */ readonly icon: ThemeIcon; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index 8389624d685f77..906f471d474c7e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -16,11 +16,12 @@ import { URI } from '../../../../../base/common/uri.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { convertLegacyChatSessionTiming, IChatDetail, IChatService, IChatSessionTiming } from '../../common/chatService/chatService.js'; import { chatModelToChatDetail } from '../../common/chatService/chatServiceImpl.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionItemMetadata, IChatSessionItemsDelta, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { getInProgressSessionDescription } from '../chatSessions/chatSessionDescription.js'; import { chatResponseStateToSessionStatus, getSessionStatusForModel } from '../chatSessions/chatSessions.contribution.js'; +import { Schemas } from '../../../../../base/common/network.js'; export class LocalAgentsSessionsController extends Disposable implements IChatSessionItemController, IWorkbenchContribution { @@ -177,6 +178,7 @@ class LocalChatSessionItem implements IChatSessionItem { readonly status: ChatSessionStatus | undefined; readonly timing: IChatSessionTiming; readonly changes: IChatSessionItem['changes']; + readonly metadata: IChatSessionItemMetadata | undefined; constructor(chatDetail: IChatDetail, model: IChatModel | undefined) { this.resource = chatDetail.sessionResource; @@ -189,6 +191,8 @@ class LocalChatSessionItem implements IChatSessionItem { deletions: chatDetail.stats.removed, files: chatDetail.stats.fileCount, } : undefined; + const repoPath = chatDetail.workingDirectory?.scheme === Schemas.file ? chatDetail.workingDirectory.fsPath : undefined; + this.metadata = repoPath ? { workingDirectoryPath: repoPath } : undefined; } isEqual(other: LocalChatSessionItem): boolean { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 57b47d9616dd36..9c174bff1ad1a3 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1363,6 +1363,11 @@ export interface IChatDetail { isActive: boolean; stats?: IChatSessionStats; lastResponseState: ResponseModelState; + /** + * The working directory URI associated with this session. + * Only populated in the sessions/agents window context. + */ + workingDirectory?: URI; } export interface IChatProviderInfo { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 1284cd8a2c2082..f8ea2d29b3aceb 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -269,10 +269,19 @@ export class ChatService extends Disposable implements IChatService { const liveLocalChats = Array.from(this._sessionModels.values()) .filter(session => this.shouldStoreSession(session)); - this._chatSessionStore.storeSessions(liveLocalChats); - const liveNonLocalChats = Array.from(this._sessionModels.values()) .filter(session => !LocalChatSessionUri.parseLocalSessionId(session.sessionResource)); + + // Synchronously update the index for all live sessions and flush it to + // storage. This is critical because `onWillSaveState` is synchronous — + // after this handler returns the storage service flushes its databases. + // The async file-write work kicked off below may complete after the + // flush, but the index must be up-to-date before the flush happens so + // that sessions are discoverable after a reload. + this._chatSessionStore.updateAndFlushIndexSync(liveLocalChats, liveNonLocalChats); + + // Kick off async file writes for session data. + this._chatSessionStore.storeSessions(liveLocalChats); this._chatSessionStore.storeSessionsMetadataOnly(liveNonLocalChats); } @@ -1922,5 +1931,6 @@ export async function chatModelToChatDetail(model: IChatModel): Promise; getPendingRequests(): readonly IChatPendingRequest[]; } @@ -2105,6 +2112,14 @@ export class ChatModel extends Disposable implements IChatModel { this._repoData = data; } + private _workingDirectory: URI | undefined; + public get workingDirectory(): URI | undefined { + return this._workingDirectory; + } + public setWorkingDirectory(uri: URI | undefined): void { + this._workingDirectory = uri; + } + getPendingRequests(): readonly IChatPendingRequest[] { return this._pendingRequests; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 4f52965588018c..69ed5452813e87 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -727,6 +727,29 @@ export class ChatSessionStore extends Disposable { return joinPath(this.transferredSessionStorageRoot, `${sessionId}.json`); } + /** + * Synchronously update the in-memory index entries for the given sessions + * and flush the index to storage. This ensures the index is persisted + * even when called from a synchronous `onWillSaveState` handler where + * async file-write work would complete after the storage service has + * already flushed. + */ + updateAndFlushIndexSync(localSessions: ChatModel[], externalSessions: ChatModel[]): void { + const index = this.internalGetIndex(); + for (const session of localSessions) { + index.entries[session.sessionId] = getSessionMetadataSync(session); + } + for (const session of externalSessions) { + const externalSessionId = session.sessionResource.toString(); + index.entries[externalSessionId] = getSessionMetadataSync(session); + } + try { + this.storageService.store(ChatIndexStorageKey, index, this.getIndexStorageScope(), StorageTarget.MACHINE); + } catch (e) { + this.reportError('indexWrite', 'Error writing index synchronously', e); + } + } + public getChatStorageFolder(): URI { return this.storageRoot; } @@ -809,60 +832,65 @@ function isChatSessionIndex(data: unknown): data is IChatSessionIndexData { return true; } -async function getSessionMetadata(session: ChatModel | ISerializableChatData): Promise { - const title = session.customTitle || (session instanceof ChatModel ? session.title : undefined); - - let stats: IChatSessionStats | undefined; - if (session instanceof ChatModel) { - stats = await awaitStatsForSession(session); - } - - const lastMessageDate = session instanceof ChatModel ? - session.lastMessageDate : - session.requests.at(-1)?.timestamp ?? session.creationDate; - - const timing: IChatSessionTiming = session instanceof ChatModel ? - session.timing : - // session is only ISerializableChatData in the old pre-fs storage data migration scenario - { - created: session.creationDate, - lastRequestStarted: session.requests.at(-1)?.timestamp, - lastRequestEnded: lastMessageDate, - }; - - let lastResponseState = session instanceof ChatModel ? - (session.lastRequest?.response?.state ?? ResponseModelState.Complete) : - ResponseModelState.Complete; +/** + * Builds session metadata synchronously from a live ChatModel. + * Used both by {@link updateAndFlushIndexSync} (where async work is not + * possible) and by {@link getSessionMetadata} (which layers on async stats). + */ +function getSessionMetadataSync(session: ChatModel): IChatSessionEntryMetadata { + const title = session.customTitle || session.title; + let lastResponseState = session.lastRequest?.response?.state ?? ResponseModelState.Complete; if (lastResponseState === ResponseModelState.Pending || lastResponseState === ResponseModelState.NeedsInput) { lastResponseState = ResponseModelState.Cancelled; } - const isExternal = session instanceof ChatModel && !LocalChatSessionUri.parseLocalSessionId(session.sessionResource); - // Persist draft input state only for external sessions; local sessions already - // have their full state serialized via storeSessions, so duplicating here would - // be wasteful and risk drift between the two locations. - // Attachments are excluded because they can contain large binary payloads - // (e.g. base64-encoded images) that would bloat the session index entry. - const rawInputState = isExternal ? (session as ChatModel).inputModel.toJSON() : undefined; + const isExternal = !LocalChatSessionUri.parseLocalSessionId(session.sessionResource); + const rawInputState = isExternal ? session.inputModel.toJSON() : undefined; const inputState = rawInputState ? { ...rawInputState, attachments: [] } : undefined; return { sessionId: session.sessionId, title: title || localize('newChat', "New Chat"), - lastMessageDate, - timing, + lastMessageDate: session.lastMessageDate, + timing: session.timing, initialLocation: session.initialLocation, - hasPendingEdits: session instanceof ChatModel ? (session.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)) : false, - isEmpty: session instanceof ChatModel ? session.getRequests().length === 0 : session.requests.length === 0, - stats, + hasPendingEdits: session.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified) ?? false, + isEmpty: session.getRequests().length === 0, isExternal, lastResponseState, - permissionLevel: session instanceof ChatModel ? session.inputModel.state.get()?.permissionLevel : undefined, + permissionLevel: session.inputModel.state.get()?.permissionLevel, inputState, }; } +async function getSessionMetadata(session: ChatModel | ISerializableChatData): Promise { + if (session instanceof ChatModel) { + const metadata = getSessionMetadataSync(session); + metadata.stats = await awaitStatsForSession(session); + return metadata; + } + + // ISerializableChatData — only used in the old pre-fs storage data migration scenario + const lastMessageDate = session.requests.at(-1)?.timestamp ?? session.creationDate; + + return { + sessionId: session.sessionId, + title: session.customTitle || localize('newChat', "New Chat"), + lastMessageDate, + timing: { + created: session.creationDate, + lastRequestStarted: session.requests.at(-1)?.timestamp, + lastRequestEnded: lastMessageDate, + }, + initialLocation: session.initialLocation, + hasPendingEdits: false, + isEmpty: session.requests.length === 0, + isExternal: false, + lastResponseState: ResponseModelState.Complete, + }; +} + export interface IChatTransfer { toWorkspace: URI; sessionResource: URI; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index ce33bc69f5ff37..a147bdc7d40465 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -25,7 +25,7 @@ import { ServiceCollection } from '../../../../../../platform/instantiation/comm import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, WillSaveStateReason } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IUserDataProfilesService, toUserDataProfile } from '../../../../../../platform/userDataProfile/common/userDataProfile.js'; @@ -1741,6 +1741,34 @@ suite('ChatService', () => { assert.strictEqual(restored?.inputText, 'unsent draft', 'Input text should be restored'); }); }); + + test('onWillSaveState persists session index synchronously so it survives reload', async () => { + const testService = createChatService(); + const storageService = instantiationService.get(IStorageService) as TestStorageService; + + // Create a session with a request so it qualifies for persistence + const ref = testService.startNewLocalSession(ChatAgentLocation.Chat); + const model = ref.object as ChatModel; + model.addRequest({ parts: [], text: 'hello world' }, { variables: [] }, 0); + + // Simulate what the storage service does before shutdown: + // fire onWillSaveState synchronously, then flush. + storageService.testEmitWillSaveState(WillSaveStateReason.SHUTDOWN); + + // Create a second ChatService from the same storage (simulating + // window reload). The session must be discoverable in history + // IMMEDIATELY — no async work from the first service needs to + // have completed. + const testService2 = createChatService(); + const historyItems = await testService2.getHistorySessionItems(); + assert.ok( + historyItems.some(item => item.sessionResource.toString() === model.sessionResource.toString()), + `Session ${model.sessionResource} should appear in history after onWillSaveState. Got: ${historyItems.map(i => i.sessionResource.toString()).join(', ')}` + ); + + // Clean up + ref.dispose(); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 4ea3b3d8ee2204..89fba2a49e829a 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -66,6 +66,8 @@ export class MockChatModel extends Disposable implements IChatModel { getRequests(): IChatRequestModel[] { return []; } setCheckpoint(requestId: string | undefined): void { } setRepoData(data: IExportableRepoData | undefined): void { this.repoData = data; } + workingDirectory: URI | undefined = undefined; + setWorkingDirectory(uri: URI | undefined): void { this.workingDirectory = uri; } readonly onDidChangePendingRequests: Event = this._register(new Emitter()).event; getPendingRequests(): readonly IChatPendingRequest[] { return []; } toExport(): IExportableChatData { From d2425f71d43f0e3143507afc9785da9a11f37485 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Fri, 8 May 2026 10:10:04 +0200 Subject: [PATCH 03/41] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../browser/copilotChatSessionsProvider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 25996054bfc2cb..7aa8edf5691c29 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -697,7 +697,9 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession * New session for local (in-process VS Code chat) sessions. * Implements {@link ICopilotChatSession} (session facade) for local sessions * that run in-process without worktrees or remote agents. - * Keeps the underlying chat model reference alive via {@link attachKeepAlive}. + * Keeps the underlying chat model alive by retaining the + * {@link IChatModelReference} returned from `startNewLocalSession` for the + * lifetime of this object. */ class LocalNewSession extends Disposable implements ICopilotChatSession { From 8972c3a60a033f640e941f76f1063cc29b3c87b4 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 10:12:16 +0200 Subject: [PATCH 04/41] update unit test --- .../test/browser/copilotChatSessionsProvider.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index aa6ff606b176d1..03442780362524 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -378,7 +378,7 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); - test('getSessions ignores non-Background/Cloud/Claude sessions', () => { + test('getSessions includes Background and Local sessions', () => { const bgResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/bg-session' }); const localResource = URI.from({ scheme: AgentSessionProviders.Local, path: '/local-session' }); model.addSession(createMockAgentSession(bgResource)); @@ -387,7 +387,7 @@ suite('CopilotChatSessionsProvider', () => { const provider = createProvider(disposables, model); const sessions = provider.getSessions(); - assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions.length, 2); }); test('getSessions includes Claude agent sessions when enabled', () => { From 03949ed645e62e8a9f9d15ec3796bd94a4b2af8b Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 10:29:46 +0200 Subject: [PATCH 05/41] Allow running the local harness in the agents window (missing testing contribution there) --- .../copilot/src/platform/testing/vscode/testProviderImpl.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/platform/testing/vscode/testProviderImpl.ts b/extensions/copilot/src/platform/testing/vscode/testProviderImpl.ts index d2d5058c1df0c1..50626ac19ed6aa 100644 --- a/extensions/copilot/src/platform/testing/vscode/testProviderImpl.ts +++ b/extensions/copilot/src/platform/testing/vscode/testProviderImpl.ts @@ -107,7 +107,11 @@ export class TestProvider extends Disposable implements ITestProvider { /** @inheritdoc */ public async hasAnyTests(): Promise { - return !!(await vscode.commands.executeCommand('vscode.testing.getControllersWithTests')).length; + try { + return !!(await vscode.commands.executeCommand('vscode.testing.getControllersWithTests')).length; + } catch { + return false; + } } /** @inheritdoc */ From 0790cc2b6976f71c63eb143a8549e898f6bda26a Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 11:59:23 +0200 Subject: [PATCH 06/41] Include userSelectedTools when sending first local session chat The _sendFirstChatLocal method calls chatService.sendRequest directly, bypassing chatWidget.acceptInput() which normally provides userSelectedTools and instructionContext. Without userSelectedTools, the copilot extension receives an empty tools map, so only a handful of tools are available in agents window local sessions. Fix by destructuring userSelectedTools from the widget's mode request options and passing it along with the instructionContext. --- .../browser/copilotChatSessionsProvider.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 7aa8edf5691c29..78bd7aac60bb53 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1987,8 +1987,21 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget for local session'); } + // Obtain user-selected tools from the chat widget so the copilot + // extension sees the full tool set. Without this, the direct + // chatService.sendRequest bypasses chatWidget.acceptInput() which + // normally provides these. + const { userSelectedTools } = chatWidget.getModeRequestOptions(); + this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for local session ${session.id}`); - const result = await this.chatService.sendRequest(session.resource, query, sendOptions); + const result = await this.chatService.sendRequest(session.resource, query, { + ...sendOptions, + userSelectedTools, + instructionContext: { + modeKind: sendOptions.modeInfo?.kind ?? ChatModeKind.Agent, + enabledTools: userSelectedTools?.get(), + }, + }); if (result.kind === 'rejected') { throw new Error(`[CopilotChatSessionsProvider] Local sendRequest rejected: ${result.reason}`); } From 71700331e929c563cd51e318e9c054e830cf0a08 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 14:20:06 +0200 Subject: [PATCH 07/41] Persist workingDirectory across reload for local sessions Serialize workingDirectory in ISerializableChatData3 and restore it in the ChatModel constructor so it survives window reload. Also cache it in the session index metadata (IChatSessionEntryMetadata) for lightweight access in the sessions list without loading the full model. This fixes the changes view in the agents window being empty after reload for local sessions, since the view needs workingDirectoryPath in the session metadata to resolve the git repository for diffs. --- .../sessions/contrib/changes/browser/changesViewModel.ts | 3 ++- .../browser/copilotChatSessionsProvider.ts | 8 ++++++++ .../contrib/chat/common/chatService/chatServiceImpl.ts | 8 ++++++-- src/vs/workbench/contrib/chat/common/model/chatModel.ts | 5 +++++ .../contrib/chat/common/model/chatSessionOperationLog.ts | 1 + .../contrib/chat/common/model/chatSessionStore.ts | 7 +++++++ 6 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index 6677d3d556a551..4695e505458aa1 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -272,8 +272,9 @@ export class ChangesViewModel extends Disposable { const metadata = this._activeSessionMetadataObs.read(reader); const repositoryPath = metadata?.repositoryPath as string | undefined; const worktreePath = metadata?.worktreePath as string | undefined; + const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; - return worktreePath ?? repositoryPath; + return worktreePath ?? repositoryPath ?? workingDirectoryPath; }); // Uncommitted changes diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 78bd7aac60bb53..f05fd95b2d8eef 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -816,18 +816,26 @@ class LocalNewSession extends Disposable implements ICopilotChatSession { const state = repo.state.read(reader); const head = state.HEAD; const branchName = head?.commit ? head.name : undefined; + const upstreamBranchName = head?.upstream + ? `${head.upstream.remote}/${head.upstream.name}` + : undefined; + const uncommittedChanges = state.workingTreeChanges.length + state.untrackedChanges.length + state.indexChanges.length; this._workspaceData.set({ ...this.sessionWorkspace, repositories: [{ ...this.sessionWorkspace.repositories[0], branchName, + upstreamBranchName, + uncommittedChanges, }], }, undefined); this._changes.set(state.workingTreeChanges.concat(state.untrackedChanges).map(el => { return { uri: el.uri, + originalUri: el.originalUri, + modifiedUri: el.modifiedUri ?? el.uri, insertions: 0, deletions: 0, }; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index f8ea2d29b3aceb..be219be1e39c18 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -410,10 +410,12 @@ export class ChatService extends Disposable implements IChatService { .filter(entry => !this._sessionModels.has(LocalChatSessionUri.forSession(entry.sessionId)) && entry.initialLocation === ChatAgentLocation.Chat && !entry.isEmpty) .map((entry): IChatDetail => { const sessionResource = LocalChatSessionUri.forSession(entry.sessionId); + const { workingDirectory: workingDirectoryStr, ...rest } = entry; return ({ - ...entry, + ...rest, sessionResource, isActive: this._sessionModels.has(sessionResource), + workingDirectory: workingDirectoryStr ? URI.parse(workingDirectoryStr) : undefined, }); }); } @@ -422,10 +424,12 @@ export class ChatService extends Disposable implements IChatService { const index = await this._chatSessionStore.getIndex(); const metadata: IChatSessionEntryMetadata | undefined = index[sessionResource.toString()]; if (metadata) { + const { workingDirectory: workingDirectoryStr, ...rest } = metadata; return { - ...metadata, + ...rest, sessionResource, isActive: this._sessionModels.has(sessionResource), + workingDirectory: workingDirectoryStr ? URI.parse(workingDirectoryStr) : undefined, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 46beee683eba7c..cb4d5539ca3bd3 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1775,6 +1775,8 @@ export interface ISerializableChatData3 extends Omit({ hasPendingEdits: Adapt.v(m => m.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)), repoData: Adapt.v(m => m.repoData, objectsEqual), pendingRequests: Adapt.t(m => m.getPendingRequests(), Adapt.array(pendingRequestSchema)), + workingDirectory: Adapt.v(m => m.workingDirectory?.toString()), }); export class ChatSessionOperationLog extends Adapt.ObjectMutationLog implements IChatDataSerializerLog { diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 69ed5452813e87..b00f28ebfc3931 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -765,6 +765,12 @@ export interface IChatSessionEntryMetadata { stats?: IChatSessionStats; lastResponseState: ResponseModelState; + /** + * The working directory URI string associated with this session. + * Persisted so it survives window reload in the agents/sessions window. + */ + workingDirectory?: string; + /** * This only exists because the migrated data from the storage service had empty sessions persisted, and it's impossible to know which ones are * currently in use. Now, `clearSession` deletes empty sessions, so old ones shouldn't take up space in the store anymore, but we still need to @@ -861,6 +867,7 @@ function getSessionMetadataSync(session: ChatModel): IChatSessionEntryMetadata { lastResponseState, permissionLevel: session.inputModel.state.get()?.permissionLevel, inputState, + workingDirectory: session.workingDirectory?.toString(), }; } From 5618f96fb1402ddb28b016f4ae8ae528789a5421 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 8 May 2026 12:57:21 +0200 Subject: [PATCH 08/41] Move details to hover (#315215) --- .../contrib/chat/browser/chat.contribution.ts | 2 +- .../chatTerminalToolConfirmationSubPart.ts | 83 +++++- .../media/toolRiskBadge.css | 17 ++ .../toolRiskBadgeWidget.ts | 65 ++++- .../chat/chatToolRiskBadge.fixture.ts | 244 +++++++++++------- 5 files changed, 303 insertions(+), 108 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index c6b1f1dc1e4bab..a3937e26c543dc 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1023,7 +1023,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.ToolRiskAssessmentEnabled]: { type: 'boolean', description: nls.localize('chat.tools.riskAssessment.enabled', "When enabled, terminal tool confirmations show an LLM-generated risk level (Safe / Caution / Review carefully) and a short explanation."), - default: false, + default: true, tags: ['experimental'], experiment: { mode: 'auto' diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index b92245fc54d906..a92b6eead6f7fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -11,7 +11,7 @@ import { asArray } from '../../../../../../../base/common/arrays.js'; import { CancellationTokenSource } from '../../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { ErrorNoTelemetry } from '../../../../../../../base/common/errors.js'; -import { createCommandUri, MarkdownString, type IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { createCommandUri, escapeMarkdownSyntaxTokens, MarkdownString, type IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; import Severity from '../../../../../../../base/common/severity.js'; import { isObject } from '../../../../../../../base/common/types.js'; @@ -191,6 +191,8 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS position: { hoverPosition: HoverPosition.LEFT }, })); + const riskBadge = this._createRiskBadge(state.parameters); + const confirmWidget = this._register(this.instantiationService.createInstance( ChatCustomConfirmationWidget, this.context, @@ -198,22 +200,83 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS title, icon: Codicon.terminal, message: elements.root, - footerBanner: this._createRiskBadge(state.parameters), + footerBanner: riskBadge?.domNode, buttons: this._createButtons(moreActions) }, )); + // Build the unsandboxed-execution reason and disclaimer markdown. When + // the risk badge is shown, surface them via its details hover (with + // labelled prefixes) instead of the dedicated disclaimer row to keep + // the confirmation compact. + interface IDetailPart { + readonly inline: IMarkdownString; + readonly hoverLabel: string; + readonly hoverBody: string; + readonly isTrusted: IMarkdownString['isTrusted']; + } + const detailParts: IDetailPart[] = []; if (terminalData.requestUnsandboxedExecution) { const reasonText = (terminalData.requestUnsandboxedExecutionReason && terminalData.requestUnsandboxedExecutionReason.trim()) || localize('chat.terminal.unsandboxedExecution.defaultReason', "The model did not provide a reason for requesting unsandboxed execution."); - const unsandboxedReasonMarkdown = new MarkdownString(undefined, { supportThemeIcons: true }); - unsandboxedReasonMarkdown.appendMarkdown(`$(${Codicon.info.id}) `); - unsandboxedReasonMarkdown.appendText(reasonText); - this._appendMarkdownPart(elements.disclaimer, unsandboxedReasonMarkdown, codeBlockRenderOptions); + const inline = new MarkdownString(undefined, { supportThemeIcons: true }); + inline.appendMarkdown(`$(${Codicon.info.id}) `); + inline.appendText(reasonText); + detailParts.push({ + inline, + hoverLabel: localize('chat.terminal.detail.sandboxInsufficient', "Sandbox insufficient:"), + hoverBody: escapeMarkdownSyntaxTokens(reasonText), + isTrusted: undefined, + }); } - if (disclaimer) { - this._appendMarkdownPart(elements.disclaimer, disclaimer, codeBlockRenderOptions); + const inline = typeof disclaimer === 'string' ? new MarkdownString(disclaimer) : disclaimer; + // For the hover, drop the leading `$(info) ` icon prefix that the + // disclaimer carries for inline rendering — the labelled prefix + // already conveys the same role. + const hoverBody = inline.value.replace(/^\s*\$\([^)]+\)\s*/, ''); + detailParts.push({ + inline, + hoverLabel: localize('chat.terminal.detail.approvalNeeded', "Approval needed:"), + hoverBody, + isTrusted: inline.isTrusted, + }); + } + + const renderInlineDisclaimers = () => { + elements.disclaimer.replaceChildren(); + for (const part of detailParts) { + this._appendMarkdownPart(elements.disclaimer, part.inline, codeBlockRenderOptions); + } + }; + + if (riskBadge && detailParts.length) { + const combined = new MarkdownString(undefined, { + supportThemeIcons: true, + isTrusted: detailParts.reduce((acc, part) => { + if (part.isTrusted === true || acc === true) { + return true; + } + if (typeof part.isTrusted === 'object' && part.isTrusted) { + const enabled = new Set([ + ...(typeof acc === 'object' && acc?.enabledCommands ? acc.enabledCommands : []), + ...part.isTrusted.enabledCommands, + ]); + return { enabledCommands: [...enabled] }; + } + return acc; + }, undefined), + }); + detailParts.forEach((part, i) => { + if (i > 0) { + combined.appendMarkdown('\n\n'); + } + combined.appendMarkdown(`**${escapeMarkdownSyntaxTokens(part.hoverLabel)}** ${part.hoverBody}`); + }); + riskBadge.setDetails(combined); + this._register(riskBadge.onDidHide(() => renderInlineDisclaimers())); + } else { + renderInlineDisclaimers(); } const hasToolConfirmationKey = ChatContextKeys.Editing.hasToolConfirmation.bindTo(this.contextKeyService); @@ -431,7 +494,7 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS return promptResult.result === true; } - private _createRiskBadge(parameters: unknown): HTMLElement | undefined { + private _createRiskBadge(parameters: unknown): ToolRiskBadgeWidget | undefined { if (!this.riskAssessmentService.isEnabled()) { return undefined; } @@ -462,7 +525,7 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS } })(); } - return widget.domNode; + return widget; } private _appendMarkdownPart(container: HTMLElement, message: string | IMarkdownString, codeBlockRenderOptions: ICodeBlockRenderOptions) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/media/toolRiskBadge.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/media/toolRiskBadge.css index b037575e81e382..f621d7905f6472 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/media/toolRiskBadge.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/media/toolRiskBadge.css @@ -43,6 +43,23 @@ white-space: nowrap; } +.chat-confirmation-widget2 > .tool-risk-badge .tool-risk-details-icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + line-height: 1; + color: var(--vscode-descriptionForeground); + cursor: pointer; + border-radius: 3px; +} + +.chat-confirmation-widget2 > .tool-risk-badge .tool-risk-details-icon:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + .chat-confirmation-widget2 > .tool-risk-badge.loading { opacity: 0.7; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/toolRiskBadgeWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/toolRiskBadgeWidget.ts index 1be38988c81db2..6175d1e291c3b8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/toolRiskBadgeWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/toolRiskBadgeWidget.ts @@ -5,6 +5,8 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { localize } from '../../../../../../../nls.js'; @@ -22,7 +24,13 @@ export class ToolRiskBadgeWidget extends Disposable { private readonly _iconEl: HTMLElement; private readonly _textEl: HTMLElement; + private readonly _detailsIconEl: HTMLElement; private readonly _hoverStore = this._register(new DisposableStore()); + private readonly _detailsHoverStore = this._register(new DisposableStore()); + private _details: IMarkdownString | string | undefined; + + private readonly _onDidHide = this._register(new Emitter()); + public readonly onDidHide: Event = this._onDidHide.event; constructor( @IHoverService private readonly _hoverService: IHoverService, @@ -32,7 +40,13 @@ export class ToolRiskBadgeWidget extends Disposable { this.domNode = dom.$(`span.${RISK_BADGE_CLASS}`); this._iconEl = dom.$('span.tool-risk-icon'); this._textEl = dom.$('span.tool-risk-text'); - this.domNode.append(this._iconEl, this._textEl); + this._detailsIconEl = dom.$('span.tool-risk-details-icon'); + this._detailsIconEl.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); + this._detailsIconEl.tabIndex = 0; + this._detailsIconEl.setAttribute('role', 'button'); + this._detailsIconEl.setAttribute('aria-label', localize('toolRisk.detailsIconLabel', "Risk assessment details")); + this.domNode.append(this._iconEl, this._textEl, this._detailsIconEl); + this._refreshDetailsHover(); this.setLoading(); } @@ -46,6 +60,7 @@ export class ToolRiskBadgeWidget extends Disposable { setHidden(): void { this.domNode.style.display = 'none'; + this._onDidHide.fire(); } setAssessment(assessment: IToolRiskAssessment): void { @@ -68,6 +83,24 @@ export class ToolRiskBadgeWidget extends Disposable { this._setHover(assessment.explanation); } + /** + * Provide additional context to surface in the trailing info icon's hover. + * The hover always notes that the assessment is AI-generated; any details + * passed here are appended below that note. + */ + setDetails(details: IMarkdownString | string | undefined): void { + this._details = details; + this._refreshDetailsHover(); + } + + /** + * The markdown content currently shown in the trailing info icon's hover. + * Exposed so component fixtures can render a preview of the hover content. + */ + getDetailsMarkdown(): IMarkdownString { + return this._buildDetailsMarkdown(); + } + private _setVariant(variant: 'loading' | 'green' | 'orange' | 'red'): void { this.domNode.classList.remove('green', 'orange', 'red', 'loading'); this.domNode.classList.add(variant); @@ -87,4 +120,34 @@ export class ToolRiskBadgeWidget extends Disposable { this._hoverStore.clear(); this._hoverStore.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.domNode, content)); } + + private _refreshDetailsHover(): void { + this._detailsHoverStore.clear(); + const md = this._buildDetailsMarkdown(); + const fallback = md.value.replace(/\$\([^)]+\)\s?/g, ''); + this._detailsHoverStore.add(this._hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + this._detailsIconEl, + { markdown: md, markdownNotSupportedFallback: fallback }, + )); + } + + private _buildDetailsMarkdown(): IMarkdownString { + const aiNote = localize('toolRisk.aiGenerated', "Risk assessments are AI-generated and may be inaccurate."); + const details = this._details; + const md = new MarkdownString(undefined, { + supportThemeIcons: true, + isTrusted: typeof details === 'object' && details ? details.isTrusted : undefined, + }); + md.appendText(aiNote); + if (details) { + md.appendMarkdown('\n\n'); + if (typeof details === 'string') { + md.appendText(details); + } else { + md.appendMarkdown(details.value); + } + } + return md; + } } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatToolRiskBadge.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatToolRiskBadge.fixture.ts index 0032bff139b048..cb6421678065f9 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatToolRiskBadge.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatToolRiskBadge.fixture.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { renderMarkdown } from '../../../../../base/browser/markdownRenderer.js'; +import { MarkdownString, type IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { ToolRiskBadgeWidget } from '../../../../contrib/chat/browser/widget/chatContentParts/toolInvocationParts/toolRiskBadgeWidget.js'; import { IToolRiskAssessment, ToolRiskLevel } from '../../../../contrib/chat/browser/tools/chatToolRiskAssessmentService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import { IFixtureMessage, renderChatWidget } from './chatWidget.fixture.js'; +import '../../../../../base/browser/ui/hover/hoverWidget.css'; import '../../../../contrib/chat/browser/widget/media/chat.css'; type RenderState = @@ -42,28 +45,112 @@ function renderBadge(context: ComponentFixtureContext, state: RenderState): void container.appendChild(itemContainer); } -function makeTerminalMessage(assessment?: IToolRiskAssessment): IFixtureMessage[] { +/** + * Renders the badge in a production-like wrapper, then renders the markdown + * that the trailing info icon's hover would display in a `.monaco-hover`-styled + * panel beside it. The fixture infrastructure stubs `IHoverService` so real + * hover popups don't render; this preview gives a stable visual review of the + * hover content for screenshot testing. + */ +function renderBadgeWithHoverPreview( + context: ComponentFixtureContext, + assessment: IToolRiskAssessment, + details: IMarkdownString | undefined, +): void { + const { container, disposableStore } = context; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + }); + + const widget = disposableStore.add(instantiationService.createInstance(ToolRiskBadgeWidget)); + widget.setAssessment(assessment); + if (details) { + widget.setDetails(details); + } + + container.style.padding = '8px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '8px'; + container.style.width = '480px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; + container.classList.add('interactive-session'); + + const itemContainer = dom.$('.interactive-item-container'); + const widgetContainer = dom.$('.chat-confirmation-widget2'); + widgetContainer.appendChild(widget.domNode); + itemContainer.appendChild(widgetContainer); + container.appendChild(itemContainer); + + const previewLabel = dom.$('div'); + previewLabel.textContent = 'Trailing info icon hover preview:'; + previewLabel.style.fontSize = '11px'; + previewLabel.style.color = 'var(--vscode-descriptionForeground)'; + container.appendChild(previewLabel); + + const hoverDom = dom.$('div.monaco-hover'); + hoverDom.style.position = 'static'; + hoverDom.style.display = 'inline-block'; + hoverDom.style.maxWidth = '420px'; + hoverDom.style.background = 'var(--vscode-editorHoverWidget-background)'; + hoverDom.style.color = 'var(--vscode-editorHoverWidget-foreground)'; + hoverDom.style.border = '1px solid var(--vscode-editorHoverWidget-border)'; + hoverDom.style.borderRadius = '3px'; + + const hoverContents = dom.$('div.markdown-hover'); + const hoverContentsInner = dom.$('div.hover-contents'); + const rendered = disposableStore.add(renderMarkdown(widget.getDetailsMarkdown(), { asyncRenderCallback: () => { } })); + hoverContentsInner.appendChild(rendered.element); + hoverContents.appendChild(hoverContentsInner); + hoverDom.appendChild(hoverContents); + container.appendChild(hoverDom); +} + +const promptInjectionDisclaimer = (() => { + const md = new MarkdownString(undefined, { supportThemeIcons: true }); + md.appendMarkdown('**Approval needed:** '); + md.appendMarkdown('Web content may contain malicious code or attempt prompt injection attacks. Auto approval denied by rule [`curl (default)`](https://example.com/settings "View rule in settings").'); + return md; +})(); + +const unsandboxedReason = (() => { + const md = new MarkdownString(undefined, { supportThemeIcons: true }); + md.appendMarkdown('**Sandbox insufficient:** '); + md.appendText('Requires elevated permissions to install system packages.'); + return md; +})(); + +const combinedDetails = (() => { + const md = new MarkdownString(undefined, { supportThemeIcons: true }); + md.appendMarkdown(unsandboxedReason.value); + md.appendMarkdown('\n\n'); + md.appendMarkdown(promptInjectionDisclaimer.value); + return md; +})(); + +function makeTerminalMessage(scenario: IRiskScenario | undefined): IFixtureMessage[] { return [{ user: '', assistant: [{ kind: 'terminalConfirmation', - command: 'git init', - riskAssessment: assessment, - riskLoading: !assessment, + command: scenario?.command ?? 'git status', + riskAssessment: scenario?.assessment, + riskLoading: !scenario, }], responseComplete: false, }]; } -function makeElicitationMessage(assessment?: IToolRiskAssessment): IFixtureMessage[] { +function makeElicitationMessage(scenario: IRiskScenario | undefined): IFixtureMessage[] { return [{ user: '', assistant: [{ kind: 'elicitation', title: 'Run in Terminal', - message: 'git push --force origin main', - riskAssessment: assessment, - riskLoading: !assessment, + message: scenario?.command ?? 'git status', + riskAssessment: scenario?.assessment, + riskLoading: !scenario, }], responseComplete: false, }]; @@ -71,34 +158,33 @@ function makeElicitationMessage(assessment?: IToolRiskAssessment): IFixtureMessa const inContextOptions = { width: 720, height: 400 }; -const greenAssessment: IToolRiskAssessment = { - risk: ToolRiskLevel.Green, - explanation: 'Initializes an empty Git repository in the current directory. No existing files are affected.', -}; - -const orangeAssessment: IToolRiskAssessment = { - risk: ToolRiskLevel.Orange, - explanation: 'Initializes a Git repository. If one already exists, this resets the configuration. Reversible.', -}; - -const redAssessment: IToolRiskAssessment = { - risk: ToolRiskLevel.Red, - explanation: 'Force-pushes to a remote branch. This rewrites history and cannot be undone.', -}; +interface IRiskScenario { + readonly command: string; + readonly assessment: IToolRiskAssessment; +} -const greenElicitationAssessment: IToolRiskAssessment = { - risk: ToolRiskLevel.Green, - explanation: 'Pushes local commits to the remote branch. No history is rewritten.', +const greenScenario: IRiskScenario = { + command: 'grep -r "TODO" src', + assessment: { + risk: ToolRiskLevel.Green, + explanation: 'Reads workspace files. No changes are made.', + }, }; -const orangeElicitationAssessment: IToolRiskAssessment = { - risk: ToolRiskLevel.Orange, - explanation: 'Force-pushes to a remote branch. Other contributors may lose commits if they have pushed since your last pull.', +const orangeScenario: IRiskScenario = { + command: 'npm install lodash', + assessment: { + risk: ToolRiskLevel.Orange, + explanation: 'Modifies tracked files in the working tree. Reversible via Git.', + }, }; -const redElicitationAssessment: IToolRiskAssessment = { - risk: ToolRiskLevel.Red, - explanation: 'Force-pushes to a remote branch. This rewrites history and cannot be undone.', +const redScenario: IRiskScenario = { + command: 'git push --force origin main', + assessment: { + risk: ToolRiskLevel.Red, + explanation: 'Force-pushes to a remote branch. This rewrites history and cannot be undone.', + }, }; export default defineThemedFixtureGroup({ path: 'chat/' }, { @@ -109,111 +195,57 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { Green: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderBadge(ctx, { kind: 'assessment', assessment: greenAssessment }), + render: (ctx) => renderBadge(ctx, { kind: 'assessment', assessment: greenScenario.assessment }), }), Orange: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderBadge(ctx, { kind: 'assessment', assessment: orangeAssessment }), + render: (ctx) => renderBadge(ctx, { kind: 'assessment', assessment: orangeScenario.assessment }), }), Red: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderBadge(ctx, { kind: 'assessment', assessment: redAssessment }), + render: (ctx) => renderBadge(ctx, { kind: 'assessment', assessment: redScenario.assessment }), }), GreenInContext: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(greenAssessment), ...inContextOptions }), + render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(greenScenario), ...inContextOptions }), }), OrangeInContext: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(orangeAssessment), ...inContextOptions }), + render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(orangeScenario), ...inContextOptions }), }), RedInContext: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(redAssessment), ...inContextOptions }), + render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(redScenario), ...inContextOptions }), }), LoadingInContext: defineComponentFixture({ labels: { kind: 'animated' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(), ...inContextOptions }), - }), - - RedWithDisclaimerInContext: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { - messages: [{ - user: '', - assistant: [{ - kind: 'terminalConfirmation', - command: 'git push --force origin main', - disclaimer: '$(info) Web content may contain malicious code or attempt prompt injection attacks. Auto approval denied by rule curl (default)', - riskAssessment: redAssessment, - }], - responseComplete: false, - }], - ...inContextOptions, - }), - }), - - RedUnsandboxedInContext: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { - messages: [{ - user: '', - assistant: [{ - kind: 'terminalConfirmation', - command: 'sudo rm -rf /tmp/build', - requestUnsandboxedExecution: true, - requestUnsandboxedExecutionReason: 'Requires elevated permissions to delete files owned by root.', - riskAssessment: redAssessment, - }], - responseComplete: false, - }], - ...inContextOptions, - }), - }), - - RedUnsandboxedWithDisclaimerInContext: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { - messages: [{ - user: '', - assistant: [{ - kind: 'terminalConfirmation', - command: 'sudo curl https://example.com/install.sh | bash', - requestUnsandboxedExecution: true, - requestUnsandboxedExecutionReason: 'Requires elevated permissions to install system packages.', - disclaimer: '$(info) Web content may contain malicious code or attempt prompt injection attacks. Auto approval denied by rule curl (default)', - riskAssessment: redAssessment, - }], - responseComplete: false, - }], - ...inContextOptions, - }), + render: (ctx) => renderChatWidget(ctx, { messages: makeTerminalMessage(undefined), ...inContextOptions }), }), GreenElicitationInContext: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(greenElicitationAssessment), ...inContextOptions }), + render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(greenScenario), ...inContextOptions }), }), OrangeElicitationInContext: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(orangeElicitationAssessment), ...inContextOptions }), + render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(orangeScenario), ...inContextOptions }), }), RedElicitationInContext: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(redElicitationAssessment), ...inContextOptions }), + render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(redScenario), ...inContextOptions }), }), LoadingElicitationInContext: defineComponentFixture({ labels: { kind: 'animated' }, - render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(), ...inContextOptions }), + render: (ctx) => renderChatWidget(ctx, { messages: makeElicitationMessage(undefined), ...inContextOptions }), }), BadgeOffInContext: defineComponentFixture({ @@ -285,4 +317,24 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { ...inContextOptions, }), }), + + HoverPreviewNoDetails: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderBadgeWithHoverPreview(ctx, redScenario.assessment, undefined), + }), + + HoverPreviewDisclaimer: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderBadgeWithHoverPreview(ctx, redScenario.assessment, promptInjectionDisclaimer), + }), + + HoverPreviewUnsandboxed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderBadgeWithHoverPreview(ctx, redScenario.assessment, unsandboxedReason), + }), + + HoverPreviewUnsandboxedWithDisclaimer: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderBadgeWithHoverPreview(ctx, redScenario.assessment, combinedDetails), + }), }); From 25350af10ff481ac165de1c826fc52877a5452cf Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 8 May 2026 15:54:45 +0200 Subject: [PATCH 09/41] remove gradient background as it doesn't work on windows/linux --- src/vs/sessions/browser/workbench.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 37cc2a7e7a2e3b..8c1d9202b8ed79 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -641,7 +641,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const workbenchClasses = coalesce([ 'monaco-workbench', 'agent-sessions-workbench', - LayoutClasses.SHELL_GRADIENT_BACKGROUND, + // LayoutClasses.SHELL_GRADIENT_BACKGROUND, platformClass, isWeb ? 'web' : undefined, isChrome ? 'chromium' : isFirefox ? 'firefox' : isSafari ? 'safari' : undefined, From e6f00551a88f723c9a7f84d2f77fcc21516821d4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 8 May 2026 15:56:26 +0200 Subject: [PATCH 10/41] Enable window commands in Agents window (#315258) - Remove IsSessionsWindowContext precondition and menu guards from New Window, Open Folder, Open..., Open Workspace from File, Open Recent, and New Window with Profile commands - In windowsMainService, prevent the Agents window from being reused when opening folders/workspaces by checking isSessionsWindow in doOpenFolderOrWorkspace, doOpenEmpty, and openInBrowserWindow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows/electron-main/windowsMainService.ts | 16 +++++++++++++--- .../workbench/browser/actions/windowActions.ts | 6 +----- .../browser/actions/workspaceActions.ts | 16 ++++++++-------- .../userDataProfile/browser/userDataProfile.ts | 1 - 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 8f56afc29ec233..b8e452ad4def25 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -770,7 +770,12 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic let windowToUse: ICodeWindow | undefined; if (!forceNewWindow && typeof openConfig.contextWindowId === 'number') { - windowToUse = this.getWindowById(openConfig.contextWindowId); // fix for https://github.com/microsoft/vscode/issues/97172 + const contextWindow = this.getWindowById(openConfig.contextWindowId); // fix for https://github.com/microsoft/vscode/issues/97172 + if (contextWindow?.config?.isSessionsWindow) { + forceNewWindow = true; // do not replace the agents window + } else { + windowToUse = contextWindow; + } } return this.openInBrowserWindow({ @@ -792,7 +797,12 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.logService.trace('windowsManager#doOpenFolderOrWorkspace', { folderOrWorkspace, filesToOpen }); if (!forceNewWindow && !windowToUse && typeof openConfig.contextWindowId === 'number') { - windowToUse = this.getWindowById(openConfig.contextWindowId); // fix for https://github.com/microsoft/vscode/issues/49587 + const contextWindow = this.getWindowById(openConfig.contextWindowId); // fix for https://github.com/microsoft/vscode/issues/49587 + if (contextWindow?.config?.isSessionsWindow) { + forceNewWindow = true; // do not replace the agents window + } else { + windowToUse = contextWindow; + } } return this.openInBrowserWindow({ @@ -1506,7 +1516,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic let window: ICodeWindow | undefined; if (!options.forceNewWindow && !options.forceNewTabbedWindow) { - window = options.windowToUse || lastActiveWindow; + window = options.windowToUse || (lastActiveWindow?.config?.isSessionsWindow ? undefined : lastActiveWindow); if (window) { window.focus(); } diff --git a/src/vs/workbench/browser/actions/windowActions.ts b/src/vs/workbench/browser/actions/windowActions.ts index 26002cd9e986b9..9db25c4aec8a46 100644 --- a/src/vs/workbench/browser/actions/windowActions.ts +++ b/src/vs/workbench/browser/actions/windowActions.ts @@ -8,7 +8,7 @@ import { IWindowOpenable } from '../../../platform/window/common/window.js'; import { IDialogService } from '../../../platform/dialogs/common/dialogs.js'; import { MenuRegistry, MenuId, Action2, registerAction2 } from '../../../platform/actions/common/actions.js'; import { KeyChord, KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; -import { IsMainWindowFullscreenContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; +import { IsMainWindowFullscreenContext } from '../../common/contextkeys.js'; import { IsMacNativeContext, IsDevelopmentContext, IsWebContext, IsIOSContext } from '../../../platform/contextkey/common/contextkeys.js'; import { Categories } from '../../../platform/action/common/actionCommonCategories.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../platform/keybinding/common/keybindingsRegistry.js'; @@ -290,7 +290,6 @@ export class OpenRecentAction extends BaseOpenRecentAction { }, category: Categories.File, f1: true, - precondition: IsSessionsWindowContext.negate(), keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyR, @@ -422,7 +421,6 @@ class NewWindowAction extends Action2 { mnemonicTitle: localize({ key: 'miNewWindow', comment: ['&& denotes a mnemonic'] }, "New &&Window"), }, f1: true, - precondition: IsSessionsWindowContext.negate(), keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: isWeb ? (isWindows ? KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.Shift | KeyCode.KeyN) : KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.KeyN) : KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyN, @@ -432,7 +430,6 @@ class NewWindowAction extends Action2 { id: MenuId.MenubarFileMenu, group: '1_new', order: 3, - when: IsSessionsWindowContext.negate() } }); } @@ -520,5 +517,4 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { submenu: MenuId.MenubarRecentMenu, group: '2_open', order: 4, - when: IsSessionsWindowContext.negate() }); diff --git a/src/vs/workbench/browser/actions/workspaceActions.ts b/src/vs/workbench/browser/actions/workspaceActions.ts index f8342a7530b014..795ac6e007f048 100644 --- a/src/vs/workbench/browser/actions/workspaceActions.ts +++ b/src/vs/workbench/browser/actions/workspaceActions.ts @@ -61,7 +61,7 @@ export class OpenFolderAction extends Action2 { title: localize2('openFolder', 'Open Folder...'), category: Categories.File, f1: true, - precondition: ContextKeyExpr.and(OpenFolderWorkspaceSupportContext, IsSessionsWindowContext.negate()), + precondition: OpenFolderWorkspaceSupportContext, keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: undefined, @@ -97,7 +97,7 @@ export class OpenFolderViaWorkspaceAction extends Action2 { title: localize2('openFolder', 'Open Folder...'), category: Categories.File, f1: true, - precondition: ContextKeyExpr.and(OpenFolderWorkspaceSupportContext.toNegated(), WorkbenchStateContext.isEqualTo('workspace'), IsSessionsWindowContext.negate()), + precondition: ContextKeyExpr.and(OpenFolderWorkspaceSupportContext.toNegated(), WorkbenchStateContext.isEqualTo('workspace')), keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyO @@ -123,7 +123,7 @@ export class OpenFileFolderAction extends Action2 { title: OpenFileFolderAction.LABEL, category: Categories.File, f1: true, - precondition: ContextKeyExpr.and(IsMacNativeContext, OpenFolderWorkspaceSupportContext, IsSessionsWindowContext.negate()), + precondition: ContextKeyExpr.and(IsMacNativeContext, OpenFolderWorkspaceSupportContext), keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyO @@ -148,7 +148,7 @@ class OpenWorkspaceAction extends Action2 { title: localize2('openWorkspaceAction', 'Open Workspace from File...'), category: Categories.File, f1: true, - precondition: ContextKeyExpr.and(EnterMultiRootWorkspaceSupportContext, IsSessionsWindowContext.negate()) + precondition: EnterMultiRootWorkspaceSupportContext }); } @@ -353,7 +353,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { title: localize({ key: 'miOpenFolder', comment: ['&& denotes a mnemonic'] }, "Open &&Folder...") }, order: 2, - when: ContextKeyExpr.and(OpenFolderWorkspaceSupportContext, IsSessionsWindowContext.negate()) + when: OpenFolderWorkspaceSupportContext }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -363,7 +363,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { title: localize({ key: 'miOpenFolder', comment: ['&& denotes a mnemonic'] }, "Open &&Folder...") }, order: 2, - when: ContextKeyExpr.and(OpenFolderWorkspaceSupportContext.toNegated(), WorkbenchStateContext.isEqualTo('workspace'), IsSessionsWindowContext.negate()) + when: ContextKeyExpr.and(OpenFolderWorkspaceSupportContext.toNegated(), WorkbenchStateContext.isEqualTo('workspace')) }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -373,7 +373,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { title: localize({ key: 'miOpen', comment: ['&& denotes a mnemonic'] }, "&&Open...") }, order: 1, - when: ContextKeyExpr.and(IsMacNativeContext, OpenFolderWorkspaceSupportContext, IsSessionsWindowContext.negate()) + when: ContextKeyExpr.and(IsMacNativeContext, OpenFolderWorkspaceSupportContext) }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { @@ -383,7 +383,7 @@ MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { title: localize({ key: 'miOpenWorkspace', comment: ['&& denotes a mnemonic'] }, "Open Wor&&kspace from File...") }, order: 3, - when: ContextKeyExpr.and(EnterMultiRootWorkspaceSupportContext, IsSessionsWindowContext.negate()) + when: EnterMultiRootWorkspaceSupportContext }); MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { diff --git a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts index 38fe14ed54c721..4f071ed670b23c 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts +++ b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts @@ -203,7 +203,6 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements submenu: OpenProfileMenu, group: '1_new', order: 4, - when: IsSessionsWindowContext.negate() }); } From 56fe2c308c3bfac8b27bdb3b3922c7c40b837a93 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 8 May 2026 15:40:44 +0200 Subject: [PATCH 11/41] Makes random deterministic in fixtures --- src/vs/base/test/common/randomOverwrite.ts | 36 +++++++++++++++++++ .../browser/componentFixtures/fixtureUtils.ts | 4 +++ 2 files changed, 40 insertions(+) create mode 100644 src/vs/base/test/common/randomOverwrite.ts diff --git a/src/vs/base/test/common/randomOverwrite.ts b/src/vs/base/test/common/randomOverwrite.ts new file mode 100644 index 00000000000000..b176012894bb9c --- /dev/null +++ b/src/vs/base/test/common/randomOverwrite.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 { IDisposable } from '../../common/lifecycle.js'; + +/** + * Replace `Math.random` with a seeded pseudo-random number generator + * (mulberry32). Returns a disposable that restores the previous `Math.random`. + * + * Follows the same push/restore pattern as {@link pushGlobalTimeApi}. + */ +export function pushRandomOverwrite(seed: number): IDisposable { + const previous = Math.random; + Math.random = mulberry32(seed); + return { + dispose: () => { + Math.random = previous; + }, + }; +} + +/** + * Mulberry32 — a fast, high-quality 32-bit seeded PRNG that produces values in [0, 1). + * TODO@hediet: Use random.ts + */ +function mulberry32(seed: number): () => number { + let s = seed | 0; + return () => { + s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 0x100000000; + }; +} diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 82143442b9e749..731912b365887d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -112,6 +112,7 @@ import './fixtures.css'; // Import color registrations to ensure colors are available import { IdleDeadline, installFakeRunWhenIdle } from '../../../../base/common/async.js'; import { buildHistoryFromTasks, renderSwimlanes } from '../../../../base/test/common/executionGraph.js'; +import { pushRandomOverwrite } from '../../../../base/test/common/randomOverwrite.js'; import { captureGlobalTimeApi, createLoggingTimeApi, @@ -866,6 +867,9 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed render: async (container: HTMLElement, context) => { const disposableStore = new DisposableStore(); + // Replace Math.random with a seeded PRNG so fixtures render deterministically. + disposableStore.add(pushRandomOverwrite(42)); + // Do not enable virtual time in explorer ui, as multiple fixtures are rendered in parallel. const virtualTimeEnabled = (options.virtualTime?.enabled ?? true) && context.host.kind !== 'explorer-ui'; // Detect disposable leaks the same way unit tests do (`ensureNoDisposablesAreLeakedInTestSuite`). From 155aa2e33c779632a58ae76ac71b0ba1112d4121 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 8 May 2026 16:07:29 +0200 Subject: [PATCH 12/41] issue: indicate agents window in issue reporter body (#315261) When reporting an issue from the agents window, include 'Window: Agents' in the generated issue body so triagers can identify the source window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/contrib/issue/browser/issueReporterModel.ts | 3 ++- src/vs/workbench/contrib/issue/browser/issueService.ts | 5 ++++- src/vs/workbench/contrib/issue/common/issue.ts | 1 + .../workbench/contrib/issue/electron-browser/issueService.ts | 3 +++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts b/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts index 139ef875c36c53..2a56272ee239ec 100644 --- a/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts +++ b/src/vs/workbench/contrib/issue/browser/issueReporterModel.ts @@ -45,6 +45,7 @@ export interface IssueReporterData { experimentInfo?: string; restrictedMode?: boolean; isUnsupported?: boolean; + isSessionsWindow?: boolean; } export class IssueReporterModel { @@ -92,7 +93,7 @@ export class IssueReporterModel { } return ` Type: ${this.getIssueTypeTitle()} - +${this._data.isSessionsWindow ? '\nWindow: Agents\n' : ''} ${this._data.issueDescription} ${this.getExtensionVersion()} VS Code version: ${this._data.versionInfo && this._data.versionInfo.vscodeVersion} diff --git a/src/vs/workbench/contrib/issue/browser/issueService.ts b/src/vs/workbench/contrib/issue/browser/issueService.ts index a86214998136ff..269a34d765f6fe 100644 --- a/src/vs/workbench/contrib/issue/browser/issueService.ts +++ b/src/vs/workbench/contrib/issue/browser/issueService.ts @@ -21,6 +21,7 @@ import { IIssueFormService, IssueReporterData, IssueReporterExtensionData, Issue import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; import { IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IIntegrityService } from '../../../services/integrity/common/integrity.js'; @@ -40,7 +41,8 @@ export class BrowserIssueService implements IWorkbenchIssueService { @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IOpenerService private readonly openerService: IOpenerService + @IOpenerService private readonly openerService: IOpenerService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService ) { } async openReporter(options: Partial): Promise { @@ -133,6 +135,7 @@ export class BrowserIssueService implements IWorkbenchIssueService { experiments: experiments?.join('\n'), restrictedMode: !this.workspaceTrustManagementService.isWorkspaceTrusted(), isUnsupported, + isSessionsWindow: this.environmentService.isSessionsWindow, githubAccessToken }, options); diff --git a/src/vs/workbench/contrib/issue/common/issue.ts b/src/vs/workbench/contrib/issue/common/issue.ts index bf9c4d0e51e21c..6492620eab4c96 100644 --- a/src/vs/workbench/contrib/issue/common/issue.ts +++ b/src/vs/workbench/contrib/issue/common/issue.ts @@ -73,6 +73,7 @@ export interface IssueReporterData extends WindowData { experiments?: string; restrictedMode: boolean; isUnsupported: boolean; + isSessionsWindow: boolean; githubAccessToken: string; issueTitle?: string; issueBody?: string; diff --git a/src/vs/workbench/contrib/issue/electron-browser/issueService.ts b/src/vs/workbench/contrib/issue/electron-browser/issueService.ts index 7587d24af5be7b..2fd60d6b856d0e 100644 --- a/src/vs/workbench/contrib/issue/electron-browser/issueService.ts +++ b/src/vs/workbench/contrib/issue/electron-browser/issueService.ts @@ -16,6 +16,7 @@ import { IIssueFormService, IssueReporterData, IssueReporterExtensionData, Issue import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; import { IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IIntegrityService } from '../../../services/integrity/common/integrity.js'; export class NativeIssueService implements IWorkbenchIssueService { @@ -30,6 +31,7 @@ export class NativeIssueService implements IWorkbenchIssueService { @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @IIntegrityService private readonly integrityService: IIntegrityService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { } async openReporter(dataOverrides: Partial = {}): Promise { @@ -98,6 +100,7 @@ export class NativeIssueService implements IWorkbenchIssueService { experiments: experiments?.join('\n'), restrictedMode: !this.workspaceTrustManagementService.isWorkspaceTrusted(), isUnsupported, + isSessionsWindow: this.environmentService.isSessionsWindow, githubAccessToken }, dataOverrides); From ed8c515818855e9d80e46bd4b427837493854edd Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 8 May 2026 16:12:18 +0200 Subject: [PATCH 13/41] Adds fixture for https://github.com/microsoft/vscode/issues/309796 --- .../chat/chatWidget.fixture.ts | 28 +++++++++++++++++-- .../browser/componentFixtures/fixtureUtils.ts | 3 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index ac329c8373828b..0338a458ba7678 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -37,8 +37,8 @@ export interface IFixtureMessage { readonly assistant?: ReadonlyArray< | { kind: 'markdown'; text: string } | { kind: 'progress'; text: string } - | { kind: 'terminalConfirmation'; command: string; title?: string; disclaimer?: string; requestUnsandboxedExecution?: boolean; requestUnsandboxedExecutionReason?: string; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean } - | { kind: 'elicitation'; title: string; message: string; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean } + | { kind: 'terminalConfirmation'; command: string; title?: string; disclaimer?: string; requestUnsandboxedExecution?: boolean; requestUnsandboxedExecutionReason?: string; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean; confirmation?: { commandLine: string; cwdLabel?: string; cdPrefix?: string } } + | { kind: 'elicitation'; title: string; message: string; confirmation?: { commandLine: string; cwdLabel?: string; cdPrefix?: string }; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean } >; readonly responseComplete?: boolean; } @@ -180,6 +180,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options language: 'pwsh', requestUnsandboxedExecution: part.requestUnsandboxedExecution, requestUnsandboxedExecutionReason: part.requestUnsandboxedExecutionReason, + confirmation: part.confirmation, }, }, fixtureToolData, @@ -321,6 +322,26 @@ const PENDING_TOOL_APPROVAL: IFixtureMessage[] = [ }, ]; +// https://github.com/microsoft/vscode/issues/309796 +const ISSUE_309796_MISSING_BACKSLASH: IFixtureMessage[] = [ + { + user: 'install dependencies in the server directory', + assistant: [ + { + kind: 'terminalConfirmation', + command: 'cd packages\\server && npm install', + title: 'Run `pwsh` command within `packages\\server`?', + confirmation: { + commandLine: 'npm install', + cwdLabel: 'packages\\server', + cdPrefix: 'cd packages\\server && ', + }, + }, + ], + responseComplete: false, + }, +]; + const STREAMING: IFixtureMessage[] = [ { user: 'Search the workspace for TODO comments', @@ -356,5 +377,8 @@ export default defineThemedFixtureGroup({ path: 'chat/widget/' }, { SimpleQA: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: SIMPLE_QA }) }), Streaming: defineComponentFixture({ labels: { kind: 'animated' }, render: ctx => renderChatWidget(ctx, { messages: STREAMING }) }), PendingToolApproval: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL }) }), + bugs: defineThemedFixtureGroup({ + 'issue-309796-missing-backslash': defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: ISSUE_309796_MISSING_BACKSLASH }) }), + }), MultiTurn: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: MULTI_TURN }) }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 731912b365887d..ad7130f0f7ee7f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -1025,6 +1025,7 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed }); const wantsTimeTrace = !!context.input && typeof context.input === 'object' && !!(context.input as Record).outputTimeTrace; + if (wantsTimeTrace && virtualTimeEnabled && p.history.length > 0) { const startTime = p.history[0].time; const history = buildHistoryFromTasks(p.history, startTime); @@ -1046,7 +1047,7 @@ interface ThemedFixtureGroupOptions { readonly labels?: ThemedFixtureGroupLabels; } -type ThemedFixtureGroupFixtures = Record; +type ThemedFixtureGroupFixtures = Record>; /** * Creates a nested fixture group from themed fixtures. From 27ecfb15cf857d582d1718135f4204aadd61143d Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 16:15:11 +0200 Subject: [PATCH 14/41] Thread session workingDirectory through tools, prompts, and confirmations In the agents window, each chat session has its own working directory that may differ from the current workspace folders (which change when switching between sessions). This caused tools to search the wrong folder, show spurious 'Allow reading external files?' prompts, and render incorrect workspace_info in the system prompt. Core plumbing: - Add workingDirectory to IToolInvocationContext, IToolInvocationPreparationContext, ILanguageModelToolConfirmationRef, and IChatAgentRequest - Enrich tool invocation context from model.workingDirectory in invokeTool() - Include workingDirectory in toolInvocationToken built in extHostTypeConverters - Pass workingDirectory through LanguageModelToolInvocationOptions and LanguageModelToolInvocationPrepareOptions (proposed API) - Revive workingDirectory URI in extHostLanguageModelTools Tool fixes (when workingDirectory is set, use it exclusively): - chatExternalPathConfirmation: auto-approve paths within workingDirectory - isFileExternalAndNeedsConfirmation / isDirExternalAndNeedsConfirmation / assertFileOkForTool: treat workingDirectory as workspace-internal - createEditConfirmation: use workingDirectory for edit trust checks - All edit tools (create_file, replace_string, multi_replace, apply_patch, insert_edit, edit_notebook, create_directory): pass workingDirectory - resolveToolUri: resolve relative paths against workingDirectory - inputGlobToPattern: scope unscoped globs to workingDirectory - file_search / grep_search: scope searches to workingDirectory - semantic_search: prefer workingDirectory for cwd - run_in_terminal: prefer workingDirectory for terminal cwd - fetchPageTool: check workingDirectory for file URI trust - readFileTool / listDirTool / viewImageTool: pass workingDirectory Prompt fixes: - WorkspaceFoldersHint: show workingDirectory instead of workspace folders - AgentMultirootWorkspaceStructure: generate file tree from workingDirectory --- .../prompts/node/agent/agentPrompt.tsx | 22 ++++-- .../panel/workspace/workspaceStructure.tsx | 10 ++- .../tools/node/abstractReplaceStringTool.tsx | 4 +- .../extension/tools/node/applyPatchTool.tsx | 4 +- .../tools/node/createDirectoryTool.tsx | 4 +- .../extension/tools/node/createFileTool.tsx | 4 +- .../tools/node/editFileToolUtils.tsx | 13 +++- .../extension/tools/node/editNotebookTool.tsx | 4 +- .../extension/tools/node/findFilesTool.tsx | 2 +- .../tools/node/findTextInFilesTool.tsx | 11 ++- .../extension/tools/node/insertEditTool.tsx | 4 +- .../src/extension/tools/node/listDirTool.tsx | 2 +- .../src/extension/tools/node/readFileTool.tsx | 4 +- .../tools/node/searchSubagentTool.ts | 6 +- .../src/extension/tools/node/toolUtils.ts | 71 ++++++++++++++----- .../extension/tools/node/viewImageTool.tsx | 4 +- .../api/common/extHostLanguageModelTools.ts | 2 + .../api/common/extHostTypeConverters.ts | 2 +- .../tools/languageModelToolsService.ts | 20 ++++-- .../contrib/chat/browser/tools/renameTool.ts | 2 +- .../contrib/chat/browser/tools/toolHelpers.ts | 11 ++- .../contrib/chat/browser/tools/usagesTool.ts | 2 +- .../common/chatService/chatServiceImpl.ts | 1 + .../chat/common/participants/chatAgents.ts | 6 ++ .../chatExternalPathConfirmation.ts | 16 +++-- .../languageModelToolsConfirmationService.ts | 6 ++ .../common/tools/languageModelToolsService.ts | 11 +++ .../builtInTools/fetchPageTool.ts | 13 ++-- .../browser/tools/runInTerminalTool.ts | 13 +++- ...scode.proposed.chatParticipantPrivate.d.ts | 12 ++++ 30 files changed, 220 insertions(+), 66 deletions(-) diff --git a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx index c9db290a34cede..4ff2f38f1613d3 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx @@ -244,7 +244,8 @@ export class AgentPrompt extends PromptElement { const isNewChat = this.props.promptContext.history?.length === 0; // TODO:@bhavyau find a better way to extract session resource const sessionResource = (this.props.promptContext.tools?.toolInvocationToken as any)?.sessionResource as string | undefined; - const rendered = await renderPromptElement(this.instantiationService, endpoint, GlobalAgentContext, { enableCacheBreakpoints: this.props.enableCacheBreakpoints, availableTools: this.props.promptContext.tools?.availableTools, isNewChat, sessionResource }, undefined, undefined); + const workingDirectory = (this.props.promptContext.tools?.toolInvocationToken as any)?.workingDirectory as URI | undefined; + const rendered = await renderPromptElement(this.instantiationService, endpoint, GlobalAgentContext, { enableCacheBreakpoints: this.props.enableCacheBreakpoints, availableTools: this.props.promptContext.tools?.availableTools, isNewChat, sessionResource, workingDirectory }, undefined, undefined); const msg = rendered.messages.at(0)?.content; if (msg) { firstTurn?.setMetadata(new GlobalContextMessageMetadata(msg, this.instantiationService.invokeFunction(getGlobalContextCacheKey))); @@ -258,6 +259,7 @@ interface GlobalAgentContextProps extends BasePromptElementProps { readonly availableTools?: readonly LanguageModelToolInformation[]; readonly isNewChat?: boolean; readonly sessionResource?: string; + readonly workingDirectory?: URI; } /** @@ -274,8 +276,8 @@ class GlobalAgentContext extends PromptElement { - - + + {this.props.isNewChat && } @@ -646,9 +648,13 @@ class CurrentEditorContext extends PromptElement { } } -class WorkspaceFoldersHint extends PromptElement { +interface WorkspaceFoldersHintProps extends BasePromptElementProps { + readonly workingDirectory?: URI; +} + +class WorkspaceFoldersHint extends PromptElement { constructor( - props: BasePromptElementProps, + props: WorkspaceFoldersHintProps, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService, ) { @@ -656,7 +662,11 @@ class WorkspaceFoldersHint extends PromptElement { } async render(state: void, sizing: PromptSizing) { - const folders = this.workspaceService.getWorkspaceFolders(); + // When workingDirectory is set (agents window), use it exclusively. + // Only fall back to workspace folders when no workingDirectory is specified. + const folders = this.props.workingDirectory + ? [this.props.workingDirectory] + : this.workspaceService.getWorkspaceFolders(); if (folders.length > 0) { return ( <> diff --git a/extensions/copilot/src/extension/prompts/node/panel/workspace/workspaceStructure.tsx b/extensions/copilot/src/extension/prompts/node/panel/workspace/workspaceStructure.tsx index 7fb12c525d5043..f2f902a7a85922 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/workspace/workspaceStructure.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/workspace/workspaceStructure.tsx @@ -8,6 +8,7 @@ import { IPromptPathRepresentationService } from '../../../../../platform/prompt import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; import { createFencedCodeBlock } from '../../../../../util/common/markdown'; import { CancellationToken } from '../../../../../util/vs/base/common/cancellation'; +import { basename } from '../../../../../util/vs/base/common/resources'; import { URI } from '../../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; import { ToolName } from '../../../../tools/common/toolNames'; @@ -17,6 +18,7 @@ type WorkspaceStructureProps = BasePromptElementProps & { maxSize: number; excludeDotFiles?: boolean; readonly availableTools?: readonly vscode.LanguageModelToolInformation[]; + readonly workingDirectory?: URI; }; export class WorkspaceStructure extends PromptElement { @@ -103,9 +105,13 @@ export class MultirootWorkspaceStructure extends PromptElement | undefined, token?: vscode.CancellationToken): Promise<{ label: string; tree: IFileTreeData }[]> { - const folders = this.workspaceService.getWorkspaceFolders(); + // When workingDirectory is set (agents window), use it exclusively. + // Only fall back to workspace folders when no workingDirectory is specified. + const folders = this.props.workingDirectory + ? [this.props.workingDirectory] + : this.workspaceService.getWorkspaceFolders(); return this.instantiationService.invokeFunction(accessor => Promise.all(folders.map(async folder => ({ - label: this.workspaceService.getWorkspaceFolderName(folder), + label: this.props.workingDirectory ? basename(folder) : this.workspaceService.getWorkspaceFolderName(folder), tree: await workspaceVisualFileTree(accessor, folder, { maxLength: this.props.maxSize / folders.length, excludeDotFiles: this.props.excludeDotFiles }, token ?? CancellationToken.None) })))); } diff --git a/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx b/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx index 6a3b74f3cddea1..33c97798034571 100644 --- a/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx +++ b/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx @@ -585,7 +585,9 @@ export abstract class AbstractReplaceStringTool this.generateConfirmationDetails(replaceInputs, urisNeedingConfirmation, options, token), - options.forceConfirmationReason + options.forceConfirmationReason, + undefined, + options.workingDirectory, ); } diff --git a/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx b/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx index 2e75378e552ee3..cb3e8584092a57 100644 --- a/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx +++ b/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx @@ -672,7 +672,9 @@ export class ApplyPatchTool implements ICopilotTool { uris, this._promptContext?.allowedEditUris, (urisNeedingConfirmation) => this.generatePatchConfirmationDetails(options, urisNeedingConfirmation, token), - options.forceConfirmationReason + options.forceConfirmationReason, + undefined, + options.workingDirectory, ); } diff --git a/extensions/copilot/src/extension/tools/node/createDirectoryTool.tsx b/extensions/copilot/src/extension/tools/node/createDirectoryTool.tsx index 63a8fd5421111a..c10ceeec92ad5e 100644 --- a/extensions/copilot/src/extension/tools/node/createDirectoryTool.tsx +++ b/extensions/copilot/src/extension/tools/node/createDirectoryTool.tsx @@ -54,7 +54,9 @@ export class CreateDirectoryTool implements ICopilotTool async () => { return 'Creating the directory:\n\n' + createFencedCodeBlock('plaintext', uri.fsPath); }, - options.forceConfirmationReason + options.forceConfirmationReason, + undefined, + options.workingDirectory, ); return { diff --git a/extensions/copilot/src/extension/tools/node/createFileTool.tsx b/extensions/copilot/src/extension/tools/node/createFileTool.tsx index e9559f86892106..ec07d28e076ab3 100644 --- a/extensions/copilot/src/extension/tools/node/createFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/createFileTool.tsx @@ -179,7 +179,9 @@ export class CreateFileTool implements ICopilotTool { '', // Empty initial content content ), - options.forceConfirmationReason + options.forceConfirmationReason, + undefined, + options.workingDirectory, ); return { diff --git a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index 67d95b8445a26b..8e71e274781c3c 100644 --- a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx @@ -934,7 +934,7 @@ export function makeUriConfirmationChecker(configuration: IConfigurationService, }; } -export async function createEditConfirmation(accessor: ServicesAccessor, uris: readonly URI[], allowedUris: ResourceSet | undefined, detailMessage?: (urisNeedingConfirmation: readonly URI[]) => Promise, forceConfirmationReason?: string, getWorkspaceFolder?: (resource: URI) => URI | undefined): Promise { +export async function createEditConfirmation(accessor: ServicesAccessor, uris: readonly URI[], allowedUris: ResourceSet | undefined, detailMessage?: (urisNeedingConfirmation: readonly URI[]) => Promise, forceConfirmationReason?: string, getWorkspaceFolder?: (resource: URI) => URI | undefined, workingDirectory?: URI): Promise { // If forceConfirmationReason is provided, require confirmation for all URIs if (forceConfirmationReason) { const details = detailMessage ? await detailMessage(uris) : undefined; @@ -949,7 +949,16 @@ export async function createEditConfirmation(accessor: ServicesAccessor, uris: r } const workspaceService = accessor.get(IWorkspaceService); - getWorkspaceFolder = getWorkspaceFolder ?? workspaceService.getWorkspaceFolder.bind(workspaceService); + // When workingDirectory is set (agents window), use it exclusively for determining + // whether a file is inside the workspace. Only fall back to workspace folders otherwise. + if (!getWorkspaceFolder) { + if (workingDirectory) { + getWorkspaceFolder = (resource: URI) => + extUriBiasedIgnorePathCase.isEqualOrParent(resource, workingDirectory) ? workingDirectory : undefined; + } else { + getWorkspaceFolder = workspaceService.getWorkspaceFolder.bind(workspaceService); + } + } const checker = makeUriConfirmationChecker(accessor.get(IConfigurationService), getWorkspaceFolder, accessor.get(ICustomInstructionsService)); const needsConfirmation = (await Promise.all(uris .map(async uri => ({ uri, reason: await checker(uri) })) diff --git a/extensions/copilot/src/extension/tools/node/editNotebookTool.tsx b/extensions/copilot/src/extension/tools/node/editNotebookTool.tsx index 8e88a73c1cdb29..4a58dc7e0e9412 100644 --- a/extensions/copilot/src/extension/tools/node/editNotebookTool.tsx +++ b/extensions/copilot/src/extension/tools/node/editNotebookTool.tsx @@ -323,7 +323,9 @@ export class EditNotebookTool implements ICopilotTool { return l10n.t('Edit {0}', formatUriForFileWidget(uri)); } }, - options.forceConfirmationReason + options.forceConfirmationReason, + undefined, + options.workingDirectory, ); return { diff --git a/extensions/copilot/src/extension/tools/node/findFilesTool.tsx b/extensions/copilot/src/extension/tools/node/findFilesTool.tsx index 0b6d20a3b73895..c12ab8526a78ba 100644 --- a/extensions/copilot/src/extension/tools/node/findFilesTool.tsx +++ b/extensions/copilot/src/extension/tools/node/findFilesTool.tsx @@ -54,7 +54,7 @@ export class FindFilesTool implements ICopilotTool { const modelFamily = endpoint?.family; // The input _should_ be a pattern matching inside a workspace, folder, but sometimes we get absolute paths, so try to resolve them - const globResult = inputGlobToPattern(options.input.query, this.workspaceService, modelFamily); + const globResult = inputGlobToPattern(options.input.query, this.workspaceService, modelFamily, options.workingDirectory); void this.sendSearchToolTelemetry(options, globResult.folderName); diff --git a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx index a53de7241af1d4..8626acb30ddcb7 100644 --- a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx +++ b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx @@ -9,6 +9,7 @@ import type * as vscode from 'vscode'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { OffsetLineColumnConverter } from '../../../platform/editing/common/offsetLineColumnConverter'; import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; +import { RelativePattern } from '../../../platform/filesystem/common/fileTypes'; import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; import { ISearchService } from '../../../platform/search/common/searchService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; @@ -64,8 +65,14 @@ export class FindTextInFilesTool implements ICopilotTool { uri ? [uri] : [], this.promptContext?.allowedEditUris, async () => '```\n' + options.input.code + '\n```', - options.forceConfirmationReason + options.forceConfirmationReason, + undefined, + options.workingDirectory, ); } diff --git a/extensions/copilot/src/extension/tools/node/listDirTool.tsx b/extensions/copilot/src/extension/tools/node/listDirTool.tsx index 5aa3b047d102cd..390a97169d941e 100644 --- a/extensions/copilot/src/extension/tools/node/listDirTool.tsx +++ b/extensions/copilot/src/extension/tools/node/listDirTool.tsx @@ -54,7 +54,7 @@ class ListDirTool implements vscode.LanguageModelTool { // Check if directory is external (outside workspace) const isExternal = this.instantiationService.invokeFunction( - (accessor: ServicesAccessor) => isDirExternalAndNeedsConfirmation(accessor, uri, this._promptContext, { readOnly: true }) + (accessor: ServicesAccessor) => isDirExternalAndNeedsConfirmation(accessor, uri, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) ); if (isExternal) { diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 38d453bcc27803..201c928df19062 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -218,7 +218,7 @@ export class ReadFileTool implements ICopilotTool { // Check if file is external (outside workspace, not open in editor, etc.) const isExternal = await this.instantiationService.invokeFunction( - accessor => isFileExternalAndNeedsConfirmation(accessor, uri!, this._promptContext, { readOnly: true }) + accessor => isFileExternalAndNeedsConfirmation(accessor, uri!, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) ); if (isExternal) { @@ -243,7 +243,7 @@ export class ReadFileTool implements ICopilotTool { }; } - await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri!, this._promptContext, { readOnly: true })); + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri!, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory })); try { documentSnapshot = await this.getSnapshot(uri); diff --git a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts index aafb5598f166ab..74cb9989bce840 100644 --- a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts +++ b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts @@ -88,9 +88,11 @@ class SearchSubagentTool implements ICopilotTool { }; } async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { - // Get the current working directory from workspace folders + // Get the current working directory — prefer the session's working directory + // (agents window) over the first workspace folder. const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - const cwd = workspaceFolders.length > 0 ? workspaceFolders[0].fsPath : undefined; + const cwd = options.workingDirectory?.fsPath + ?? (workspaceFolders.length > 0 ? workspaceFolders[0].fsPath : undefined); const searchInstruction = [ `Find relevant code snippets for: ${options.input.query}`, diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index 2c8a0cb59df023..4819ac42563765 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -62,8 +62,10 @@ export interface InputGlobResult { * - Absolute paths within a workspace folder * - Patterns prefixed with a workspace folder name (e.g. `folderName/src/**`) * - Patterns prefixed with `** /folderName/...` in multi-root workspaces + * - When `workingDirectory` is provided (agents window), unscoped patterns + * are scoped to it so searches target the session's folder. */ -export function inputGlobToPattern(query: string, workspaceService: IWorkspaceService, modelFamily: string | undefined): InputGlobResult { +export function inputGlobToPattern(query: string, workspaceService: IWorkspaceService, modelFamily: string | undefined, workingDirectory?: URI): InputGlobResult { let pattern: vscode.GlobPattern = query; let folderName: string | undefined; let folderRelativePattern: string | undefined; @@ -71,21 +73,31 @@ export function inputGlobToPattern(query: string, workspaceService: IWorkspaceSe if (isAbsolute(query)) { try { const uri = URI.file(query); - const workspaceFolder = workspaceService.getWorkspaceFolder(uri); - if (workspaceFolder) { - const relative = extUriBiasedIgnorePathCase.relativePath(workspaceFolder, uri) || ''; - pattern = new RelativePattern(workspaceFolder, relative); - folderName = workspaceService.getWorkspaceFolderName(workspaceFolder); - folderRelativePattern = relative; + // When workingDirectory is set, resolve against it exclusively. + // Only fall back to workspace folders when no workingDirectory. + if (workingDirectory) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, workingDirectory)) { + const relative = extUriBiasedIgnorePathCase.relativePath(workingDirectory, uri) || ''; + pattern = new RelativePattern(workingDirectory, relative); + folderRelativePattern = relative; + } + } else { + const workspaceFolder = workspaceService.getWorkspaceFolder(uri); + if (workspaceFolder) { + const relative = extUriBiasedIgnorePathCase.relativePath(workspaceFolder, uri) || ''; + pattern = new RelativePattern(workspaceFolder, relative); + folderName = workspaceService.getWorkspaceFolderName(workspaceFolder); + folderRelativePattern = relative; + } } } catch (e) { // ignore } } - // In multi-root workspaces, detect patterns like "folderName/src/**" or "**/folderName/src/**" - // and rewrite to a RelativePattern scoped to that folder. - if (typeof pattern === 'string' && workspaceService.getWorkspaceFolders().length > 1) { + // In multi-root workspaces (and only when no workingDirectory), detect patterns + // like "folderName/src/**" or "**/folderName/src/**" and rewrite to a RelativePattern. + if (typeof pattern === 'string' && !workingDirectory && workspaceService.getWorkspaceFolders().length > 1) { let raw = pattern; if (raw.startsWith('**/')) { raw = raw.slice(3); @@ -108,6 +120,13 @@ export function inputGlobToPattern(query: string, workspaceService: IWorkspaceSe } } + // When a working directory is set (agents window) and the pattern is still + // unscoped (a plain string, not a RelativePattern), scope it to the session's + // working directory so searches target the correct folder. + if (typeof pattern === 'string' && workingDirectory) { + pattern = new RelativePattern(workingDirectory, pattern); + } + const patterns = [pattern]; // For gpt-4.1, it struggles to append /** to the pattern itself, so here we work around it by @@ -162,6 +181,7 @@ export async function isFileOkForTool(accessor: ServicesAccessor, uri: URI, buil export interface AssertFileOkForToolOptions { readOnly?: boolean; + workingDirectory?: URI; } export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: AssertFileOkForToolOptions): Promise { @@ -177,7 +197,13 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, await assertFileNotContentExcluded(accessor, uri); const normalizedUri = normalizePath(uri); - if (workspaceService.getWorkspaceFolder(normalizedUri)) { + // When a working directory is set (agents window), it is the source of truth. + // Only fall back to workspace folders when no working directory is specified. + if (options?.workingDirectory) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(normalizedUri, options.workingDirectory)) { + return; + } + } else if (workspaceService.getWorkspaceFolder(normalizedUri)) { return; } if (options?.readOnly && isUriUnderAdditionalReadAccessPaths(normalizedUri, configurationService)) { @@ -269,7 +295,7 @@ export async function assertFileNotContentExcluded(accessor: ServicesAccessor, u } } -export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean }): Promise { +export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean; workingDirectory?: URI }): Promise { const workspaceService = accessor.get(IWorkspaceService); const tabsAndEditorsService = accessor.get(ITabsAndEditorsService); const customInstructionsService = accessor.get(ICustomInstructionsService); @@ -281,8 +307,14 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces const normalizedUri = normalizePath(uri); - // Not external if: in workspace, untitled, instructions file, session resource, or open in editor - if (workspaceService.getWorkspaceFolder(normalizedUri)) { + // When a working directory is set (agents window), it is the source of truth + // for determining whether a file is "internal". Only fall back to workspace + // folders when no working directory is specified. + if (options?.workingDirectory) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(normalizedUri, options.workingDirectory)) { + return false; + } + } else if (workspaceService.getWorkspaceFolder(normalizedUri)) { return false; } if (options?.readOnly && isUriUnderAdditionalReadAccessPaths(normalizedUri, configurationService)) { @@ -317,15 +349,20 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces return true; } -export function isDirExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean }): boolean { +export function isDirExternalAndNeedsConfirmation(accessor: ServicesAccessor, uri: URI, buildPromptContext?: IBuildPromptContext, options?: { readOnly?: boolean; workingDirectory?: URI }): boolean { const workspaceService = accessor.get(IWorkspaceService); const customInstructionsService = accessor.get(ICustomInstructionsService); const configurationService = accessor.get(IConfigurationService); const normalizedUri = normalizePath(uri); - // Not external if: in workspace or external instructions folder - if (workspaceService.getWorkspaceFolder(normalizedUri)) { + // When a working directory is set (agents window), it is the source of truth. + // Only fall back to workspace folders when no working directory is specified. + if (options?.workingDirectory) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(normalizedUri, options.workingDirectory)) { + return false; + } + } else if (workspaceService.getWorkspaceFolder(normalizedUri)) { return false; } if (options?.readOnly && isUriUnderAdditionalReadAccessPaths(normalizedUri, configurationService)) { diff --git a/extensions/copilot/src/extension/tools/node/viewImageTool.tsx b/extensions/copilot/src/extension/tools/node/viewImageTool.tsx index ceab72b632cc38..a807903f6113f9 100644 --- a/extensions/copilot/src/extension/tools/node/viewImageTool.tsx +++ b/extensions/copilot/src/extension/tools/node/viewImageTool.tsx @@ -64,7 +64,7 @@ export class ViewImageTool implements ICopilotTool { this.assertImageFile(uri); const isExternal = await this.instantiationService.invokeFunction( - accessor => isFileExternalAndNeedsConfirmation(accessor, uri, this._promptContext, { readOnly: true }) + accessor => isFileExternalAndNeedsConfirmation(accessor, uri, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory }) ); if (isExternal) { @@ -87,7 +87,7 @@ export class ViewImageTool implements ICopilotTool { }; } - await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri, this._promptContext, { readOnly: true })); + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri, this._promptContext, { readOnly: true, workingDirectory: options.workingDirectory })); return { invocationMessage: new MarkdownString(l10n.t`Viewing image ${formatUriForFileWidget(uri)}`), diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 7c224277f99fc3..ef3117f6c4d815 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -192,6 +192,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; options.chatSessionResource = URI.revive(dto.context?.sessionResource); + options.workingDirectory = URI.revive(dto.context?.workingDirectory); options.subAgentInvocationId = dto.subAgentInvocationId; options.traceparent = dto.traceparent; options.tracestate = dto.tracestate; @@ -296,6 +297,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape chatRequestId: context.chatRequestId, chatSessionResource: context.chatSessionResource, chatInteractionId: context.chatInteractionId, + workingDirectory: URI.revive(context.workingDirectory), forceConfirmationReason: context.forceConfirmationReason }; if (context.forceConfirmationReason) { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index f4441fbc648d73..50934fa8f3da8e 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3451,7 +3451,7 @@ export namespace ChatAgentRequest { acceptedConfirmationData: request.acceptedConfirmationData, rejectedConfirmationData: request.rejectedConfirmationData, location2, - toolInvocationToken: Object.freeze({ sessionResource: request.sessionResource }) as never, + toolInvocationToken: Object.freeze({ sessionResource: request.sessionResource, workingDirectory: URI.revive(request.workingDirectory) }) as never, tools, model, modelConfiguration, diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 5c3da450d93bfc..7b4a8f683766bc 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -462,6 +462,11 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo this._logService.debug(`[LanguageModelToolsService#invokeTool] Ignoring tool ${dto.toolId} for cancelled/complete request ${request.id}`); throw new CancellationError(); } + + // Enrich context with working directory from the model if available + if (model?.workingDirectory && !dto.context.workingDirectory) { + dto = { ...dto, context: { ...dto.context, workingDirectory: model.workingDirectory } }; + } } // Check if there's an existing pending tool call from streaming phase BEFORE hook check @@ -657,7 +662,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo this.ensureToolDetails(dto, toolResult, tool.data, toolInvocation); const afterExecuteState = await toolInvocation?.didExecuteTool(toolResult, undefined, () => - this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource, dto.chatRequestId)); + this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource, dto.chatRequestId, dto.context?.workingDirectory)); if (toolInvocation && afterExecuteState?.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { const postConfirm = await IChatToolInvocation.awaitPostConfirmation(toolInvocation, token); @@ -815,7 +820,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo key: approveCombination.key, }; } - const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource, dto.chatRequestId, combination); + const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource, dto.chatRequestId, combination, dto.context?.workingDirectory); return { autoConfirmed, preparedInvocation }; } @@ -829,7 +834,8 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo chatSessionResource: dto.context?.sessionResource, chatInteractionId: dto.chatInteractionId, modelId: dto.modelId, - forceConfirmationReason: forceConfirmationReason + forceConfirmationReason: forceConfirmationReason, + workingDirectory: dto.context?.workingDirectory, }, token); const raceResult = await Promise.race([ @@ -1142,7 +1148,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return true; } - private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined, combination?: { label: string; key: string }): Promise { + private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined, combination?: { label: string; key: string }, workingDirectory?: URI): Promise { const tool = this._tools.get(toolId); if (!tool) { return undefined; @@ -1161,7 +1167,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - const reason = this._confirmationService.getPreConfirmAction({ toolId, source, parameters, chatSessionResource, combination }); + const reason = this._confirmationService.getPreConfirmAction({ toolId, source, parameters, chatSessionResource, workingDirectory, combination }); if (reason) { return reason; } @@ -1188,7 +1194,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined): Promise { + private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined, workingDirectory?: URI): Promise { // Bypass post-execution confirmation under Auto-Approve / Autopilot, // unless enterprise policy disables global auto-approve. const sessionAutoApprove = chatSessionResource && !this._isAutoApprovePolicyRestricted() && this._isSessionInAutoApproveLevel(chatSessionResource); @@ -1204,7 +1210,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove }; } - return this._confirmationService.getPostConfirmAction({ toolId, source, parameters, chatSessionResource }); + return this._confirmationService.getPostConfirmAction({ toolId, source, parameters, chatSessionResource, workingDirectory }); } private async _checkGlobalAutoApprove(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts index 3828c4f36b9c90..52adb0434a03fd 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -176,7 +176,7 @@ export class RenameTool extends Disposable implements IToolImpl { const input = invocation.parameters as IRenameToolInput; // --- resolve URI --- - const uri = resolveToolUri(input, this._workspaceContextService); + const uri = resolveToolUri(input, this._workspaceContextService, invocation.context?.workingDirectory); if (!uri) { return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); } diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts index 28284cad6ae7f0..3d27d864c428d8 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolHelpers.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { joinPath } from '../../../../../base/common/resources.js'; import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; import { ITextModel } from '../../../../../editor/common/model.js'; @@ -20,13 +21,19 @@ export interface ISymbolToolInput { /** * Resolves a URI from tool input. Accepts either a full URI string or a - * workspace-relative file path. + * workspace-relative file path. When a {@link workingDirectory} is provided + * (agents window), relative paths are resolved against it first. */ -export function resolveToolUri(input: ISymbolToolInput, workspaceContextService: IWorkspaceContextService): URI | undefined { +export function resolveToolUri(input: ISymbolToolInput, workspaceContextService: IWorkspaceContextService, workingDirectory?: URI): URI | undefined { if (input.uri) { return URI.parse(input.uri); } if (input.filePath) { + // Prefer the session's working directory when available (agents window) + if (workingDirectory) { + return joinPath(workingDirectory, input.filePath); + } + const folders = workspaceContextService.getWorkspace().folders; if (folders.length === 1) { return folders[0].toResource(input.filePath); diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts index c446f871fd0823..f6748109522143 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -170,7 +170,7 @@ export class UsagesTool extends Disposable implements IToolImpl { const input = invocation.parameters as ISymbolToolInput; // --- resolve URI --- - const uri = resolveToolUri(input, this._workspaceContextService); + const uri = resolveToolUri(input, this._workspaceContextService, invocation.context?.workingDirectory); if (!uri) { return errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index be219be1e39c18..37480b935962c1 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1292,6 +1292,7 @@ export class ChatService extends Disposable implements IChatService { hooks: collectedHooks, hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), isSystemInitiated: options?.isSystemInitiated, + workingDirectory: model.workingDirectory, }; let isInitialTools = true; diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 4de51aa176f66d..8cfef4cbb74a93 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -154,6 +154,12 @@ export interface IChatAgentRequest { userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; + /** + * The working directory URI for the session, if set. + * In the agents window, each session can have its own working directory + * that differs from the current workspace folders. + */ + workingDirectory?: URI; /** * Collected hooks configuration for this request. * Contains all hooks defined in hooks .json files, organized by hook type. diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts index 526fb48384ece9..51c7ea51420b2a 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts @@ -103,12 +103,20 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT return undefined; } - // Check workspace-level allowlist - const workspaceFolders = this._getWorkspaceFolders(); - for (const folderUri of workspaceFolders) { - if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { + // When a working directory is set (agents window), it is the source of truth + // for determining whether a path is workspace-internal. Only fall back to the + // workspace-level allowlist when no working directory is specified. + if (ref.workingDirectory) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, ref.workingDirectory)) { return { type: ToolConfirmKind.UserAction }; } + } else { + const workspaceFolders = this._getWorkspaceFolders(); + for (const folderUri of workspaceFolders) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { + return { type: ToolConfirmKind.UserAction }; + } + } } // Check session-level allowlist diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index c294eafca4d2be..c0925fa3129b0b 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -41,6 +41,12 @@ export interface ILanguageModelToolConfirmationRef { source: ToolDataSource; parameters: unknown; chatSessionResource?: URI; + /** + * The working directory URI for the session, if set. + * Used by confirmation contributions to check if a path is within + * the session's working directory (agents window). + */ + workingDirectory?: URI; /** When set, the confirmation service will offer combination-level approval actions */ combination?: { /** Human-readable label for the approval option */ diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 4538b86eb70fc5..e5326f91fbc08e 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -209,6 +209,12 @@ export interface IToolInvocation { export interface IToolInvocationContext { readonly sessionResource: URI; + /** + * The working directory URI associated with this session. + * Only set in the agents window context where each session can + * have its own working directory that differs from the workspace folders. + */ + readonly workingDirectory?: URI; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -226,6 +232,11 @@ export interface IToolInvocationPreparationContext { modelId?: string; /** If set, tells the tool that it should include confirmation messages. */ forceConfirmationReason?: string; + /** + * The working directory URI for the session, if set. + * Used by tools to resolve relative paths and check file access. + */ + workingDirectory?: URI; } export type ToolInputOutputBase = { diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts index c2262ce51c1c20..67296649232b4a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts @@ -9,6 +9,7 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { extname } from '../../../../../base/common/path.js'; +import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -180,10 +181,14 @@ export class FetchWebPageTool implements IToolImpl { const allFetchedUris = new ResourceSet([...webUris.values(), ...validFileUris]); // File URIs that are inside the workspace don't need confirmation — they're already accessible // and don't carry the web content risks (prompt injection, malicious redirects). - // File URIs outside the workspace are treated like web URIs and require confirmation. - const fileUrisOutsideWorkspace = validFileUris.filter( - uri => !this._workspaceContextService.getWorkspaceFolder(uri) - ); + // When a working directory is set (agents window), it is the source of truth; + // only fall back to workspace folders when no working directory is specified. + const fileUrisOutsideWorkspace = validFileUris.filter(uri => { + if (context.workingDirectory) { + return !extUriBiasedIgnorePathCase.isEqualOrParent(uri, context.workingDirectory); + } + return !this._workspaceContextService.getWorkspaceFolder(uri); + }); const urlsNeedingConfirmation = new ResourceSet([...webUris.values(), ...fileUrisOutsideWorkspace]); const pastTenseMessage = invalid.length diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 4d458c25718f90..2311665081f92c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -664,9 +664,16 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { (async () => { let cwd = await instance?.getCwdResource(); if (!cwd) { - const activeWorkspaceRootUri = this._historyService.getLastActiveWorkspaceRoot(); - const workspaceFolder = activeWorkspaceRootUri ? this._workspaceContextService.getWorkspaceFolder(activeWorkspaceRootUri) ?? undefined : undefined; - cwd = workspaceFolder?.uri; + // Prefer the session's working directory (agents window) over the + // last active workspace root, which may point to a different session's folder. + const sessionModel = chatSessionResource ? this._chatService.getSession(chatSessionResource) : undefined; + if (sessionModel?.workingDirectory) { + cwd = sessionModel.workingDirectory; + } else { + const activeWorkspaceRootUri = this._historyService.getLastActiveWorkspaceRoot(); + const workspaceFolder = activeWorkspaceRootUri ? this._workspaceContextService.getWorkspaceFolder(activeWorkspaceRootUri) ?? undefined : undefined; + cwd = workspaceFolder?.uri; + } } return cwd; })(), diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 51bdf465a0da61..e5a1abb3ccdd2e 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -299,6 +299,12 @@ declare module 'vscode' { chatSessionResource?: Uri; chatInteractionId?: string; terminalCommand?: string; + /** + * The working directory URI for the session, if set. + * In the agents window, each session can have its own working directory + * that differs from the current workspace folders. + */ + workingDirectory?: Uri; /** * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ @@ -334,6 +340,12 @@ declare module 'vscode' { chatRequestId?: string; chatSessionResource?: Uri; chatInteractionId?: string; + /** + * The working directory URI for the session, if set. + * In the agents window, each session can have its own working directory + * that differs from the current workspace folders. + */ + workingDirectory?: Uri; /** * If set, tells the tool that it should include confirmation messages. */ From 385d3501bf907e9c2e0979d2f6e56c3191a482b5 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 8 May 2026 16:16:10 +0200 Subject: [PATCH 15/41] sessions: mark background sessions as unread when turn completes (#315263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session transitions from InProgress to a terminal status (Completed, NeedsInput, Error) while it is not the active session, mark it as unread in the SessionsListModelService so the sessions list sidebar shows the unread indicator. Previously, SessionsListModelService only tracked read state via a simple set — sessions were marked read when opened but never marked unread when a background turn completed. This caused existing sessions to appear as read even after new turns finished, while new sessions (never in the read set) correctly showed as unread. Fixes microsoft/vscode#311985 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/views/sessionsListModelService.ts | 27 ++++++- .../browser/sessionsListModelService.test.ts | 75 +++++++++++++++++-- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsListModelService.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsListModelService.ts index e8693eb5df9f0e..3237620bcef092 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsListModelService.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsListModelService.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; +import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; export const enum SessionListModelChangeKind { @@ -61,6 +61,7 @@ export class SessionsListModelService extends Disposable implements ISessionsLis private readonly _pinnedSessionIds: Set; private readonly _readSessionIds: Set; + private readonly _lastKnownStatus = new Map(); constructor( @IStorageService private readonly storageService: IStorageService, @@ -75,6 +76,29 @@ export class SessionsListModelService extends Disposable implements ISessionsLis for (const session of e.removed) { this.deleteSession(session); } + + // When a session completes a turn in the background (transitions + // from InProgress to a terminal status) mark it as unread so the + // sessions list shows the indicator. + const activeSessionId = this.sessionsManagementService.activeSession.get()?.sessionId; + for (const session of e.changed) { + const previous = this._lastKnownStatus.get(session.sessionId); + const current = session.status.get(); + this._lastKnownStatus.set(session.sessionId, current); + + if ( + previous === SessionStatus.InProgress && + current !== SessionStatus.InProgress && + current !== SessionStatus.Untitled && + session.sessionId !== activeSessionId + ) { + this.markUnread(session); + } + } + + for (const session of e.added) { + this._lastKnownStatus.set(session.sessionId, session.status.get()); + } })); } @@ -143,6 +167,7 @@ export class SessionsListModelService extends Disposable implements ISessionsLis // -- Cleanup -- private deleteSession(session: ISession): void { + this._lastKnownStatus.delete(session.sessionId); const changes: { sessionId: string; kind: SessionListModelChangeKind }[] = []; if (this._pinnedSessionIds.delete(session.sessionId)) { this.saveSet(SessionsListModelService.PINNED_SESSIONS_KEY, this._pinnedSessionIds); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts index 04a7af76568720..15c1292733030b 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts @@ -6,17 +6,17 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionListModelChangeEvent, SessionListModelChangeKind, SessionsListModelService } from '../../browser/views/sessionsListModelService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { mock } from '../../../../../base/test/common/mock.js'; -function createSession(id: string): ISession { +function createSession(id: string, status: SessionStatus = SessionStatus.Completed): ISession { return { sessionId: id, resource: URI.parse(`session://${id}`), @@ -27,7 +27,7 @@ function createSession(id: string): ISession { workspace: observableValue(`workspace-${id}`, undefined), title: observableValue(`title-${id}`, id), updatedAt: observableValue(`updatedAt-${id}`, new Date()), - status: observableValue(`status-${id}`, SessionStatus.Completed), + status: observableValue(`status-${id}`, status), changesets: observableValue(`changesets-${id}`, []), changes: observableValue(`changes-${id}`, []), modelId: observableValue(`modelId-${id}`, undefined), @@ -49,14 +49,17 @@ suite('SessionsListModelService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); let service: SessionsListModelService; let sessionsChangedEmitter: Emitter; + let activeSession: ISettableObservable; setup(() => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); sessionsChangedEmitter = disposables.add(new Emitter()); + activeSession = observableValue('activeSession', undefined); instantiationService.stub(ISessionsManagementService, { ...mock(), onDidChangeSessions: sessionsChangedEmitter.event, + activeSession, }); service = disposables.add(instantiationService.createInstance(SessionsListModelService)); }); @@ -291,6 +294,66 @@ suite('SessionsListModelService', () => { assert.strictEqual(service.isSessionPinned(s2), true); }); + test('marks session unread when it transitions from InProgress to Completed in background', () => { + const session = createSession('s1', SessionStatus.InProgress); + service.markRead(session); + + // Seed the last-known status as InProgress + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); + assert.strictEqual(service.isSessionRead(session), true); + + // Turn completes while session is not active + (session.status as ISettableObservable).set(SessionStatus.Completed, undefined); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); + + assert.strictEqual(service.isSessionRead(session), false); + }); + + test('does not mark active session unread when it transitions from InProgress to Completed', () => { + const session = createSession('s1', SessionStatus.InProgress); + service.markRead(session); + + // Make session the active one + activeSession.set({ ...session, activeChat: constObservable(session.mainChat) }, undefined); + + // Seed the last-known status as InProgress + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); + + // Turn completes while session IS active + (session.status as ISettableObservable).set(SessionStatus.Completed, undefined); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); + + assert.strictEqual(service.isSessionRead(session), true); + }); + + test('marks session unread when it transitions from InProgress to NeedsInput in background', () => { + const session = createSession('s1', SessionStatus.InProgress); + service.markRead(session); + + // Seed the last-known status as InProgress + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); + + // Session needs input while not active + (session.status as ISettableObservable).set(SessionStatus.NeedsInput, undefined); + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); + + assert.strictEqual(service.isSessionRead(session), false); + }); + + test('does not mark session unread when status does not change from InProgress', () => { + const session = createSession('s1'); + service.markRead(session); + + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + // Session was Completed and stays Completed (e.g. title changed) + sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); + + assert.strictEqual(service.isSessionRead(session), true); + assert.strictEqual(changeCount, 0); + }); + // -- Storage persistence -- test('state is loaded from storage on construction', () => { @@ -302,7 +365,7 @@ suite('SessionsListModelService', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, storageService); - instantiationService.stub(ISessionsManagementService, { ...mock(), onDidChangeSessions: disposables.add(new Emitter()).event }); + instantiationService.stub(ISessionsManagementService, { ...mock(), onDidChangeSessions: disposables.add(new Emitter()).event, activeSession: constObservable(undefined) }); const loadedService = disposables.add(instantiationService.createInstance(SessionsListModelService)); assert.strictEqual(loadedService.isSessionPinned(createSession('s1')), true); @@ -317,7 +380,7 @@ suite('SessionsListModelService', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, storageService); - instantiationService.stub(ISessionsManagementService, { ...mock(), onDidChangeSessions: disposables.add(new Emitter()).event }); + instantiationService.stub(ISessionsManagementService, { ...mock(), onDidChangeSessions: disposables.add(new Emitter()).event, activeSession: constObservable(undefined) }); const loadedService = disposables.add(instantiationService.createInstance(SessionsListModelService)); // Should not throw and should return empty state From ca4058ac0432eb8408b5daa46eb03866ef89760e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 8 May 2026 16:20:24 +0200 Subject: [PATCH 16/41] Add Ctrl+R keybinding for sessions picker in agents window (#315259) Bind Ctrl+R (Ctrl+R on macOS) to the Show Sessions Picker command, scoped to the sessions window via IsSessionsWindowContext. This overrides the Open Recent workspace picker keybinding in the agents window so Ctrl+R opens the sessions picker instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/contrib/sessions/browser/sessionsActions.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index ad8d37e09f5a47..76116e242abdc8 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -4,12 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { fromNow } from '../../../../base/common/date.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { SessionsCategories } from '../../../common/categories.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; @@ -25,6 +28,12 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { title: localize2('showSessionsPicker', "Show Sessions Picker"), f1: true, category: SessionsCategories.Sessions, + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.KeyR, + mac: { primary: KeyMod.WinCtrl | KeyCode.KeyR }, + weight: KeybindingWeight.WorkbenchContrib + 1, + when: IsSessionsWindowContext, + }, }); } From 17d62bd650b1f44102c2f636c64fb477593a2dd1 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 8 May 2026 14:29:40 +0000 Subject: [PATCH 17/41] Agents - refactor pull request actions (#315266) * Agents - switch pull request actions away from the agentic loop * Fix compilation --- extensions/copilot/package.json | 44 ++--- extensions/copilot/package.nls.json | 1 - .../vscode-node/test/mockOctoKitService.ts | 1 + .../chatSessions/vscode-node/chatSessions.ts | 9 +- .../vscode-node/copilotCLIChatSessions.ts | 144 ++++++-------- .../copilotCLIChatSessionsContribution.ts | 143 ++++++-------- .../vscode-node/pullRequestCreationService.ts | 186 ++++++++++++++++++ .../platform/github/common/githubService.ts | 37 ++++ .../github/common/octoKitServiceImpl.ts | 11 +- .../contrib/changes/browser/changesView.ts | 8 +- .../browser/views/sessionsViewActions.ts | 20 +- 11 files changed, 395 insertions(+), 209 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestCreationService.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index ea84a92696ac0a..159f3c0372580d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2838,50 +2838,43 @@ { "command": "github.copilot.sessions.commit", "title": "%github.copilot.command.sessions.commit%", - "enablement": "!sessions.hasGitOperationInProgress", + "enablement": "!chatSessionRequestInProgress && !sessions.hasGitOperationInProgress", "icon": "$(git-commit)", "category": "GitHub Copilot" }, { "command": "github.copilot.sessions.commitAndSync", "title": "%github.copilot.command.sessions.commitAndSync%", - "enablement": "!sessions.hasGitOperationInProgress", + "enablement": "!chatSessionRequestInProgress && !sessions.hasGitOperationInProgress", "icon": "$(sync)", "category": "GitHub Copilot" }, { "command": "github.copilot.sessions.sync", "title": "%github.copilot.command.sessions.sync%", - "enablement": "!sessions.hasGitOperationInProgress", + "enablement": "!chatSessionRequestInProgress && !sessions.hasGitOperationInProgress", "icon": "$(sync)", "category": "GitHub Copilot" }, - { - "command": "github.copilot.sessions.discardChanges", - "title": "%github.copilot.command.sessions.discardChanges%", - "enablement": "!chatSessionRequestInProgress", - "icon": "$(discard)", - "category": "GitHub Copilot" - }, { "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR", "title": "%github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR%", - "enablement": "!chatSessionRequestInProgress", + "enablement": "!chatSessionRequestInProgress && !sessions.hasGitOperationInProgress", "icon": "$(git-pull-request-create)", "category": "GitHub Copilot" }, { - "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR", - "title": "%github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR%", - "enablement": "!chatSessionRequestInProgress", - "icon": "$(sync)", + "command": "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR", + "title": "%github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR%", + "enablement": "!chatSessionRequestInProgress && !sessions.hasGitOperationInProgress", + "icon": "$(git-pull-request-draft)", "category": "GitHub Copilot" }, { - "command": "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR", - "title": "%github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR%", + "command": "github.copilot.sessions.discardChanges", + "title": "%github.copilot.command.sessions.discardChanges%", "enablement": "!chatSessionRequestInProgress", - "icon": "$(git-pull-request-draft)", + "icon": "$(discard)", "category": "GitHub Copilot" }, { @@ -5166,19 +5159,14 @@ }, { "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && (sessions.hasUncommittedChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", + "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && !sessions.isAgentHostSession", "group": "2_pull_request@1" }, { "command": "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && (sessions.hasUncommittedChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", + "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && !sessions.hasPullRequest && !sessions.isAgentHostSession", "group": "2_pull_request@2" }, - { - "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && sessions.hasPullRequest && sessions.hasOpenPullRequest && !sessions.isAgentHostSession && (sessions.hasIncomingChanges || sessions.hasOutgoingChanges || sessions.hasUncommittedChanges)", - "group": "2_pull_request@3" - }, { "command": "github.copilot.sessions.commit", "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && !sessions.isAgentHostSession", @@ -5191,7 +5179,7 @@ }, { "command": "github.copilot.sessions.sync", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == workspace && sessions.hasGitRepository && sessions.hasUpstream && !sessions.hasUncommittedChanges && (sessions.hasIncomingChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", + "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.hasUpstream && !sessions.hasUncommittedChanges && (sessions.hasIncomingChanges || sessions.hasOutgoingChanges) && !sessions.isAgentHostSession", "group": "4_sync@1" }, { @@ -5517,10 +5505,6 @@ "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR", "when": "false" }, - { - "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR", - "when": "false" - }, { "command": "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR", "when": "false" diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 99dab091d4cebd..c3306769a4cf48 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -473,7 +473,6 @@ "github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge": "Merge Changes", "github.copilot.chat.mergeCopilotCLIAgentSessionChanges.mergeAndSync": "Merge Changes & Sync", "github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR": "Create Pull Request", - "github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR": "Sync Pull Request", "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR": "Create Draft Pull Request", "github.copilot.command.checkoutPullRequestReroute.title": "Checkout", "github.copilot.command.cloudSessions.openRepository.title": "Browse repositories...", diff --git a/extensions/copilot/src/extension/agents/vscode-node/test/mockOctoKitService.ts b/extensions/copilot/src/extension/agents/vscode-node/test/mockOctoKitService.ts index ea37cf30e25bf6..aaf5a604721c67 100644 --- a/extensions/copilot/src/extension/agents/vscode-node/test/mockOctoKitService.ts +++ b/extensions/copilot/src/extension/agents/vscode-node/test/mockOctoKitService.ts @@ -28,6 +28,7 @@ export class MockOctoKitService implements IOctoKitService { addPullRequestComment = async () => null; getAllOpenSessions = async () => []; getAllSessions = async () => []; + createPullRequest = async () => ({ number: 0, url: '' }); getPullRequestFromGlobalId = async () => null; getPullRequestFiles = async () => []; closePullRequest = async () => false; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 17281884a233c3..b89529d11ece10 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -77,6 +77,7 @@ import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from '. import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from './folderRepositoryManagerImpl'; import { PRContentProvider } from './prContentProvider'; +import { IPullRequestCreationService, PullRequestCreationService } from './pullRequestCreationService'; import { IPullRequestDetectionService, PullRequestDetectionService } from './pullRequestDetectionService'; import { IPullRequestFileChangesService, PullRequestFileChangesService } from './pullRequestFileChangesService'; import { ISessionOptionGroupBuilder, SessionOptionGroupBuilder } from './sessionOptionGroupBuilder'; @@ -191,6 +192,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)], [IChatSessionMetadataStore, new SyncDescriptor(ChatSessionMetadataStore)], [IChatFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)], + [IPullRequestCreationService, new SyncDescriptor(PullRequestCreationService)], [IPullRequestDetectionService, new SyncDescriptor(PullRequestDetectionService)], [ISessionOptionGroupBuilder, new SyncDescriptor(SessionOptionGroupBuilder)], [ISessionRequestLifecycle, new SyncDescriptor(SessionRequestLifecycle)], @@ -225,6 +227,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService)); const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels)); const copilotCLIFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService)); + const pullRequestCreationService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IPullRequestCreationService)); this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker))); this._register(copilotcliAgentInstaService.createInstance(CopilotCLIContrib)); @@ -235,7 +238,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); - this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitCommitMessageService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, logService)); + this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitCommitMessageService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, pullRequestCreationService, logService)); // #endregion const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); @@ -265,6 +268,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)], [IChatSessionMetadataStore, new SyncDescriptor(ChatSessionMetadataStore)], [IChatFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)], + [IPullRequestCreationService, new SyncDescriptor(PullRequestCreationService)], ...getServices() )); @@ -326,6 +330,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService)); const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels)); const copilotFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService)); + const pullRequestCreationService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IPullRequestCreationService)); this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker))); this._register(copilotcliAgentInstaService.createInstance(CopilotCLIContrib)); @@ -336,7 +341,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant)); const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider)); this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider)); - this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitCommitMessageService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, logService)); + this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitCommitMessageService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, pullRequestCreationService, logService)); // #endregion const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore)); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 30f7544e7a46e0..506b1ec1ed1009 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -55,7 +55,7 @@ import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_ import { ISessionRequestLifecycle } from './sessionRequestLifecycle'; import { ICopilotCLIChatSessionInitializer, SessionInitOptions } from '../copilotcli/vscode-node/copilotCLIChatSessionInitializer'; import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences'; - +import { IPullRequestCreationService } from './pullRequestCreationService'; export interface ICopilotCLIChatSessionItemProvider extends IDisposable { refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise; @@ -1032,6 +1032,7 @@ export function registerCLIChatCommands( fileSystemService: IFileSystemService, sessionTracker: ICopilotCLISessionTracker, terminalIntegration: ICopilotCLITerminalIntegration, + pullRequestCreationService: IPullRequestCreationService, logService: ILogService ): IDisposable { const disposableStore = new DisposableStore(); @@ -1583,7 +1584,11 @@ export function registerCLIChatCommands( return; } - if (repository.state.workingTreeChanges.length === 0 && repository.state.indexChanges.length === 0 && repository.state.untrackedChanges.length === 0) { + if ( + repository.state.workingTreeChanges.length === 0 && + repository.state.indexChanges.length === 0 && + repository.state.untrackedChanges.length === 0 + ) { return; } @@ -1606,6 +1611,20 @@ export function registerCLIChatCommands( } }; + const sync = async (sessionId: string) => { + const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); + const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId); + + const repositoryUri = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : workspaceFolder; + const repository = repositoryUri ? await gitCommitMessageService.getRepository(repositoryUri) : undefined; + if (!repository) { + return; + } + + await repository.pull(); + await repository.push(); + }; + disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commit', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { const resource = sessionItemOrResource instanceof vscode.Uri ? sessionItemOrResource @@ -1657,18 +1676,7 @@ export function registerCLIChatCommands( try { await setHasGitOperationInProgress(sessionId, true); - - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId); - - const repositoryUri = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : workspaceFolder; - const repository = repositoryUri ? await gitCommitMessageService.getRepository(repositoryUri) : undefined; - if (!repository) { - return; - } - - await repository.pull(); - await repository.push(); + await sync(sessionId); } finally { await setHasGitOperationInProgress(sessionId, false); } @@ -1702,7 +1710,7 @@ export function registerCLIChatCommands( await gitService.restore(repository.rootUri, resources.map(r => r.fsPath), { ref }); })); - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { + const createPullRequest = async (sessionItemOrResource: vscode.ChatSessionItem | vscode.Uri | undefined, isDraft: boolean) => { const resource = sessionItemOrResource instanceof vscode.Uri ? sessionItemOrResource : sessionItemOrResource?.resource; @@ -1711,92 +1719,58 @@ export function registerCLIChatCommands( return; } - try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.')); - return; - } - } catch (error) { - logService.error(`Failed to check worktree properties for createPR: ${error instanceof Error ? error.message : String(error)}`); - return; - } - - await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', { - resource, - prompt: builtinSlashSCommands.createPr, - }); - })); - - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { - const resource = sessionItemOrResource instanceof vscode.Uri - ? sessionItemOrResource - : sessionItemOrResource?.resource; - - if (!resource) { + const sessionId = SessionIdForCLI.parse(resource); + let worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); + if (!worktreeProperties || worktreeProperties.version !== 2) { + vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.')); return; } try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - vscode.window.showErrorMessage(l10n.t('Creating a draft pull request is only supported for worktree-based sessions.')); - return; - } - } catch (error) { - logService.error(`Failed to check worktree properties for createDraftPR: ${error instanceof Error ? error.message : String(error)}`); - return; - } + await setHasGitOperationInProgress(sessionId, true); - await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', { - resource, - prompt: builtinSlashSCommands.createDraftPr, - }); - })); + const worktreeUri = vscode.Uri.file(worktreeProperties.worktreePath); - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { - const resource = sessionItemOrResource instanceof vscode.Uri - ? sessionItemOrResource - : sessionItemOrResource?.resource; + // Commit uncommitted changes + await commit(sessionId, false); - if (!resource) { - return; - } + const branchName = worktreeProperties.branchName; + const baseBranchName = worktreeProperties.baseBranchName; - let pullRequestUrl: string | undefined = undefined; + // Create the pull request + const pullRequestUrl = await pullRequestCreationService.createPullRequest( + { repositoryUri: worktreeUri, branchName, baseBranchName, isDraft }, + CancellationToken.None); - try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - vscode.window.showErrorMessage(l10n.t('Updating a pull request is only supported for worktree-based sessions.')); + if (!pullRequestUrl) { return; } - pullRequestUrl = worktreeProperties.pullRequestUrl; + worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); + if (worktreeProperties && worktreeProperties.version === 2) { + await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { + ...worktreeProperties, + changes: undefined, + pullRequestUrl + }); + } + + await contentProvider.refreshSession({ reason: 'update', sessionId }); } catch (error) { - logService.error(`Failed to check worktree properties for updatePR: ${error instanceof Error ? error.message : String(error)}`); - return; + const errorMessage = error instanceof Error ? error.message : String(error); + logService.error(`Failed to create pull request for session ${sessionId}: ${errorMessage}`); + vscode.window.showErrorMessage(l10n.t('Failed to create pull request: {0}', errorMessage)); + } finally { + await setHasGitOperationInProgress(sessionId, false); } + }; - if (!pullRequestUrl) { - vscode.window.showErrorMessage(l10n.t('No pull request URL found for this session.')); - return; - } + disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { + await createPullRequest(sessionItemOrResource, false); + })); - await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', { - resource, - prompt: builtinSlashSCommands.updatePr, - attachedContext: [{ - id: 'github-pull-request', - fullName: pullRequestUrl, - icon: new vscode.ThemeIcon('git-pull-request'), - value: vscode.Uri.parse(pullRequestUrl), - kind: 'generic' - }] - }); + disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { + await createPullRequest(sessionItemOrResource, true); })); disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToWorktree', async (args?: { worktreeUri?: vscode.Uri; fileUri?: vscode.Uri }) => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index bbc5c0a91ffcab..d85396c5ccf7dd 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -57,6 +57,7 @@ import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions'; import { getCopilotCLIModelDetails, persistCopilotCLIResponseModelId } from './copilotCLIModelDetails'; import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; +import { IPullRequestCreationService } from './pullRequestCreationService'; import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences'; import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker'; @@ -2134,6 +2135,7 @@ export function registerCLIChatCommands( cliFolderMruService: IChatFolderMruService, envService: INativeEnvService, fileSystemService: IFileSystemService, + pullRequestCreationService: IPullRequestCreationService, logService: ILogService ): IDisposable { const disposableStore = new DisposableStore(); @@ -2658,7 +2660,11 @@ export function registerCLIChatCommands( return; } - if (repository.state.workingTreeChanges.length === 0 && repository.state.indexChanges.length === 0 && repository.state.untrackedChanges.length === 0) { + if ( + repository.state.workingTreeChanges.length === 0 && + repository.state.indexChanges.length === 0 && + repository.state.untrackedChanges.length === 0 + ) { return; } @@ -2681,6 +2687,20 @@ export function registerCLIChatCommands( } }; + const sync = async (sessionId: string) => { + const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); + const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId); + + const repositoryUri = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : workspaceFolder; + const repository = repositoryUri ? await gitCommitMessageService.getRepository(repositoryUri) : undefined; + if (!repository) { + return; + } + + await repository.pull(); + await repository.push(); + }; + disposableStore.add(vscode.commands.registerCommand('github.copilot.sessions.commit', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { const resource = sessionItemOrResource instanceof vscode.Uri ? sessionItemOrResource @@ -2732,18 +2752,7 @@ export function registerCLIChatCommands( try { await setHasGitOperationInProgress(sessionId, true); - - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - const workspaceFolder = await copilotCliWorkspaceSession.getSessionWorkspaceFolder(sessionId); - - const repositoryUri = worktreeProperties ? Uri.file(worktreeProperties.worktreePath) : workspaceFolder; - const repository = repositoryUri ? await gitCommitMessageService.getRepository(repositoryUri) : undefined; - if (!repository) { - return; - } - - await repository.pull(); - await repository.push(); + await sync(sessionId); } finally { await setHasGitOperationInProgress(sessionId, false); } @@ -2777,7 +2786,7 @@ export function registerCLIChatCommands( await gitService.restore(repository.rootUri, resources.map(r => r.fsPath), { ref }); })); - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { + const createPullRequest = async (sessionItemOrResource: vscode.ChatSessionItem | vscode.Uri | undefined, isDraft: boolean) => { const resource = sessionItemOrResource instanceof vscode.Uri ? sessionItemOrResource : sessionItemOrResource?.resource; @@ -2786,92 +2795,58 @@ export function registerCLIChatCommands( return; } - try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.')); - return; - } - } catch (error) { - logService.error(`Failed to check worktree properties for createPR: ${error instanceof Error ? error.message : String(error)}`); - return; - } - - await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', { - resource, - prompt: builtinSlashSCommands.createPr, - }); - })); - - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { - const resource = sessionItemOrResource instanceof vscode.Uri - ? sessionItemOrResource - : sessionItemOrResource?.resource; - - if (!resource) { + const sessionId = SessionIdForCLI.parse(resource); + let worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); + if (!worktreeProperties || worktreeProperties.version !== 2) { + vscode.window.showErrorMessage(l10n.t('Creating a pull request is only supported for worktree-based sessions.')); return; } try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - vscode.window.showErrorMessage(l10n.t('Creating a draft pull request is only supported for worktree-based sessions.')); - return; - } - } catch (error) { - logService.error(`Failed to check worktree properties for createDraftPR: ${error instanceof Error ? error.message : String(error)}`); - return; - } + await setHasGitOperationInProgress(sessionId, true); - await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', { - resource, - prompt: builtinSlashSCommands.createDraftPr, - }); - })); + const worktreeUri = vscode.Uri.file(worktreeProperties.worktreePath); - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { - const resource = sessionItemOrResource instanceof vscode.Uri - ? sessionItemOrResource - : sessionItemOrResource?.resource; + // Commit uncommitted changes + await commit(sessionId, false); - if (!resource) { - return; - } + const branchName = worktreeProperties.branchName; + const baseBranchName = worktreeProperties.baseBranchName; - let pullRequestUrl: string | undefined = undefined; + // Create the pull request + const pullRequestUrl = await pullRequestCreationService.createPullRequest( + { repositoryUri: worktreeUri, branchName, baseBranchName, isDraft }, + CancellationToken.None); - try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - vscode.window.showErrorMessage(l10n.t('Updating a pull request is only supported for worktree-based sessions.')); + if (!pullRequestUrl) { return; } - pullRequestUrl = worktreeProperties.pullRequestUrl; + worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); + if (worktreeProperties && worktreeProperties.version === 2) { + await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { + ...worktreeProperties, + changes: undefined, + pullRequestUrl + }); + } + + await copilotcliSessionItemProvider.refreshSession({ reason: 'update', sessionId }); } catch (error) { - logService.error(`Failed to check worktree properties for updatePR: ${error instanceof Error ? error.message : String(error)}`); - return; + const errorMessage = error instanceof Error ? error.message : String(error); + logService.error(`Failed to create pull request for session ${sessionId}: ${errorMessage}`); + vscode.window.showErrorMessage(l10n.t('Failed to create pull request: {0}', errorMessage)); + } finally { + await setHasGitOperationInProgress(sessionId, false); } + }; - if (!pullRequestUrl) { - vscode.window.showErrorMessage(l10n.t('No pull request URL found for this session.')); - return; - } + disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { + await createPullRequest(sessionItemOrResource, false); + })); - await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', { - resource, - prompt: builtinSlashSCommands.updatePr, - attachedContext: [{ - id: 'github-pull-request', - fullName: pullRequestUrl, - icon: new vscode.ThemeIcon('git-pull-request'), - value: vscode.Uri.parse(pullRequestUrl), - kind: 'generic' - }] - }); + disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { + await createPullRequest(sessionItemOrResource, true); })); disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToWorktree', async (args?: { worktreeUri?: vscode.Uri; fileUri?: vscode.Uri }) => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestCreationService.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestCreationService.ts new file mode 100644 index 00000000000000..5470e8b3109d62 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestCreationService.ts @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService'; +import { Diff } from '../../../platform/git/common/gitDiffService'; +import { Repository } from '../../../platform/git/vscode/git'; +import { createServiceIdentifier } from '../../../util/common/services'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { GitHubPullRequestTitleAndDescriptionGenerator } from '../../prompt/node/githubPullRequestTitleAndDescriptionGenerator'; +import { IOctoKitService } from '../../../platform/github/common/githubService'; +import { escapeRegExpCharacters } from '../../../util/vs/base/common/strings'; + +export interface PullRequestContext { + readonly commitMessages: string[]; + readonly patches: readonly Diff[]; +} + +export interface CreatePullRequestOptions { + readonly repositoryUri: vscode.Uri; + readonly branchName: string; + readonly baseBranchName: string; + readonly isDraft: boolean; +} + +export interface IPullRequestCreationService { + readonly _serviceBrand: undefined; + + /** + * Pushes the session branch to its remote, generates a title and description, + * and creates a pull request for the session. + * + * @returns The URL of the created pull request, or `undefined` if creation was + * cancelled before completion. Throws on any unrecoverable error. + */ + createPullRequest(options: CreatePullRequestOptions, token: vscode.CancellationToken): Promise; +} + +export const IPullRequestCreationService = createServiceIdentifier('IPullRequestCreationService'); + +export class PullRequestCreationService extends Disposable implements IPullRequestCreationService { + declare readonly _serviceBrand: undefined; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IGitService private readonly gitService: IGitService, + @IOctoKitService private readonly octoKitService: IOctoKitService, + ) { + super(); + } + + async createPullRequest(options: CreatePullRequestOptions, token: vscode.CancellationToken): Promise { + const { repositoryUri, branchName, baseBranchName, isDraft } = options; + + const repository = await this.gitService.openRepository(repositoryUri); + const repositoryContext = await this.gitService.getRepository(repositoryUri); + + if (!repository || !repositoryContext) { + throw new Error(l10n.t('Could not find the repository for branch \'{0}\'.', branchName)); + } + + // Resolve owner/repo from the (preferred upstream's) remote. + const githubRepoInfo = getGitHubRepoInfoFromContext(repositoryContext); + const remoteInformation = githubRepoInfo + ? repository.state.remotes.find(remote => + githubRepoInfo.remoteUrl === remote.fetchUrl || githubRepoInfo.remoteUrl === remote.pushUrl) + : undefined; + + if (!githubRepoInfo || !remoteInformation) { + throw new Error(l10n.t('Could not determine the GitHub remote for branch \'{0}\'.', branchName)); + } + + // Push the branch (set upstream when missing). + const head = repository.state.HEAD; + const setUpstream = !head?.upstream; + await repository.push(remoteInformation.name, branchName, setUpstream); + + if (token.isCancellationRequested) { + return undefined; + } + + // Collect commits and patches. + const context = await collectPullRequestContext(repository, baseBranchName, branchName, token); + if (token.isCancellationRequested) { + return undefined; + } + + let title: string | undefined; + let description: string | undefined; + if (context && (context.commitMessages.length > 0 || context.patches.length > 0)) { + const generator = this.instantiationService.createInstance(GitHubPullRequestTitleAndDescriptionGenerator); + try { + const result = await generator.provideTitleAndDescription({ + commitMessages: context.commitMessages, + patches: context.patches.map(p => p.diff), + compareBranch: branchName, + }, token); + + title = result?.title; + description = result?.description; + } finally { + generator.dispose(); + } + } + + if (token.isCancellationRequested) { + return undefined; + } + + // Base branch name may contain the remote name as a prefix, so we + // need to remove it since the API expects just the branch name. + const normalizedBaseBranchName = baseBranchName.replace( + new RegExp(`^${escapeRegExpCharacters(remoteInformation.name)}/`), + '' + ); + + const createdPullRequest = await this.octoKitService.createPullRequest( + githubRepoInfo.id.org, + githubRepoInfo.id.repo, + title ?? branchName, + description ?? '', + branchName, + normalizedBaseBranchName, + isDraft, + {}, + ); + + return createdPullRequest.url; + } +} + +async function collectPullRequestContext( + repository: Repository, + baseBranchName: string, + branchName: string, + token: CancellationToken +): Promise { + if (baseBranchName === branchName) { + return { commitMessages: [], patches: [] }; + } + + if (token.isCancellationRequested) { + return undefined; + } + + const mergeBase = await repository.getMergeBase(baseBranchName, branchName); + if (!mergeBase) { + return undefined; + } + + if (token.isCancellationRequested) { + return undefined; + } + + // Use `mergeBase..branchName` so that reverse merges from the base + // branch are excluded; `reverse: true` returns commits oldest-first to + // match the shape consumed by the PR title/description prompt. + const commits = await repository.log({ range: `${mergeBase}..${branchName}`, reverse: true }); + const commitMessages = commits.map(commit => commit.message); + + if (token.isCancellationRequested) { + return undefined; + } + + const diffChanges = await repository.diffBetweenWithStats(mergeBase, branchName) ?? []; + const patches: Diff[] = []; + for (const change of diffChanges) { + const patch = await repository.diffBetweenPatch(mergeBase, branchName, change.uri.fsPath); + if (!patch) { + continue; + } + + patches.push({ ...change, diff: patch }); + } + + if (token.isCancellationRequested) { + return undefined; + } + + return { commitMessages, patches }; +} diff --git a/extensions/copilot/src/platform/github/common/githubService.ts b/extensions/copilot/src/platform/github/common/githubService.ts index e3e2f31b9ad131..4b5da711d69d1d 100644 --- a/extensions/copilot/src/platform/github/common/githubService.ts +++ b/extensions/copilot/src/platform/github/common/githubService.ts @@ -192,6 +192,11 @@ export interface PullRequestFile { sha?: string; } +export interface CreatedPullRequest { + number: number; + url: string; +} + interface GitHubContentResponse { content?: string; encoding?: string; @@ -280,6 +285,19 @@ export interface IOctoKitService { */ addPullRequestComment(pullRequestId: string, commentBody: string, authOptions: AuthOptions): Promise; + /** + * Creates a pull request. + * @param owner The repository owner + * @param repo The repository name + * @param title The pull request title + * @param body The pull request body + * @param head The source branch name + * @param base The target branch name + * @param draft Whether to create the PR as a draft + * @param authOptions - Authentication options. By default, uses silent auth and throws {@link PermissiveAuthRequiredError} if not authenticated. + */ + createPullRequest(owner: string, repo: string, title: string, body: string, head: string, base: string, draft: boolean, authOptions: AuthOptions): Promise; + /** * Gets all open Copilot sessions. * @param authOptions - Authentication options. By default, uses silent auth and throws {@link PermissiveAuthRequiredError} if not authenticated. @@ -523,6 +541,25 @@ export class BaseOctoKitService { return addPullRequestCommentGraphQLRequest(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, token, pullRequestId, commentBody); } + protected async createPullRequestWithToken(owner: string, repo: string, title: string, body: string, head: string, base: string, draft: boolean, token: string): Promise { + const response = await this._makeGHAPIRequest(`repos/${owner}/${repo}/pulls`, 'POST', token, { + title, + body, + head, + base, + draft, + }); + + if (!response?.html_url || typeof response.number !== 'number') { + throw new Error(`Failed to create pull request for ${owner}/${repo}`); + } + + return { + url: response.html_url, + number: response.number, + }; + } + protected async getPullRequestFromSessionWithToken(globalId: string, token: string): Promise { return getPullRequestFromGlobalId(this._fetcherService, this._logService, this._telemetryService, this._capiClientService.dotcomAPIURL, token, globalId); } diff --git a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts index f88d10f160f6c4..7210d9489fd1f4 100644 --- a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts +++ b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts @@ -10,7 +10,7 @@ import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { AssignableActor, getAssignableActorsWithAssignableUsers, getAssignableActorsWithSuggestedActors, getErrorCode, PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; -import { AuthOptions, BaseOctoKitService, CCAEnabledResult, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PermissiveAuthRequiredError, PullRequestFile, RemoteAgentJobResponse } from './githubService'; +import { AuthOptions, BaseOctoKitService, CCAEnabledResult, CreatedPullRequest, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PermissiveAuthRequiredError, PullRequestFile, RemoteAgentJobResponse } from './githubService'; export class OctoKitService extends BaseOctoKitService implements IOctoKitService { declare readonly _serviceBrand: undefined; @@ -214,6 +214,15 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic return this.addPullRequestCommentWithToken(pullRequestId, commentBody, authToken); } + async createPullRequest(owner: string, repo: string, title: string, body: string, head: string, base: string, draft: boolean, authOptions: AuthOptions): Promise { + const authToken = (await this._getPermissiveSession(authOptions))?.accessToken; + if (!authToken) { + this._logService.trace('No authentication token available for createPullRequest'); + throw new PermissiveAuthRequiredError(); + } + return this.createPullRequestWithToken(owner, repo, title, body, head, base, draft, authToken); + } + async getAllSessions(nwo: string | undefined, open: boolean, authOptions: AuthOptions): Promise { try { const authToken = (await this._getPermissiveSession(authOptions))?.accessToken; diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index ac12b57d1b519c..393e0e34e28f87 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -187,7 +187,7 @@ class ChangesButtonBarWidget extends Disposable { private _getButtonConfiguration(action: IAction, outgoingChanges: number, reviewState: { isLoading: boolean; commentCount: number | undefined }, hasGitOperationInProgress: boolean, runningLabelObs: IObservable): { showIcon: boolean; showLabel: boolean; isSecondary?: boolean; customLabel?: string | IMarkdownString; customLabelObs?: IObservable; customClass?: string } | undefined { if ( action.id === 'github.copilot.sessions.commit' || - action.id === 'github.copilot.sessions.commitAndSync' + action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR' ) { if (!hasGitOperationInProgress) { return { showIcon: true, showLabel: true, isSecondary: false }; @@ -198,7 +198,10 @@ class ChangesButtonBarWidget extends Disposable { }); return { showIcon: false, showLabel: true, isSecondary: false, customLabelObs }; } - if (action.id === 'github.copilot.sessions.sync') { + if ( + action.id === 'github.copilot.sessions.sync' || + action.id === 'github.copilot.sessions.commitAndSync' + ) { const labelWithCount = outgoingChanges > 0 ? `${action.label} ${outgoingChanges}↑` : `${action.label}`; @@ -213,7 +216,6 @@ class ChangesButtonBarWidget extends Disposable { } if ( action.id === 'github.copilot.claude.sessions.sync' || - action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR' || action.id === AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID ) { const customLabel = outgoingChanges > 0 diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 6869a239f924d6..9ab521cc432de2 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -798,9 +798,23 @@ registerAction2(class MarkSessionAsDoneAction extends Action2 { IsSessionsWindowContext, IsActiveSessionArchivedContext.negate(), ActiveSessionContextKeys.HasGitRepository.isEqualTo(true), - ActiveSessionContextKeys.HasIncomingChanges.isEqualTo(false), - ActiveSessionContextKeys.HasOutgoingChanges.isEqualTo(false), - ActiveSessionContextKeys.HasUncommittedChanges.isEqualTo(false) + ContextKeyExpr.or( + // Merge scenario + ContextKeyExpr.and( + ActiveSessionContextKeys.IsMergeBaseBranchProtected.isEqualTo(false), + ActiveSessionContextKeys.HasIncomingChanges.isEqualTo(false), + ActiveSessionContextKeys.HasOutgoingChanges.isEqualTo(false), + ActiveSessionContextKeys.HasUncommittedChanges.isEqualTo(false) + ), + // Pull-request scenario + ContextKeyExpr.and( + ActiveSessionContextKeys.IsMergeBaseBranchProtected.isEqualTo(true), + ActiveSessionContextKeys.HasPullRequest.isEqualTo(true), + ActiveSessionContextKeys.HasIncomingChanges.isEqualTo(false), + ActiveSessionContextKeys.HasOutgoingChanges.isEqualTo(false), + ActiveSessionContextKeys.HasUncommittedChanges.isEqualTo(false) + ) + ) ) }] }); From d239dacf579f4e451cd7cf3bdc6dcf3af20c3432 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 16:34:17 +0200 Subject: [PATCH 18/41] Address review feedback --- .../chat/common/chatSessionsService.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index fb03b825d392ae..0b7bd7e15eac85 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -138,23 +138,23 @@ export interface IChatSessionItem { export interface IChatSessionItemMetadata { //#region Changes metadata (for sessions window) - repositoryPath?: string; - workingDirectoryPath?: string; - firstCheckpointRef?: string; - lastCheckpointRef?: string; - worktreePath?: string; - uncommittedChanges?: number; - baseRefOid?: string; - headRefOid?: string; - branchName?: string; - branch?: string; - baseBranchName?: string; - baseBranch?: string; - baseBranchProtected?: boolean; - hasGitHubRemote?: boolean; - upstreamBranchName?: string; - incomingChanges?: number; - outgoingChanges?: number; + readonly repositoryPath?: string; + readonly workingDirectoryPath?: string; + readonly firstCheckpointRef?: string; + readonly lastCheckpointRef?: string; + readonly worktreePath?: string; + readonly uncommittedChanges?: number; + readonly baseRefOid?: string; + readonly headRefOid?: string; + readonly branchName?: string; + readonly branch?: string; + readonly baseBranchName?: string; + readonly baseBranch?: string; + readonly baseBranchProtected?: boolean; + readonly hasGitHubRemote?: boolean; + readonly upstreamBranchName?: string; + readonly incomingChanges?: number; + readonly outgoingChanges?: number; //#endregion readonly [key: string]: unknown; From 50b89bfe87db658048e52291db59dd2d5d5037eb Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 16:35:18 +0200 Subject: [PATCH 19/41] More review feedback --- .../browser/agentSessions/localAgentSessionsController.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index 906f471d474c7e..498c97651e9aa7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -191,8 +191,8 @@ class LocalChatSessionItem implements IChatSessionItem { deletions: chatDetail.stats.removed, files: chatDetail.stats.fileCount, } : undefined; - const repoPath = chatDetail.workingDirectory?.scheme === Schemas.file ? chatDetail.workingDirectory.fsPath : undefined; - this.metadata = repoPath ? { workingDirectoryPath: repoPath } : undefined; + const workingDirectoryPath = chatDetail.workingDirectory?.scheme === Schemas.file ? chatDetail.workingDirectory.fsPath : undefined; + this.metadata = workingDirectoryPath ? { workingDirectoryPath: workingDirectoryPath } : undefined; } isEqual(other: LocalChatSessionItem): boolean { From f9760df269c3b75f1d4fcde879c1a989aa0c686d Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 16:47:56 +0200 Subject: [PATCH 20/41] Add unit tests --- .../tools/node/test/toolUtils.spec.ts | 114 ++++++++++++++++++ .../test/browser/tools/toolHelpers.test.ts | 21 ++++ .../chatExternalPathConfirmation.test.ts | 41 +++++++ 3 files changed, 176 insertions(+) diff --git a/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts b/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts index 4333b9f7f0f8cb..7d42f4d2eab9eb 100644 --- a/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/toolUtils.spec.ts @@ -223,6 +223,58 @@ suite('toolUtils - additionalReadAccessPaths', () => { expect(invokeIsDirExternalAndNeedsConfirmation(URI.file('/external/dir'), false)).toBe(true); }); }); + + describe('workingDirectory support', () => { + const workingDir = URI.file('/my-project'); + + function invokeAssertFileOkWithWd(uri: URI) { + return instantiationService.invokeFunction(acc => assertFileOkForTool(acc, uri, undefined, { workingDirectory: workingDir })); + } + + function invokeIsFileExternalWithWd(uri: URI) { + return instantiationService.invokeFunction(acc => isFileExternalAndNeedsConfirmation(acc, uri, undefined, { readOnly: true, workingDirectory: workingDir })); + } + + function invokeIsDirExternalWithWd(uri: URI) { + return instantiationService.invokeFunction(acc => isDirExternalAndNeedsConfirmation(acc, uri, undefined, { readOnly: true, workingDirectory: workingDir })); + } + + test('assertFileOkForTool allows file within workingDirectory', async () => { + await expect(invokeAssertFileOkWithWd(URI.file('/my-project/src/index.ts'))).resolves.toBeUndefined(); + }); + + test('assertFileOkForTool rejects file outside workingDirectory', async () => { + await expect(invokeAssertFileOkWithWd(URI.file('/other-project/file.ts'))) + .rejects.toThrow(/outside of the workspace/); + }); + + test('assertFileOkForTool rejects workspace file when workingDirectory is set', async () => { + // /workspace is the workspace folder, but workingDirectory overrides it + await expect(invokeAssertFileOkWithWd(URI.file('/workspace/file.ts'))) + .rejects.toThrow(/outside of the workspace/); + }); + + test('isFileExternalAndNeedsConfirmation: file within workingDirectory is not external', async () => { + expect(await invokeIsFileExternalWithWd(URI.file('/my-project/src/file.ts'))).toBe(false); + }); + + test('isFileExternalAndNeedsConfirmation: workspace file is external when workingDirectory is set', async () => { + await expect(invokeIsFileExternalWithWd(URI.file('/workspace/file.ts'))) + .rejects.toThrow(/does not exist/); + }); + + test('isDirExternalAndNeedsConfirmation: dir within workingDirectory is not external', () => { + expect(invokeIsDirExternalWithWd(URI.file('/my-project/src'))).toBe(false); + }); + + test('isDirExternalAndNeedsConfirmation: workspace dir is external when workingDirectory is set', () => { + expect(invokeIsDirExternalWithWd(URI.file('/workspace/subdir'))).toBe(true); + }); + + test('isDirExternalAndNeedsConfirmation: dir outside workingDirectory is external', () => { + expect(invokeIsDirExternalWithWd(URI.file('/other-project/dir'))).toBe(true); + }); + }); }); suite('toolUtils - isDirExternalAndNeedsConfirmation with skill folders', () => { @@ -655,3 +707,65 @@ describe('inputGlobToPattern - multi-root workspace', () => { expect(result.folderName).toBeUndefined(); }); }); + +describe('inputGlobToPattern - workingDirectory', () => { + const workingDir = URI.file('/projects/ski-planner'); + const folder1 = URI.file('/workspace/other-project'); + const workspaceService = new MultiRootWorkspaceService([folder1]); + + test('unscoped glob pattern is scoped to workingDirectory', () => { + const result = inputGlobToPattern('**/*.ts', workspaceService, undefined, workingDir); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toMatchObject({ baseUri: workingDir, pattern: '**/*.ts' }); + }); + + test('unscoped relative pattern is scoped to workingDirectory', () => { + const result = inputGlobToPattern('src/**', workspaceService, undefined, workingDir); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toMatchObject({ baseUri: workingDir, pattern: 'src/**' }); + }); + + test('absolute path within workingDirectory is resolved relative to it', () => { + const result = inputGlobToPattern('/projects/ski-planner/src/index.ts', workspaceService, undefined, workingDir); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toMatchObject({ baseUri: workingDir, pattern: 'src/index.ts' }); + expect(result.folderRelativePattern).toBe('src/index.ts'); + }); + + test('absolute path outside workingDirectory is not rewritten to relative', () => { + const result = inputGlobToPattern('/other/path/file.ts', workspaceService, undefined, workingDir); + expect(result.patterns).toHaveLength(1); + // Still scoped to workingDirectory as an unscoped string pattern + expect(result.patterns[0]).toMatchObject({ baseUri: workingDir, pattern: '/other/path/file.ts' }); + expect(result.folderRelativePattern).toBeUndefined(); + }); + + test('absolute path in workspace folder is NOT resolved against workspace when workingDirectory is set', () => { + const result = inputGlobToPattern('/workspace/other-project/src', workspaceService, undefined, workingDir); + expect(result.patterns).toHaveLength(1); + // Should NOT resolve against the workspace folder — workingDirectory takes precedence + expect(result.folderName).toBeUndefined(); + }); + + test('folder-name rewriting is suppressed when workingDirectory is set', () => { + const multiRoot = new MultiRootWorkspaceService([folder1, URI.file('/workspace/vscode')]); + const result = inputGlobToPattern('vscode/src/**', multiRoot, undefined, workingDir); + expect(result.patterns).toHaveLength(1); + // Should NOT rewrite to the workspace folder — scoped to workingDirectory instead + expect(result.patterns[0]).toMatchObject({ baseUri: workingDir, pattern: 'vscode/src/**' }); + expect(result.folderName).toBeUndefined(); + }); + + test('bare wildcard is scoped to workingDirectory', () => { + const result = inputGlobToPattern('*', workspaceService, undefined, workingDir); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toMatchObject({ baseUri: workingDir, pattern: '*' }); + }); + + test('without workingDirectory, falls back to workspace folders for absolute paths', () => { + const result = inputGlobToPattern('/workspace/other-project/src', workspaceService, undefined); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toMatchObject({ baseUri: folder1, pattern: 'src' }); + expect(result.folderName).toBe('other-project'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts index b54cf20029b979..3fd22d61141724 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/toolHelpers.test.ts @@ -64,6 +64,27 @@ suite('Tool Helpers', () => { const result = resolveToolUri({ symbol: 'x', lineContent: 'x' }, ws); assert.strictEqual(result, undefined); }); + + test('resolves filePath against workingDirectory when provided', () => { + const ws = createMockWorkspaceService(URI.parse('file:///other-workspace')); + const workingDirectory = URI.parse('file:///session-dir'); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', filePath: 'src/index.ts' }, ws, workingDirectory); + assert.strictEqual(result?.toString(), 'file:///session-dir/src/index.ts'); + }); + + test('workingDirectory takes precedence over workspace folders', () => { + const ws = createMockWorkspaceService(URI.parse('file:///workspace')); + const workingDirectory = URI.parse('file:///my-project'); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', filePath: 'file.ts' }, ws, workingDirectory); + assert.strictEqual(result?.toString(), 'file:///my-project/file.ts'); + }); + + test('uri field ignores workingDirectory', () => { + const ws = createMockWorkspaceService(); + const workingDirectory = URI.parse('file:///session-dir'); + const result = resolveToolUri({ symbol: 'x', lineContent: 'x', uri: 'file:///absolute/path.ts' }, ws, workingDirectory); + assert.strictEqual(result?.toString(), 'file:///absolute/path.ts'); + }); }); suite('findLineNumber', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/chatExternalPathConfirmation.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/chatExternalPathConfirmation.test.ts index a046a5302bb76f..bf00e482361ddc 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/chatExternalPathConfirmation.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/chatExternalPathConfirmation.test.ts @@ -201,4 +201,45 @@ suite('ChatExternalPathConfirmationContribution', () => { assert.strictEqual(contribution.getPreConfirmAction(ref), undefined); }); + + suite('workingDirectory', () => { + + function createRefWithWorkingDir(filePath: string, workingDirectory: URI): ILanguageModelToolConfirmationRef { + return { + toolId: 'copilot_readFile', + source, + parameters: { filePath }, + chatSessionResource: sessionResource, + workingDirectory, + }; + } + + test('file within workingDirectory is auto-approved', () => { + const contribution = createContribution(); + const ref = createRefWithWorkingDir('/my-project/src/file.ts', URI.file('/my-project')); + assert.deepStrictEqual(contribution.getPreConfirmAction(ref), { type: ToolConfirmKind.UserAction }); + }); + + test('file outside workingDirectory is not auto-approved', () => { + const contribution = createContribution(); + const ref = createRefWithWorkingDir('/other-project/file.ts', URI.file('/my-project')); + assert.strictEqual(contribution.getPreConfirmAction(ref), undefined); + }); + + test('workingDirectory takes precedence over workspace allowlist', () => { + // Even if workspace allowlist would approve it, workingDirectory is checked exclusively + const contribution = createContribution(); + const ref = createRefWithWorkingDir('/my-project/file.ts', URI.file('/my-project')); + const result = contribution.getPreConfirmAction(ref); + assert.deepStrictEqual(result, { type: ToolConfirmKind.UserAction }); + }); + + test('workspace-allowed file is not approved when workingDirectory excludes it', () => { + // The file is NOT in the workingDirectory, so it should not be approved + // even though the workspace allowlist is not checked when workingDirectory is set + const contribution = createContribution(); + const ref = createRefWithWorkingDir('/workspace/file.ts', URI.file('/different-dir')); + assert.strictEqual(contribution.getPreConfirmAction(ref), undefined); + }); + }); }); From 7fadeba2075b6bb72d53271275002be31392337a Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Fri, 8 May 2026 16:49:22 +0200 Subject: [PATCH 21/41] Address review feedback --- .../browser/copilotChatSessions.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts index 08f7470ea87184..eeaded602ae6ad 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -32,7 +32,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'boolean', default: false, tags: ['experimental'], - description: localize('sessions.chat.localAgent.enabled', "Enable Local VS Code chat sessions in the Agents app. Start in-process chat sessions directly, without a background agent or worktree."), + description: localize('sessions.chat.localAgent.enabled', "Enable Local VS Code chat sessions in the Agents Window."), }, }, }); From 41b2ab0607fba4e47e555fd28c2ceed8e8f8927a Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 8 May 2026 16:50:13 +0200 Subject: [PATCH 22/41] extensions: rename `supportSessionsWindow` setting to `supportAgentsWindow` (#315265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * extensions: rename supportSessionsWindow setting to supportAgentsWindow Renames the \xtensions.supportSessionsWindow\ setting and its associated constant \EXTENSIONS_SUPPORT_SESSIONS_WINDOW\ to \xtensions.supportAgentsWindow\ and \EXTENSIONS_SUPPORT_AGENTS_WINDOW\ respectively, to align with the renaming of the Sessions window to the Agents window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * extensions: add vscodevim.vim to sessions window allowed extensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/extensions/browser/extensions.contribution.ts | 6 +++--- .../browser/sessionsWindowAllowedExtensions.ts | 1 + .../test/browser/extensionEnablementService.test.ts | 4 ++-- .../extensions/common/extensionManifestPropertiesService.ts | 4 ++-- .../test/common/extensionManifestPropertiesService.test.ts | 4 ++-- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 60db6589ab1116..82c7072ff42e7a 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -56,7 +56,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; import { IWorkspaceExtensionsConfigService } from '../../../services/extensionRecommendations/common/workspaceExtensionsConfig.js'; -import { EXTENSIONS_SUPPORT_SESSIONS_WINDOW } from '../../../services/extensions/common/extensionManifestPropertiesService.js'; +import { EXTENSIONS_SUPPORT_AGENTS_WINDOW } from '../../../services/extensions/common/extensionManifestPropertiesService.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; @@ -215,10 +215,10 @@ Registry.as(ConfigurationExtensions.Configuration) } }] }, - [EXTENSIONS_SUPPORT_SESSIONS_WINDOW]: { + [EXTENSIONS_SUPPORT_AGENTS_WINDOW]: { type: 'object', scope: ConfigurationScope.APPLICATION, - markdownDescription: localize('extensions.supportSessionsWindow', "Override the Agents window support of an extension. Extensions using `true` will be enabled in the Agents window even when they would otherwise be disabled."), + markdownDescription: localize('extensions.supportAgentsWindow', "Override the Agents window support of an extension. Extensions using `true` will be enabled in the Agents window even when they would otherwise be disabled."), patternProperties: { '([a-z0-9A-Z][a-z0-9-A-Z]*)\\.([a-z0-9A-Z][a-z0-9-A-Z]*)$': { type: 'boolean', diff --git a/src/vs/workbench/services/extensionManagement/browser/sessionsWindowAllowedExtensions.ts b/src/vs/workbench/services/extensionManagement/browser/sessionsWindowAllowedExtensions.ts index 511d2705695b6d..eeeb3375385cb3 100644 --- a/src/vs/workbench/services/extensionManagement/browser/sessionsWindowAllowedExtensions.ts +++ b/src/vs/workbench/services/extensionManagement/browser/sessionsWindowAllowedExtensions.ts @@ -20,4 +20,5 @@ export const SESSIONS_WINDOW_ALLOWED_EXTENSIONS: ReadonlySet = new Set id.toLowerCase())); diff --git a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts index 4a23062fc708c3..dc5b2120c2a415 100644 --- a/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts +++ b/src/vs/workbench/services/extensionManagement/test/browser/extensionEnablementService.test.ts @@ -30,7 +30,7 @@ import { IHostService } from '../../../host/browser/host.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IExtensionBisectService } from '../../browser/extensionBisect.js'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, WorkspaceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; -import { EXTENSIONS_SUPPORT_SESSIONS_WINDOW, ExtensionManifestPropertiesService, IExtensionManifestPropertiesService } from '../../../extensions/common/extensionManifestPropertiesService.js'; +import { EXTENSIONS_SUPPORT_AGENTS_WINDOW, ExtensionManifestPropertiesService, IExtensionManifestPropertiesService } from '../../../extensions/common/extensionManifestPropertiesService.js'; import { TestChatEntitlementService, TestContextService, TestProductService, TestWorkspaceTrustEnablementService, TestWorkspaceTrustManagementService } from '../../../../test/common/workbenchTestServices.js'; import { TestWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; import { ExtensionManagementService } from '../../common/extensionManagementService.js'; @@ -1267,7 +1267,7 @@ suite('ExtensionEnablementService Test', () => { }); test('test configured extensions are enabled in sessions window', async () => { - await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(EXTENSIONS_SUPPORT_SESSIONS_WINDOW, { 'pub.withMain': true, 'pub.nonThemeContrib': true }); + await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(EXTENSIONS_SUPPORT_AGENTS_WINDOW, { 'pub.withMain': true, 'pub.nonThemeContrib': true }); instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: true }); testObject = disposableStore.add(new TestExtensionEnablementService(instantiationService)); diff --git a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts index 0fe9ce70ee097f..1226578ad61ca3 100644 --- a/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts +++ b/src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts @@ -22,7 +22,7 @@ import { isWeb } from '../../../../base/common/platform.js'; export const IExtensionManifestPropertiesService = createDecorator('extensionManifestPropertiesService'); -export const EXTENSIONS_SUPPORT_SESSIONS_WINDOW = 'extensions.supportSessionsWindow'; +export const EXTENSIONS_SUPPORT_AGENTS_WINDOW = 'extensions.supportAgentsWindow'; const SESSIONS_WINDOW_ALLOWED_CONTRIBUTION_POINTS: ReadonlySet = new Set([ 'themes', @@ -382,7 +382,7 @@ export class ExtensionManifestPropertiesService extends Disposable implements IE private getConfiguredSessionsWindowSupport(manifest: IExtensionManifest): boolean | undefined { if (this._configuredSessionsWindowSupportMap === null) { const configuredSessionsWindowSupportMap = new ExtensionIdentifierMap(); - const configuredSessionsWindowSupport = this.configurationService.getValue<{ [key: string]: boolean }>(EXTENSIONS_SUPPORT_SESSIONS_WINDOW) || {}; + const configuredSessionsWindowSupport = this.configurationService.getValue<{ [key: string]: boolean }>(EXTENSIONS_SUPPORT_AGENTS_WINDOW) || {}; for (const id of Object.keys(configuredSessionsWindowSupport)) { if (configuredSessionsWindowSupport[id] !== undefined) { configuredSessionsWindowSupportMap.set(id, configuredSessionsWindowSupport[id]); diff --git a/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts b/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts index 6147ec33ac54d4..a9c6d56d42a848 100644 --- a/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts +++ b/src/vs/workbench/services/extensions/test/common/extensionManifestPropertiesService.test.ts @@ -14,7 +14,7 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IWorkspaceTrustEnablementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; -import { EXTENSIONS_SUPPORT_SESSIONS_WINDOW, ExtensionManifestPropertiesService } from '../../common/extensionManifestPropertiesService.js'; +import { EXTENSIONS_SUPPORT_AGENTS_WINDOW, ExtensionManifestPropertiesService } from '../../common/extensionManifestPropertiesService.js'; import { TestProductService, TestWorkspaceTrustEnablementService } from '../../../../test/common/workbenchTestServices.js'; suite('ExtensionManifestPropertiesService - ExtensionKind', () => { @@ -146,7 +146,7 @@ suite('ExtensionManifestPropertiesService - SessionsWindowSupport', () => { }); test('uses configured sessions window support override', async () => { - await testConfigurationService.setUserConfiguration(EXTENSIONS_SUPPORT_SESSIONS_WINDOW, { 'pub.a': true, 'pub.b': false }); + await testConfigurationService.setUserConfiguration(EXTENSIONS_SUPPORT_AGENTS_WINDOW, { 'pub.a': true, 'pub.b': false }); testObject = createTestObject(); assert.deepStrictEqual([ From cefcfa09a9d3ce4636bc2006be9de9b856a0ca6a Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 8 May 2026 16:32:18 +0200 Subject: [PATCH 23/41] Limit error message header to first line --- build/lib/screenshotDiffReport.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/lib/screenshotDiffReport.ts b/build/lib/screenshotDiffReport.ts index 1176ab802361a4..dfbcee8b53af66 100644 --- a/build/lib/screenshotDiffReport.ts +++ b/build/lib/screenshotDiffReport.ts @@ -424,7 +424,8 @@ function generateMarkdown( for (let i = 0; i < errored.length; i++) { const entry = errored[i]; const open = i < EXPAND_FIRST_N ? ' open' : ''; - const header = `${entry.fixtureId} — ${escapeMarkdown(entry.errorMessage)}`; + const headerMessage = entry.errorMessage.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? entry.errorMessage; + const header = `${entry.fixtureId} — ${escapeMarkdown(headerMessage)}`; const fullStack = entry.errorStack ?? entry.errorMessage; const fullBlock = `${header}\n\n\`\`\`\n${fullStack}\n\`\`\`\n\n\n`; From 389e051f8e2524922e8b71632bc22f2258c647d6 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 8 May 2026 14:52:07 +0000 Subject: [PATCH 24/41] Agents - show outgoing changes while action is running (#315274) --- src/vs/sessions/contrib/changes/browser/changesView.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 393e0e34e28f87..fd852bf0d72b50 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -208,11 +208,7 @@ class ChangesButtonBarWidget extends Disposable { if (!hasGitOperationInProgress) { return { showIcon: true, showLabel: true, isSecondary: false, customLabel: labelWithCount }; } - const customLabelObs = derived(reader => { - const running = runningLabelObs.read(reader); - return `$(loading) ${running ?? labelWithCount}`; - }); - return { showIcon: false, showLabel: true, isSecondary: false, customLabelObs }; + return { showIcon: false, showLabel: true, isSecondary: false, customLabel: `$(loading) ${labelWithCount}` }; } if ( action.id === 'github.copilot.claude.sessions.sync' || From cc7bc1537c9b5cb7d7417c9f12febe5861dfea8a Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Fri, 8 May 2026 10:37:54 -0500 Subject: [PATCH 25/41] Address code review comments --- .../contrib/chat/browser/chatStatus/chatStatusEntry.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index f5c2868613bbf9..191bc3eb727eb0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -34,6 +34,8 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu static readonly ID = 'workbench.contrib.chatStatusBarEntry'; + private static readonly TITLE_BAR_CONTEXT_KEYS = new Set(['updateTitleBar', InEditorZenModeContext.key]); + private entry: IStatusbarEntryAccessor | undefined = undefined; private readonly activeCodeEditorListener = this._register(new MutableDisposable()); @@ -107,7 +109,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.update())); this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.update())); this._register(this.contextKeyService.onDidChangeContext(e => { - if (e.affectsSome(new Set(['updateTitleBar', InEditorZenModeContext.key]))) { + if (e.affectsSome(ChatStatusBarEntry.TITLE_BAR_CONTEXT_KEYS)) { this.update(); } })); @@ -265,6 +267,11 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu return false; } + // Title bar sign-in button only shows when user is signed out + if (this.chatEntitlementService.entitlement !== ChatEntitlement.Unknown) { + return false; + } + if (this.chatEntitlementService.sentiment.hidden || this.chatEntitlementService.sentiment.disabledInWorkspace) { return false; } From 95ad92df62c85e69bcb04b47e6262d423b641c4c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 8 May 2026 17:46:57 +0200 Subject: [PATCH 26/41] Use SVG icon instead of codicon in Open in Agents titlebar widget (#315286) Reverts the agents widget icon from codicon-agent back to the sessions-icon.svg background image with grayscale filter, matching the pattern used by the Open in VS Code widget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentSessionsActions.ts | 2 +- .../agentSessions/media/openInAgents.css | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) 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 24fda747c0c3dd..9037eb760f1cf1 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -104,7 +104,7 @@ class OpenWorkspaceInAgentsTitleBarWidget extends BaseActionViewItem { container.setAttribute('aria-label', hoverText); this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, hoverText)); - const icon = append(container, $('span.open-in-agents-titlebar-widget-icon.codicon.codicon-agent')); + const icon = append(container, $('span.open-in-agents-titlebar-widget-icon')); icon.setAttribute('aria-hidden', 'true'); const labelEl = append(container, $('span.open-in-agents-titlebar-widget-label')); diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/media/openInAgents.css b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/media/openInAgents.css index 73e3dafbb3ffa7..08ee9b9dd8c2a5 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/media/openInAgents.css +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/media/openInAgents.css @@ -34,10 +34,26 @@ width: 16px; height: 16px; flex: 0 0 auto; - font-size: 16px; - display: flex; - align-items: center; - justify-content: center; + background-image: url('../../../../../../sessions/browser/media/sessions-icon.svg'); + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + filter: grayscale(1); +} + +.monaco-enable-motion .monaco-workbench .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-icon, +.monaco-workbench.monaco-enable-motion .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-icon { + transition: filter 160ms ease; +} + +.monaco-reduce-motion .monaco-workbench .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-icon, +.monaco-workbench.monaco-reduce-motion .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-icon { + transition-duration: 0ms !important; +} + +.monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-icon, +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible > .open-in-agents-titlebar-widget-icon { + filter: none; } From 4b27aff3a1b4e83ce92cdaf040d16ed8c19b1803 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 8 May 2026 09:32:04 -0700 Subject: [PATCH 27/41] Remove unused export const (#315244) --- src/vs/workbench/contrib/terminal/common/terminal.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 15ad6cbcec969a..aad6a569f74048 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -60,12 +60,6 @@ export interface ITerminalProfileResolverService { getEnvironment(remoteAuthority: string | undefined): Promise; } -/* - * When there were shell integration args injected - * and createProcess returns an error, this exit code will be used. - */ -export const ShellIntegrationExitCode = 633; - export interface IRegisterContributedProfileArgs { extensionIdentifier: string; id: string; title: string; options: ICreateContributedTerminalProfileOptions; titleTemplate?: string; } From 615782e76f79251305beb73a354e461fc51a35ca Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 8 May 2026 17:05:53 +0200 Subject: [PATCH 28/41] Approval telemetry (#315215) --- .../tools/languageModelToolsService.ts | 66 +++++++++++++++++++ .../abstractToolConfirmationSubPart.ts | 2 +- .../chat/common/chatService/chatService.ts | 3 +- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 7b4a8f683766bc..af82cdf409e0e2 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -603,6 +603,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo this.playAccessibilitySignal([toolInvocation], dto.context?.sessionResource); } const userConfirmed = await IChatToolInvocation.awaitConfirmation(toolInvocation, token); + this._logToolApprovalTelemetry(tool, dto, userConfirmed); if (userConfirmed.type === ToolConfirmKind.Denied) { throw new CancellationError(); } @@ -624,6 +625,8 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo dto.parameters = dto.toolSpecificData.rawInput; dto.toolSpecificData = undefined; } + } else { + this._logToolApprovalTelemetry(tool, dto, autoConfirmed ?? { type: ToolConfirmKind.ConfirmationNotNeeded }); } } else { prepareTimeWatch = StopWatch.create(true); @@ -734,6 +737,39 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return this.prepareToolInvocation(tool, dto, forceConfirmationReason, token); } + private _logToolApprovalTelemetry(tool: IToolEntry, dto: IToolInvocation, reason: ConfirmedReason): void { + const confirmKindNames: Record = { + [ToolConfirmKind.Denied]: 'denied', + [ToolConfirmKind.ConfirmationNotNeeded]: 'confirmationNotNeeded', + [ToolConfirmKind.Setting]: 'setting', + [ToolConfirmKind.LmServicePerTool]: 'lmServicePerTool', + [ToolConfirmKind.UserAction]: 'userAction', + [ToolConfirmKind.Skipped]: 'skipped', + }; + const allowedConfirmationNotNeededReasons = new Set(['auto-approve-all', 'inlineChat']); + let confirmationNotNeededReason: string | undefined; + if (reason.type === ToolConfirmKind.ConfirmationNotNeeded && reason.reason) { + const raw = typeof reason.reason === 'string' ? reason.reason : reason.reason.value; + confirmationNotNeededReason = allowedConfirmationNotNeededReasons.has(raw) ? raw : 'other'; + } + const terminalData = dto.toolSpecificData?.kind === 'terminal' ? dto.toolSpecificData : undefined; + this._telemetryService.publicLog2( + 'chat.toolApproval', + { + confirmKind: confirmKindNames[reason.type], + settingId: reason.type === ToolConfirmKind.Setting ? reason.id : undefined, + lmServiceScope: reason.type === ToolConfirmKind.LmServicePerTool ? reason.scope : undefined, + customButtonKind: reason.type === ToolConfirmKind.UserAction ? reason.selectedButtonKind : undefined, + confirmationNotNeededReason, + sandboxWrapped: terminalData?.commandLine.isSandboxWrapped, + requestUnsandboxedExecution: terminalData?.requestUnsandboxedExecution, + chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined, + toolId: tool.data.id, + toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined, + toolSourceKind: tool.data.source.type, + }); + } + /** * Determines the auto-confirm decision based on a preToolUse hook result. * If the hook returned 'allow', auto-approves. If 'ask', forces confirmation @@ -1693,3 +1729,33 @@ type LanguageModelToolInvokedClassification = { owner: 'roblourens'; comment: 'Provides insight into the usage of language model tools.'; }; + +type ToolApprovalEvent = { + confirmKind: string; + settingId: string | undefined; + lmServiceScope: string | undefined; + customButtonKind: string | undefined; + confirmationNotNeededReason: string | undefined; + sandboxWrapped: boolean | undefined; + requestUnsandboxedExecution: boolean | undefined; + chatSessionId: string | undefined; + toolId: string; + toolExtensionId: string | undefined; + toolSourceKind: string; +}; + +type ToolApprovalClassification = { + confirmKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the confirmation was resolved (userAction, setting, lmServicePerTool, confirmationNotNeeded, denied, skipped). Anything other than userAction implies auto-approval. "denied" and "skipped" mean the tool did not run; otherwise it ran (note: a custom Deny button click resolves as userAction since the tool still runs and the chosen label is passed to it; see customButtonKind to distinguish).' }; + settingId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When confirmKind is setting, the configuration id that auto-approved the tool.' }; + lmServiceScope: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When confirmKind is lmServicePerTool, the scope (session/workspace/profile).' }; + customButtonKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When the user clicked a custom button on the confirmation widget, whether the button represents approve or deny semantics. Undefined when no custom button was clicked.' }; + confirmationNotNeededReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When confirmKind is confirmationNotNeeded, a stable identifier for why the tool did not require confirmation. Limited to a known allowlist (e.g. auto-approve-all, inlineChat); set to "other" for any other reason; undefined when no reason was supplied.' }; + sandboxWrapped: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'For terminal tool calls, whether this specific invocation runs inside the agent terminal sandbox. Undefined for non-terminal tools.' }; + requestUnsandboxedExecution: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'For terminal tool calls, whether the model requested to bypass the sandbox for this invocation. Undefined for non-terminal tools.' }; + chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat session that the tool was used within, if applicable.' }; + toolId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the tool used.' }; + toolExtensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension that contributed the tool.' }; + toolSourceKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source (mcp/extension/internal) of the tool.' }; + owner: 'chrmarti'; + comment: 'Provides insight into how tool confirmations are resolved (user action vs. auto-approval).'; +}; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index 243492253495b0..94c5130b60cc0a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -160,7 +160,7 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca const makeAction = (option: ConfirmationOption): IChatConfirmationButton<(() => void)> => ({ label: option.label, data: () => { - this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: option.id }); + this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: option.id, selectedButtonKind: option.kind }); }, }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 9c174bff1ad1a3..c765aef5df12c1 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -33,6 +33,7 @@ import { HookTypeValue } from '../promptSyntax/hookTypes.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatParserContext } from '../requestParser/chatRequestParser.js'; import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult, IToolResultInputOutputDetails, ToolDataSource } from '../tools/languageModelToolsService.js'; +import { ConfirmationOptionKind } from '../../../../../platform/agentHost/common/state/protocol/state.js'; export interface IChatRequest { message: string; @@ -624,7 +625,7 @@ export type ConfirmedReason = | { type: ToolConfirmKind.ConfirmationNotNeeded; reason?: string | IMarkdownString } | { type: ToolConfirmKind.Setting; id: string } | { type: ToolConfirmKind.LmServicePerTool; scope: 'session' | 'workspace' | 'profile' } - | { type: ToolConfirmKind.UserAction; selectedButton?: string } + | { type: ToolConfirmKind.UserAction; selectedButton?: string; selectedButtonKind?: ConfirmationOptionKind } | { type: ToolConfirmKind.Skipped }; export interface IChatToolInvocation { From 152ffe54ea6377901e427561e2a13edefa96c332 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 8 May 2026 09:42:01 -0700 Subject: [PATCH 29/41] agentHost: support image and blob user-message attachments Adopts the agent-host protocol's MessageAttachment surface so user-attached images, file references, selections, paste, prompt text, and prompt files round-trip through the agent host and into the underlying agent SDK. - Expand `_convertVariableToAttachment` in the chat-side handler to cover image, symbol, paste, promptText, promptFile, and selection variants in addition to file/directory. - On the agent host, snapshot inline `EmbeddedResource` payloads and remote `Resource` attachments to disk under `/attachments//` and rewrite the action to reference them via local `file:` URIs, keeping large blobs out of the in-memory state tree. - Read remote attachment bytes through `toAgentClientUri` so the existing `vscode-agent-client` filesystem provider routes the request to the originating client. - Auto-approve `read` permission requests for any path under the session's `attachments` directory in the Copilot agent's `handlePermissionRequest`. - Translate protocol attachments back to chat-layer `IChatRequestVariableEntry`s when building history (`turnsToHistory`), the active turn synthesis, the server-initiated turn (`startServerRequest`), and the pending/queued message sync, so attachments survive history replay and pending message round-trips. - Restore SDK-side attachments in `mapSessionEvents`'s `user.message` handling so resumed sessions retain their attachments. - Forward attachments from the workbench to the SDK in `_toSdkAttachment`, mapping Resource selections to `selection`, directories to `directory`, files to `file`, and `EmbeddedResource` blobs to `blob`. Fixes https://github.com/microsoft/vscode/issues/315137 (Commit message generated by Copilot) --- .../agentHost/common/sessionDataService.ts | 9 + .../platform/agentHost/node/agentService.ts | 166 +++++++++++++- .../node/copilot/copilotAgentSession.ts | 51 ++++- .../node/copilot/mapSessionEvents.ts | 99 ++++++++- .../agentHost/test/node/agentService.test.ts | 210 +++++++++++++++++- .../agentHost/agentHostSessionHandler.ts | 116 ++++++++-- .../agentHost/stateToProgressAdapter.ts | 111 ++++++++- .../common/chatService/chatServiceImpl.ts | 4 +- .../chat/common/chatSessionsService.ts | 2 +- .../stateToProgressAdapter.test.ts | 2 +- .../common/chatService/chatService.test.ts | 4 +- 11 files changed, 727 insertions(+), 47 deletions(-) diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index d78d718861bd71..d0228d27bfd460 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -13,6 +13,15 @@ export const ISessionDataService = createDecorator('session /** Filename of the per-session SQLite database. */ export const SESSION_DB_FILENAME = 'session.db'; +/** + * Subdirectory under a session's data directory that holds snapshotted + * user-message attachments (e.g. pasted images, fetched file references). + * The agent host writes these on dispatch so large blobs stay out of the + * in-memory state tree, and reads of files under this directory are + * auto-approved by the agent's permission flow. + */ +export const SESSION_ATTACHMENTS_DIRNAME = 'attachments'; + // ---- File-edit types ---------------------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 23e2ddf73545c1..cac659c1c3d5b2 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -9,9 +9,10 @@ import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableResourceMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; +import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js'; import { equals as objectEquals } from '../../../base/common/objects.js'; import { observableValue } from '../../../base/common/observable.js'; -import { isEqual } from '../../../base/common/resources.js'; +import { extname as resourcesExtname, isEqual, joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; @@ -19,10 +20,12 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; +import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; +import type { SessionPendingMessageSetAction, SessionTurnStartedAction } from '../common/state/protocol/actions.js'; import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUriPrefix, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; @@ -35,6 +38,7 @@ import { IAgentHostGitService } from './agentHostGitService.js'; import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; +import { toAgentClientUri } from '../common/agentClientUri.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -761,9 +765,43 @@ export class AgentService extends Disposable implements IAgentService { return false; } + /** + * Per-client sequencer that serialises action dispatches whose + * processing requires an asynchronous prelude (e.g. snapshotting + * user-message attachments into the session database before the + * action is reduced into state). Actions that don't need any + * asynchronous prelude bypass the queue entirely as long as no + * earlier action from the same client is still pending. + * + * todo@connor4312: we can drop this when sending a message become a command + */ + private readonly _clientDispatchQueues = new Map>(); + dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); + const pending = this._clientDispatchQueues.get(clientId); + if (!pending && !this._needsAsyncRewrite(action)) { + this._dispatchActionNow(action, clientId, clientSeq); + return; + } + const next = (pending ?? Promise.resolve()).then(async () => { + const rewritten: SessionAction | TerminalAction | IRootConfigChangedAction = this._needsAsyncRewrite(action) + ? await this._rewriteUserMessageAttachments(action, clientId) + : action; + this._dispatchActionNow(rewritten, clientId, clientSeq); + }).catch(err => { + this._logService.error(`[AgentService] async dispatchAction failed: ${toErrorMessage(err)}`); + }); + + this._clientDispatchQueues.set(clientId, next.finally(() => { + if (this._clientDispatchQueues.get(clientId) === next) { + this._clientDispatchQueues.delete(clientId); + } + })); + } + + private _dispatchActionNow(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { const origin = { clientId, clientSeq }; this._stateManager.dispatchClientAction(action, origin); if (action.type === ActionType.RootConfigChanged) { @@ -772,6 +810,130 @@ export class AgentService extends Disposable implements IAgentService { this._sideEffects.handleAction(action); } + private _needsAsyncRewrite(action: SessionAction | TerminalAction | IRootConfigChangedAction): action is SessionTurnStartedAction | SessionPendingMessageSetAction { + if (action.type !== ActionType.SessionTurnStarted && action.type !== ActionType.SessionPendingMessageSet) { + return false; + } + const attachmentsRootStr = this._attachmentsRoot(URI.parse(action.session)).toString(); + return !!action.userMessage.attachments?.some(a => this._isRewritableAttachment(a, attachmentsRootStr)); + } + + private _isRewritableAttachment(attachment: MessageAttachment, attachmentsRootStr: string): boolean { + if (attachment.type === MessageAttachmentKind.EmbeddedResource) { + return true; + } + + return false; + } + + private _attachmentsRoot(session: URI): URI { + return joinPath(this._sessionDataService.getSessionDataDir(session), SESSION_ATTACHMENTS_DIRNAME); + } + + /** + * Snapshot inline / client-resident attachment payloads onto disk + * under the session's data directory and rewrite the action to + * reference them via local `file:` URIs. Keeps potentially large + * blobs (e.g. pasted images) out of the in-memory state tree while + * letting the agent consume them via the standard {@link IFileService} + * surface — no special URI scheme or blob round-tripping needed. + * + * Failures are isolated per-attachment: if a rewrite cannot be + * performed (no client connection registered, `resourceRead` rejects, + * etc.) the original attachment is preserved so the agent still has a + * chance to make use of it. + */ + private async _rewriteUserMessageAttachments(action: T, clientId: string): Promise { + const attachments = action.userMessage.attachments; + if (!attachments?.length) { + return action; + } + const attachmentsRoot = this._attachmentsRoot(URI.parse(action.session)); + const attachmentsRootStr = attachmentsRoot.toString(); + const rewritten = await Promise.all(attachments.map(a => this._rewriteSingleAttachment(a, attachmentsRoot, attachmentsRootStr, clientId))); + return { + ...action, + userMessage: { ...action.userMessage, attachments: rewritten }, + }; + } + + private async _rewriteSingleAttachment(attachment: MessageAttachment, attachmentsRoot: URI, attachmentsRootStr: string, clientId: string): Promise { + try { + if (attachment.type === MessageAttachmentKind.EmbeddedResource) { + const bytes = decodeBase64(attachment.data).buffer; + const basename = this._attachmentBasename(attachment.label, attachment.contentType); + return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot); + } + if (attachment.type === MessageAttachmentKind.Resource && this._isRewritableAttachment(attachment, attachmentsRootStr)) { + const originalUri = URI.parse(attachment.uri); + const bytes = await this._readClientResource(originalUri, clientId); + const basename = this._attachmentBasename(attachment.label, getMediaMime(originalUri.path)); + return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot); + } + } catch (err) { + this._logService.warn(`[AgentService] Failed to rewrite attachment '${attachment.label}': ${toErrorMessage(err)}`); + } + return attachment; + } + + /** + * Reads `originalUri` through the `vscode-agent-client` filesystem + * provider so it is fetched from the originating client. Falls back to + * a direct read against `originalUri` when no client filesystem + * authority is registered for `clientId` (e.g. unit tests, in-process + * agent host with a local URI). + */ + private async _readClientResource(originalUri: URI, clientId: string): Promise { + const proxiedUri = clientId ? toAgentClientUri(originalUri, clientId) : originalUri; + try { + const contents = await this._fileService.readFile(proxiedUri); + return contents.value.buffer; + } catch (err) { + if (proxiedUri !== originalUri) { + const contents = await this._fileService.readFile(originalUri); + return contents.value.buffer; + } + throw err; + } + } + + private async _writeAndRewrite( + original: MessageAttachment, + bytes: Uint8Array, + basename: string, + attachmentsRoot: URI, + ): Promise { + const id = generateUuid(); + const target = joinPath(attachmentsRoot, id, basename); + await this._fileService.writeFile(target, VSBuffer.wrap(bytes)); + const rewritten: MessageResourceAttachment = { + type: MessageAttachmentKind.Resource, + uri: target.toString(), + label: original.label, + displayKind: original.displayKind, + range: original.range, + _meta: original._meta, + }; + if (original.type === MessageAttachmentKind.Resource && original.selection) { + rewritten.selection = original.selection; + } + return rewritten; + } + + /** + * Pick a sensible on-disk basename for the snapshotted attachment, + * preserving a usable extension where possible so the SDK and other + * downstream consumers can detect the right type from the path alone. + */ + private _attachmentBasename(label: string, contentType: string | undefined): string { + const safeLabel = (label || 'attachment').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_'); + if (resourcesExtname(URI.file(safeLabel))) { + return safeLabel; + } + const ext = contentType ? getExtensionForMimeType(contentType) : undefined; + return ext ? `${safeLabel}${ext}` : safeLabel; + } + async resourceList(uri: URI): Promise { let stat; try { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index e74d351798f6c2..5d643a180b5948 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -23,7 +23,7 @@ import { platformSessionSchema } from '../../common/agentHostSchema.js'; import { AgentSignal } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type URI as ProtocolURI, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; @@ -202,6 +202,8 @@ export class CopilotAgentSession extends Disposable { private readonly _editTracker: FileEditTracker; /** Session database reference. */ private readonly _databaseRef: IReference; + /** On-disk root for per-session data (database, attachments, …). */ + private readonly _sessionDataDir: URI; /** Protocol turn ID set by {@link send}, used for file edit tracking. */ private _turnId = ''; /** SDK session wrapper, set by {@link initializeSession}. */ @@ -261,6 +263,7 @@ export class CopilotAgentSession extends Disposable { this._databaseRef = sessionDataService.openDatabase(options.sessionUri); this._register(toDisposable(() => this._databaseRef.dispose())); + this._sessionDataDir = sessionDataService.getSessionDataDir(options.sessionUri); this._editTracker = this._instantiationService.createInstance(FileEditTracker, options.sessionUri.toString(), this._databaseRef.object); @@ -527,11 +530,17 @@ export class CopilotAgentSession extends Disposable { /** * Translate a protocol {@link MessageAttachment} into the Copilot CLI - * SDK's `attachments` payload shape. Only resource attachments are - * forwarded — simple and embedded resources are dropped because the - * CLI SDK does not consume them. The {@link MessageAttachmentBase.displayKind} - * advisory hint controls whether a resource is treated as a file, - * directory, or a selection. + * SDK's `attachments` payload shape. Resource attachments map to the + * SDK's reference-style `file`/`directory`/`selection` variants (the + * {@link MessageAttachmentBase.displayKind} advisory hint controls + * which one). Embedded resources (e.g. inline image bytes) map to the + * SDK's `blob` variant. Resource attachments that point at a + * `session-db:` URI are also forwarded as `blob` — the agent host + * snapshots inline / client-resident attachment bytes into the + * session database before dispatching the turn, so by the time we + * see them here the bytes are local and the original URI is gone. + * Simple attachments are dropped — the SDK has no equivalent shape + * for them. * * For selections we read the resource content from disk and slice it * by the carried range (the protocol's {@link TextSelection} only @@ -539,6 +548,9 @@ export class CopilotAgentSession extends Disposable { * selection downgrades to a plain file reference. */ private async _toSdkAttachment(attachment: MessageAttachment): Promise { + if (attachment.type === MessageAttachmentKind.EmbeddedResource) { + return { type: 'blob' as const, data: attachment.data, mimeType: attachment.contentType, displayName: attachment.label }; + } if (attachment.type !== MessageAttachmentKind.Resource) { return undefined; } @@ -688,6 +700,20 @@ export class CopilotAgentSession extends Disposable { return { kind: 'approve-once' }; } + // Auto-approve reads of files under the session's attachments + // directory. The agent host writes user-message attachments + // (pasted images, snapshotted client-side files, etc.) there + // before dispatching the turn; the agent ends up needing to + // read those same files back, and prompting the user to + // approve a read of bytes they themselves attached is + // redundant. + if (request.kind === 'read' && typeof request.path === 'string' + && this._isSessionAttachmentPath(request.path) + ) { + this._logService.info(`[Copilot:${this.sessionId}] Auto-approving session attachment ${request.path}`); + return { kind: 'approve-once' }; + } + // Auto-approve reads of large-tool-output temp files written by the // Copilot SDK itself. The SDK spills oversized tool results to // `os.tmpdir()/copilot-tool-output-…txt` and then asks the model @@ -778,6 +804,19 @@ export class CopilotAgentSession extends Disposable { return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, sessionDir) ? permissionPath : undefined; } + /** + * Returns true when `permissionPath` lives under this session's + * `/attachments` directory — i.e. the bytes were + * written by the agent host's user-message attachment rewriter and so + * are already user-supplied content that does not need to be + * re-confirmed via a permission prompt. + */ + private _isSessionAttachmentPath(permissionPath: string): boolean { + const attachmentsDir = normalizePath(URI.joinPath(this._sessionDataDir, SESSION_ATTACHMENTS_DIRNAME)); + const permissionUri = normalizePath(URI.file(permissionPath)); + return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, attachmentsDir); + } + /** * Builds an {@link FileEdit} preview for a write permission request. * diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 45b71057b2026b..863bd82b9c52ed 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -3,13 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { MessageOptions } from '@github/copilot-sdk'; +import { basename } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn, type UserMessage } from '../../common/state/sessionState.js'; import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from './fileEditTracker.js'; +import { getMediaMime } from '../../../../base/common/mime.js'; function tryStringify(value: unknown): string | undefined { try { @@ -65,9 +69,17 @@ export interface ISessionEventMessage { * as user turns. */ source?: string; + /** + * Attachments persisted with the user message by the SDK. Mirrors + * the SDK's `UserMessageAttachment` union; intentionally typed + * locally so we don't pull the SDK package into shared code. + */ + attachments?: readonly ISdkUserMessageAttachment[]; }; } +type ISdkUserMessageAttachment = Required['attachments'][number]; + /** Minimal event shape for `skill.invoked`, used to synthesize a tool-style render. */ export interface ISessionEventSkillInvoked { type: 'skill.invoked'; @@ -145,15 +157,16 @@ interface ISubagentInfo { * own builder so inner events route there directly. */ interface ITurnBuilder { - readonly id: string; - readonly userMessage: { text: string }; + id: string; + userMessage: UserMessage; readonly responseParts: ResponsePart[]; /** Tool starts seen but not yet completed in this turn, keyed by toolCallId. */ readonly pendingTools: Map; } -function newTurnBuilder(id: string, text: string): ITurnBuilder { - return { id, userMessage: { text }, responseParts: [], pendingTools: new Map() }; +function newTurnBuilder(id: string, text: string, attachments?: MessageAttachment[]): ITurnBuilder { + const userMessage: UserMessage = attachments?.length ? { text, attachments } : { text }; + return { id, userMessage, responseParts: [], pendingTools: new Map() }; } function finalizeTurn(builder: ITurnBuilder, state: TurnState): Turn { @@ -302,6 +315,7 @@ export async function mapSessionEvents( const d = (e as ISessionEventMessage).data; const messageId = d?.messageId ?? d?.interactionId ?? ''; const content = d?.content ?? ''; + const attachments = sdkAttachmentsToProtocol(d?.attachments); if (d?.parentToolCallId) { // User messages with a parent tool call route into the // subagent's transcript. They never start a new parent @@ -315,12 +329,15 @@ export async function mapSessionEvents( content, }); } + if (attachments?.length) { + builder.userMessage = { ...builder.userMessage, attachments }; + } } else { // A new top-level user message starts a new parent turn. if (parentBuilder) { turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); } - parentBuilder = newTurnBuilder(messageId, content); + parentBuilder = newTurnBuilder(messageId, content, attachments); } break; } @@ -428,6 +445,76 @@ export async function mapSessionEvents( return { turns, subagentTurnsByToolCallId: subagentTurns }; } +/** + * Translates the SDK's `UserMessageAttachment[]` payload back into the + * agent-protocol {@link MessageAttachment} shape. Blob attachments are + * surfaced as inline {@link MessageAttachmentKind.EmbeddedResource} + * payloads; file/directory/selection variants reconstruct local + * `Resource` attachments. We don't try to re-link these to the on-disk + * snapshots produced by the agent host's attachment rewriter — the SDK + * keeps a copy of the bytes / paths it actually saw on send, which is + * the authoritative record for replay. + */ +function sdkAttachmentsToProtocol( + attachments: readonly ISdkUserMessageAttachment[] | undefined, +): MessageAttachment[] | undefined { + if (!attachments?.length) { + return undefined; + } + const out: MessageAttachment[] = []; + for (const a of attachments) { + const converted = sdkAttachmentToProtocol(a); + if (converted) { + out.push(converted); + } + } + return out.length > 0 ? out : undefined; +} + +function sdkAttachmentToProtocol( + attachment: ISdkUserMessageAttachment, +): MessageAttachment | undefined { + switch (attachment.type) { + case 'file': { + return { + type: MessageAttachmentKind.Resource, + uri: URI.file(attachment.path).toString(), + label: attachment.displayName || basename(attachment.path), + displayKind: getMediaMime(attachment.path)?.startsWith('image/') ? 'image' : 'document', + }; + } + case 'directory': { + return { + type: MessageAttachmentKind.Resource, + uri: URI.file(attachment.path).toString(), + label: attachment.displayName || basename(attachment.path), + displayKind: 'directory', + }; + } + case 'selection': { + return { + type: MessageAttachmentKind.Resource, + uri: URI.file(attachment.filePath).toString(), + label: attachment.displayName, + displayKind: 'selection', + selection: { range: attachment.selection! }, + }; + } + case 'blob': { + const displayKind = attachment.mimeType.startsWith('image/') ? 'image' : undefined; + return { + type: MessageAttachmentKind.EmbeddedResource, + label: attachment.displayName ?? 'attachment', + data: attachment.data, + contentType: attachment.mimeType, + displayKind, + }; + } + default: + return undefined; + } +} + /** * Builds a {@link ToolCallCompletedState}-shaped response part from an * SDK `tool.execution_complete` event. Restores file-edit content diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 4cf1f2b9a90e30..620caf54d76095 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { mkdtempSync, readFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; -import { VSBuffer } from '../../../../base/common/buffer.js'; +import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { joinPath } from '../../../../base/common/resources.js'; @@ -22,7 +22,8 @@ import { AgentSession } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { SessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, SessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; +import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; @@ -171,6 +172,211 @@ suite('AgentService (node dispatcher)', () => { }); }); + // ---- attachment rewriting ------------------------------------------ + + suite('user-message attachment rewriting', () => { + + /** + * Sets up an {@link AgentService} backed by an in-memory file system + * and a {@link createSessionDataService} that points at a fixed + * directory. Returns the wired-up service and the URI under which + * snapshotted attachments should land. + */ + async function setup(): Promise<{ + svc: AgentService; + agent: MockAgent; + session: URI; + attachmentsRoot: URI; + warnings: string[]; + }> { + const sessionDataDir = URI.from({ scheme: Schemas.inMemory, path: '/session-data' }); + const attachmentsRoot = joinPath(sessionDataDir, 'attachments'); + await fileService.createFolder(attachmentsRoot); + const sessionDataService = createSessionDataService(); + // Override getSessionDataDir so the rewriter writes under our + // in-memory file system instead of the helper's default path. + sessionDataService.getSessionDataDir = () => sessionDataDir; + const warnings: string[] = []; + const logService = new class extends NullLogService { + override warn(message: string): void { warnings.push(message); } + }; + const svc = disposables.add(new AgentService(logService, fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + svc.registerProvider(agent); + const session = await svc.createSession({ provider: 'copilot' }); + return { svc, agent, session, attachmentsRoot, warnings }; + } + + async function dispatchTurnAndWait(svc: AgentService, agent: MockAgent, session: URI, attachments: MessageResourceAttachment[] | { type: MessageAttachmentKind.EmbeddedResource; label: string; data: string; contentType: string; displayKind?: string }[]): Promise { + svc.dispatchAction( + { + type: ActionType.SessionTurnStarted, + session: session.toString(), + turnId: 'turn-1', + userMessage: { text: 'hello', attachments: attachments as never }, + }, + 'test-client', 1, + ); + // dispatchAction queues an async rewrite and the side-effect + // handler is invoked from the same continuation; poll until the + // agent has observed the (rewritten) sendMessage. + for (let i = 0; i < 20 && agent.sendMessageCalls.length === 0; i++) { + await new Promise(r => setTimeout(r, 5)); + } + } + + test('snapshots EmbeddedResource attachments to disk and rewrites to a Resource URI under the session attachments folder', async () => { + const { svc, agent, session, attachmentsRoot } = await setup(); + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.EmbeddedResource, + label: 'paste.png', + data: encodeBase64(VSBuffer.wrap(png)), + contentType: 'image/png', + displayKind: 'image', + } as never]); + + assert.strictEqual(agent.sendMessageCalls.length, 1); + const rewritten = agent.sendMessageCalls[0].attachments; + assert.strictEqual(rewritten?.length, 1); + const a = rewritten[0]; + assert.strictEqual(a.type, MessageAttachmentKind.Resource); + if (a.type !== MessageAttachmentKind.Resource) { return; } + assert.strictEqual(a.label, 'paste.png'); + assert.strictEqual(a.displayKind, 'image'); + assert.ok(a.uri.startsWith(attachmentsRoot.toString() + '/'), `attachment uri ${a.uri} should be under ${attachmentsRoot.toString()}/`); + // File on disk holds exactly the original bytes + const written = await fileService.readFile(URI.parse(a.uri)); + assert.deepStrictEqual([...written.value.buffer], [...png]); + }); + + test('preserves existing displayKind / range / selection / _meta on rewrite', async () => { + const { svc, agent, session } = await setup(); + const range = { start: { line: 1, character: 0 }, end: { line: 1, character: 4 } }; + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.EmbeddedResource, + label: 'note.txt', + data: encodeBase64(VSBuffer.fromString('alpha\nbeta\ngamma')), + contentType: 'text/plain', + // EmbeddedResource carries optional selection too + // (textual resources only); make sure the rewriter copies it. + displayKind: 'selection', + } as never]); + + const rewritten = agent.sendMessageCalls[0].attachments![0]; + assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource); + if (rewritten.type !== MessageAttachmentKind.Resource) { return; } + // `displayKind` is preserved as-is from the original attachment. + assert.strictEqual(rewritten.displayKind, 'selection'); + + void range; // selection round-trip on EmbeddedResource is covered by the next test + }); + + test('snapshots Resource attachments by reading the original file and rewriting to a local snapshot', async () => { + const { svc, agent, session, attachmentsRoot, warnings } = await setup(); + const sourceUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/source.txt' }); + await fileService.writeFile(sourceUri, VSBuffer.fromString('hello world')); + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.Resource, + uri: sourceUri.toString(), + label: 'source.txt', + displayKind: 'document', + }]); + + const rewritten = agent.sendMessageCalls[0].attachments![0]; + assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource); + if (rewritten.type !== MessageAttachmentKind.Resource) { return; } + assert.notStrictEqual(rewritten.uri, sourceUri.toString(), `should be rewritten to the snapshot URI; warnings=${JSON.stringify(warnings)}; got ${rewritten.uri}`); + assert.ok(rewritten.uri.startsWith(attachmentsRoot.toString() + '/')); + assert.strictEqual(rewritten.label, 'source.txt'); + assert.strictEqual(rewritten.displayKind, 'document'); + + const snapshot = await fileService.readFile(URI.parse(rewritten.uri)); + assert.strictEqual(snapshot.value.toString(), 'hello world'); + }); + + test('preserves selection range on Resource rewrite', async () => { + const { svc, agent, session, attachmentsRoot } = await setup(); + const sourceUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/sel.txt' }); + await fileService.writeFile(sourceUri, VSBuffer.fromString('alpha\nbeta\ngamma')); + const range = { start: { line: 1, character: 0 }, end: { line: 1, character: 4 } }; + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.Resource, + uri: sourceUri.toString(), + label: 'sel.txt', + displayKind: 'selection', + selection: { range }, + }]); + + const rewritten = agent.sendMessageCalls[0].attachments![0]; + assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource); + if (rewritten.type !== MessageAttachmentKind.Resource) { return; } + assert.ok(rewritten.uri.startsWith(attachmentsRoot.toString() + '/'), 'should be rewritten to a snapshot URI'); + assert.deepStrictEqual(rewritten.selection?.range, range); + assert.strictEqual(rewritten.displayKind, 'selection'); + }); + + test('passes directory Resource attachments through unchanged', async () => { + const { svc, agent, session } = await setup(); + const dirUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/dir' }); + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.Resource, + uri: dirUri.toString(), + label: 'dir', + displayKind: 'directory', + }]); + + assert.deepStrictEqual(agent.sendMessageCalls[0].attachments, [{ + type: MessageAttachmentKind.Resource, + uri: dirUri.toString(), + label: 'dir', + displayKind: 'directory', + }]); + }); + + test('does not re-snapshot attachments that already point under the session attachments folder', async () => { + const { svc, agent, session, attachmentsRoot } = await setup(); + const existing = joinPath(attachmentsRoot, 'previous-id', 'note.txt'); + await fileService.writeFile(existing, VSBuffer.fromString('already snapshotted')); + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.Resource, + uri: existing.toString(), + label: 'note.txt', + displayKind: 'document', + }]); + + const a = agent.sendMessageCalls[0].attachments?.[0]; + assert.ok(a && a.type === MessageAttachmentKind.Resource); + assert.strictEqual(a.uri, existing.toString(), 'second-pass rewrite should be a no-op'); + }); + + test('preserves the original attachment when the source cannot be read', async () => { + const { svc, agent, session } = await setup(); + const missingUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/missing.txt' }); + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.Resource, + uri: missingUri.toString(), + label: 'missing.txt', + displayKind: 'document', + }]); + + assert.deepStrictEqual(agent.sendMessageCalls[0].attachments, [{ + type: MessageAttachmentKind.Resource, + uri: missingUri.toString(), + label: 'missing.txt', + displayKind: 'document', + }]); + }); + }); + suite('createSession', () => { test('creates session via specified provider', async () => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 34bbc08bad979d..119aec68bea73d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { encodeBase64 } from '../../../../../../base/common/buffer.js'; +import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; @@ -38,19 +38,21 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IChatWidgetService } from '../../chat.js'; import { ChatRequestQueueKind, ConfirmedReason, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatQuestionAnswerValue, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult } from '../../../common/chatSessionsService.js'; -import type { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { isImageVariableEntry, type IChatRequestVariableEntry, type IImageVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; +import { type IChatRequestVariableData } from '../../../common/model/chatModel.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; import { AgentHostEditingSession } from './agentHostEditingSession.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, userMessageToVariableData, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; // ============================================================================= // AgentHostSessionHandler - renderer-side handler for a single agent host @@ -212,7 +214,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { private readonly _onWillDispose = this._register(new Emitter()); readonly onWillDispose = this._onWillDispose.event; - private readonly _onDidStartServerRequest = this._register(new Emitter<{ prompt: string }>()); + private readonly _onDidStartServerRequest = this._register(new Emitter<{ prompt: string; variableData?: IChatRequestVariableData }>()); readonly onDidStartServerRequest = this._onDidStartServerRequest.event; readonly interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; @@ -283,13 +285,13 @@ class AgentHostChatSession extends Disposable implements IChatSession { * Resets the progress observable and signals listeners to create a new * request+response pair in the chat model. */ - startServerRequest(prompt: string): void { + startServerRequest(prompt: string, variableData?: IChatRequestVariableData): void { this._logService.info('[AgentHost] Server-initiated request started'); transaction(tx => { this.progressObs.set([], tx); this.isCompleteObs.set(false, tx); }); - this._onDidStartServerRequest.fire({ prompt }); + this._onDidStartServerRequest.fire({ prompt, variableData }); } } @@ -609,6 +611,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC prompt: sessionState.activeTurn.userMessage.text, participant: this._config.agentId, modelId: lookup.toLanguageModelId(activeRawModelId), + variableData: userMessageToVariableData(sessionState.activeTurn.userMessage, this._config.connectionAuthority), }); history.push({ type: 'response', @@ -817,13 +820,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const prevQueued = protocolState?.queuedMessages ?? []; // Compute current state from chat model - let currentSteering: { id: string; text: string } | undefined; - const currentQueued: { id: string; text: string }[] = []; + interface IPendingSnapshot { id: string; text: string; attachments?: MessageAttachment[] } + let currentSteering: IPendingSnapshot | undefined; + const currentQueued: IPendingSnapshot[] = []; for (const p of pending) { + const variables = p.request.variableData?.variables ?? []; + const messageAttachments = this._variableEntriesToAttachments(variables, sessionResource); + const attachments = messageAttachments.length > 0 ? messageAttachments : undefined; + const snapshot: IPendingSnapshot = { id: p.request.id, text: p.request.message.text, attachments }; if (p.kind === ChatRequestQueueKind.Steering) { - currentSteering = { id: p.request.id, text: p.request.message.text }; + currentSteering = snapshot; } else { - currentQueued.push({ id: p.request.id, text: p.request.message.text }); + currentQueued.push(snapshot); } } @@ -835,7 +843,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session, kind: PendingMessageKind.Steering, id: currentSteering.id, - userMessage: { text: currentSteering.text }, + userMessage: { text: currentSteering.text, attachments: currentSteering.attachments }, }); } } else if (prevSteering) { @@ -869,7 +877,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session, kind: PendingMessageKind.Queued, id: q.id, - userMessage: { text: q.text }, + userMessage: { text: q.text, attachments: q.attachments }, }); } } @@ -984,7 +992,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC previousQueuedIds = currentQueuedIds; // Signal the session to create a new request+response pair - chatSession.startServerRequest(activeTurn.userMessage.text); + chatSession.startServerRequest( + activeTurn.userMessage.text, + userMessageToVariableData(activeTurn.userMessage, this._config.connectionAuthority), + ); // Set up turn progress tracking — reuse the same state-to-progress // translation as _handleTurn, but pipe output to progressObs/isCompleteObs @@ -2482,33 +2493,62 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] { + return this._variableEntriesToAttachments(request.variables.variables, request.sessionResource); + } + + private _variableEntriesToAttachments(variables: readonly IChatRequestVariableEntry[], sessionResource: URI): MessageAttachment[] { const attachments: MessageAttachment[] = []; - for (const v of request.variables.variables) { - const attachment = this._convertVariableToAttachment(v, request.sessionResource); + for (const v of variables) { + const attachment = this._convertVariableToAttachment(v, sessionResource); if (attachment) { attachments.push(attachment); } } if (attachments.length > 0) { - this._logService.trace(`[AgentHost] Converted ${attachments.length} attachments from ${request.variables.variables.length} variables`); + this._logService.trace(`[AgentHost] Converted ${attachments.length} attachments from ${variables.length} variables`); } return attachments; } private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI): MessageAttachment | undefined { - if ((v.kind === 'file' || (v.kind === 'implicit' && v.isSelection)) && isLocation(v.value)) { - return this._toSelectionAttachment(v.value, v.name, sessionResource, v._meta); + // File / implicit attachments: a Location → selection, a URI → resource. + if ((v.kind === 'file' || v.kind === 'implicit') && isLocation(v.value)) { + return this._toSelectionAttachment(v.value, v.name, 'selection', sessionResource, v._meta); } - if (v.kind === 'file' && v.value instanceof URI) { + if ((v.kind === 'file' || v.kind === 'implicit') && v.value instanceof URI) { return this._toResourceAttachment(v.value, v.name, 'document', sessionResource, v._meta); } if (v.kind === 'directory' && v.value instanceof URI) { return this._toResourceAttachment(v.value, v.name, 'directory', sessionResource, v._meta); } + // Symbol: a Location with a 'symbol' display hint. + if (v.kind === 'symbol' && isLocation(v.value)) { + return this._toSelectionAttachment(v.value, v.name, 'symbol', sessionResource, v._meta); + } + // Prompt files (.prompt.md) — treated as a referenced document. + if (v.kind === 'promptFile' && v.value instanceof URI) { + return this._toResourceAttachment(v.value, v.name, 'document', sessionResource, v._meta); + } + // Image: send inline as base64 when we have the bytes; otherwise fall + // back to a file resource reference. + if (isImageVariableEntry(v)) { + return this._toImageAttachment(v, sessionResource); + } + // Pasted code, prompt text, and free-form string entries: surface their + // textual representation as an opaque attachment. + if (v.kind === 'paste') { + return this._toSimpleAttachment(v.name, v.code, v._meta); + } + if (v.kind === 'promptText') { + return this._toSimpleAttachment(v.name, v.value, v._meta); + } + if (v.kind === 'string' && typeof v.value === 'string') { + return this._toSimpleAttachment(v.name, v.value, v._meta); + } return undefined; } - private _toResourceAttachment(uri: URI, label: string, displayKind: 'document' | 'directory', sessionResource: URI, _meta: Record | undefined): MessageAttachment | undefined { + private _toResourceAttachment(uri: URI, label: string, displayKind: string, sessionResource: URI, _meta: Record | undefined): MessageAttachment | undefined { if (uri.scheme !== 'file') { return undefined; } @@ -2520,7 +2560,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return attachment; } - private _toSelectionAttachment(location: Location, label: string, sessionResource: URI, _meta: Record | undefined): MessageAttachment | undefined { + private _toSelectionAttachment(location: Location, label: string, displayKind: string, sessionResource: URI, _meta: Record | undefined): MessageAttachment | undefined { if (location.uri.scheme !== 'file') { return undefined; } @@ -2529,7 +2569,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC type: MessageAttachmentKind.Resource, uri: attachmentUri.toString(), label, - displayKind: 'selection', + displayKind, selection: { range: this._toTextRange(location.range) }, }; if (_meta) { @@ -2538,6 +2578,38 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return attachment; } + private _toImageAttachment(v: IImageVariableEntry, sessionResource: URI): MessageAttachment | undefined { + const buffer = coerceImageBuffer(v.value); + const contentType = v.mimeType ?? 'image/png'; + if (buffer) { + const attachment: MessageAttachment = { + type: MessageAttachmentKind.EmbeddedResource, + label: v.name, + displayKind: 'image', + data: encodeBase64(VSBuffer.wrap(buffer)), + contentType, + }; + if (v._meta) { + attachment._meta = v._meta; + } + return attachment; + } + // No inline bytes — fall back to a file reference if one is available. + const refUri = v.references?.find(r => URI.isUri(r.reference))?.reference; + if (URI.isUri(refUri)) { + return this._toResourceAttachment(refUri, v.name, 'image', sessionResource, v._meta); + } + return undefined; + } + + private _toSimpleAttachment(label: string, modelRepresentation: string | undefined, _meta: Record | undefined): MessageAttachment { + const attachment: MessageAttachment = { type: MessageAttachmentKind.Simple, label, modelRepresentation }; + if (_meta) { + attachment._meta = _meta; + } + return attachment; + } + private _toTextRange(range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }) { return { start: { line: range.startLineNumber - 1, character: range.startColumn - 1 }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index b84d3855fe6d46..6daab25196c46f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -3,20 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { escapeMarkdownLinkLabel, IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type ActiveTurn, type ICompletedToolCall, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { type FileEdit, type StringOrMarkdown } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange, type UserMessage } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; +import { type IChatRequestVariableData } from '../../../common/model/chatModel.js'; +import type { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { type IToolConfirmationMessages, type IToolData, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { basename, isEqual } from '../../../../../../base/common/resources.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; +import type { IRange } from '../../../../../../editor/common/core/range.js'; /** * Constructs a terminal tool session ID from a terminal URI and backend session. @@ -117,7 +122,7 @@ export interface TurnModelLookup { * The `lookup` callback is responsible for any session-level fallback (e.g. * `summary.model?.id` when usage hasn't reported a model yet). */ -export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string | undefined, lookup?: TurnModelLookup): IChatSessionHistoryItem[] { +export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string, lookup?: TurnModelLookup): IChatSessionHistoryItem[] { const history: IChatSessionHistoryItem[] = []; for (const turn of turns) { const rawModelId = turn.usage?.model; @@ -125,7 +130,8 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part const modelName = lookup?.toModelDisplayName(rawModelId); // Request - history.push({ id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId }); + const variableData = userMessageToVariableData(turn.userMessage, connectionAuthority); + history.push({ id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId, variableData }); // Response parts — iterate the unified responseParts array const parts: IChatProgress[] = []; @@ -170,6 +176,105 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part return history; } +/** + * Converts a turn's persisted {@link UserMessage} into the chat-layer + * {@link IChatRequestVariableData} shape so attachments survive a + * history replay (and pending/server-initiated turn synthesis). Returns + * `undefined` when the message has no convertible attachments. + */ +export function userMessageToVariableData(userMessage: UserMessage, connectionAuthority: string): IChatRequestVariableData | undefined { + return messageAttachmentsToVariableData(userMessage.attachments, connectionAuthority); +} + +export function messageAttachmentsToVariableData(attachments: readonly MessageAttachment[] | undefined, connectionAuthority: string): IChatRequestVariableData | undefined { + if (!attachments?.length) { + return undefined; + } + const variables: IChatRequestVariableEntry[] = []; + for (const a of attachments) { + const v = messageAttachmentToVariableEntry(a, connectionAuthority); + if (v) { + variables.push(v); + } + } + return variables.length > 0 ? { variables } : undefined; +} + +function messageAttachmentToVariableEntry(attachment: MessageAttachment, connectionAuthority: string): IChatRequestVariableEntry | undefined { + if (attachment.type === MessageAttachmentKind.Resource) { + const uri = toAgentHostUri(URI.parse(attachment.uri), connectionAuthority); + const name = attachment.label; + const id = uri.toString() + (attachment.selection + ? `:${attachment.selection.range.start.line}-${attachment.selection.range.end.line}` + : ''); + const _meta = attachment._meta; + + if (attachment.displayKind === 'directory') { + return { kind: 'directory', id, name, value: uri, _meta }; + } + if (attachment.displayKind === 'image') { + return { + kind: 'image', + id, + name, + value: uri, + isURL: true, + references: [{ kind: 'reference', reference: uri }], + _meta, + }; + } + if (attachment.selection) { + return { + kind: 'file', + id, + name, + value: { uri, range: textRangeToIRange(attachment.selection.range) }, + _meta, + }; + } + return { kind: 'file', id, name, value: uri, _meta }; + } + + if (attachment.type === MessageAttachmentKind.EmbeddedResource) { + if (!attachment.contentType.startsWith('image/')) { + return { + kind: 'generic', + id: generateUuid(), + name: attachment.label, + value: decodeBase64(attachment.data).buffer, + _meta: attachment._meta, + }; + } + + return { + kind: 'image', + id: generateUuid(), + name: attachment.label || 'image', + value: decodeBase64(attachment.data).buffer, + mimeType: attachment.contentType, + isURL: false, + _meta: attachment._meta, + }; + } + + return { + kind: 'generic', + id: generateUuid(), + name: attachment.label, + value: attachment.modelRepresentation || attachment.label, + _meta: attachment._meta, + }; +} + +function textRangeToIRange(range: TextRange): IRange { + return { + startLineNumber: range.start.line + 1, + startColumn: range.start.character + 1, + endLineNumber: range.end.line + 1, + endColumn: range.end.character + 1, + }; +} + /** * Converts an active (in-progress) turn's accumulated state into progress * items suitable for replaying into the chat UI when reconnecting to a diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 1284cd8a2c2082..2f8a3c9a04cf9d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -772,7 +772,7 @@ export class ChatService extends Disposable implements IChatService { // Handle server-initiated requests (e.g. consumed queued messages). if (providedSession.onDidStartServerRequest) { - disposables.add(providedSession.onDidStartServerRequest(({ prompt }) => { + disposables.add(providedSession.onDidStartServerRequest(({ prompt, variableData }) => { // Complete any in-flight request if (lastRequest?.response && !lastRequest.response.isComplete) { lastRequest.response.complete(); @@ -781,7 +781,7 @@ export class ChatService extends Disposable implements IChatService { // Create a new request in the model const agent = this.chatAgentService.getAgent(chatSessionType); const parsedRequest = parseAgentHostHistoryPrompt(prompt, agent); - lastRequest = model.addRequest(parsedRequest, { variables: [] }, 0, undefined, agent); + lastRequest = model.addRequest(parsedRequest, variableData ?? { variables: [] }, 0, undefined, agent); // Reset progress tracking for the new turn lastProgressLength = 0; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 15106d674cee36..c5f4be82096f59 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -224,7 +224,7 @@ export interface IChatSession extends IDisposable { * queued message). The consumer should create a new request+response pair in * the model and prepare to receive progress via {@link progressObs}. */ - readonly onDidStartServerRequest?: Event<{ prompt: string }>; + readonly onDidStartServerRequest?: Event<{ prompt: string; variableData?: IChatRequestVariableData }>; /** * Editing session transferred from a previously-untitled chat session in `onDidCommitChatSessionItem`. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index ef642f71173ed5..d545b419f48749 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -60,7 +60,7 @@ function finalizeToolInvocation(invocation: Parameters[0], turns: Parameters[1], participantId: Parameters[2], lookup?: Parameters[4]) { - return rawTurnsToHistory(backendSession, turns, participantId, undefined, lookup); + return rawTurnsToHistory(backendSession, turns, participantId, 'local', lookup); } /** diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index ce33bc69f5ff37..65c5a0f9f0d569 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -47,7 +47,7 @@ import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReferenc import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; -import { ChatModel, IChatModel, ISerializableChatData } from '../../../common/model/chatModel.js'; +import { ChatModel, IChatModel, IChatRequestVariableData, ISerializableChatData } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; @@ -1562,7 +1562,7 @@ suite('ChatService', () => { readonly progressObs?: ISettableObservable; readonly isCompleteObs?: ISettableObservable; readonly interruptActiveResponseCallback?: () => Promise; - readonly onDidStartServerRequest?: Event<{ prompt: string }>; + readonly onDidStartServerRequest?: Event<{ prompt: string; variableData?: IChatRequestVariableData }>; readonly history?: readonly IChatSessionHistoryItem[]; } From 3b8129f1d0e4b8f6ead1685fc8d0b2c57eb933c6 Mon Sep 17 00:00:00 2001 From: Yogeshwaran C <84272111+yogeshwaran-c@users.noreply.github.com> Date: Fri, 8 May 2026 22:19:14 +0530 Subject: [PATCH 30/41] Strip codicons from terminal quickpick filter matching (#313197) --- .../quickAccess/browser/terminalQuickAccess.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/quickAccess/browser/terminalQuickAccess.ts b/src/vs/workbench/contrib/terminalContrib/quickAccess/browser/terminalQuickAccess.ts index cdf713d88ca524..6ffb0e9c53b4ce 100644 --- a/src/vs/workbench/contrib/terminalContrib/quickAccess/browser/terminalQuickAccess.ts +++ b/src/vs/workbench/contrib/terminalContrib/quickAccess/browser/terminalQuickAccess.ts @@ -6,7 +6,7 @@ import { localize } from '../../../../../nls.js'; import { IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IPickerQuickAccessItem, PickerQuickAccessProvider, TriggerAction } from '../../../../../platform/quickinput/browser/pickerQuickAccess.js'; -import { matchesFuzzy } from '../../../../../base/common/filters.js'; +import { matchesFuzzyIconAware, parseLabelWithIcons } from '../../../../../base/common/iconLabels.js'; import { ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../terminal/browser/terminal.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { TerminalCommandId } from '../../../terminal/common/terminal.js'; @@ -101,7 +101,7 @@ export class TerminalQuickAccessProvider extends PickerQuickAccessProvider Date: Fri, 8 May 2026 17:17:09 +0200 Subject: [PATCH 31/41] Escapes model id in chatEditHunk event --- src/vs/platform/telemetry/common/telemetry.ts | 8 ++++++++ .../chat/common/chatService/chatServiceTelemetry.ts | 4 ++-- .../aiEditTelemetry/aiEditTelemetryServiceImpl.ts | 8 ++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/telemetry/common/telemetry.ts b/src/vs/platform/telemetry/common/telemetry.ts index 5cd0d8cbdc68db..68be6127148d2a 100644 --- a/src/vs/platform/telemetry/common/telemetry.ts +++ b/src/vs/platform/telemetry/common/telemetry.ts @@ -63,6 +63,14 @@ export function telemetryLevelEnabled(service: ITelemetryService, level: Telemet return service.telemetryLevel >= level; } +/** + * Replaces `/` and `\` with `|` in model identifiers to prevent the + * telemetry pipeline from redacting them as file paths. + */ +export function escapeModelIdForTelemetry(modelId: string | undefined): string { + return modelId?.replace(/[\/\\]/g, '|') ?? ''; +} + export interface ITelemetryEndpoint { id: string; aiKey: string; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 5af294835ecf04..9db4c24c11a748 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { isLocation } from '../../../../../editor/common/languages.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { escapeModelIdForTelemetry, ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IChatAgentData } from '../participants/chatAgents.js'; import { ChatRequestModel, IChatRequestVariableData } from '../model/chatModel.js'; import { ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart } from '../requestParser/chatParserTypes.js'; @@ -231,7 +231,7 @@ export class ChatServiceTelemetry { lineCount: action.action.lineCount, hasRemainingEdits: action.action.hasRemainingEdits, requestId: action.requestId, - modelId: action.modelId ?? '', + modelId: escapeModelIdForTelemetry(action.modelId), modeId: action.modeId ?? '', }); } diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts index 1c5d06843a7166..cc3bbf0e3686ae 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts @@ -5,7 +5,7 @@ import { EditSuggestionId } from '../../../../../../editor/common/textModelEditSource.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { escapeModelIdForTelemetry, ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { DataChannelForwardingTelemetryService, forwardToChannelIf, isCopilotLikeExtension } from '../../../../../../platform/dataChannel/browser/forwardingTelemetryService.js'; import { IAiEditTelemetryService, IEditTelemetryCodeAcceptedData, IEditTelemetryCodeRejectedData, IEditTelemetryCodeSuggestedData } from './aiEditTelemetryService.js'; import { IRandomService } from '../../randomService.js'; @@ -86,7 +86,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { editLinesDeleted: data.editDeltaInfo?.linesRemoved, modeId: data.modeId, - modelId: data.modelId?.replace(/[\/\\]/g, '|'), + modelId: escapeModelIdForTelemetry(data.modelId), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, sourceRequestId: data.sourceRequestId, @@ -170,7 +170,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { editLinesDeleted: data.editDeltaInfo?.linesRemoved, modeId: data.modeId, - modelId: data.modelId?.replace(/[\/\\]/g, '|'), + modelId: escapeModelIdForTelemetry(data.modelId), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, sourceRequestId: data.sourceRequestId, acceptanceMethod: data.acceptanceMethod, @@ -246,7 +246,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { editLinesDeleted: data.editDeltaInfo?.linesRemoved, modeId: data.modeId, - modelId: data.modelId?.replace(/[\/\\]/g, '|'), + modelId: escapeModelIdForTelemetry(data.modelId), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, sourceRequestId: data.sourceRequestId, rejectionMethod: data.rejectionMethod, From d1ace9a04cb8cdaf009af88a90c09084b4c36059 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 8 May 2026 18:06:40 +0200 Subject: [PATCH 32/41] Fixes tests --- src/vs/platform/telemetry/common/telemetry.ts | 4 ++-- .../contrib/chat/common/chatService/chatServiceTelemetry.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/telemetry/common/telemetry.ts b/src/vs/platform/telemetry/common/telemetry.ts index 68be6127148d2a..193b45cb9eb27a 100644 --- a/src/vs/platform/telemetry/common/telemetry.ts +++ b/src/vs/platform/telemetry/common/telemetry.ts @@ -67,8 +67,8 @@ export function telemetryLevelEnabled(service: ITelemetryService, level: Telemet * Replaces `/` and `\` with `|` in model identifiers to prevent the * telemetry pipeline from redacting them as file paths. */ -export function escapeModelIdForTelemetry(modelId: string | undefined): string { - return modelId?.replace(/[\/\\]/g, '|') ?? ''; +export function escapeModelIdForTelemetry(modelId: string | undefined): string | undefined { + return modelId?.replace(/[\/\\]/g, '|'); } export interface ITelemetryEndpoint { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 9db4c24c11a748..738a45866215ed 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -231,7 +231,7 @@ export class ChatServiceTelemetry { lineCount: action.action.lineCount, hasRemainingEdits: action.action.hasRemainingEdits, requestId: action.requestId, - modelId: escapeModelIdForTelemetry(action.modelId), + modelId: escapeModelIdForTelemetry(action.modelId) ?? '', modeId: action.modeId ?? '', }); } From b72c91bf53b2764770924e4cf312542b5e6504bf Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 8 May 2026 10:15:55 -0700 Subject: [PATCH 33/41] agentHost: rewrite Resource attachments and omit undefined fields Addresses review feedback and CI failures on the agent-host attachment support change. - Restore the `Resource` branch in `_isRewritableAttachment` (and thus in `_needsAsyncRewrite`) so non-directory `Resource` attachments not already under the session attachments folder are snapshotted, matching the PR's stated intent and the new tests. - Restore the `isSelection` gate when converting `implicit` variable entries: a bare visible-document implicit attachment should not become a `selection` attachment. - Avoid emitting `attachments: undefined` and `variableData: undefined` fields on the synced pending message actions and synthesised history entries, so the existing deep-equal test assertions keep passing. (Commit message generated by Copilot) --- .../platform/agentHost/node/agentService.ts | 12 ++++++++- .../agentHost/agentHostSessionHandler.ts | 27 ++++++++++++++----- .../agentHost/stateToProgressAdapter.ts | 6 ++++- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index cac659c1c3d5b2..8ae86c7ad744a1 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -822,7 +822,17 @@ export class AgentService extends Disposable implements IAgentService { if (attachment.type === MessageAttachmentKind.EmbeddedResource) { return true; } - + if (attachment.type === MessageAttachmentKind.Resource) { + // Don't try to fetch directories or already-rewritten attachments + // (whose URIs already point under our session attachments folder). + if (attachment.displayKind === 'directory') { + return false; + } + if (attachment.uri.startsWith(attachmentsRootStr)) { + return false; + } + return true; + } return false; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 119aec68bea73d..7beb1b64bb243a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -606,13 +606,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (sessionState.activeTurn) { activeTurnId = sessionState.activeTurn.id; const activeRawModelId = sessionState.activeTurn.usage?.model ?? fallbackRawModelId; - history.push({ + const requestItem: IChatSessionHistoryItem & { type: 'request' } = { type: 'request', prompt: sessionState.activeTurn.userMessage.text, participant: this._config.agentId, modelId: lookup.toLanguageModelId(activeRawModelId), - variableData: userMessageToVariableData(sessionState.activeTurn.userMessage, this._config.connectionAuthority), - }); + }; + const variableData = userMessageToVariableData(sessionState.activeTurn.userMessage, this._config.connectionAuthority); + if (variableData) { + requestItem.variableData = variableData; + } + history.push(requestItem); history.push({ type: 'response', parts: [], @@ -838,12 +842,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // --- Steering --- if (currentSteering) { if (currentSteering.id !== prevSteering?.id) { + const userMessage: { text: string; attachments?: MessageAttachment[] } = { text: currentSteering.text }; + if (currentSteering.attachments) { + userMessage.attachments = currentSteering.attachments; + } this._dispatchAction({ type: ActionType.SessionPendingMessageSet, session, kind: PendingMessageKind.Steering, id: currentSteering.id, - userMessage: { text: currentSteering.text, attachments: currentSteering.attachments }, + userMessage, }); } } else if (prevSteering) { @@ -872,12 +880,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const prevQueuedIds = new Set(prevQueued.map(q => q.id)); for (const q of currentQueued) { if (!prevQueuedIds.has(q.id)) { + const userMessage: { text: string; attachments?: MessageAttachment[] } = { text: q.text }; + if (q.attachments) { + userMessage.attachments = q.attachments; + } this._dispatchAction({ type: ActionType.SessionPendingMessageSet, session, kind: PendingMessageKind.Queued, id: q.id, - userMessage: { text: q.text, attachments: q.attachments }, + userMessage, }); } } @@ -2512,7 +2524,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI): MessageAttachment | undefined { // File / implicit attachments: a Location → selection, a URI → resource. - if ((v.kind === 'file' || v.kind === 'implicit') && isLocation(v.value)) { + // Only the selection variant of an implicit attachment becomes a + // `selection`; the bare visible-document case stays a plain file + // reference (or, when there's no value at all, gets dropped). + if ((v.kind === 'file' || (v.kind === 'implicit' && v.isSelection)) && isLocation(v.value)) { return this._toSelectionAttachment(v.value, v.name, 'selection', sessionResource, v._meta); } if ((v.kind === 'file' || v.kind === 'implicit') && v.value instanceof URI) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 6daab25196c46f..9f9f144ac5d7fc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -130,8 +130,12 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part const modelName = lookup?.toModelDisplayName(rawModelId); // Request + const requestItem: IChatSessionHistoryItem & { type: 'request' } = { id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId }; const variableData = userMessageToVariableData(turn.userMessage, connectionAuthority); - history.push({ id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId, variableData }); + if (variableData) { + requestItem.variableData = variableData; + } + history.push(requestItem); // Response parts — iterate the unified responseParts array const parts: IChatProgress[] = []; From 0c161d38659efb4e856bbb8477325d77a8f142ca Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 8 May 2026 10:25:56 -0700 Subject: [PATCH 34/41] sessions: fix Customizations single-entry width overflow (#315125) The single-entry sidebar 'Customizations' button had `width: 100%` set on both the link-button container and the inner button. Combined with the inherited `margin: 0 10px` from the per-section rule, this overflowed the panel by 20px and was visible as a misaligned focus outline that extended past the panel edge. Drop the explicit widths and let the parent's default `align-items: stretch` plus the inherited margins size the entry, matching the per-section items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/media/customizationsToolbar.css | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index e19c6caf71e97f..ac63c1188626c9 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -173,14 +173,9 @@ padding: 6px 0 10px 0; } -/* Override the shared `flex: 1` on the link-button container so the single - entry sizes to its content height instead of stretching to fill the - flex-column toolbar. */ +/* Disable the shared `flex: 1` so the single entry doesn't stretch to fill + the flex-column toolbar. Width is handled by the inherited `margin: 0 10px` + and the parent's default `align-items: stretch`. */ .ai-customization-toolbar.single-entry .customization-link-button-container { flex: none; - width: 100%; -} - -.ai-customization-toolbar.single-entry .customization-link-button { - width: 100%; } From 5c97d336bd4ccbfd3202255d49b0d04dcae1513d Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 8 May 2026 10:29:06 -0700 Subject: [PATCH 35/41] agentHost: revert undefined-field omission, update tests instead Reverts the manual omission of undefined `attachments` / `variableData` fields in the pending message sync and history synthesis paths. Updates the corresponding deep-equal test assertions to expect the new shape instead. (Commit message generated by Copilot) --- .../agentHost/agentHostSessionHandler.ts | 22 +++++-------------- .../agentHost/stateToProgressAdapter.ts | 6 +---- .../agentHostChatContribution.test.ts | 4 ++-- .../stateToProgressAdapter.test.ts | 1 + 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 15b5e87e574ca8..1fc88af263f25f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -591,17 +591,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (sessionState.activeTurn) { activeTurnId = sessionState.activeTurn.id; const activeRawModelId = sessionState.activeTurn.usage?.model ?? fallbackRawModelId; - const requestItem: IChatSessionHistoryItem & { type: 'request' } = { + history.push({ type: 'request', prompt: sessionState.activeTurn.userMessage.text, participant: this._config.agentId, modelId: lookup.toLanguageModelId(activeRawModelId), - }; - const variableData = userMessageToVariableData(sessionState.activeTurn.userMessage, this._config.connectionAuthority); - if (variableData) { - requestItem.variableData = variableData; - } - history.push(requestItem); + variableData: userMessageToVariableData(sessionState.activeTurn.userMessage, this._config.connectionAuthority), + }); history.push({ type: 'response', parts: [], @@ -829,16 +825,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // --- Steering --- if (currentSteering) { if (currentSteering.id !== prevSteering?.id || currentSteering.text !== prevSteering.userMessage.text) { - const userMessage: { text: string; attachments?: MessageAttachment[] } = { text: currentSteering.text }; - if (currentSteering.attachments) { - userMessage.attachments = currentSteering.attachments; - } this._dispatchAction({ type: ActionType.SessionPendingMessageSet, session, kind: PendingMessageKind.Steering, id: currentSteering.id, - userMessage, + userMessage: { text: currentSteering.text, attachments: currentSteering.attachments }, }); } } else if (prevSteering) { @@ -868,16 +860,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC for (const q of currentQueued) { const prev = prevQueuedById.get(q.id); if (!prev || q.text !== prev.userMessage.text) { - const userMessage: { text: string; attachments?: MessageAttachment[] } = { text: q.text }; - if (q.attachments) { - userMessage.attachments = q.attachments; - } this._dispatchAction({ type: ActionType.SessionPendingMessageSet, session, kind: PendingMessageKind.Queued, id: q.id, - userMessage, + userMessage: { text: q.text, attachments: q.attachments }, }); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 9f9f144ac5d7fc..6daab25196c46f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -130,12 +130,8 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part const modelName = lookup?.toModelDisplayName(rawModelId); // Request - const requestItem: IChatSessionHistoryItem & { type: 'request' } = { id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId }; const variableData = userMessageToVariableData(turn.userMessage, connectionAuthority); - if (variableData) { - requestItem.variableData = variableData; - } - history.push(requestItem); + history.push({ id: turn.id, type: 'request', prompt: turn.userMessage.text, participant: participantId, modelId, variableData }); // Response parts — iterate the unified responseParts array const parts: IChatProgress[] = []; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index ebce9b85a2f2e7..091ecacb238416 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -3176,7 +3176,7 @@ suite('AgentHostChatContribution', () => { session: backendSession.toString(), kind: 'queued', id: 'queued-request-1', - userMessage: { text }, + userMessage: { text, attachments: undefined }, }); }); @@ -3218,7 +3218,7 @@ suite('AgentHostChatContribution', () => { session: backendSession.toString(), kind: 'queued', id: 'queued-request-1', - userMessage: { text }, + userMessage: { text, attachments: undefined }, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index d545b419f48749..d03c00412cc0e0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -188,6 +188,7 @@ suite('stateToProgressAdapter', () => { prompt: 'Use restored model', participant: 'participant-1', modelId: 'agent-host-copilot:gpt-5', + variableData: undefined, }); }); From 1a8d9d0bae4aea8d3fb493cd01d8efd493f11591 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 8 May 2026 10:30:48 -0700 Subject: [PATCH 36/41] Add proposal for custom editor diff/merge priority Fixes #292379 For #138525 Also related to work in #315174 --- .../common/extensionsApiProposals.ts | 3 + .../customEditor/browser/customEditors.ts | 2 + .../common/contributedCustomEditors.ts | 13 ++- .../customEditor/common/customEditor.ts | 6 ++ .../customEditor/common/extensionPoint.ts | 26 +++++ .../editor/browser/editorResolverService.ts | 72 +++++++++----- .../editor/common/editorResolverService.ts | 10 +- .../browser/editorResolverService.test.ts | 96 +++++++++++++++++++ .../vscode.proposed.customEditorPriority.d.ts | 6 ++ 9 files changed, 201 insertions(+), 33 deletions(-) create mode 100644 src/vscode-dts/vscode.proposed.customEditorPriority.d.ts diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 1953604537dcab..b87c8ebad481ec 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -215,6 +215,9 @@ const _allApiProposals = { customEditorMove: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts', }, + customEditorPriority: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorPriority.d.ts', + }, dataChannels: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.dataChannels.d.ts', }, diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts index 905e06f941ee48..b95759eac611c0 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts @@ -177,6 +177,8 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ label: contributedEditor.displayName, detail: contributedEditor.providerDisplayName, priority: contributedEditor.priority, + diffEditorPriority: contributedEditor.diffEditorPriority, + mergeEditorPriority: contributedEditor.mergeEditorPriority, }, { singlePerResource: () => !(this.getCustomEditorCapabilities(contributedEditor.id)?.supportsMultipleEditorsPerDocument ?? false) diff --git a/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts b/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts index b8c132aad40d17..ea3797cbb2a5d6 100644 --- a/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts +++ b/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts @@ -49,13 +49,16 @@ export class ContributedCustomEditors extends Disposable { this._editors.clear(); for (const extension of extensions) { + const hasCustomEditorPriorityProposal = extension.description.enabledApiProposals?.includes('customEditorPriority') ?? false; for (const webviewEditorContribution of extension.value) { this.add(new CustomEditorInfo({ id: webviewEditorContribution.viewType, displayName: webviewEditorContribution.displayName, providerDisplayName: extension.description.isBuiltin ? nls.localize('builtinProviderDisplayName', "Built-in") : extension.description.displayName || extension.description.identifier.value, selector: webviewEditorContribution.selector || [], - priority: getPriorityFromContribution(webviewEditorContribution, extension.description), + priority: getPriorityFromContribution(webviewEditorContribution, extension.description, 'priority') ?? RegisteredEditorPriority.default, + diffEditorPriority: hasCustomEditorPriorityProposal ? getPriorityFromContribution(webviewEditorContribution, extension.description, 'diffEditorPriority') : undefined, + mergeEditorPriority: hasCustomEditorPriorityProposal ? getPriorityFromContribution(webviewEditorContribution, extension.description, 'mergeEditorPriority') : undefined, })); } } @@ -92,8 +95,10 @@ export class ContributedCustomEditors extends Disposable { function getPriorityFromContribution( contribution: ICustomEditorsExtensionPoint, extension: IExtensionDescription, -): RegisteredEditorPriority { - switch (contribution.priority as CustomEditorPriority | undefined) { + field: 'priority' | 'diffEditorPriority' | 'mergeEditorPriority', +): RegisteredEditorPriority | undefined { + const value = contribution[field] as CustomEditorPriority | undefined; + switch (value) { case CustomEditorPriority.default: return RegisteredEditorPriority.default; @@ -105,6 +110,6 @@ function getPriorityFromContribution( return extension.isBuiltin ? RegisteredEditorPriority.builtin : RegisteredEditorPriority.default; default: - return RegisteredEditorPriority.default; + return undefined; } } diff --git a/src/vs/workbench/contrib/customEditor/common/customEditor.ts b/src/vs/workbench/contrib/customEditor/common/customEditor.ts index 474cb684507cfe..ed8cf4ffc9e6f8 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditor.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditor.ts @@ -97,6 +97,8 @@ export interface CustomEditorDescriptor { readonly displayName: string; readonly providerDisplayName: string; readonly priority: RegisteredEditorPriority; + readonly diffEditorPriority?: RegisteredEditorPriority; + readonly mergeEditorPriority?: RegisteredEditorPriority; readonly selector: readonly CustomEditorSelector[]; } @@ -106,6 +108,8 @@ export class CustomEditorInfo implements CustomEditorDescriptor { public readonly displayName: string; public readonly providerDisplayName: string; public readonly priority: RegisteredEditorPriority; + public readonly diffEditorPriority?: RegisteredEditorPriority; + public readonly mergeEditorPriority?: RegisteredEditorPriority; public readonly selector: readonly CustomEditorSelector[]; constructor(descriptor: CustomEditorDescriptor) { @@ -113,6 +117,8 @@ export class CustomEditorInfo implements CustomEditorDescriptor { this.displayName = descriptor.displayName; this.providerDisplayName = descriptor.providerDisplayName; this.priority = descriptor.priority; + this.diffEditorPriority = descriptor.diffEditorPriority; + this.mergeEditorPriority = descriptor.mergeEditorPriority; this.selector = descriptor.selector; } diff --git a/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts b/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts index 1e029d8945c227..96bd238f43a7b9 100644 --- a/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts +++ b/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts @@ -20,6 +20,8 @@ const Fields = Object.freeze({ displayName: 'displayName', selector: 'selector', priority: 'priority', + diffEditorPriority: 'diffEditorPriority', + mergeEditorPriority: 'mergeEditorPriority', }); const customEditorsContributionSchema = { @@ -70,6 +72,30 @@ const customEditorsContributionSchema = { nls.localize('contributes.priority.option', 'The editor is not automatically used when the user opens a resource, but a user can switch to the editor using the `Reopen With` command.'), ], default: CustomEditorPriority.default + }, + [Fields.diffEditorPriority]: { + type: 'string', + markdownDescription: nls.localize('contributes.diffEditorPriority', 'Controls if the custom editor is enabled automatically when the user opens a diff. When not specified, the value of `priority` is used.'), + enum: [ + CustomEditorPriority.default, + CustomEditorPriority.option, + ], + markdownEnumDescriptions: [ + nls.localize('contributes.diffEditorPriority.default', 'The editor is automatically used when the user opens a diff, provided that no other default custom editors are registered for that resource.'), + nls.localize('contributes.diffEditorPriority.option', 'The editor is not automatically used when the user opens a diff, but a user can switch to the editor using the `Reopen With` command.'), + ], + }, + [Fields.mergeEditorPriority]: { + type: 'string', + markdownDescription: nls.localize('contributes.mergeEditorPriority', 'Controls if the custom editor is enabled automatically when the user opens a merge editor. When not specified, the value of `priority` is used.'), + enum: [ + CustomEditorPriority.default, + CustomEditorPriority.option, + ], + markdownEnumDescriptions: [ + nls.localize('contributes.mergeEditorPriority.default', 'The editor is automatically used when the user opens a merge editor, provided that no other default custom editors are registered for that resource.'), + nls.localize('contributes.mergeEditorPriority.option', 'The editor is not automatically used when the user opens a merge editor, but a user can switch to the editor using the `Reopen With` command.'), + ], } } } as const satisfies IJSONSchema; diff --git a/src/vs/workbench/services/editor/browser/editorResolverService.ts b/src/vs/workbench/services/editor/browser/editorResolverService.ts index c00d15a9a2819e..4ea821a141db7b 100644 --- a/src/vs/workbench/services/editor/browser/editorResolverService.ts +++ b/src/vs/workbench/services/editor/browser/editorResolverService.ts @@ -3,30 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as glob from '../../../../base/common/glob.js'; import { distinct, insert } from '../../../../base/common/arrays.js'; +import { PauseableEmitter } from '../../../../base/common/event.js'; +import * as glob from '../../../../base/common/glob.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { basename, extname, isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { EditorActivation, EditorResolution, IEditorOptions } from '../../../../platform/editor/common/editor.js'; -import { DEFAULT_EDITOR_ASSOCIATION, EditorResourceAccessor, EditorInputWithOptions, IResourceSideBySideEditorInput, isEditorInputWithOptions, isEditorInputWithOptionsAndGroup, isResourceDiffEditorInput, isResourceSideBySideEditorInput, isUntitledResourceEditorInput, isResourceMergeEditorInput, IUntypedEditorInput, SideBySideEditor, isResourceMultiDiffEditorInput } from '../../../common/editor.js'; -import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorGroup, IEditorGroupsService } from '../common/editorGroupsService.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { RegisteredEditorInfo, RegisteredEditorPriority, RegisteredEditorOptions, EditorAssociation, EditorAssociations, diffEditorsAssociationsSettingId, editorsAssociationsSettingId, globMatchesResource, IEditorResolverService, priorityToRank, ResolvedEditor, ResolvedStatus, EditorInputFactoryObject } from '../common/editorResolverService.js'; -import { QuickPickItem, IKeyMods, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; -import { localize } from '../../../../nls.js'; -import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IKeyMods, IQuickInputService, IQuickPickItem, IQuickPickSeparator, QuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { DEFAULT_EDITOR_ASSOCIATION, EditorInputWithOptions, EditorResourceAccessor, IResourceSideBySideEditorInput, isEditorInputWithOptions, isEditorInputWithOptionsAndGroup, isResourceDiffEditorInput, isResourceMergeEditorInput, isResourceMultiDiffEditorInput, isResourceSideBySideEditorInput, isUntitledResourceEditorInput, IUntypedEditorInput, SideBySideEditor } from '../../../common/editor.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; +import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { findGroup } from '../common/editorGroupFinder.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IEditorGroup, IEditorGroupsService } from '../common/editorGroupsService.js'; +import { diffEditorsAssociationsSettingId, EditorAssociation, EditorAssociations, EditorInputFactoryObject, editorsAssociationsSettingId, globMatchesResource, IEditorResolverService, priorityToRank, RegisteredEditorInfo, RegisteredEditorOptions, RegisteredEditorPriority, ResolvedEditor, ResolvedStatus } from '../common/editorResolverService.js'; import { PreferredGroup } from '../common/editorService.js'; -import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; -import { PauseableEmitter } from '../../../../base/common/event.js'; interface RegisteredEditor { globPattern: string | glob.IRelativePattern; @@ -39,7 +39,8 @@ type RegisteredEditors = Array; const enum EditorAssociationType { Editor, - DiffEditor + DiffEditor, + MergeEditor } export class EditorResolverService extends Disposable implements IEditorResolverService { @@ -130,7 +131,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver let resource = EditorResourceAccessor.getCanonicalUri(untypedEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); // If it was resolved before we await for the extensions to activate and then proceed with resolution or else the backing extensions won't be registered - const editorAssociationType = isResourceDiffEditorInput(untypedEditor) ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + const editorAssociationType = isResourceDiffEditorInput(untypedEditor) ? EditorAssociationType.DiffEditor : isResourceMergeEditorInput(untypedEditor) ? EditorAssociationType.MergeEditor : EditorAssociationType.Editor; if (this.cache && resource && (this.resourceMatchesCache(resource) || this.resourceMatchesUserAssociation(resource, editorAssociationType))) { await this.extensionService.whenInstalledExtensionsRegistered(); } @@ -272,12 +273,14 @@ export class EditorResolverService extends Disposable implements IEditorResolver } private getAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { - if (associationType === EditorAssociationType.Editor) { - return this.getAssociationsForResource(resource); + if (associationType === EditorAssociationType.DiffEditor || associationType === EditorAssociationType.MergeEditor) { + const diffAssociations = this.getAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId); + if (diffAssociations.length) { + return diffAssociations; + } } - const diffAssociations = this.getAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId); - return diffAssociations.length ? diffAssociations : this.getAssociationsForResource(resource); + return this.getAssociationsForResource(resource); } private getAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { @@ -406,6 +409,9 @@ export class EditorResolverService extends Disposable implements IEditorResolver if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { continue; } + if (associationType === EditorAssociationType.MergeEditor && !editor.editorFactoryObject.createMergeEditorInput) { + continue; + } const foundInSettings = userSettings.find(setting => setting.viewType === editor.editorInfo.id); if ((foundInSettings && editor.editorInfo.priority !== RegisteredEditorPriority.exclusive) || globMatchesResource(key, resource)) { @@ -415,11 +421,13 @@ export class EditorResolverService extends Disposable implements IEditorResolver } // Return the editors sorted by their priority return matchingEditors.sort((a, b) => { + const aPriority = this.getEffectivePriority(a.editorInfo, associationType); + const bPriority = this.getEffectivePriority(b.editorInfo, associationType); // Very crude if priorities match longer glob wins as longer globs are normally more specific - if (priorityToRank(b.editorInfo.priority) === priorityToRank(a.editorInfo.priority) && typeof b.globPattern === 'string' && typeof a.globPattern === 'string') { + if (priorityToRank(bPriority) === priorityToRank(aPriority) && typeof b.globPattern === 'string' && typeof a.globPattern === 'string') { return b.globPattern.length - a.globPattern.length; } - return priorityToRank(b.editorInfo.priority) - priorityToRank(a.editorInfo.priority); + return priorityToRank(bPriority) - priorityToRank(aPriority); }); } @@ -450,6 +458,9 @@ export class EditorResolverService extends Disposable implements IEditorResolver if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { return false; } + if (associationType === EditorAssociationType.MergeEditor && !editor.editorFactoryObject.createMergeEditorInput) { + return false; + } if (editor.options?.canSupportResource !== undefined) { return editor.editorInfo.id === viewType && editor.options.canSupportResource(resource); @@ -472,7 +483,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver const associationsFromSetting = this.getAssociationsForResourceByType(resource, associationType); // We only want minPriority+ if no user defined setting is found, else we won't resolve an editor const minPriority = editorId === EditorResolution.EXCLUSIVE_ONLY ? RegisteredEditorPriority.exclusive : RegisteredEditorPriority.builtin; - let possibleEditors = editors.filter(editor => priorityToRank(editor.editorInfo.priority) >= priorityToRank(minPriority) && editor.editorInfo.id !== DEFAULT_EDITOR_ASSOCIATION.id); + let possibleEditors = editors.filter(editor => priorityToRank(this.getEffectivePriority(editor.editorInfo, associationType)) >= priorityToRank(minPriority) && editor.editorInfo.id !== DEFAULT_EDITOR_ASSOCIATION.id); if (possibleEditors.length === 0) { return { editor: associationsFromSetting[0] && minPriority !== RegisteredEditorPriority.exclusive ? findMatchingEditor(editors, associationsFromSetting[0].viewType) : undefined, @@ -480,7 +491,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver }; } // If the editor is exclusive we use that, else use the user setting, else we check canSupportResource, else take the viewtype of first possible editor - const selectedViewType = possibleEditors[0].editorInfo.priority === RegisteredEditorPriority.exclusive ? + const selectedViewType = this.getEffectivePriority(possibleEditors[0].editorInfo, associationType) === RegisteredEditorPriority.exclusive ? possibleEditors[0].editorInfo.id : associationsFromSetting[0]?.viewType || (possibleEditors.find(editor => (!editor.options?.canSupportResource || editor.options.canSupportResource(resource)))?.editorInfo.id) || @@ -491,7 +502,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver // Filter out exclusive before we check for conflicts as exclusive editors cannot be manually chosen // similar to above, need to check canSupportResource if nothing is exclusive possibleEditors = possibleEditors - .filter(editor => editor.editorInfo.priority !== RegisteredEditorPriority.exclusive) + .filter(editor => this.getEffectivePriority(editor.editorInfo, associationType) !== RegisteredEditorPriority.exclusive) .filter(editor => !editor.options?.canSupportResource || editor.options.canSupportResource(resource)); if (associationsFromSetting.length === 0 && possibleEditors.length > 1) { conflictingDefault = true; @@ -503,6 +514,17 @@ export class EditorResolverService extends Disposable implements IEditorResolver }; } + private getEffectivePriority(editorInfo: RegisteredEditorInfo, associationType: EditorAssociationType): RegisteredEditorPriority { + switch (associationType) { + case EditorAssociationType.DiffEditor: + return editorInfo.diffEditorPriority ?? editorInfo.priority; + case EditorAssociationType.MergeEditor: + return editorInfo.mergeEditorPriority ?? editorInfo.priority; + default: + return editorInfo.priority; + } + } + private async doResolveEditor(editor: IUntypedEditorInput, group: IEditorGroup, selectedEditor: RegisteredEditor): Promise { let options = editor.options; const resource = EditorResourceAccessor.getCanonicalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }); @@ -646,7 +668,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver type StoredChoice = { [key: string]: string[]; }; - const associationType = isResourceDiffEditorInput(untypedInput) ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + const associationType = isResourceDiffEditorInput(untypedInput) ? EditorAssociationType.DiffEditor : isResourceMergeEditorInput(untypedInput) ? EditorAssociationType.MergeEditor : EditorAssociationType.Editor; const editors = this.findMatchingEditors(resource, associationType); const storedChoices: StoredChoice = JSON.parse(this.storageService.get(EditorResolverService.conflictingDefaultsStorageID, StorageScope.PROFILE, '{}')); const globForResource = `*${extname(resource)}`; diff --git a/src/vs/workbench/services/editor/common/editorResolverService.ts b/src/vs/workbench/services/editor/common/editorResolverService.ts index 09b6169f7b0162..7e5049008b9b70 100644 --- a/src/vs/workbench/services/editor/common/editorResolverService.ts +++ b/src/vs/workbench/services/editor/common/editorResolverService.ts @@ -102,10 +102,12 @@ export type RegisteredEditorOptions = { }; export type RegisteredEditorInfo = { - id: string; - label: string; - detail?: string; - priority: RegisteredEditorPriority; + readonly id: string; + readonly label: string; + readonly detail?: string; + readonly priority: RegisteredEditorPriority; + readonly diffEditorPriority?: RegisteredEditorPriority; + readonly mergeEditorPriority?: RegisteredEditorPriority; }; type EditorInputFactoryResult = EditorInputWithOptions | Promise; diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index 96c70b95b247c7..b66183a2bcea35 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -675,4 +675,100 @@ suite('EditorResolverService', () => { defaultEditor.dispose(); customEditor.dispose(); }); + + test('Diff editor Resolve - diffEditorPriority overrides priority for diffs', async () => { + const CUSTOM_EDITOR_INPUT_ID = 'testCustomEditorForDiffPriority'; + const [part, service, accessor] = await createEditorResolverService(); + const registeredEditor = service.registerEditor('*.test-diff-priority', + { + id: 'TEST_EDITOR', + label: 'Test Editor Label', + detail: 'Test Editor Details', + priority: RegisteredEditorPriority.default, + diffEditorPriority: RegisteredEditorPriority.option, + }, + {}, + { + createEditorInput: ({ resource, options }, group) => ({ editor: constructDisposableFileEditorInput(URI.parse(resource.toString()), CUSTOM_EDITOR_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original, options }, group) => ({ + editor: accessor.instantiationService.createInstance( + DiffEditorInput, + 'name', + 'description', + constructDisposableFileEditorInput(URI.parse(original.toString()), CUSTOM_EDITOR_INPUT_ID, disposables), + constructDisposableFileEditorInput(URI.parse(modified.toString()), CUSTOM_EDITOR_INPUT_ID, disposables), + undefined) + }) + } + ); + + // Regular editor should use custom editor (priority: default) + const editorResolution = await service.resolveEditor({ resource: URI.file('my://resource.test-diff-priority') }, part.activeGroup); + assert.ok(editorResolution); + assert.notStrictEqual(typeof editorResolution, 'number'); + if (editorResolution !== ResolvedStatus.ABORT && editorResolution !== ResolvedStatus.NONE) { + assert.strictEqual(editorResolution.editor.typeId, CUSTOM_EDITOR_INPUT_ID); + editorResolution.editor.dispose(); + } else { + assert.fail('Expected editor to resolve successfully'); + } + + // Diff editor should NOT use custom editor (diffEditorPriority: option) + const diffResolution = await service.resolveEditor({ + original: { resource: URI.file('my://resource.test-diff-priority') }, + modified: { resource: URI.file('my://resource.test-diff-priority') } + }, part.activeGroup); + assert.ok(diffResolution); + // With diffEditorPriority: option, the custom editor should not be selected as default + if (diffResolution !== ResolvedStatus.ABORT && diffResolution !== ResolvedStatus.NONE) { + assert.notStrictEqual(diffResolution.editor.typeId, CUSTOM_EDITOR_INPUT_ID, + 'Custom editor with diffEditorPriority:option should not be used for diffs'); + diffResolution.editor.dispose(); + } + + registeredEditor.dispose(); + }); + + test('Diff editor Resolve - diffEditorPriority defaults to priority when not set', async () => { + const CUSTOM_EDITOR_INPUT_ID = 'testCustomEditorNoDiffPriority'; + const [part, service, accessor] = await createEditorResolverService(); + const registeredEditor = service.registerEditor('*.test-no-diff-priority', + { + id: 'TEST_EDITOR', + label: 'Test Editor Label', + detail: 'Test Editor Details', + priority: RegisteredEditorPriority.default, + // diffEditorPriority not set - should fall back to priority + }, + {}, + { + createEditorInput: ({ resource, options }, group) => ({ editor: constructDisposableFileEditorInput(URI.parse(resource.toString()), CUSTOM_EDITOR_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original, options }, group) => ({ + editor: accessor.instantiationService.createInstance( + DiffEditorInput, + 'name', + 'description', + constructDisposableFileEditorInput(URI.parse(original.toString()), CUSTOM_EDITOR_INPUT_ID, disposables), + constructDisposableFileEditorInput(URI.parse(modified.toString()), CUSTOM_EDITOR_INPUT_ID, disposables), + undefined) + }) + } + ); + + // Diff editor should use custom editor since diffEditorPriority falls back to priority: default + const diffResolution = await service.resolveEditor({ + original: { resource: URI.file('my://resource.test-no-diff-priority') }, + modified: { resource: URI.file('my://resource.test-no-diff-priority') } + }, part.activeGroup); + assert.ok(diffResolution); + assert.notStrictEqual(typeof diffResolution, 'number'); + if (diffResolution !== ResolvedStatus.ABORT && diffResolution !== ResolvedStatus.NONE) { + assert.strictEqual(diffResolution.editor.typeId, 'workbench.editors.diffEditorInput'); + diffResolution.editor.dispose(); + } else { + assert.fail('Expected diff editor to resolve successfully'); + } + + registeredEditor.dispose(); + }); }); diff --git a/src/vscode-dts/vscode.proposed.customEditorPriority.d.ts b/src/vscode-dts/vscode.proposed.customEditorPriority.d.ts new file mode 100644 index 00000000000000..9f9eed400f3558 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.customEditorPriority.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/292379 From 79b6fb78a4dc129d6097e393e8cc6b9cb3050a59 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 8 May 2026 10:50:02 -0700 Subject: [PATCH 37/41] chat: remove 'Bridged' badge from MCP servers in AI Customizations UI (#315319) The 'Bridged' badge that appeared next to MCP server names in the chat customizations editor has been removed. This badge was shown when the active harness was not Local, indicating the server was forwarded to agent sessions. It is no longer needed. - Remove the bridgedBadge DOM element, hover tooltip, and autorun from McpServerItemRenderer - Remove bridgedBadge from IMcpServerItemTemplateData - Remove ICustomizationHarnessService injection from McpServerItemRenderer - Simplify the accessibility aria label (no longer appends 'Bridged') - Remove unused ICustomizationHarnessService injection from McpListWidget - Remove unused derived and SessionType imports from mcpListWidget.ts - Update CSS comment referencing the Bridged badge - Update AI_CUSTOMIZATIONS.md to remove reference to the Bridged badge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 2 +- .../browser/aiCustomization/mcpListWidget.ts | 29 ++----------------- .../media/aiCustomizationManagement.css | 4 +-- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 3205ee265788ce..380bb55f2e2951 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -248,7 +248,7 @@ The Agents sidebar `AICustomizationShortcutsWidget` supports three entrypoint mo ### Item Badges -`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. For skills with UI integrations, the badge reads "UI Integration" with a tooltip describing which UI surface invokes the skill. The badge text is also included in search filtering. +`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name. For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. For skills with UI integrations, the badge reads "UI Integration" with a tooltip describing which UI surface invokes the skill. The badge text is also included in search filtering. ### Embedded Detail Editors diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index b96cd2e717c89f..878d6369e9c503 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -23,7 +23,7 @@ import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { isContributionDisabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; -import { autorun, derived } from '../../../../../base/common/observable.js'; +import { autorun } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -40,10 +40,8 @@ import { formatDisplayName, truncateToFirstLine } from './aiCustomizationListWid import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; -import { SessionType } from '../../common/chatSessionsService.js'; const $ = DOM.$; @@ -121,7 +119,6 @@ interface IMcpServerItemTemplateData { readonly name: HTMLElement; readonly description: HTMLElement; readonly status: HTMLElement; - readonly bridgedBadge: HTMLElement; readonly disposables: DisposableStore; } @@ -135,7 +132,6 @@ class McpServerItemRenderer implements IListRenderer { - const activeId = this.harnessService.activeHarness.read(reader); - templateData.bridgedBadge.style.display = activeId !== SessionType.Local ? '' : 'none'; - })); - templateData.disposables.add(this.hoverService.setupManagedHover( - getDefaultHoverDelegate('mouse'), - templateData.bridgedBadge, - localize('bridgedHover', "This server is managed by VS Code and forwarded to all compatible agent sessions."), - )); - if (element.type === 'builtin-item') { templateData.container.classList.add('builtin'); templateData.name.textContent = formatDisplayName(element.label); @@ -408,7 +390,6 @@ export class McpListWidget extends Disposable { @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IDialogService private readonly dialogService: IDialogService, @IConfigurationService private readonly configurationService: IConfigurationService, - @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, ) { super(); this.element = $('.mcp-list-widget'); @@ -528,11 +509,7 @@ export class McpListWidget extends Disposable { if (element.type === 'group-header') { return localize('mcpGroupAriaLabel', "{0}, {1} items, {2}", element.label, element.count, element.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); } - const label = element.type === 'builtin-item' ? element.label : element.server.label; - return derived(reader => { - const isBridged = this.harnessService.activeHarness.read(reader) !== SessionType.Local; - return isBridged ? localize('mcpServerBridgedAriaLabel', "{0}. {1}", label, localize('bridged', "Bridged")) : label; - }); + return element.type === 'builtin-item' ? element.label : element.server.label; }, getWidgetAriaLabel() { return localize('mcpServersListAriaLabel', "MCP Servers"); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 4d93a4a389478f..cc170411857bdd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -479,7 +479,7 @@ opacity: 0.6; } -/* Shared inline badge style — used by MCP "Bridged" badge and item badges */ +/* Shared inline badge style — used for item badges */ .inline-badge { flex-shrink: 0; font-size: 10px; @@ -510,7 +510,7 @@ margin-right: 4px; } -/* MCP bridged badge — shown inline next to the server name */ +/* MCP server name row — inline layout for the server name */ .mcp-server-item .mcp-server-name-row { display: flex; align-items: center; From dd3ad60121b5b5b1c20301ce3e9f1c2c7a52262e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 8 May 2026 19:50:27 +0200 Subject: [PATCH 38/41] Replace "Agents app" with "Agents window" in user-facing strings (#315302) Fixes #315270 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-main/windowsMainService.ts | 34 +++++++++---------- .../browser/account.contribution.ts | 4 +-- .../aquarium/browser/aquarium.contribution.ts | 2 +- .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../copilotChatSessions.contribution.ts | 2 +- .../browser/accessibilityConfiguration.ts | 2 +- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index b8e452ad4def25..9feeb83ebd981e 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -765,28 +765,31 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic return window; } - private doOpenEmpty(openConfig: IOpenConfiguration, forceNewWindow: boolean, remoteAuthority: string | undefined, filesToOpen: IFilesToOpen | undefined, emptyWindowBackupInfo?: IEmptyWindowBackupInfo): Promise { - this.logService.trace('windowsManager#doOpenEmpty', { restore: !!emptyWindowBackupInfo, remoteAuthority, filesToOpen, forceNewWindow }); - - let windowToUse: ICodeWindow | undefined; + private resolveContextWindow(openConfig: IOpenConfiguration, forceNewWindow: boolean): { windowToUse: ICodeWindow | undefined; forceNewWindow: boolean } { if (!forceNewWindow && typeof openConfig.contextWindowId === 'number') { - const contextWindow = this.getWindowById(openConfig.contextWindowId); // fix for https://github.com/microsoft/vscode/issues/97172 + const contextWindow = this.getWindowById(openConfig.contextWindowId); if (contextWindow?.config?.isSessionsWindow) { - forceNewWindow = true; // do not replace the agents window - } else { - windowToUse = contextWindow; + return { windowToUse: undefined, forceNewWindow: true }; // do not replace the agents window } + return { windowToUse: contextWindow, forceNewWindow }; } + return { windowToUse: undefined, forceNewWindow }; + } + + private doOpenEmpty(openConfig: IOpenConfiguration, forceNewWindow: boolean, remoteAuthority: string | undefined, filesToOpen: IFilesToOpen | undefined, emptyWindowBackupInfo?: IEmptyWindowBackupInfo): Promise { + this.logService.trace('windowsManager#doOpenEmpty', { restore: !!emptyWindowBackupInfo, remoteAuthority, filesToOpen, forceNewWindow }); + + const resolved = this.resolveContextWindow(openConfig, forceNewWindow); return this.openInBrowserWindow({ userEnv: openConfig.userEnv, cli: openConfig.cli, initialStartup: openConfig.initialStartup, remoteAuthority, - forceNewWindow, + forceNewWindow: resolved.forceNewWindow, forceNewTabbedWindow: openConfig.forceNewTabbedWindow, filesToOpen, - windowToUse, + windowToUse: resolved.windowToUse, emptyWindowBackupInfo, forceProfile: openConfig.forceProfile, forceTempProfile: openConfig.forceTempProfile @@ -796,13 +799,10 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic private doOpenFolderOrWorkspace(openConfig: IOpenConfiguration, folderOrWorkspace: IWorkspacePathToOpen | ISingleFolderWorkspacePathToOpen, forceNewWindow: boolean, filesToOpen: IFilesToOpen | undefined, windowToUse?: ICodeWindow): Promise { this.logService.trace('windowsManager#doOpenFolderOrWorkspace', { folderOrWorkspace, filesToOpen }); - if (!forceNewWindow && !windowToUse && typeof openConfig.contextWindowId === 'number') { - const contextWindow = this.getWindowById(openConfig.contextWindowId); // fix for https://github.com/microsoft/vscode/issues/49587 - if (contextWindow?.config?.isSessionsWindow) { - forceNewWindow = true; // do not replace the agents window - } else { - windowToUse = contextWindow; - } + if (!windowToUse) { + const resolved = this.resolveContextWindow(openConfig, forceNewWindow); + windowToUse = resolved.windowToUse; + forceNewWindow = resolved.forceNewWindow; } return this.openInBrowserWindow({ diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 456b988680466c..055c72a0a42ade 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -110,8 +110,8 @@ registerAction2(class extends Action2 { const accountLabel = defaultAccount.accountName; const { confirmed } = await dialogService.confirm({ type: Severity.Info, - message: localize('agenticSignOutMessage', "Sign out of the Agents app?"), - detail: localize('agenticSignOutDetail', "This will sign out '{0}' from the Agents app.", accountLabel), + message: localize('agenticSignOutMessage', "Sign out of the Agents window?"), + detail: localize('agenticSignOutDetail', "This will sign out '{0}' from the Agents window.", accountLabel), primaryButton: localize({ key: 'agenticSignOutButton', comment: ['&& denotes a mnemonic'] }, "&&Sign Out") }); diff --git a/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts index 22bbff401e8096..a6174c55ada0d7 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts @@ -17,7 +17,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis [SESSIONS_DEVELOPER_JOY_ENABLED_SETTING]: { type: 'boolean', default: product.quality !== 'stable', - description: localize('sessions.developerJoy.enabled', "Adds an easter egg to the Agents application."), + description: localize('sessions.developerJoy.enabled', "Adds an easter egg to the Agents window."), tags: ['experimental'], }, }, diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 1141d5459ccc3e..8225e2446600ac 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -23,7 +23,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat const viewsService = accessor.get(IViewsService); const content: string[] = []; - content.push(localize('sessionsChat.overview', "You are in the Agents app. The Agents app is a dedicated workspace for working with AI agents. It provides a chat interface, a changes view for reviewing agent-generated changes, a file explorer, and customization options.")); + content.push(localize('sessionsChat.overview', "You are in the Agents window. The Agents window is a dedicated workspace for working with AI agents. It provides a chat interface, a changes view for reviewing agent-generated changes, a file explorer, and customization options.")); content.push(localize('sessionsChat.input', "You are in the chat input. Type a message and press Enter to send it.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts index eeaded602ae6ad..1f3ce21e7d8fbe 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -26,7 +26,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'boolean', default: true, experiment: { mode: 'startup' }, - description: localize('sessions.chat.claudeAgent.enabled', "Enable Claude Agent sessions in the Agents app. Start and resume agentic coding sessions powered by Anthropic's Claude Agent SDK directly. Uses your existing Copilot subscription."), + description: localize('sessions.chat.claudeAgent.enabled', "Enable Claude Agent sessions in the Agents window. Start and resume agentic coding sessions powered by Anthropic's Claude Agent SDK directly. Uses your existing Copilot subscription."), }, [LOCAL_SESSION_ENABLED_SETTING]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index cf9b6123a2de3d..2e66c87a17c578 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -208,7 +208,7 @@ const configuration: IConfigurationNode = { ...baseVerbosityProperty }, [AccessibilityVerbositySettingId.SessionsChat]: { - description: localize('verbosity.sessionsChat', 'Provide information about how to access the Agents app accessibility help menu when the chat input is focused.'), + description: localize('verbosity.sessionsChat', 'Provide information about how to access the Agents window accessibility help menu when the chat input is focused.'), ...baseVerbosityProperty }, [AccessibilityVerbositySettingId.ChatQuestionCarousel]: { From 1242adc13cd3ff6d99c20f30bed803d3c6691421 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 8 May 2026 19:54:17 +0200 Subject: [PATCH 39/41] sessions: restore last active session on reload (#315312) On reload, the agents window now restores the last active session instead of showing the new-session view. - Add `restoreLastActiveSession()` to `ISessionsManagementService` and call it from `Workbench.restore()` during startup. - Switch away from the new-session view synchronously (before any await) to prevent `NewChatViewPane` from rendering and accidentally cancelling the restore token via `createNewSession`. - Wait for the session provider to register if the session isn't available immediately, then delegate to `_doOpenSession`. - Show a progress indicator on `ChatViewId` during restore (200ms delay to avoid flicker on fast restores); cancel it immediately if the user navigates to the new-session view. - `_startOpenSession` cancels any in-flight open/restore and returns a fresh token; all navigation paths (openSession, openChat, createNewSession, openNewSessionView, openNewChatInSession) now call it so concurrent operations are safely cancelled. - Fire `_onDidOpenNewSessionView` in `openNewSessionView` so the restore progress promise can race against it and dismiss early. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/workbench.ts | 3 + .../browser/sessionsManagementService.ts | 142 +++++++++++++++++- .../sessions/common/sessionsManagement.ts | 7 + 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 8c1d9202b8ed79..a3626a7f7dc328 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -844,6 +844,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Restore parts (open default view containers) this.restoreParts(); + // Restore the last active session (progress is shown inside the service). + this.sessionsManagementService.restoreLastActiveSession(); + // Set lifecycle phase to `Restored` lifecycleService.phase = LifecyclePhase.Restored; diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 065515701a963e..416c501368784f 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, ISettableObservable, autorun, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatViewId, ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IProgressService } from '../../../../platform/progress/common/progress.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionArchivedContext, IsActiveSessionBackgroundProviderContext, IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; @@ -62,6 +64,9 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen private readonly _isActiveSessionArchived: IContextKey; private readonly _supportsMultiChat: IContextKey; private _activeChatObservable: ISettableObservable | undefined; + /** Cancelled on every navigation action so in-flight async opens bail out. */ + private readonly _openSessionCts = this._register(new MutableDisposable()); + private readonly _onDidOpenNewSessionView = this._register(new Emitter()); private _activeSessionDisposables = this._register(new DisposableStore()); private readonly _providerListeners = this._register(new DisposableMap()); private readonly _sessionStates: ResourceMap; @@ -74,6 +79,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IProgressService private readonly progressService: IProgressService, ) { super(); @@ -203,8 +209,19 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } } + /** + * Cancel any in-flight open-session/restore and return a fresh cancellation token. + */ + private _startOpenSession() { + this._openSessionCts.value?.cancel(); + const cts = new CancellationTokenSource(); + this._openSessionCts.value = cts; + return cts.token; + } + async openChat(session: ISession, chatUri: URI): Promise { const t0 = Date.now(); + const token = this._startOpenSession(); this.logService.trace(`[SessionsManagement] openChat start uri=${chatUri.toString()} provider=${session.providerId}`); this.isNewChatSessionContext.set(false); this.setActiveSession(session); @@ -229,11 +246,24 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } this._isNewChatInSessionContext.set(false); - await this.chatWidgetService.openSession(chatUri, ChatViewPaneTarget); + try { + await this.chatWidgetService.openSession(chatUri, ChatViewPaneTarget); + } catch (e) { + if (token.isCancellationRequested) { + this.logService.trace('[SessionsManagement] openChat: suppressed error because user navigated away'); + return; + } + throw e; + } this.logService.trace(`[SessionsManagement] openChat done total=${Date.now() - t0}ms uri=${chatUri.toString()}`); } async openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise { + const token = this._startOpenSession(); + await this._doOpenSession(sessionResource, token, options); + } + + private async _doOpenSession(sessionResource: URI, token: CancellationToken, options?: { preserveFocus?: boolean }): Promise { const t0 = Date.now(); const sessionData = this.getSession(sessionResource); if (!sessionData) { @@ -248,7 +278,15 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen // Open the active chat (which may have been restored to the last active chat) const activeChat = this._activeSession.get()?.activeChat.get(); const openUri = activeChat?.resource ?? sessionData.resource; - await this.chatWidgetService.openSession(openUri, ChatViewPaneTarget, { preserveFocus: options?.preserveFocus }); + try { + await this.chatWidgetService.openSession(openUri, ChatViewPaneTarget, { preserveFocus: options?.preserveFocus }); + } catch (e) { + if (token.isCancellationRequested) { + this.logService.trace('[SessionsManagement] openSession: suppressed error because user navigated away'); + return; + } + throw e; + } this.logService.trace(`[SessionsManagement] openSession done total=${Date.now() - t0}ms uri=${sessionResource.toString()}`); } @@ -258,6 +296,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } createNewSession(providerId: string, repositoryUri: URI, sessionTypeId?: string): ISession { + this._startOpenSession(); if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } @@ -344,15 +383,23 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen if (this.isNewChatSessionContext.get()) { return; } + this._startOpenSession(); // Restore the pending new session if one exists, so pickers // re-derive their state from the still-alive session object. // Otherwise clear active session (first time / after send). this.setActiveSession(this._pendingNewSession ?? undefined); this.isNewChatSessionContext.set(true); this._isNewChatInSessionContext.set(false); + this._onDidOpenNewSessionView.fire(); + + // Clear isActive so the new-session view is restored on reload + for (const [, state] of this._sessionStates) { + state.isActive = false; + } } openNewChatInSession(session: ISession): void { + this._startOpenSession(); const provider = this._getProvider(session); if (!provider) { this.logService.warn(`[SessionsManagement] openNewChatInSession: provider '${session.providerId}' not found`); @@ -500,6 +547,93 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this.storageService.store(ACTIVE_SESSION_STATES_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); } + private _getLastActiveSessionState(): ISessionState | undefined { + for (const [, state] of this._sessionStates) { + if (state.isActive) { + return state; + } + } + return undefined; + } + + async restoreLastActiveSession(): Promise { + const lastActive = this._getLastActiveSessionState(); + if (!lastActive) { + return; + } + + // Synchronously switch away from the new-session view before any await so + // that NewChatViewPane never renders and cannot call createNewSession() + // which would cancel our restore token. + this.isNewChatSessionContext.set(false); + this._isNewChatInSessionContext.set(false); + + const sessionResource = URI.parse(lastActive.sessionResource); + const token = this._startOpenSession(); + + const doRestore = async () => { + // Session may already be available if the provider registered early + const existing = this.getSession(sessionResource); + if (existing) { + try { + await this._doOpenSession(sessionResource, token); + } catch { + if (!token.isCancellationRequested) { + this.openNewSessionView(); + } + } + return; + } + + // Wait for the session to become available via provider registration. + // Cancel if the user navigates while we are waiting. + await new Promise(resolve => { + const disposables = new DisposableStore(); + + const cancel = () => { + disposables.dispose(); + resolve(); + }; + + disposables.add(token.onCancellationRequested(cancel)); + + const tryRestore = () => { + if (token.isCancellationRequested) { + cancel(); + return; + } + + const session = this.getSession(sessionResource); + if (session) { + disposables.dispose(); + this._doOpenSession(sessionResource, token).then(resolve, () => { + if (!token.isCancellationRequested) { + this.openNewSessionView(); + } + resolve(); + }); + } + }; + + disposables.add(this.onDidChangeSessions(() => tryRestore())); + disposables.add(this.sessionsProvidersService.onDidChangeProviders(() => tryRestore())); + }); + }; + + const restorePromise = doRestore(); + if (!this.isNewChatSessionContext.get()) { + // Race against new-session navigation so progress stops immediately + // when the user opens the new session view, but not when they open + // another existing session (which should show its own progress). + const progressPromise = Promise.race([ + restorePromise, + new Promise(resolve => this._onDidOpenNewSessionView.event(() => resolve())) + ]); + this.progressService.withProgress({ location: ChatViewId, delay: 200 }, () => progressPromise); + } + await restorePromise; + } + // -- Session Actions -- private _getProvider(session: ISession): ISessionsProvider | undefined { diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 64ad47c3dcfb32..6584902b555521 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -85,6 +85,13 @@ export interface ISessionsManagementService { */ openChat(session: ISession, chatUri: URI): Promise; + /** + * Restore the last active session from persisted state. + * Waits until the session provider is available and then opens the session. + * Falls back to the new-session view if the session is not found. + */ + restoreLastActiveSession(): Promise; + /** * Switch to the new-session view. * No-op if the current session is already a new session. From 25035fb53bf7e552cdaf247e269551b560077211 Mon Sep 17 00:00:00 2001 From: Lars Jeppesen Date: Fri, 8 May 2026 19:54:54 +0200 Subject: [PATCH 40/41] fixes https://github.com/microsoft/vscode/issues/291188 (#314713) --- src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index 8f47275eae4193..36209197e9259b 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -2064,7 +2064,7 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer return false; } } - await this._editorService.saveAll({ reason: SaveReason.AUTO }); + await this._editorService.saveAll({ reason: SaveReason.EXPLICIT }); return true; } @@ -3234,7 +3234,7 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer private _reRunTaskCommand(onlyRerun?: boolean): void { ProblemMatcherRegistry.onReady().then(() => { - return this._editorService.saveAll({ reason: SaveReason.AUTO }).then(() => { // make sure all dirty editors are saved + return this._editorService.saveAll({ reason: SaveReason.EXPLICIT }).then(() => { // make sure all dirty editors are saved const executeResult = this._getTaskSystem().rerun(); if (executeResult) { return this._handleExecuteResult(executeResult); From b0e2250b0b5ca6810d70ab9b91abf82e65ae49ab Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 8 May 2026 10:54:57 -0700 Subject: [PATCH 41/41] chat: hide plugin actions for synced customization items (#315320) Items that come from the 'vscode-synced-customization' scheme are backed by a synthetic plugin that is purely an implementation detail of the sync mechanism. Showing 'Show Plugin' and 'Uninstall Plugin' context menu actions for those items is confusing because the plugin concept is not user-facing in that context. Tighten WHEN_ITEM_IS_PLUGIN to also require that the plugin URI does NOT start with the 'vscode-synced-customization:' scheme, so all three plugin-related menu items (inline trash icon, 'Uninstall Plugin', and 'Show Plugin') are suppressed for synced customization entries. Fixes: #314879 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationManagement.contribution.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 27857a46af4c15..15115d687d50a9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -30,6 +30,7 @@ import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../browser/e import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IWorkbenchExtensionManagementService } from '../../../../services/extensionManagement/common/extensionManagement.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -44,6 +45,7 @@ import { IChatWidgetService } from '../chat.js'; import { AgentPluginItemKind } from '../agentPluginEditor/agentPluginItems.js'; import { AI_CUSTOMIZATION_ITEM_DISABLED_KEY, + AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, @@ -449,8 +451,16 @@ const WHEN_ITEM_IS_DELETABLE = ContextKeyExpr.and( /** * When clause that shows an action only for plugin items. + * + * Synced customizations are bundled into a synthetic plugin (under the + * `vscode-synced-customization:` scheme) as an implementation detail of the + * sync mechanism. Their plugin identity is not user-facing, so we hide + * plugin-related actions ("Show Plugin", "Uninstall Plugin") for them. */ -const WHEN_ITEM_IS_PLUGIN = ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.plugin); +const WHEN_ITEM_IS_PLUGIN = ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.plugin), + ContextKeyExpr.regex(AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, new RegExp(`^${SYNCED_CUSTOMIZATION_SCHEME}:`)).negate(), +); // Register context menu items