show inline install X recommendations for alternative agents (#280738)

* show inline install X recommendations for alternative agents

* polish

* move to ChatAgentRecommendation contribution

* fix incorrect registrations

* no f1

* update distro f7daaf6841
This commit is contained in:
Josh Spicer 2025-12-02 15:49:48 -08:00 committed by GitHub
parent b675b458ed
commit 34e8399e06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 202 additions and 78 deletions

View File

@ -1,7 +1,7 @@
{
"name": "code-oss-dev",
"version": "1.107.0",
"distro": "55e124105c9ac2939053cdc9f50ff7d84fc86294",
"distro": "f7daaf68414ef6e47bec698f8babc297f0d90f0d",
"author": {
"name": "Microsoft Corporation"
},

View File

@ -43,6 +43,7 @@ export interface IChatSessionRecommendation {
readonly displayName: string;
readonly name: string;
readonly description: string;
readonly postInstallCommand?: string;
}
export type ConfigurationSyncStore = {

View File

@ -226,6 +226,7 @@ export class MenuId {
static readonly TimelineFilterSubMenu = new MenuId('TimelineFilterSubMenu');
static readonly AgentSessionsTitle = new MenuId('AgentSessionsTitle');
static readonly AgentSessionsFilterSubMenu = new MenuId('AgentSessionsFilterSubMenu');
static readonly AgentSessionsInstallActions = new MenuId('AgentSessionsInstallActions');
static readonly AgentSessionItemToolbar = new MenuId('AgentSessionItemToolbar');
static readonly AccountsContext = new MenuId('AccountsContext');
static readonly SidebarTitle = new MenuId('SidebarTitle');

View File

@ -0,0 +1,163 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Codicon } from '../../../../../base/common/codicons.js';
import { TimeoutTimer } from '../../../../../base/common/async.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { localize2 } from '../../../../../nls.js';
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';
import { IExtensionGalleryService } from '../../../../../platform/extensionManagement/common/extensionManagement.js';
import { ICommandService, CommandsRegistry } from '../../../../../platform/commands/common/commands.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
import { IWorkbenchContribution } from '../../../../common/contributions.js';
import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
import { IWorkbenchExtensionManagementService } from '../../../../services/extensionManagement/common/extensionManagement.js';
import { CHAT_CATEGORY } from './chatActions.js';
import { IChatSessionRecommendation } from '../../../../../base/common/product.js';
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
const INSTALL_CONTEXT_PREFIX = 'chat.installRecommendationAvailable';
export class ChatAgentRecommendation extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.chatAgentRecommendation';
private readonly availabilityContextKeys = new Map<string, IContextKey<boolean>>();
private refreshRequestId = 0;
constructor(
@IProductService private readonly productService: IProductService,
@IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService,
@IWorkbenchExtensionManagementService private readonly extensionManagementService: IWorkbenchExtensionManagementService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
) {
super();
const recommendations = this.productService.chatSessionRecommendations;
if (!recommendations?.length || !this.extensionGalleryService.isEnabled()) {
return;
}
for (const recommendation of recommendations) {
this.registerRecommendation(recommendation);
}
this.refreshInstallAvailability();
const refresh = () => this.refreshInstallAvailability();
this._register(this.extensionManagementService.onProfileAwareDidInstallExtensions(refresh));
this._register(this.extensionManagementService.onProfileAwareDidUninstallExtension(refresh));
this._register(this.extensionManagementService.onDidChangeProfile(refresh));
}
private registerRecommendation(recommendation: IChatSessionRecommendation): void {
const extensionKey = ExtensionIdentifier.toKey(recommendation.extensionId);
const commandId = `chat.installRecommendation.${extensionKey}`;
const availabilityContextId = `${INSTALL_CONTEXT_PREFIX}.${extensionKey}`;
const availabilityContext = new RawContextKey<boolean>(availabilityContextId, false).bindTo(this.contextKeyService);
this.availabilityContextKeys.set(extensionKey, availabilityContext);
const title = localize2('chat.installRecommendation', "Install {0}", recommendation.displayName);
this._register(registerAction2(class extends Action2 {
constructor() {
super({
id: commandId,
title,
tooltip: recommendation.description,
f1: false,
category: CHAT_CATEGORY,
icon: Codicon.extensions,
precondition: ContextKeyExpr.equals(availabilityContextId, true),
menu: [
{
id: MenuId.AgentSessionsInstallActions,
group: '0_install',
when: ContextKeyExpr.equals(availabilityContextId, true)
},
{
id: MenuId.AgentSessionsTitle,
group: 'navigation@98',
when: ContextKeyExpr.equals(availabilityContextId, true)
}
]
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const commandService = accessor.get(ICommandService);
const productService = accessor.get(IProductService);
const installPreReleaseVersion = productService.quality !== 'stable';
await commandService.executeCommand('workbench.extensions.installExtension', recommendation.extensionId, {
installPreReleaseVersion
});
await runPostInstallCommand(commandService, recommendation.postInstallCommand);
}
}));
}
private refreshInstallAvailability(): void {
if (!this.availabilityContextKeys.size) {
return;
}
const currentRequest = ++this.refreshRequestId;
this.extensionManagementService.getInstalled().then(installedExtensions => {
if (currentRequest !== this.refreshRequestId) {
return;
}
const installed = new Set(installedExtensions.map(ext => ExtensionIdentifier.toKey(ext.identifier.id)));
for (const [extensionKey, context] of this.availabilityContextKeys) {
context.set(!installed.has(extensionKey));
}
}, () => {
if (currentRequest !== this.refreshRequestId) {
return;
}
for (const [, context] of this.availabilityContextKeys) {
context.set(false);
}
});
}
}
async function runPostInstallCommand(commandService: ICommandService, commandId: string | undefined): Promise<void> {
if (!commandId) {
return;
}
await waitForCommandRegistration(commandId);
try {
await commandService.executeCommand(commandId);
} catch {
// Command failed or was cancelled; ignore.
}
}
function waitForCommandRegistration(commandId: string): Promise<void> {
if (CommandsRegistry.getCommands().has(commandId)) {
return Promise.resolve();
}
return new Promise<void>(resolve => {
const timer = new TimeoutTimer();
const listener = CommandsRegistry.onDidRegisterCommand((id: string) => {
if (id === commandId) {
listener.dispose();
timer.dispose();
resolve();
}
});
timer.cancelAndSet(() => {
listener.dispose();
resolve();
}, 10_000);
});
}

View File

@ -3,11 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { KeyCode } from '../../../../../base/common/keyCodes.js';
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
import { IChatSessionRecommendation } from '../../../../../base/common/product.js';
import Severity from '../../../../../base/common/severity.js';
import * as nls from '../../../../../nls.js';
import { localize } from '../../../../../nls.js';
@ -15,14 +13,10 @@ import { Action2, MenuId, MenuRegistry } from '../../../../../platform/actions/c
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { IExtensionGalleryService } from '../../../../../platform/extensionManagement/common/extensionManagement.js';
import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';
import { AUX_WINDOW_GROUP, SIDE_GROUP } from '../../../../services/editor/common/editorService.js';
import { IWorkbenchExtensionManagementService } from '../../../../services/extensionManagement/common/extensionManagement.js';
import { IViewsService } from '../../../../services/views/common/viewsService.js';
import { ChatContextKeys } from '../../common/chatContextKeys.js';
import { IChatService } from '../../common/chatService.js';
@ -313,65 +307,6 @@ export class ToggleAgentSessionsViewLocationAction extends Action2 {
}
}
export class ChatSessionsGettingStartedAction extends Action2 {
static readonly ID = 'chat.sessions.gettingStarted';
constructor() {
super({
id: ChatSessionsGettingStartedAction.ID,
title: nls.localize2('chat.sessions.gettingStarted.action', "Getting Started with Chat Sessions"),
icon: Codicon.sendToRemoteAgent,
f1: false,
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const productService = accessor.get(IProductService);
const quickInputService = accessor.get(IQuickInputService);
const extensionManagementService = accessor.get(IWorkbenchExtensionManagementService);
const extensionGalleryService = accessor.get(IExtensionGalleryService);
const recommendations = productService.chatSessionRecommendations;
if (!recommendations || recommendations.length === 0) {
return;
}
const installedExtensions = await extensionManagementService.getInstalled();
const isExtensionAlreadyInstalled = (extensionId: string) => {
return installedExtensions.find(installed => installed.identifier.id === extensionId);
};
const quickPickItems = recommendations.map((recommendation: IChatSessionRecommendation) => {
const extensionInstalled = !!isExtensionAlreadyInstalled(recommendation.extensionId);
return {
label: recommendation.displayName,
description: recommendation.description,
detail: extensionInstalled
? nls.localize('chatSessions.extensionAlreadyInstalled', "'{0}' is already installed", recommendation.extensionName)
: nls.localize('chatSessions.installExtension', "Installs '{0}'", recommendation.extensionName),
extensionId: recommendation.extensionId,
disabled: extensionInstalled,
};
});
const selected = await quickInputService.pick(quickPickItems, {
title: nls.localize('chatSessions.selectExtension', "Install Agents..."),
placeHolder: nls.localize('chatSessions.pickPlaceholder', "Install agents from the extension marketplace"),
canPickMany: true,
});
if (!selected) {
return;
}
const galleryExtensions = await extensionGalleryService.getExtensions(selected.map(item => ({ id: item.extensionId })), CancellationToken.None);
if (!galleryExtensions) {
return;
}
await extensionManagementService.installGalleryExtensions(galleryExtensions.map(extension => ({ extension, options: { preRelease: productService.quality !== 'stable' } })));
}
}
// Register the menu item - show for all local chat sessions (including history items)
MenuRegistry.appendMenuItem(MenuId.ChatSessionsMenu, {
command: {

View File

@ -88,6 +88,7 @@ export class AgentSessionsView extends ViewPane {
() => didResolve.p
);
}));
}
protected override renderBody(container: HTMLElement): void {
@ -185,13 +186,12 @@ export class AgentSessionsView extends ViewPane {
}
}
// Install more
actions.push(new Separator());
actions.push(toAction({
id: 'install-extensions',
label: localize('chatSessions.installExtensions', "Install Agents..."),
run: () => this.commandService.executeCommand('chat.sessions.gettingStarted')
}));
const installMenuActions = this.menuService.getMenuActions(MenuId.AgentSessionsInstallActions, this.scopedContextKeyService, { shouldForwardArgs: true });
const installActionBar = getActionBarActions(installMenuActions, () => true);
if (installActionBar.primary.length > 0) {
actions.push(new Separator());
actions.push(...installActionBar.primary);
}
return actions;
}

View File

@ -80,7 +80,8 @@ import { registerMoveActions } from './actions/chatMoveActions.js';
import { registerNewChatActions } from './actions/chatNewActions.js';
import { registerChatPromptNavigationActions } from './actions/chatPromptNavigationActions.js';
import { registerQuickChatActions } from './actions/chatQuickInputActions.js';
import { ChatSessionsGettingStartedAction, DeleteChatSessionAction, OpenChatSessionInNewEditorGroupAction, OpenChatSessionInNewWindowAction, OpenChatSessionInSidebarAction, RenameChatSessionAction, ToggleAgentSessionsViewLocationAction, ToggleChatSessionsDescriptionDisplayAction } from './actions/chatSessionActions.js';
import { ChatAgentRecommendation } from './actions/chatAgentRecommendationActions.js';
import { DeleteChatSessionAction, OpenChatSessionInNewEditorGroupAction, OpenChatSessionInNewWindowAction, OpenChatSessionInSidebarAction, RenameChatSessionAction, ToggleAgentSessionsViewLocationAction, ToggleChatSessionsDescriptionDisplayAction } from './actions/chatSessionActions.js';
import { registerChatTitleActions } from './actions/chatTitleActions.js';
import { registerChatElicitationActions } from './actions/chatElicitationActions.js';
import { registerChatToolActions } from './actions/chatToolActions.js';
@ -1173,6 +1174,7 @@ registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribu
registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually);
registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(ChatAgentRecommendation.ID, ChatAgentRecommendation, WorkbenchPhase.Eventually);
registerWorkbenchContribution2(ChatEditingEditorAccessibility.ID, ChatEditingEditorAccessibility, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(ChatEditingEditorOverlay.ID, ChatEditingEditorOverlay, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(SimpleBrowserOverlay.ID, SimpleBrowserOverlay, WorkbenchPhase.AfterRestored);
@ -1247,7 +1249,6 @@ registerAction2(OpenChatSessionInNewWindowAction);
registerAction2(OpenChatSessionInNewEditorGroupAction);
registerAction2(OpenChatSessionInSidebarAction);
registerAction2(ToggleChatSessionsDescriptionDisplayAction);
registerAction2(ChatSessionsGettingStartedAction);
registerAction2(ToggleAgentSessionsViewLocationAction);
ChatWidget.CONTRIBS.push(ChatDynamicVariableModel);

View File

@ -8,7 +8,7 @@ import { raceCancellationError } from '../../../../base/common/async.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
import { MutableDisposable, combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../base/common/map.js';
import { Schemas } from '../../../../base/common/network.js';
import * as resources from '../../../../base/common/resources.js';
@ -633,8 +633,31 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
const disposableStore = new DisposableStore();
this._contributionDisposables.set(contribution.type, disposableStore);
disposableStore.add(this._registerAgent(contribution, ext));
disposableStore.add(this._registerCommands(contribution));
const providerDependentDisposables = new MutableDisposable<IDisposable>();
disposableStore.add(providerDependentDisposables);
// NOTE: Only those extensions that register as 'content providers' should have agents and commands auto-registered
// This relationship may change in the future as the API grows.
const reconcileProviderRegistrations = () => {
if (this._contentProviders.has(contribution.type)) {
if (!providerDependentDisposables.value) {
providerDependentDisposables.value = combinedDisposable(
this._registerAgent(contribution, ext),
this._registerCommands(contribution)
);
}
} else {
providerDependentDisposables.clear();
}
};
reconcileProviderRegistrations();
disposableStore.add(this.onDidChangeContentProviderSchemes(({ added, removed }) => {
if (added.includes(contribution.type) || removed.includes(contribution.type)) {
reconcileProviderRegistrations();
}
}));
disposableStore.add(this._registerMenuItems(contribution, ext));
}