Path: blob/main/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.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 { coalesce } from '../../../../base/common/arrays.js';6import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';7import { IReader, autorun, observableValue } from '../../../../base/common/observable.js';8import { localize2 } from '../../../../nls.js';9import { Action2, registerAction2, MenuId, MenuRegistry, isIMenuItem } from '../../../../platform/actions/common/actions.js';10import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js';11import { MarshalledId } from '../../../../base/common/marshallingIds.js';12import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';13import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';14import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';15import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';16import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';17import { ModelPickerActionItem, IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js';18import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js';19import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';20import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';21import { Menus } from '../../../browser/menus.js';22import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';23import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';24import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js';25import { CLAUDE_CODE_SESSION_TYPE, COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE, ISession } from '../../../services/sessions/common/session.js';26import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';27import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js';28import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.js';29import { IsolationPicker } from './isolationPicker.js';30import { BranchPicker } from './branchPicker.js';31import { ModePicker } from './modePicker.js';32import { CloudModelPicker } from './modelPicker.js';33import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js';34import { ClaudePermissionModePicker } from './claudePermissionModePicker.js';3536const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLI_SESSION_TYPE);37const IsActiveSessionCopilotCloud = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLOUD_SESSION_TYPE);38const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, COPILOT_PROVIDER_ID);39const IsActiveSessionCopilotChatCLI = ContextKeyExpr.and(IsActiveSessionCopilotCLI, IsActiveCopilotChatSessionProvider);40const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(IsActiveSessionCopilotCloud, IsActiveCopilotChatSessionProvider);41const IsActiveSessionClaudeCode = ContextKeyExpr.equals(ActiveSessionTypeContext.key, CLAUDE_CODE_SESSION_TYPE);42const IsActiveSessionCopilotChatClaudeCode = ContextKeyExpr.and(IsActiveSessionClaudeCode, IsActiveCopilotChatSessionProvider);4344// -- Actions --4546registerAction2(class extends Action2 {47constructor() {48super({49id: 'sessions.defaultCopilot.isolationPicker',50title: localize2('isolationPicker', "Isolation Mode"),51f1: false,52menu: [{53id: Menus.NewSessionRepositoryConfig,54group: 'navigation',55order: 1,56when: ContextKeyExpr.and(57IsNewChatSessionContext,58IsActiveSessionCopilotChatCLI,59ContextKeyExpr.equals('config.github.copilot.chat.cli.isolationOption.enabled', true),60),61}],62});63}64override async run(): Promise<void> { /* handled by action view item */ }65});6667registerAction2(class extends Action2 {68constructor() {69super({70id: 'sessions.defaultCopilot.branchPicker',71title: localize2('branchPicker', "Branch"),72f1: false,73precondition: ActiveSessionHasGitRepositoryContext,74menu: [{75id: Menus.NewSessionRepositoryConfig,76group: 'navigation',77order: 2,78when: ContextKeyExpr.and(IsNewChatSessionContext, IsActiveSessionCopilotChatCLI),79}],80});81}82override async run(): Promise<void> { /* handled by action view item */ }83});8485registerAction2(class extends Action2 {86constructor() {87super({88id: 'sessions.defaultCopilot.modePicker',89title: localize2('modePicker', "Mode"),90f1: false,91menu: [{92id: Menus.NewSessionConfig,93group: 'navigation',94order: 0,95when: IsActiveSessionCopilotChatCLI,96}],97});98}99override async run(): Promise<void> { /* handled by action view item */ }100});101102registerAction2(class extends Action2 {103constructor() {104super({105id: 'sessions.defaultCopilot.localModelPicker',106title: localize2('localModelPicker', "Model"),107f1: false,108menu: [{109id: Menus.NewSessionConfig,110group: 'navigation',111order: 1,112when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatClaudeCode),113}],114});115}116override async run(): Promise<void> { /* handled by action view item */ }117});118119registerAction2(class extends Action2 {120constructor() {121super({122id: 'sessions.defaultCopilot.cloudModelPicker',123title: localize2('cloudModelPicker', "Model"),124f1: false,125menu: [{126id: Menus.NewSessionConfig,127group: 'navigation',128order: 1,129when: IsActiveSessionCopilotChatCloud,130}],131});132}133override async run(): Promise<void> { /* handled by action view item */ }134});135136registerAction2(class extends Action2 {137constructor() {138super({139id: 'sessions.defaultCopilot.permissionPicker',140title: localize2('permissionPicker', "Permissions"),141f1: false,142menu: [{143id: Menus.NewSessionControl,144group: 'navigation',145order: 1,146when: IsActiveSessionCopilotChatCLI,147}],148});149}150override async run(): Promise<void> { /* handled by action view item */ }151});152153registerAction2(class extends Action2 {154constructor() {155super({156id: 'sessions.defaultCopilot.claudePermissionModePicker',157title: localize2('claudePermissionModePicker', "Permission Mode"),158f1: false,159menu: [{160id: Menus.NewSessionControl,161group: 'navigation',162order: 1,163when: IsActiveSessionCopilotChatClaudeCode,164}],165});166}167override async run(): Promise<void> { /* handled by action view item */ }168});169170// -- Helper --171172/**173* Wraps a standalone picker widget as a {@link BaseActionViewItem}174* so it can be rendered by a {@link MenuWorkbenchToolBar}.175*/176class PickerActionViewItem extends BaseActionViewItem {177constructor(private readonly picker: { render(container: HTMLElement): void; dispose(): void }, disposable?: IDisposable) {178super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } });179if (disposable) {180this._register(disposable);181}182}183184override render(container: HTMLElement): void {185this.picker.render(container);186}187188override dispose(): void {189this.picker.dispose();190super.dispose();191}192}193194// -- Action View Item Registrations --195196class CopilotPickerActionViewItemContribution extends Disposable implements IWorkbenchContribution {197198static readonly ID = 'workbench.contrib.copilotPickerActionViewItems';199200constructor(201@IActionViewItemService actionViewItemService: IActionViewItemService,202@IInstantiationService instantiationService: IInstantiationService,203) {204super();205206this._register(actionViewItemService.register(207Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.isolationPicker',208() => {209const picker = instantiationService.createInstance(IsolationPicker);210return new PickerActionViewItem(picker);211},212));213this._register(actionViewItemService.register(214Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.branchPicker',215() => {216const picker = instantiationService.createInstance(BranchPicker);217return new PickerActionViewItem(picker);218},219));220this._register(actionViewItemService.register(221Menus.NewSessionConfig, 'sessions.defaultCopilot.modePicker',222() => {223const picker = instantiationService.createInstance(ModePicker);224return new PickerActionViewItem(picker);225},226));227this._register(actionViewItemService.register(228Menus.NewSessionConfig, 'sessions.defaultCopilot.localModelPicker',229() => {230const picker = instantiationService.createInstance(SessionModelPicker);231return new PickerActionViewItem(picker);232},233));234this._register(actionViewItemService.register(235Menus.NewSessionConfig, 'sessions.defaultCopilot.cloudModelPicker',236() => {237const picker = instantiationService.createInstance(CloudModelPicker);238return new PickerActionViewItem(picker);239},240));241this._register(actionViewItemService.register(242Menus.NewSessionControl, 'sessions.defaultCopilot.permissionPicker',243() => {244const delegate = instantiationService.createInstance(CopilotPermissionPickerDelegate);245const picker = instantiationService.createInstance(PermissionPicker, delegate);246return new PickerActionViewItem(picker, delegate);247},248));249this._register(actionViewItemService.register(250Menus.NewSessionControl, 'sessions.defaultCopilot.claudePermissionModePicker',251() => {252const picker = instantiationService.createInstance(ClaudePermissionModePicker);253return new PickerActionViewItem(picker);254},255));256}257}258259// -- Model Picker Helpers --260261/**262* Returns a storage key scoped to the given session type.263*/264export function modelPickerStorageKey(sessionType: string): string {265return `sessions.modelPicker.${sessionType}.selectedModelId`;266}267268/**269* A model picker widget that persists the selected model per session type and270* syncs the selection to the active session's provider. Instantiated via DI,271* consistent with the other picker widgets in this file.272*/273export class SessionModelPicker extends Disposable {274275private readonly _currentModel = observableValue<ILanguageModelChatMetadataAndIdentifier | undefined>('currentModel', undefined);276private readonly _delegate: IModelPickerDelegate;277private readonly _modelPicker: ModelPickerActionItem;278private _lastSessionType: string | undefined;279private _lastPushedSessionId: string | undefined;280281constructor(282@IInstantiationService instantiationService: IInstantiationService,283@ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService,284@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,285@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,286@IStorageService private readonly _storageService: IStorageService,287) {288super();289290this._delegate = {291currentModel: this._currentModel,292setModel: (model: ILanguageModelChatMetadataAndIdentifier) => {293this._currentModel.set(model, undefined);294const session = this._sessionsManagementService.activeSession.get();295if (session) {296this._storageService.store(modelPickerStorageKey(session.sessionType), model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE);297const provider = this._sessionsProvidersService.getProviders().find(p => p.id === session.providerId);298provider?.setModel(session.sessionId, model.identifier);299}300},301getModels: () => getAvailableModels(this._languageModelsService, this._sessionsManagementService),302useGroupedModelPicker: () => true,303showManageModelsAction: () => false,304showUnavailableFeatured: () => false,305showFeatured: () => true,306};307308const pickerOptions: IChatInputPickerOptions = {309hideChevrons: observableValue('hideChevrons', false),310};311const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } };312this._modelPicker = instantiationService.createInstance(ModelPickerActionItem, action, this._delegate, pickerOptions);313314this._initModel();315this._register(this._languageModelsService.onDidChangeLanguageModels(() => this._initModel()));316317// When the active session changes, re-init (may switch session type).318// _initModel() calls _delegate.setModel() which already forwards to319// the provider, so no additional provider.setModel() call is needed.320this._register(autorun(reader => {321const session = this._sessionsManagementService.activeSession.read(reader);322if (session) {323this._initModel();324}325}));326}327328private _initModel(): void {329const session = this._sessionsManagementService.activeSession.get();330const sessionType = session?.sessionType;331332// Reset the current model when switching session types so we load the333// remembered model for the new type instead of carrying over the old one.334if (sessionType !== this._lastSessionType) {335this._currentModel.set(undefined, undefined);336this._lastSessionType = sessionType;337}338339const models = getAvailableModels(this._languageModelsService, this._sessionsManagementService);340this._modelPicker.setEnabled(models.length > 0);341if (models.length === 0) {342return;343}344345const current = this._currentModel.get();346if (!current) {347const rememberedModelId = sessionType ? this._storageService.get(modelPickerStorageKey(sessionType), StorageScope.PROFILE) : undefined;348const remembered = rememberedModelId ? models.find(m => m.identifier === rememberedModelId) : undefined;349this._delegate.setModel(remembered ?? models[0]);350this._lastPushedSessionId = session?.sessionId;351} else if (session && session.sessionId !== this._lastPushedSessionId && models.some(m => m.identifier === current.identifier)) {352// Active session changed (e.g. user switched repository) but the353// previously selected model is still available. Re-push it so the354// new session's provider receives setModel — otherwise the request355// would be sent with the default model even though the picker UI356// still shows the user's selection. See #313385.357//358// Gated on sessionId so unrelated re-invocations of _initModel359// (e.g. from onDidChangeLanguageModels) don't redundantly write360// storage and dispatch provider.setModel for the same session.361this._delegate.setModel(current);362this._lastPushedSessionId = session.sessionId;363}364}365366render(container: HTMLElement): void {367this._modelPicker.render(container);368}369370override dispose(): void {371this._modelPicker.dispose();372super.dispose();373}374}375376export function getAvailableModels(377languageModelsService: ILanguageModelsService,378sessionsManagementService: ISessionsManagementService,379): ILanguageModelChatMetadataAndIdentifier[] {380const session = sessionsManagementService.activeSession.get();381if (!session) {382return [];383}384return languageModelsService.getLanguageModelIds()385.map(id => {386const metadata = languageModelsService.lookupLanguageModel(id);387return metadata ? { metadata, identifier: id } : undefined;388})389.filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && m.metadata.targetChatSessionType === session.sessionType);390}391392// -- Context Key Contribution --393394class CopilotActiveSessionContribution extends Disposable implements IWorkbenchContribution {395396static readonly ID = 'workbench.contrib.copilotActiveSession';397398constructor(399@ISessionsManagementService sessionsManagementService: ISessionsManagementService,400@ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService,401@IContextKeyService contextKeyService: IContextKeyService,402) {403super();404405const hasRepositoryKey = ActiveSessionHasGitRepositoryContext.bindTo(contextKeyService);406407this._register(autorun((reader: IReader) => {408const session = sessionsManagementService.activeSession.read(reader);409if (session?.providerId === COPILOT_PROVIDER_ID) {410const provider = sessionsProvidersService.getProvider(session.providerId);411const providerSession = provider instanceof CopilotChatSessionsProvider ? provider.getSession(session.sessionId) : undefined;412const isLoading = providerSession?.loading.read(reader);413hasRepositoryKey.set(!isLoading && !!providerSession?.gitRepository);414} else {415hasRepositoryKey.set(false);416}417}));418}419}420421registerWorkbenchContribution2(CopilotPickerActionViewItemContribution.ID, CopilotPickerActionViewItemContribution, WorkbenchPhase.AfterRestored);422registerWorkbenchContribution2(CopilotActiveSessionContribution.ID, CopilotActiveSessionContribution, WorkbenchPhase.AfterRestored);423424/**425* Bridges extension-contributed context menu actions from {@link MenuId.AgentSessionsContext}426* to {@link SessionItemContextMenuId} for the new sessions view.427* Registers wrapper commands that resolve {@link ISession} → {@link IAgentSession}428* and forward to the original command with marshalled context.429*/430class CopilotSessionContextMenuBridge extends Disposable implements IWorkbenchContribution {431static readonly ID = 'copilotChatSessions.contextMenuBridge';432433private readonly _bridgedIds = new Set<string>();434435constructor(436@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,437@ICommandService private readonly commandService: ICommandService,438) {439super();440this._bridgeItems();441this._register(MenuRegistry.onDidChangeMenu(menuIds => {442if (menuIds.has(MenuId.AgentSessionsContext)) {443this._bridgeItems();444}445}));446}447448private _bridgeItems(): void {449const items = MenuRegistry.getMenuItems(MenuId.AgentSessionsContext).filter(isIMenuItem);450for (const item of items) {451const commandId = item.command.id;452if (!commandId.startsWith('github.copilot.')) {453continue;454}455if (commandId === 'github.copilot.cli.sessions.delete') {456continue; // Delete is handled natively via sessionsManagementService457}458if (this._bridgedIds.has(commandId)) {459continue;460}461this._bridgedIds.add(commandId);462463const wrapperId = `sessionsViewPane.bridge.${commandId}`;464this._register(CommandsRegistry.registerCommand(wrapperId, (accessor, context?: ISession | ISession[]) => {465if (!context) {466return;467}468const sessions = Array.isArray(context) ? context : [context];469const agentSessions = coalesce(sessions.map(s => this.agentSessionsService.getSession(s.resource)));470if (agentSessions.length === 0) {471return;472}473return this.commandService.executeCommand(commandId, {474session: agentSessions[0],475sessions: agentSessions,476$mid: MarshalledId.AgentSessionContext,477});478}));479480const providerWhen = ContextKeyExpr.equals(ChatSessionProviderIdContext.key, COPILOT_PROVIDER_ID);481this._register(MenuRegistry.appendMenuItem(SessionItemContextMenuId, {482command: { ...item.command, id: wrapperId },483group: item.group,484order: item.order,485when: item.when ? ContextKeyExpr.and(providerWhen, item.when) : providerWhen,486}));487}488}489}490491registerWorkbenchContribution2(CopilotSessionContextMenuBridge.ID, CopilotSessionContextMenuBridge, WorkbenchPhase.AfterRestored);492493registerAction2(class DeleteSessionAction extends Action2 {494constructor() {495super({496id: 'sessionsViewPane.copilot.deleteSession',497title: localize2('deleteSession', "Delete..."),498menu: [{499id: SessionItemContextMenuId,500group: '1_edit',501order: 4,502when: ContextKeyExpr.equals(ChatSessionProviderIdContext.key, COPILOT_PROVIDER_ID),503}]504});505}506async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise<void> {507if (!context) {508return;509}510const sessions = Array.isArray(context) ? context : [context];511const sessionsManagementService = accessor.get(ISessionsManagementService);512for (const session of sessions) {513await sessionsManagementService.deleteSession(session);514}515}516});517518519