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`; 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/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/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/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/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/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 */ 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/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..8ae86c7ad744a1 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,140 @@ 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; + } + 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; + } + + 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/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/platform/telemetry/common/telemetry.ts b/src/vs/platform/telemetry/common/telemetry.ts index 5cd0d8cbdc68db..193b45cb9eb27a 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 | undefined { + return modelId?.replace(/[\/\\]/g, '|'); +} + export interface ITelemetryEndpoint { id: string; aiKey: string; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 8f56afc29ec233..9feeb83ebd981e 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -765,23 +765,31 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic return window; } + private resolveContextWindow(openConfig: IOpenConfiguration, forceNewWindow: boolean): { windowToUse: ICodeWindow | undefined; forceNewWindow: boolean } { + if (!forceNewWindow && typeof openConfig.contextWindowId === 'number') { + const contextWindow = this.getWindowById(openConfig.contextWindowId); + if (contextWindow?.config?.isSessionsWindow) { + 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 }); - 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 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 @@ -791,8 +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') { - windowToUse = this.getWindowById(openConfig.contextWindowId); // fix for https://github.com/microsoft/vscode/issues/49587 + if (!windowToUse) { + const resolved = this.resolveContextWindow(openConfig, forceNewWindow); + windowToUse = resolved.windowToUse; + forceNewWindow = resolved.forceNewWindow; } 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/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/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 37cc2a7e7a2e3b..a3626a7f7dc328 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, @@ -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/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/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index ac12b57d1b519c..fd852bf0d72b50 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,22 +198,20 @@ 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}`; 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' || - 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/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index 11638d7dd92359..4695e505458aa1 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'; @@ -89,7 +89,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>; @@ -231,11 +231,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; @@ -253,7 +253,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); }); } @@ -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/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/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 48f4c4cf073bc5..1f3ce21e7d8fbe 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'; @@ -26,7 +26,13 @@ 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', + default: false, + tags: ['experimental'], + description: localize('sessions.chat.localAgent.enabled', "Enable Local VS Code chat sessions in the Agents Window."), }, }, }); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index c4c468d4b5840b..1a1f8b08fe2570 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -24,7 +24,7 @@ import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from import { Menus } from '../../../browser/menus.js'; import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.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 { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; import { BranchPicker } from './branchPicker.js'; @@ -34,14 +34,17 @@ import { IsolationPicker } from './isolationPicker.js'; import { ModePicker } from './modePicker.js'; import { CloudModelPicker } from './modelPicker.js'; import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.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 14150c54152727..f05fd95b2d8eef 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, sessionFileChangesEqual, 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, sessionFileChangesEqual, 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,209 @@ 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 alive by retaining the + * {@link IChatModelReference} returned from `startNewLocalSession` for the + * lifetime of this object. + */ +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; + 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, + }; + }), 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 +909,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 +1412,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 +1422,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 +1441,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 +1463,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 +1487,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 +1498,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 +1531,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 +1602,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 +1845,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 +1887,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 +1971,71 @@ 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'); + } + + // 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, + userSelectedTools, + instructionContext: { + modeKind: sendOptions.modeInfo?.kind ?? ChatModeKind.Agent, + enabledTools: userSelectedTools?.get(), + }, + }); + 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 +2655,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/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', () => { 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%; } 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, + }, }); } 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/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) + ) + ) ) }] }); 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 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/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 8e74c17b9783a2..575c06a346db5b 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -52,6 +52,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 @@ -211,7 +221,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/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. 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/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/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]: { 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 3c811b9c9c0de8..1fc88af263f25f 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'; @@ -37,19 +37,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 @@ -211,7 +213,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']; @@ -282,13 +284,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 }); } } @@ -594,6 +596,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', @@ -804,13 +807,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); } } @@ -822,7 +830,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) { @@ -857,7 +865,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 }, }); } } @@ -972,7 +980,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 @@ -2479,33 +2490,65 @@ 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 { + // File / implicit attachments: a Location → selection, a URI → resource. + // 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, sessionResource, v._meta); + 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; } @@ -2517,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; } @@ -2526,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) { @@ -2535,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/browser/agentSessions/localAgentSessionsController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index 8389624d685f77..498c97651e9aa7 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 workingDirectoryPath = chatDetail.workingDirectory?.scheme === Schemas.file ? chatDetail.workingDirectory.fsPath : undefined; + this.metadata = workingDirectoryPath ? { workingDirectoryPath: workingDirectoryPath } : undefined; } isEqual(other: LocalChatSessionItem): boolean { 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 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; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index c6b1f1dc1e4bab..8c279b3fc2eae1 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' @@ -1561,6 +1561,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 5bd5543ad6e3fc..2a42a6eadf7f1e 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 b91e2604e65744..d03f2a73f0fe6f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -25,11 +25,17 @@ 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 { 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()); @@ -46,6 +52,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 +108,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(ChatStatusBarEntry.TITLE_BAR_CONTEXT_KEYS)) { + this.update(); + } + })); this._register(this.completionsService.onDidChangeIsSnoozing(() => this.update())); @@ -115,7 +127,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,11 +250,12 @@ 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, @@ -250,6 +263,34 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu }; } + private isSignInTitleBarAffordanceVisible(): boolean { + if (isWeb) { + 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; + } + + 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/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 5c3da450d93bfc..af82cdf409e0e2 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 @@ -598,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(); } @@ -619,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); @@ -657,7 +665,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); @@ -729,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 @@ -815,7 +856,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 +870,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 +1184,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 +1203,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 +1230,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 +1246,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 { @@ -1687,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/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/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/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/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 57b47d9616dd36..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 { @@ -1363,6 +1364,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..189ef96c8d2539 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); } @@ -401,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, }); }); } @@ -413,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, }; } @@ -772,7 +785,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 +794,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; @@ -1279,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; @@ -1922,5 +1936,6 @@ export async function chatModelToChatDetail(model: IChatModel): Promise; + 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/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 11e37c226dce51..f1d0c20e24a3c5 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -63,6 +63,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', diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index aa624dfbe52862..cb4d5539ca3bd3 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1584,6 +1584,13 @@ export interface IChatModel extends IDisposable { readonly repoData: IExportableRepoData | undefined; setRepoData(data: IExportableRepoData | undefined): void; + /** + * The working directory URI associated with this session. + * Only set in the sessions/agents window context. + */ + readonly workingDirectory: URI | undefined; + setWorkingDirectory(uri: URI | undefined): void; + readonly onDidChangePendingRequests: Event; getPendingRequests(): readonly IChatPendingRequest[]; } @@ -1768,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 4f52965588018c..b00f28ebfc3931 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; } @@ -742,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 @@ -809,57 +838,63 @@ 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, + workingDirectory: session.workingDirectory?.toString(), + }; +} + +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, }; } 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/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; } 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/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 ef642f71173ed5..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 @@ -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); } /** @@ -188,6 +188,7 @@ suite('stateToProgressAdapter', () => { prompt: 'Use restored model', participant: 'participant-1', modelId: 'agent-host-copilot:gpt-5', + variableData: undefined, }); }); 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/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index ce33bc69f5ff37..b869c81006bec2 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'; @@ -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[]; } @@ -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 { 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); + }); + }); }); 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/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, 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/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); 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); 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; } 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/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; 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/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([ 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), + }), }); 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 82143442b9e749..ad7130f0f7ee7f 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`). @@ -1021,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); @@ -1042,7 +1047,7 @@ interface ThemedFixtureGroupOptions { readonly labels?: ThemedFixtureGroupLabels; } -type ThemedFixtureGroupFixtures = Record; +type ThemedFixtureGroupFixtures = Record>; /** * Creates a nested fixture group from themed fixtures. 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. */ 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