diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts index 93421451fc1b4f..a82ebca33577d6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts @@ -111,6 +111,7 @@ interface IInstalledDiscoveryItem { readonly itemId?: string; readonly removable?: boolean; readonly mcpServerId?: string; + readonly mcpConnectorName?: string; readonly disabled?: boolean; readonly catalogResource?: ICustomizationMarketplaceResource; readonly promptDetail?: IAICustomizationListItem; @@ -391,6 +392,7 @@ class DiscoveryResultRenderer implements IListRenderer { - if (element.promptDetail || element.pluginDetail || element.mcpDetail || !element.catalogResource) { - this.onOpenInstalled(element); - return; - } - this.onOpenDetails(element.catalogResource); - }); + if (element.kind === 'installed' && opensInstalled) { + registerOpenListeners(() => this.onOpenInstalled(element)); } else { - registerOpenListeners(() => this.onOpenDetails(element.resource)); + registerOpenListeners(() => this.onOpenDetails(element.kind === 'installed' ? element.catalogResource! : element.resource)); } if (!installed) { @@ -743,10 +738,11 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto )); this._register(this.resultList.onDidOpen(event => { if (event.element?.kind === 'installed') { - if (event.element.promptDetail || event.element.pluginDetail || event.element.mcpDetail || !event.element.catalogResource) { + const resource = event.element.catalogResource; + if (!resource || this.getInstallState(resource).kind === 'installed') { this.openInstalledItem(event.element); } else { - this.openMarketplaceItem(event.element.catalogResource, 'search'); + this.openMarketplaceItem(resource, 'search'); } } else if (event.element?.kind === 'available') { this.openMarketplaceItem(event.element.resource, 'search'); @@ -1436,6 +1432,9 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto type, section: getSectionForCatalogType(type), catalogResource: resource, + uri: state.target.kind === 'skill' || state.target.kind === 'plugin' ? state.target.uri : undefined, + mcpServerId: state.target.kind === 'mcp' ? state.target.id : undefined, + mcpConnectorName: state.target.kind === 'copilotConnector' ? state.target.name : undefined, }); } } else { @@ -1579,13 +1578,16 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto const card = DOM.append(parent, $('.customization-discovery-card')); const resourceKey = getCustomizationMarketplaceResourceKey(item); card.dataset.resourceKey = resourceKey; + const state = this.getInstallState(item); const primaryAction = createCustomizationCardPrimaryAction( card, - localize('customizationDiscovery.openDetails', "View details for {0}", item.displayName), + state.kind === 'installed' + ? localize('customizationDiscovery.openInstalled', "Open installed customization {0}", item.displayName) + : localize('customizationDiscovery.openDetails', "View details for {0}", item.displayName), 'customization-discovery-card-primary', ); this.browsePrimaryActions.set(resourceKey, primaryAction); - this.browseDisposables.add(DOM.addDisposableListener(primaryAction, DOM.EventType.CLICK, () => this.openMarketplaceItem(item, 'browse'))); + this.browseDisposables.add(DOM.addDisposableListener(primaryAction, DOM.EventType.CLICK, () => this.openCatalogItem(item, 'browse'))); const icon = DOM.append(primaryAction, $('.customization-discovery-card-icon')); const type = getCatalogType(item); renderCustomizationMarketplaceIcon( @@ -1609,7 +1611,6 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto this.browseDisposables.add(this.hoverService.setupDelayedHover(name, { content: item.displayName })); this.browseDisposables.add(this.hoverService.setupDelayedHover(description, { content: item.description })); const actions = DOM.append(card, $('.customization-discovery-card-actions')); - const state = this.getInstallState(item); const setupUrl = state.kind === 'unavailable' ? state.setupUrl : undefined; const installError = this.installErrors.get(getCustomizationMarketplaceResourceKey(item)); const cancellable = isCancellableConnectorOperation(item, state); @@ -1867,10 +1868,29 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto }); } + private openCatalogItem(resource: ICustomizationMarketplaceResource, mode: ICustomizationMarketplaceOrigin['mode']): void { + const type = getCatalogType(resource); + const state = this.getInstallState(resource); + if (type && state.kind === 'installed' && this.callbacks.openInstalled) { + this.callbacks.openInstalled({ + section: getSectionForCatalogType(type), + name: resource.displayName, + uri: state.target.kind === 'skill' || state.target.kind === 'plugin' ? state.target.uri : undefined, + mcpServerId: state.target.kind === 'mcp' ? state.target.id : undefined, + mcpConnectorName: state.target.kind === 'copilotConnector' ? state.target.name : undefined, + }); + return; + } + this.openMarketplaceItem(resource, mode); + } + private openInstalledItem(item: IInstalledDiscoveryItem): void { this.callbacks.openInstalled?.({ section: item.section, + name: item.name, uri: item.uri, + mcpServerId: item.mcpServerId, + mcpConnectorName: item.mcpConnectorName, promptDetail: item.promptDetail, pluginDetail: item.pluginDetail, mcpDetail: item.mcpDetail, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index f15a65a1e9c21e..ef4a6e9307bdb3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -114,7 +114,7 @@ import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agent import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js'; import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; -import { AICustomizationWelcomePage, type ICustomizationMarketplaceOrigin, type ICustomizationMigrationCategorySummary } from './aiCustomizationWelcomePage.js'; +import { AICustomizationWelcomePage, type ICustomizationMarketplaceOrigin, type ICustomizationMigrationCategorySummary, type IInstalledCustomizationTarget } from './aiCustomizationWelcomePage.js'; import { ICustomizationMarketplaceInstallService } from '../../common/customizationMarketplaceInstallService.js'; import { type CustomizationMigrationTargetFolders, type IMigratedCustomizationsWithFailureReasonsResult, migrateCustomizations, resolveWorkspaceMigrationTargetFolder } from './customizationMigration.js'; import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory, homepageMigrationCategories, type ICustomizationMigrationCategory } from './customizationMigrationCategories.js'; @@ -343,6 +343,7 @@ export class AICustomizationManagementEditor extends EditorPane { private sectionsList!: WorkbenchList; private contentContainer!: HTMLElement; private listWidget!: AICustomizationListWidget; + private listWidgetSectionLoad: Promise = Promise.resolve(); private mcpListWidget: McpListWidget | undefined; private pluginListWidget: PluginListWidget | undefined; private modelsWidget: ChatModelsWidget | undefined; @@ -954,19 +955,7 @@ export class AICustomizationManagementEditor extends EditorPane { { selectSection: (section) => this.selectSection(section), selectSectionWithMarketplace: (section) => this.selectSection(section, { showMarketplace: true }), - openInstalled: target => { - const origin: CustomizationDetailBaseOrigin = { kind: 'discover' }; - this.selectSection(target.section); - if (target.promptDetail) { - void this.openCustomizationItem(target.promptDetail, origin); - } else if (target.pluginDetail) { - void this.showEmbeddedPluginDetail(target.pluginDetail, origin); - } else if (target.mcpDetail) { - void this.showEmbeddedMcpDetail(target.mcpDetail, origin); - } else if (target.uri) { - void this.revealCustomizationByUri(target.uri); - } - }, + openInstalled: target => void this.revealInstalledCustomization(target), openMarketplaceItem: (resource, origin) => { this.showMarketplaceDetail(resource, origin); }, @@ -3067,8 +3056,9 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateContentVisibility(); // Load items for the new section (only for prompts-based sections) + this.listWidgetSectionLoad = Promise.resolve(); if (this.isPromptsSection(section)) { - void this.listWidget.setSection(section); + this.listWidgetSectionLoad = this.listWidget.setSection(section); } // Re-layout after visibility change so the newly-visible widget can @@ -3154,6 +3144,12 @@ export class AICustomizationManagementEditor extends EditorPane { const welcomeVisible = isWelcome && !isEditorMode && !isMigrationMode && !isDetailMode; this.welcomePage.container.style.display = welcomeVisible ? '' : 'none'; this.welcomePage.setVisible(this.isVisible() && welcomeVisible); + this.homeButton?.classList.toggle('selected', welcomeVisible); + if (welcomeVisible) { + this.homeButton?.setAttribute('aria-current', 'page'); + } else { + this.homeButton?.removeAttribute('aria-current'); + } } if (this.promptsContentContainer) { this.promptsContentContainer.style.display = !isEditorMode && !isMigrationMode && !isDetailMode && isPromptsSection ? '' : 'none'; @@ -3681,6 +3677,26 @@ export class AICustomizationManagementEditor extends EditorPane { } } + public async revealInstalledCustomization(target: IInstalledCustomizationTarget): Promise { + this.selectSection(target.section); + await this.listWidgetSectionLoad; + if (this.isPromptsSection(target.section) && target.uri) { + await this.revealCustomizationByUri(target.uri); + return; + } + for (let attempt = 0; attempt < 10; attempt++) { + const revealed = target.section === AICustomizationManagementSection.Plugins && target.uri + ? await this.pluginListWidget?.revealAndSelectItemByUri(target.uri) + : target.section === AICustomizationManagementSection.McpServers + ? this.mcpListWidget?.revealAndSelectServer(target.mcpServerId, target.name, target.mcpConnectorName) + : true; + if (revealed !== false) { + return; + } + await timeout(100); + } + } + /** * Scrolls the active list widget so the last item is visible. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts index 9efdeae866dc0f..1a24116f1a3aec 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts @@ -38,13 +38,7 @@ export interface ICustomizationMigrationCategorySummary { export interface IWelcomePageCallbacks { selectSection(section: AICustomizationManagementSection): void; selectSectionWithMarketplace(section: AICustomizationManagementSection): void; - openInstalled?(target: { - readonly section: AICustomizationManagementSection; - readonly uri?: URI; - readonly promptDetail?: IAICustomizationListItem; - readonly pluginDetail?: IAgentPluginItem; - readonly mcpDetail?: IMcpServerDetailInput; - }): void; + openInstalled?(target: IInstalledCustomizationTarget): void; openMarketplaceItem(resource: ICustomizationMarketplaceResource, origin: ICustomizationMarketplaceOrigin): void; closeEditor(): void; reviewMigrations(): void; @@ -58,6 +52,17 @@ export interface IWelcomePageCallbacks { prefillChat(query: string, options?: { isPartialQuery?: boolean; newChat?: boolean }): void; } +export interface IInstalledCustomizationTarget { + readonly section: AICustomizationManagementSection; + readonly name: string; + readonly uri?: URI; + readonly mcpServerId?: string; + readonly mcpConnectorName?: string; + readonly promptDetail?: IAICustomizationListItem; + readonly pluginDetail?: IAgentPluginItem; + readonly mcpDetail?: IMcpServerDetailInput; +} + export interface ICustomizationMarketplaceOrigin { readonly resourceKey: string; readonly mode: 'browse' | 'search'; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index bf4506fc8b2fce..a8101e484f87e7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -3179,6 +3179,52 @@ export class McpListWidget extends Disposable { } } + revealAndSelectServer(serverId: string | undefined, serverName: string, connectorName?: string): boolean { + if (this.searchQuery) { + this.searchInput.value = ''; + this.searchQuery = ''; + this.delayedFilter.cancel(); + void this.filterServers(); + return false; + } + const entry = this.getVisibleMcpEntries().find(entry => { + if (connectorName) { + return entry.type === 'builtin-item' && entry.connector?.connector.name === connectorName; + } + if (serverId) { + switch (entry.type) { + case 'server-item': + case 'session-server-item': + return entry.server.id === serverId; + case 'builtin-item': + return entry.id === serverId || entry.activeSessionServer?.id === serverId; + case 'group-header': + case 'marketplace-item': + return false; + } + } + switch (entry.type) { + case 'server-item': + return entry.server.label === serverName; + case 'session-server-item': + return entry.server.name === serverName; + case 'builtin-item': + return entry.label === serverName || entry.connector?.connector.displayName === serverName; + case 'group-header': + case 'marketplace-item': + return false; + } + }); + if (!entry) { + return false; + } + this.list.reveal(entry); + this.list.setFocus([entry]); + this.list.setSelection([entry]); + this.list.domFocus(); + return true; + } + /** * Focuses the list. */ 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 2de249eca1757f..320a3548521cd7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -75,6 +75,13 @@ background-color: var(--vscode-list-hoverBackground); } +.ai-customization-management-editor .sidebar-home-button.selected { + background-color: var(--vscode-list-inactiveSelectionBackground); + color: var(--vscode-list-inactiveSelectionForeground); + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder, transparent); + outline-offset: -1px; +} + .ai-customization-management-editor .sidebar-home-button:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 1185056cbd2108..0f4132e025c847 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -2226,6 +2226,30 @@ export class PluginListWidget extends Disposable { } } + async revealAndSelectItemByUri(uri: URI): Promise { + if (this.browseMode) { + this.toggleBrowseMode(false); + } + if (this.searchQuery) { + this.searchInput.value = ''; + this.searchQuery = ''; + this.delayedFilter.cancel(); + } + await this.filterPlugins(); + const entry = this.getVisiblePluginEntries().find(entry => + entry.type === 'plugin-item' && isEqual(entry.item.plugin.uri, uri) + || entry.type === 'remote-item' && isEqual(entry.item.uri, uri) + ); + if (!entry) { + return false; + } + this.list.reveal(entry); + this.list.setFocus([entry]); + this.list.setSelection([entry]); + this.list.domFocus(); + return true; + } + focus(): void { if (this.cardScrollableNode.style.display !== 'none') { if (this.firstCardFocusElement) { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts index cf828051bd36e6..e1640ea5c7d35a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts @@ -906,6 +906,87 @@ suite('AICustomizationDiscoveryPage', () => { }); }); + test('installed browse cards open the installed customization list', async () => { + const candidate = resource('installed-skill', { + displayName: 'Security skill', + mediaType: CustomizationMarketplaceMediaType.Skill, + }); + const skillUri = URI.file('/workspace/.github/skills/security/SKILL.md'); + const fixture = createPage(['agentFinder']); + fixture.setInstallState(candidate, { kind: 'installed', target: { kind: 'skill', uri: skillUri } }); + fixture.notifyInstallChange(); + fixture.page.setVisible(true); + await fixture.requests[0].result.complete({ items: [candidate] }); + await timeout(0); + const primaryAction = fixture.container.querySelector('.customization-discovery-card-primary'); + assert.ok(primaryAction); + primaryAction.click(); + assert.deepStrictEqual({ + ariaLabel: primaryAction.getAttribute('aria-label'), + marketplace: fixture.openedDetails, + installed: fixture.openedInstalled, + }, { + ariaLabel: 'Open installed customization Security skill', + marketplace: [], + installed: [{ + section: AICustomizationManagementSection.Skills, + name: 'Security skill', + uri: skillUri, + mcpServerId: undefined, + mcpConnectorName: undefined, + }], + }); + }); + + test('installed Connector browse cards preserve the Connector identity', async () => { + const candidate = resource('mail', { + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + displayName: 'Mail', + installation: { kind: 'copilotConnector', name: 'mail' }, + }); + const fixture = createPage([CustomizationMarketplaceSources.CopilotConnectors.id]); + fixture.setInstallState(candidate, { kind: 'installed', target: { kind: 'copilotConnector', name: 'mail' } }); + fixture.page.setVisible(true); + await fixture.requests[0].result.complete({ items: [candidate] }); + await timeout(0); + const primaryAction = fixture.container.querySelector('.customization-discovery-card-primary'); + assert.ok(primaryAction); + primaryAction.click(); + + assert.deepStrictEqual(fixture.openedInstalled, [{ + section: AICustomizationManagementSection.McpServers, + name: 'Mail', + uri: undefined, + mcpServerId: undefined, + mcpConnectorName: 'mail', + }]); + }); + + test('recorded missing browse cards open details instead of an installed list', async () => { + const candidate = resource('missing-skill', { + displayName: 'Missing skill', + mediaType: CustomizationMarketplaceMediaType.Skill, + }); + const fixture = createPage(['agentFinder']); + fixture.setInstallState(candidate, { kind: 'missing', target: { kind: 'skill', uri: URI.file('/workspace/.github/skills/missing/SKILL.md') } }); + fixture.page.setVisible(true); + await fixture.requests[0].result.complete({ items: [candidate] }); + await timeout(0); + const primaryAction = fixture.container.querySelector('.customization-discovery-card-primary'); + assert.ok(primaryAction); + primaryAction.click(); + + assert.deepStrictEqual({ + ariaLabel: primaryAction.getAttribute('aria-label'), + openedDetails: fixture.openedDetails.map(resource => resource.identifier), + openedInstalled: fixture.openedInstalled, + }, { + ariaLabel: 'View details for Missing skill', + openedDetails: ['missing-skill'], + openedInstalled: [], + }); + }); + test('available search rows open details while setup actions stay isolated', async () => { const setupUrl = URI.parse('https://example.com/setup'); const fixture = createPage(['agentFinder'], undefined, undefined, setupUrl); @@ -946,7 +1027,7 @@ suite('AICustomizationDiscoveryPage', () => { assertImageReplacesFallback(fixture.container, '.customization-discovery-result-icon'); }); - test('catalog-backed installed skills open their installed detail page', async () => { + test('catalog-backed installed skills open their installed customization list', async () => { const candidate = resource('installed-skill', { displayName: 'Local mail skill', mediaType: CustomizationMarketplaceMediaType.Skill }); const fixture = createPage(['agentFinder']); fixture.setInstallState(candidate, { kind: 'installed', target: { kind: 'skill', uri: URI.file('/workspace/.github/skills/mail/SKILL.md') } }); @@ -966,8 +1047,8 @@ suite('AICustomizationDiscoveryPage', () => { marketplace: fixture.openedDetails, installed: fixture.openedInstalled.map(target => ({ section: target.section, - name: target.promptDetail?.name, - uri: target.promptDetail?.uri.toString(), + name: target.name, + uri: target.uri?.toString(), })), }, { rows: ['Local mail skill'], @@ -1033,16 +1114,30 @@ suite('AICustomizationDiscoveryPage', () => { detail: fixture.container.querySelector('.customization-discovery-result-detail')?.textContent, actions: [...fixture.container.querySelectorAll('.customization-discovery-result-actions .monaco-button')].map(element => element.textContent), }; + const primaryAction = fixture.container.querySelector('.customization-discovery-result-primary'); + assert.ok(primaryAction); + primaryAction.click(); + const navigation = { + ariaLabel: primaryAction.getAttribute('aria-label'), + openedDetails: fixture.openedDetails.map(resource => resource.identifier), + openedInstalled: [...fixture.openedInstalled], + }; const repair = [...fixture.container.querySelectorAll('.customization-discovery-result-actions .monaco-button')].find(button => button.textContent === 'Repair'); assert.ok(repair); repair.click(); await timeout(0); assert.deepStrictEqual({ before, + navigation, repairs: fixture.repairs, actionsAfter: [...fixture.container.querySelectorAll('.customization-discovery-result-actions .monaco-button')].map(element => element.textContent), }, { before: { detail: 'Skill · GitHub Feed · Missing files', actions: ['Repair', 'Uninstall'] }, + navigation: { + ariaLabel: 'View details for Repair mail skill', + openedDetails: ['repair-mail'], + openedInstalled: [], + }, repairs: ['repair-mail'], actionsAfter: ['Uninstall'], }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 734343824304ae..61a725ba9f79cb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -40,7 +40,7 @@ import { AICustomizationManagementSection, AICustomizationSources, type AICustom import { CustomizationMigrationCategoryId, getCustomizationMigrationCategory, ICustomizationMigrationCategory } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; import type { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import type { CustomizationMigrationTargetFolders, IMigratedCustomizationsWithFailureReasonsResult } from '../../../browser/aiCustomization/customizationMigration.js'; -import type { ICustomizationMigrationCategorySummary } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; +import type { ICustomizationMigrationCategorySummary, IInstalledCustomizationTarget } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; import { AICustomizationManagementEditorInput } from '../../../browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { aiCustomizationManagementSectionRegistry, IAICustomizationManagementSectionWidget } from '../../../browser/aiCustomization/aiCustomizationManagementSectionRegistry.js'; import { IMcpServerDetailInput } from '../../../browser/aiCustomization/embeddedMcpServerDetail.js'; @@ -82,6 +82,64 @@ suite('aiCustomizationManagementEditor', () => { ], [true, false, false, false]); }); + test('routes installed discovery items to their focused list rows', async () => { + const skillUri = URI.file('/skills/security/SKILL.md'); + const pluginUri = URI.file('/plugins/security'); + const calls: string[] = []; + const sectionLoad = new DeferredPromise(); + const editor = { + listWidgetSectionLoad: Promise.resolve(), + selectSection: (section: AICustomizationManagementSection) => { + calls.push(`section:${section}`); + editor.listWidgetSectionLoad = section === AICustomizationManagementSection.Skills ? sectionLoad.p : Promise.resolve(); + }, + isPromptsSection: (section: AICustomizationManagementSection) => section === AICustomizationManagementSection.Skills, + revealCustomizationByUri: async (uri: URI) => { calls.push(`prompt:${uri.path}`); }, + pluginListWidget: { + revealAndSelectItemByUri: async (uri: URI) => { + calls.push(`plugin:${uri.path}`); + return true; + }, + }, + mcpListWidget: { + revealAndSelectServer: (serverId: string | undefined, name: string, connectorName?: string) => { + calls.push(`mcp:${serverId}:${name}:${connectorName}`); + return true; + }, + }, + }; + const revealInstalledCustomization = Reflect.get(AICustomizationManagementEditor.prototype, 'revealInstalledCustomization') as ( + this: typeof editor, + target: IInstalledCustomizationTarget, + ) => Promise; + + const skillNavigation = revealInstalledCustomization.call(editor, { section: AICustomizationManagementSection.Skills, name: 'Security skill', uri: skillUri }); + await timeout(0); + const beforeSectionLoaded = [...calls]; + sectionLoad.complete(); + await skillNavigation; + await revealInstalledCustomization.call(editor, { section: AICustomizationManagementSection.Plugins, name: 'Security plugin', uri: pluginUri }); + await revealInstalledCustomization.call(editor, { section: AICustomizationManagementSection.McpServers, name: 'Security server', mcpServerId: 'security-server' }); + await revealInstalledCustomization.call(editor, { section: AICustomizationManagementSection.McpServers, name: 'Mail', mcpConnectorName: 'mail' }); + + assert.deepStrictEqual({ + beforeSectionLoaded, + calls, + }, { + beforeSectionLoaded: [`section:${AICustomizationManagementSection.Skills}`], + calls: [ + `section:${AICustomizationManagementSection.Skills}`, + 'prompt:/skills/security/SKILL.md', + `section:${AICustomizationManagementSection.Plugins}`, + 'plugin:/plugins/security', + `section:${AICustomizationManagementSection.McpServers}`, + 'mcp:security-server:Security server:undefined', + `section:${AICustomizationManagementSection.McpServers}`, + 'mcp:undefined:Mail:mail', + ], + }); + }); + test('marks an MCP detail migratable from the authoritative candidate', () => { const editor = createTestEditor(); const sourceUri = URI.file('/workspace/.vscode/mcp.json'); @@ -581,7 +639,9 @@ suite('aiCustomizationManagementEditor', () => { test('showing Discover from its navigation button resets its filters', () => { const { editor } = createContributedSectionEditor(); const calls: string[] = []; + const homeButton = $('button'); Object.assign(editor, { + homeButton, welcomePage: { container: $('div'), setVisible() { }, @@ -593,7 +653,15 @@ suite('aiCustomizationManagementEditor', () => { editor.showWelcomePage({ resetFilters: true }); - assert.deepStrictEqual(calls, ['resetFilters']); + assert.deepStrictEqual({ + calls, + selected: homeButton.classList.contains('selected'), + ariaCurrent: homeButton.getAttribute('aria-current'), + }, { + calls: ['resetFilters'], + selected: true, + ariaCurrent: 'page', + }); }); test('a contributed section with no source settings remains hidden', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index f73a34bb2b091b..006dd3eb9df3b7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -236,6 +236,79 @@ function createMcpAccessTestWidget(access: McpAccessValue, policyAccess: McpAcce suite('mcpListWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('reveals, selects, and focuses an installed server by ID', () => { + const targetEntry = { + type: 'server-item' as const, + server: { id: 'security-server', label: 'Security server' }, + }; + const sameNameEntry = { + type: 'server-item' as const, + server: { id: 'other-server', label: 'Security server' }, + }; + const calls: string[] = []; + const widget = Object.assign(Object.create(McpListWidget.prototype), { + searchQuery: '', + currentTreeGroups: [{ + element: { type: 'group-header' }, + children: [sameNameEntry, targetEntry], + }], + list: { + reveal: (entry: object) => calls.push(entry === targetEntry ? 'reveal' : 'reveal-other'), + setFocus: (entries: readonly object[]) => calls.push(entries[0] === targetEntry ? 'focus' : 'focus-other'), + setSelection: (entries: readonly object[]) => calls.push(entries[0] === targetEntry ? 'select' : 'select-other'), + domFocus: () => calls.push('dom-focus'), + }, + }) as McpListWidget; + + assert.deepStrictEqual({ + revealed: widget.revealAndSelectServer('security-server', 'Security server'), + calls, + }, { + revealed: true, + calls: ['reveal', 'focus', 'select', 'dom-focus'], + }); + }); + + test('reveals a Connector by its stable Connector name', () => { + const sameNameEntry = { + type: 'server-item' as const, + server: { id: 'other-server', label: 'Mail' }, + }; + const connectorEntry = { + type: 'builtin-item' as const, + id: 'copilot-connector:mail:mail-mcp', + label: 'mail-mcp', + description: 'Connector: Mail', + connector: { + id: 'mail:mail-mcp', + connector: { name: 'mail', displayName: 'Mail' }, + serverName: 'mail-mcp', + }, + }; + const calls: string[] = []; + const widget = Object.assign(Object.create(McpListWidget.prototype), { + searchQuery: '', + currentTreeGroups: [{ + element: { type: 'group-header' }, + children: [sameNameEntry, connectorEntry], + }], + list: { + reveal: (entry: object) => calls.push(entry === connectorEntry ? 'reveal' : 'reveal-other'), + setFocus: (entries: readonly object[]) => calls.push(entries[0] === connectorEntry ? 'focus' : 'focus-other'), + setSelection: (entries: readonly object[]) => calls.push(entries[0] === connectorEntry ? 'select' : 'select-other'), + domFocus: () => calls.push('dom-focus'), + }, + }) as McpListWidget; + + assert.deepStrictEqual({ + revealed: widget.revealAndSelectServer(undefined, 'Mail', 'mail'), + calls, + }, { + revealed: true, + calls: ['reveal', 'focus', 'select', 'dom-focus'], + }); + }); + test('routes only an exactly recorded MCP uninstall through the marketplace', async () => { const resource: ICustomizationMarketplaceResource = { sourceId: 'testSource', diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts index dfcc2b894dddd3..cc267a11cc4a09 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginListWidget.test.ts @@ -134,6 +134,40 @@ suite('pluginListWidget', () => { }); }); + test('reveals, selects, and focuses an installed plugin by URI', async () => { + const targetUri = URI.file('/plugins/security'); + const targetEntry = { + type: 'plugin-item' as const, + item: { + plugin: { uri: targetUri }, + }, + }; + const calls: string[] = []; + const widget = Object.assign(Object.create(PluginListWidget.prototype), { + browseMode: false, + searchQuery: '', + filterPlugins: async () => { }, + currentTreeGroups: [{ + element: { type: 'group-header' }, + children: [targetEntry], + }], + list: { + reveal: (entry: object) => calls.push(entry === targetEntry ? 'reveal' : 'reveal-other'), + setFocus: (entries: readonly object[]) => calls.push(entries[0] === targetEntry ? 'focus' : 'focus-other'), + setSelection: (entries: readonly object[]) => calls.push(entries[0] === targetEntry ? 'select' : 'select-other'), + domFocus: () => calls.push('dom-focus'), + }, + }) as PluginListWidget; + + assert.deepStrictEqual({ + revealed: await widget.revealAndSelectItemByUri(targetUri), + calls, + }, { + revealed: true, + calls: ['reveal', 'focus', 'select', 'dom-focus'], + }); + }); + test('installed metadata contains contribution counts without enablement copy', () => { const plugin = new class extends mock() { override readonly uri = URI.file('/plugins/example');