From 8fffdf193e585d712433f0d2dcc0e0979bfd1fb5 Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Thu, 1 Oct 2026 08:26:01 -0700 Subject: [PATCH 1/3] chat: Focus installed customizations from Discover Route installed Discover items to their management lists, wait for asynchronous list loading, and reveal the matching focused row. Also keep the Discover navigation state aligned with the other sidebar tabs.\n\nFixes #338768\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationDiscoveryPage.ts | 47 +++++++------ .../aiCustomizationManagementEditor.ts | 46 +++++++++---- .../aiCustomizationWelcomePage.ts | 15 ++-- .../browser/aiCustomization/mcpListWidget.ts | 34 +++++++++ .../media/aiCustomizationManagement.css | 5 ++ .../aiCustomization/pluginListWidget.ts | 24 +++++++ .../aiCustomizationDiscoveryPage.test.ts | 37 +++++++++- .../aiCustomizationManagementEditor.test.ts | 69 ++++++++++++++++++- .../aiCustomization/mcpListWidget.test.ts | 29 ++++++++ .../aiCustomization/pluginListWidget.test.ts | 34 +++++++++ 10 files changed, 293 insertions(+), 47 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts index 93421451fc1b4f..58c6192f3eb0f4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts @@ -412,8 +412,7 @@ class DiscoveryResultRenderer implements IListRenderer { - if (element.promptDetail || element.pluginDetail || element.mcpDetail || !element.catalogResource) { - this.onOpenInstalled(element); - return; - } - this.onOpenDetails(element.catalogResource); - }); + registerOpenListeners(() => this.onOpenInstalled(element)); } else { registerOpenListeners(() => this.onOpenDetails(element.resource)); } @@ -743,11 +736,7 @@ 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) { - this.openInstalledItem(event.element); - } else { - this.openMarketplaceItem(event.element.catalogResource, 'search'); - } + this.openInstalledItem(event.element); } else if (event.element?.kind === 'available') { this.openMarketplaceItem(event.element.resource, 'search'); } @@ -1436,6 +1425,8 @@ 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, }); } } else { @@ -1579,13 +1570,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), + hasInstallationTarget(state) + ? 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 +1603,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,13 +1860,27 @@ 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 && hasInstallationTarget(state) && 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, + }); + return; + } + this.openMarketplaceItem(resource, mode); + } + private openInstalledItem(item: IInstalledDiscoveryItem): void { this.callbacks.openInstalled?.({ section: item.section, + name: item.name, uri: item.uri, - promptDetail: item.promptDetail, - pluginDetail: item.pluginDetail, - mcpDetail: item.mcpDetail, + mcpServerId: item.mcpServerId, }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index f15a65a1e9c21e..0f3aec689e6d77 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) + : 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..e2d1d684065960 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,13 @@ 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; +} + 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..8133cdc629fd7c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -3179,6 +3179,40 @@ export class McpListWidget extends Disposable { } } + revealAndSelectServer(serverId: string | undefined, serverName: string): boolean { + if (this.searchQuery) { + this.searchInput.value = ''; + this.searchQuery = ''; + this.delayedFilter.cancel(); + void this.filterServers(); + return false; + } + const entry = this.getVisibleMcpEntries().find(entry => { + switch (entry.type) { + case 'server-item': + return entry.server.id === serverId || entry.server.label === serverName; + case 'session-server-item': + return entry.server.id === serverId || entry.server.name === serverName; + case 'builtin-item': + return entry.id === serverId + || entry.activeSessionServer?.id === serverId + || 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..c0ea573ea86b3b 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,11 @@ 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); +} + .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..c4e94be8aec959 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,37 @@ 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, + }], + }); + }); + 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 +977,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 +997,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'], 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..b1ccd33979efe9 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,61 @@ 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) => { + calls.push(`mcp:${serverId}:${name}`); + 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' }); + + 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', + ], + }); + }); + test('marks an MCP detail migratable from the authoritative candidate', () => { const editor = createTestEditor(); const sourceUri = URI.file('/workspace/.vscode/mcp.json'); @@ -581,7 +636,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 +650,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..b0f098ae7a78e3 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,35 @@ 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 calls: string[] = []; + const widget = Object.assign(Object.create(McpListWidget.prototype), { + searchQuery: '', + 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 McpListWidget; + + assert.deepStrictEqual({ + revealed: widget.revealAndSelectServer('security-server', 'Security server'), + 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'); From d49b0c01583f35c9da76b30431e3bcf74d5def1f Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Thu, 1 Oct 2026 09:03:52 -0700 Subject: [PATCH 2/3] chat: Preserve Discover navigation identity Only redirect fully installed marketplace entries to management lists, and preserve stable MCP and Connector identities when revealing their rows. Keep the Discover selection visible in high-contrast themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationDiscoveryPage.ts | 22 +++++-- .../aiCustomizationManagementEditor.ts | 2 +- .../aiCustomizationWelcomePage.ts | 1 + .../browser/aiCustomization/mcpListWidget.ts | 26 ++++++-- .../media/aiCustomizationManagement.css | 2 + .../aiCustomizationDiscoveryPage.test.ts | 64 +++++++++++++++++++ .../aiCustomizationManagementEditor.test.ts | 9 ++- .../aiCustomization/mcpListWidget.test.ts | 46 ++++++++++++- 8 files changed, 154 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts index 58c6192f3eb0f4..3ecb37133edd5b 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 this.onOpenInstalled(element)); } else { - registerOpenListeners(() => this.onOpenDetails(element.resource)); + registerOpenListeners(() => this.onOpenDetails(element.kind === 'installed' ? element.catalogResource! : element.resource)); } if (!installed) { @@ -736,7 +738,12 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto )); this._register(this.resultList.onDidOpen(event => { if (event.element?.kind === 'installed') { - this.openInstalledItem(event.element); + const resource = event.element.catalogResource; + if (!resource || this.getInstallState(resource).kind === 'installed') { + this.openInstalledItem(event.element); + } else { + this.openMarketplaceItem(resource, 'search'); + } } else if (event.element?.kind === 'available') { this.openMarketplaceItem(event.element.resource, 'search'); } @@ -1427,6 +1434,7 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto 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 { @@ -1573,7 +1581,7 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto const state = this.getInstallState(item); const primaryAction = createCustomizationCardPrimaryAction( card, - hasInstallationTarget(state) + 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', @@ -1863,12 +1871,13 @@ 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 && hasInstallationTarget(state) && this.callbacks.openInstalled) { + 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; } @@ -1881,6 +1890,7 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto name: item.name, uri: item.uri, mcpServerId: item.mcpServerId, + mcpConnectorName: item.mcpConnectorName, }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 0f3aec689e6d77..ef4a6e9307bdb3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -3688,7 +3688,7 @@ export class AICustomizationManagementEditor extends EditorPane { 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) + ? this.mcpListWidget?.revealAndSelectServer(target.mcpServerId, target.name, target.mcpConnectorName) : true; if (revealed !== false) { return; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts index e2d1d684065960..73b68d81d17188 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts @@ -57,6 +57,7 @@ export interface IInstalledCustomizationTarget { readonly name: string; readonly uri?: URI; readonly mcpServerId?: string; + readonly mcpConnectorName?: string; } export interface ICustomizationMarketplaceOrigin { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 8133cdc629fd7c..a8101e484f87e7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -3179,7 +3179,7 @@ export class McpListWidget extends Disposable { } } - revealAndSelectServer(serverId: string | undefined, serverName: string): boolean { + revealAndSelectServer(serverId: string | undefined, serverName: string, connectorName?: string): boolean { if (this.searchQuery) { this.searchInput.value = ''; this.searchQuery = ''; @@ -3188,16 +3188,28 @@ export class McpListWidget extends Disposable { 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.id === serverId || entry.server.label === serverName; + return entry.server.label === serverName; case 'session-server-item': - return entry.server.id === serverId || entry.server.name === serverName; + return entry.server.name === serverName; case 'builtin-item': - return entry.id === serverId - || entry.activeSessionServer?.id === serverId - || entry.label === serverName - || entry.connector?.connector.displayName === serverName; + return entry.label === serverName || entry.connector?.connector.displayName === serverName; case 'group-header': case 'marketplace-item': return false; 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 c0ea573ea86b3b..320a3548521cd7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -78,6 +78,8 @@ .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 { 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 c4e94be8aec959..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 @@ -933,10 +933,60 @@ suite('AICustomizationDiscoveryPage', () => { 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); @@ -1064,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 b1ccd33979efe9..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 @@ -102,8 +102,8 @@ suite('aiCustomizationManagementEditor', () => { }, }, mcpListWidget: { - revealAndSelectServer: (serverId: string | undefined, name: string) => { - calls.push(`mcp:${serverId}:${name}`); + revealAndSelectServer: (serverId: string | undefined, name: string, connectorName?: string) => { + calls.push(`mcp:${serverId}:${name}:${connectorName}`); return true; }, }, @@ -120,6 +120,7 @@ suite('aiCustomizationManagementEditor', () => { 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, @@ -132,7 +133,9 @@ suite('aiCustomizationManagementEditor', () => { `section:${AICustomizationManagementSection.Plugins}`, 'plugin:/plugins/security', `section:${AICustomizationManagementSection.McpServers}`, - 'mcp:security-server:Security server', + 'mcp:security-server:Security server:undefined', + `section:${AICustomizationManagementSection.McpServers}`, + 'mcp:undefined:Mail:mail', ], }); }); 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 b0f098ae7a78e3..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 @@ -241,12 +241,16 @@ suite('mcpListWidget', () => { 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: [targetEntry], + children: [sameNameEntry, targetEntry], }], list: { reveal: (entry: object) => calls.push(entry === targetEntry ? 'reveal' : 'reveal-other'), @@ -265,6 +269,46 @@ suite('mcpListWidget', () => { }); }); + 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', From 44ae335c7e4c62e1afb92bc6db8b0ba41f15f983 Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Thu, 1 Oct 2026 13:11:33 -0700 Subject: [PATCH 3/3] chat: Preserve installed customization details Retain the upstream installed-item detail payloads while carrying the stable identities used to reveal management-list rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/aiCustomization/aiCustomizationDiscoveryPage.ts | 3 +++ .../chat/browser/aiCustomization/aiCustomizationWelcomePage.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts index 3ecb37133edd5b..a82ebca33577d6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts @@ -1891,6 +1891,9 @@ export class AICustomizationDiscoveryPage extends Disposable implements IAICusto 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/aiCustomizationWelcomePage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts index 73b68d81d17188..1a24116f1a3aec 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePage.ts @@ -58,6 +58,9 @@ export interface IInstalledCustomizationTarget { readonly uri?: URI; readonly mcpServerId?: string; readonly mcpConnectorName?: string; + readonly promptDetail?: IAICustomizationListItem; + readonly pluginDetail?: IAgentPluginItem; + readonly mcpDetail?: IMcpServerDetailInput; } export interface ICustomizationMarketplaceOrigin {