Path: blob/main/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { derived, IObservable, observableValue, ISettableObservable } from '../../../../base/common/observable.js';6import { relativePath } from '../../../../base/common/resources.js';7import { URI } from '../../../../base/common/uri.js';8import { CancellationToken } from '../../../../base/common/cancellation.js';9import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter, applyStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';10import { IChatPromptSlashCommand, IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';11import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js';12import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';13import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';14import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js';15import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';16import { ICommandService } from '../../../../platform/commands/common/commands.js';17import { ILogService } from '../../../../platform/log/common/log.js';18import { IFileService } from '../../../../platform/files/common/files.js';19import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';20import { localize } from '../../../../nls.js';21import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js';2223/**24* Agent Sessions override of IAICustomizationWorkspaceService.25* Delegates to ISessionsManagementService to provide the active session's26* worktree/repository as the project root, and supports worktree commit.27*28* Customization files are always committed to the main repository so they29* persist across worktrees. When a worktree is active the file is also30* copied into the worktree and committed there so the running session31* picks it up immediately.32*/33export class SessionsAICustomizationWorkspaceService implements IAICustomizationWorkspaceService {34declare readonly _serviceBrand: undefined;3536readonly activeProjectRoot: IObservable<URI | undefined>;37readonly hasOverrideProjectRoot: IObservable<boolean>;3839/**40* Transient override for the project root. When set, `activeProjectRoot`41* returns this value instead of the session-derived root.42*/43private readonly _overrideRoot: ISettableObservable<URI | undefined>;4445constructor(46@ISessionsManagementService private readonly sessionsService: ISessionsManagementService,47@IInstantiationService private readonly instantiationService: IInstantiationService,48@IPromptsService private readonly promptsService: IPromptsService,49@ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService,50@ICommandService private readonly commandService: ICommandService,51@ILogService private readonly logService: ILogService,52@IFileService private readonly fileService: IFileService,53@INotificationService private readonly notificationService: INotificationService,54) {55this._overrideRoot = observableValue(this, undefined);5657this.activeProjectRoot = derived(reader => {58const override = this._overrideRoot.read(reader);59if (override) {60return override;61}62const session = this.sessionsService.activeSession.read(reader);63const repo = session?.workspace.read(reader)?.repositories[0];64const root = repo?.workingDirectory ?? repo?.uri;65if (root?.scheme === AGENT_HOST_SCHEME) {66return undefined;67}68return root;69});7071this.hasOverrideProjectRoot = derived(reader => {72return this._overrideRoot.read(reader) !== undefined;73});74}7576getActiveProjectRoot(): URI | undefined {77const override = this._overrideRoot.get();78if (override) {79return override;80}81const session = this.sessionsService.activeSession.get();82const repo = session?.workspace.get()?.repositories[0];83const root = repo?.workingDirectory ?? repo?.uri;84if (root?.scheme === AGENT_HOST_SCHEME) {85return undefined;86}87return root;88}8990setOverrideProjectRoot(root: URI): void {91this._overrideRoot.set(root, undefined);92}9394clearOverrideProjectRoot(): void {95this._overrideRoot.set(undefined, undefined);96}9798readonly managementSections: readonly AICustomizationManagementSection[] = [99AICustomizationManagementSection.Agents,100AICustomizationManagementSection.Skills,101AICustomizationManagementSection.Instructions,102AICustomizationManagementSection.Hooks,103AICustomizationManagementSection.McpServers,104AICustomizationManagementSection.Plugins,105];106107getStorageSourceFilter(type: PromptsType): IStorageSourceFilter {108return this.harnessService.getStorageSourceFilter(type);109}110111readonly isSessionsWindow = true;112113readonly welcomePageFeatures = {114showGettingStartedBanner: true,115};116117/**118* Commits customization files. Always commits to the main repository119* so the change persists across worktrees. When a worktree is active120* the file is also committed there so the session sees it immediately.121*/122async commitFiles(_projectRoot: URI, fileUris: URI[]): Promise<void> {123const session = this.sessionsService.activeSession.get();124const repo = session?.workspace.get()?.repositories[0];125if (!repo?.uri) {126return;127}128129for (const fileUri of fileUris) {130await this.commitFileToRepos(fileUri, repo.uri, repo.workingDirectory);131}132}133134/**135* Commits the deletion of files that have already been removed from disk.136* Always stages + commits the removal in the main repository, and also137* in the worktree if one is active.138*/139async deleteFiles(_projectRoot: URI, fileUris: URI[]): Promise<void> {140const session = this.sessionsService.activeSession.get();141const repo = session?.workspace.get()?.repositories[0];142if (!repo?.uri) {143return;144}145146for (const fileUri of fileUris) {147await this.commitDeletionToRepos(fileUri, repo.uri, repo.workingDirectory);148}149}150151/**152* Computes the repository-relative path for a file. The file may be153* located under the worktree or the repository root.154*/155private getRelativePath(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): string | undefined {156// Try worktree first (when active, files are written under it)157if (worktreeUri) {158const rel = relativePath(worktreeUri, fileUri);159if (rel) {160return rel;161}162}163return relativePath(repositoryUri, fileUri);164}165166/**167* Commits a single file to the main repository and optionally the worktree.168* Copies the file content between trees when needed.169*/170private async commitFileToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise<void> {171const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri);172if (!relPath) {173return;174}175176const repoFileUri = URI.joinPath(repositoryUri, relPath);177178// 1. Always commit to main repository179try {180if (repoFileUri.toString() !== fileUri.toString()) {181const content = await this.fileService.readFile(fileUri);182await this.fileService.writeFile(repoFileUri, content.value);183}184await this.commandService.executeCommand(185'github.copilot.cli.sessions.commitToRepository',186{ repositoryUri, fileUri: repoFileUri }187);188} catch (error) {189this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to repository:', error);190if (worktreeUri) {191this.notificationService.notify({192severity: Severity.Warning,193message: localize('commitToRepoFailed', "Your customization was saved to this session's worktree, but we couldn't apply it to the default branch. You may need to apply it manually."),194});195}196}197198// 2. Also commit to the worktree if active199if (worktreeUri) {200const worktreeFileUri = URI.joinPath(worktreeUri, relPath);201try {202if (worktreeFileUri.toString() !== fileUri.toString()) {203const content = await this.fileService.readFile(fileUri);204await this.fileService.writeFile(worktreeFileUri, content.value);205}206await this.commandService.executeCommand(207'github.copilot.cli.sessions.commitToWorktree',208{ worktreeUri, fileUri: worktreeFileUri }209);210} catch (error) {211this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to worktree:', error);212}213}214}215216/**217* Commits the deletion of a file to the main repository and optionally218* the worktree. The file is already deleted from disk before this is called;219* `git add` on a deleted path stages the removal.220*/221private async commitDeletionToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise<void> {222const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri);223if (!relPath) {224return;225}226227const repoFileUri = URI.joinPath(repositoryUri, relPath);228229// 1. Delete from main repository if it exists there, then commit230try {231if (await this.fileService.exists(repoFileUri)) {232await this.fileService.del(repoFileUri, { useTrash: true, recursive: true });233}234await this.commandService.executeCommand(235'github.copilot.cli.sessions.commitToRepository',236{ repositoryUri, fileUri: repoFileUri }237);238} catch (error) {239this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to repository:', error);240if (worktreeUri) {241this.notificationService.notify({242severity: Severity.Warning,243message: localize('deleteFromRepoFailed', "Your customization was removed from this session's worktree, but we couldn't apply the change to the default branch. You may need to remove it manually."),244});245}246}247248// 2. Also commit the deletion in the worktree if active249if (worktreeUri) {250const worktreeFileUri = URI.joinPath(worktreeUri, relPath);251try {252// The file may already be deleted from the worktree by the caller253await this.commandService.executeCommand(254'github.copilot.cli.sessions.commitToWorktree',255{ worktreeUri, fileUri: worktreeFileUri }256);257} catch (error) {258this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to worktree:', error);259}260}261}262263async generateCustomization(type: PromptsType): Promise<void> {264const creator = this.instantiationService.createInstance(CustomizationCreatorService);265await creator.createWithAI(type);266}267268async getFilteredPromptSlashCommands(token: CancellationToken): Promise<readonly IChatPromptSlashCommand[]> {269const allCommands = await this.promptsService.getPromptSlashCommands(token);270return allCommands.filter(cmd => {271const filter = this.getStorageSourceFilter(cmd.type);272return applyStorageSourceFilter([cmd], filter).length > 0;273});274}275276private static readonly _skillUIIntegrations: ReadonlyMap<string, string> = new Map([277['act-on-feedback', localize('skillUI.actOnFeedback', "Used by the Submit Feedback button in the Changes toolbar")],278['generate-run-commands', localize('skillUI.generateRunCommands', "Used by the Run button in the title bar")],279['create-pr', localize('skillUI.createPr', "Used by the Create Pull Request button in the Changes toolbar")],280['create-draft-pr', localize('skillUI.createDraftPr', "Used by the Create Draft Pull Request button in the Changes toolbar")],281['update-pr', localize('skillUI.updatePr', "Used by the Update Pull Request button in the Changes toolbar")],282['merge-changes', localize('skillUI.mergeChanges', "Used by the Merge button in the Changes toolbar")],283['commit', localize('skillUI.commit', "Used by the Commit button in the Changes toolbar")],284]);285286getSkillUIIntegrations(): ReadonlyMap<string, string> {287return SessionsAICustomizationWorkspaceService._skillUIIntegrations;288}289}290291292