From 15dc08f24222a1cd83cf6d02f376166625d59f82 Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Thu, 1 Oct 2026 12:02:43 -0700 Subject: [PATCH 1/5] chat: Improve marketplace detail metadata and actions Show marketplace publishers beside item titles, reuse available provenance for plugin and MCP sources, and use connection-specific actions for Copilot connectors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentFinderRestProvider.ts | 15 +-- .../common/agentFinderRestProvider.test.ts | 12 ++- .../embeddedMarketplaceDetail.ts | 101 ++++++++++++------ .../media/aiCustomizationManagement.css | 12 ++- .../pluginCustomizationMarketplaceProvider.ts | 10 +- .../embeddedMarketplaceDetail.test.ts | 57 ++++++++-- ...inCustomizationMarketplaceProvider.test.ts | 17 +++ 7 files changed, 170 insertions(+), 54 deletions(-) diff --git a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts index 6a43da003ed5a6..50677773550735 100644 --- a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts +++ b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts @@ -122,7 +122,7 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide } private async resolveMcpIcons(page: ICustomizationMarketplaceSourcePage, token: CancellationToken): Promise { - if (!page.items.some(item => !item.icon && item.installation?.kind === 'mcp' && item.externalUrl)) { + if (!page.items.some(item => (!item.icon || !item.publisher) && item.installation?.kind === 'mcp' && item.externalUrl)) { return page; } @@ -137,15 +137,15 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide try { const items = await Promise.all(page.items.map(item => - !item.icon && item.installation?.kind === 'mcp' && item.externalUrl - ? limiter.queue(() => this.resolveMcpIcon(item, token, cancellation.token)) + (!item.icon || !item.publisher) && item.installation?.kind === 'mcp' && item.externalUrl + ? limiter.queue(() => this.resolveMcpMetadata(item, token, cancellation.token)) : item )); if (token.isCancellationRequested) { throw new CancellationError(); } if (timedOut) { - this.logService.warn('[AgentFinderRestProvider] Timed out resolving MCP catalog icons.'); + this.logService.warn('[AgentFinderRestProvider] Timed out resolving MCP catalog metadata.'); } return { ...page, items }; } finally { @@ -154,7 +154,7 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide } } - private async resolveMcpIcon(item: ICustomizationMarketplaceEntry, queryToken: CancellationToken, iconToken: CancellationToken): Promise { + private async resolveMcpMetadata(item: ICustomizationMarketplaceEntry, queryToken: CancellationToken, iconToken: CancellationToken): Promise { const installation = item.installation; if (installation?.kind !== 'mcp' || !item.externalUrl || queryToken.isCancellationRequested || iconToken.isCancellationRequested) { return item; @@ -166,10 +166,11 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide if (server?.icon?.light && !icon) { this.logService.warn(`[AgentFinderRestProvider] Ignoring an invalid MCP catalog icon for '${installation.name}'.`); } - return icon ? { ...item, icon } : item; + const publisher = server?.publisherDisplayName ?? server?.publisher; + return icon || publisher ? { ...item, ...(icon ? { icon } : {}), ...(publisher ? { publisher } : {}) } : item; } catch (error) { if (!queryToken.isCancellationRequested && !iconToken.isCancellationRequested) { - this.logService.warn(`[AgentFinderRestProvider] Failed to resolve the MCP catalog icon for '${installation.name}'.`, error); + this.logService.warn(`[AgentFinderRestProvider] Failed to resolve MCP catalog metadata for '${installation.name}'.`, error); } return item; } diff --git a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts index 14d332a44df421..716492e2af6310 100644 --- a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts +++ b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts @@ -159,7 +159,7 @@ suite('AgentFinderRestProvider', () => { }); }); - test('resolves an MCP registry icon through the fixed Agent Finder registry', async () => { + test('resolves MCP registry metadata through the fixed Agent Finder registry', async () => { const lookups: { url: string; manifestUrl: string | undefined; manifestVersion: string | undefined; cancelled: boolean }[] = []; const mcpGalleryService = upcastPartial({ getMcpServer: async (url, manifest, token) => { @@ -174,6 +174,8 @@ suite('AgentFinderRestProvider', () => { light: 'https://avatars.githubusercontent.com/u/213697801', dark: 'https://avatars.githubusercontent.com/u/213697801', }, + publisher: 'pgEdge', + publisherDisplayName: 'pgEdge, Inc.', }); }, }); @@ -201,7 +203,7 @@ suite('AgentFinderRestProvider', () => { url: 'https://api.mcp.github.com/oss/v0.1/servers/io.github.pgEdge/postgres-mcp/versions/latest', externalUrl: mcpServer.url, repository: undefined, - publisher: undefined, + publisher: 'pgEdge, Inc.', icon: 'https://avatars.githubusercontent.com/u/213697801', stars: undefined, }, @@ -214,7 +216,7 @@ suite('AgentFinderRestProvider', () => { }); }); - test('keeps the MCP result and logs when registry icon resolution fails', async () => { + test('keeps the MCP result and logs when registry metadata resolution fails', async () => { const mcpGalleryService = upcastPartial({ getMcpServer: async () => { throw new Error('offline'); }, }); @@ -228,7 +230,7 @@ suite('AgentFinderRestProvider', () => { warnings: warning.args.map(args => args[0]), }, { icon: undefined, - warnings: ['[AgentFinderRestProvider] Failed to resolve the MCP catalog icon for \'io.github.pgEdge/postgres-mcp\'.'], + warnings: ['[AgentFinderRestProvider] Failed to resolve MCP catalog metadata for \'io.github.pgEdge/postgres-mcp\'.'], }); }); @@ -252,7 +254,7 @@ suite('AgentFinderRestProvider', () => { }, { icon: undefined, iconCancelled: true, - warnings: ['[AgentFinderRestProvider] Timed out resolving MCP catalog icons.'], + warnings: ['[AgentFinderRestProvider] Timed out resolving MCP catalog metadata.'], }); })); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts index 18d399c8b1eea4..a204a1d5b95ac3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts @@ -44,7 +44,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { private readonly titleEl: HTMLElement; private readonly titleActionsEl: HTMLElement; private readonly descriptionEl: HTMLElement; - private readonly stateEl: HTMLElement; + private readonly publisherEl: HTMLElement; private readonly queriesEl: HTMLElement; private readonly factsEl: HTMLElement; private readonly readmeEl: HTMLElement; @@ -76,8 +76,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { const identity = DOM.append(header, $('.embedded-detail-header-text')); const nameRow = DOM.append(identity, $('.embedded-detail-name-row')); this.titleEl = DOM.append(nameRow, $('h2.embedded-detail-name')); - this.stateEl = DOM.append(nameRow, $('.inline-badge.embedded-detail-status-badge')); - this.stateEl.setAttribute('role', 'status'); + this.publisherEl = DOM.append(nameRow, $('.embedded-detail-publisher')); this.titleActionsEl = DOM.append(header, $('.embedded-detail-title-actions')); this.descriptionEl = DOM.append(this.root, $('p.embedded-detail-description.marketplace-detail-description')); @@ -124,7 +123,8 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.readmeContent = undefined; this.titleEl.textContent = ''; this.descriptionEl.textContent = ''; - this.stateEl.textContent = ''; + this.publisherEl.textContent = ''; + this.publisherEl.style.display = 'none'; this.iconDisposables.clear(); DOM.clearNode(this.iconEl); DOM.clearNode(this.titleActionsEl); @@ -143,7 +143,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { const target = getLocationTarget(state); return [ resource.displayName, - getInstallStateLabel(state), + resource.publisher, resource.description, formatList(localize('marketplaceDetail.queries', "Try this"), resource.representativeQueries), localize('marketplaceDetail.typeAccessible', "Type: {0}", getMarketplaceTypeLabel(resource)), @@ -168,6 +168,8 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.locationFactEl = undefined; this.titleEl.textContent = resource.displayName; + this.publisherEl.textContent = resource.publisher ?? ''; + this.publisherEl.style.display = resource.publisher ? '' : 'none'; this.renderIcon(); this.descriptionEl.textContent = resource.description || localize('marketplaceDetail.noDescription', "No description provided."); this.renderQueries(resource.representativeQueries); @@ -201,8 +203,6 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.locationFactEl = undefined; const state = this.installService.getInstallState(resource); - this.stateEl.textContent = getInstallStateLabel(state); - this.stateEl.classList.toggle('unavailable', state.kind === 'unavailable'); this.renderInstallAction(resource, state); const location = getLocationTarget(state); @@ -242,6 +242,10 @@ export class EmbeddedMarketplaceDetail extends Disposable { } private renderInstallAction(resource: ICustomizationMarketplaceResource, state: CustomizationMarketplaceInstallState): void { + if (resource.installation?.kind === 'copilotConnector') { + this.renderConnectorAction(resource, state); + return; + } const setupUrl = state.kind === 'unavailable' ? state.setupUrl : undefined; const label = state.kind === 'installed' ? localize('marketplaceDetail.installed', "Installed") @@ -273,6 +277,66 @@ export class EmbeddedMarketplaceDetail extends Disposable { })); } + private renderConnectorAction(resource: ICustomizationMarketplaceResource, state: CustomizationMarketplaceInstallState): void { + const setupUrl = state.kind === 'unavailable' ? state.setupUrl : undefined; + const label = state.kind === 'installed' + ? localize('marketplaceDetail.disconnect', "Disconnect") + : state.kind === 'missing' + ? localize('marketplaceDetail.reconnect', "Reconnect") + : state.kind === 'installing' + ? localize('marketplaceDetail.connecting', "Connecting...") + : state.kind === 'repairing' + ? localize('marketplaceDetail.reconnecting', "Reconnecting...") + : state.kind === 'uninstalling' + ? localize('marketplaceDetail.disconnecting', "Disconnecting...") + : state.kind === 'checking' + ? localize('marketplaceDetail.checkingConnection', "Checking...") + : setupUrl + ? localize('marketplaceDetail.viewSetup', "View Setup") + : state.kind === 'error' || state.kind === 'unavailable' + ? localize('marketplaceDetail.unavailable', "Unavailable") + : localize('marketplaceDetail.connect', "Connect"); + const action = state.kind === 'installed' + ? () => this.installService.uninstall(resource) + : state.kind === 'missing' && !state.repairUnavailableMessage + ? () => this.installService.repair(resource) + : state.kind === 'available' + ? () => this.options.install(resource) + : setupUrl + ? () => this.options.openExternal(setupUrl) + : undefined; + const button = this.installActionDisposables.add(new Button(this.titleActionsEl, { + ...defaultButtonStyles, + secondary: state.kind === 'installed', + ariaLabel: localize('marketplaceDetail.actionAria', "{0} {1}", label, resource.displayName), + })); + button.label = label; + button.enabled = action !== undefined; + button.element.setAttribute('aria-busy', String(state.kind === 'installing' || state.kind === 'checking' || state.kind === 'repairing' || state.kind === 'uninstalling')); + if (!action) { + return; + } + this.installActionDisposables.add(button.onDidClick(async () => { + try { + await action(); + if (state.kind === 'installed') { + status(localize('marketplaceDetail.disconnectedStatus', "Disconnected {0}.", resource.displayName)); + } else if (state.kind === 'missing') { + status(localize('marketplaceDetail.reconnectedStatus', "Reconnected {0}.", resource.displayName)); + } else if (state.kind === 'available') { + status(localize('marketplaceDetail.connectedStatus', "Connected {0}.", resource.displayName)); + } + } catch (error) { + const message = state.kind === 'installed' + ? localize('marketplaceDetail.disconnectError', "Could not disconnect {0}. {1}", resource.displayName, getErrorMessage(error)) + : state.kind === 'missing' + ? localize('marketplaceDetail.reconnectError', "Could not reconnect {0}. {1}", resource.displayName, getErrorMessage(error)) + : localize('marketplaceDetail.connectError', "Could not connect {0}. {1}", resource.displayName, getErrorMessage(error)); + this.notificationService.error(message); + } + })); + } + private appendFact(label: string, value: string | HTMLElement): HTMLElement { const row = DOM.append(this.factsEl, $('.embedded-detail-fact-row')); DOM.append(row, $('dt.embedded-detail-fact-label')).textContent = label; @@ -409,29 +473,6 @@ function getMarketplaceTypeLabel(resource: ICustomizationMarketplaceResource): s } } -function getInstallStateLabel(state: CustomizationMarketplaceInstallState): string { - switch (state.kind) { - case 'available': - return localize('marketplaceDetail.availableState', "Available to install"); - case 'installing': - return localize('marketplaceDetail.installingState', "Installation in progress"); - case 'checking': - return localize('marketplaceDetail.checkingState', "Checking installation"); - case 'installed': - return localize('marketplaceDetail.installedState', "Installed"); - case 'missing': - return localize('marketplaceDetail.missingState', "Installation needs repair"); - case 'repairing': - return localize('marketplaceDetail.repairingState', "Repair in progress"); - case 'uninstalling': - return localize('marketplaceDetail.uninstallingState', "Uninstall in progress"); - case 'error': - return localize('marketplaceDetail.errorState', "Installation error: {0}", state.message); - case 'unavailable': - return localize('marketplaceDetail.unavailableState', "Unavailable: {0}", state.message); - } -} - function formatList(label: string, values: readonly string[]): string | undefined { return values.length ? `${label}: ${values.join(', ')}` : undefined; } 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..cf8263f81c3881 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1776,10 +1776,6 @@ per-word capitalization does not survive translation. */ margin: 0; } -.ai-customization-management-editor .embedded-marketplace-detail .embedded-detail-status-badge.unavailable { - color: var(--vscode-errorForeground); -} - .ai-customization-management-editor .marketplace-detail-source-facts { display: flex; flex-direction: column; @@ -2015,6 +2011,14 @@ per-word capitalization does not survive translation. */ margin-top: 1px; } +.ai-customization-management-editor .embedded-marketplace-detail .embedded-detail-publisher { + align-self: flex-end; + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-regular); +} + .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .embedded-detail-disable-button.monaco-button { color: var(--vscode-descriptionForeground); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts index 2916fbea2ebabc..146a619e45f84e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts @@ -10,7 +10,7 @@ import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatConfiguration } from '../../common/constants.js'; import { DEFAULT_PLUGIN_MARKETPLACE, parseMarketplaceReference } from '../../common/plugins/marketplaceReference.js'; -import { IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType, PluginSourceKind } from '../../common/plugins/pluginMarketplaceService.js'; const defaultMarketplaceId = parseMarketplaceReference(DEFAULT_PLUGIN_MARKETPLACE)!.canonicalId; const pluginMarketplaceSourceInfo: ICustomizationMarketplaceSourceInfo = { @@ -116,6 +116,7 @@ function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'de capabilities: [], representativeQueries: [], originLabel: plugin.marketplace, + publisher: getPluginPublisher(plugin), version: plugin.version, url: plugin.readmeUri, ...(plugin.readmeUri ? { readmeUri: plugin.readmeUri } : {}), @@ -125,6 +126,13 @@ function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'de }; } +function getPluginPublisher(plugin: IMarketplacePlugin): string | undefined { + const repository = plugin.sourceDescriptor.kind === PluginSourceKind.GitHub + ? plugin.sourceDescriptor.repo + : plugin.marketplaceReference.githubRepo; + return repository?.split('/', 1)[0]; +} + function getPluginMarketplaceTypes(mediaType: string | undefined): ReadonlySet | undefined { if (!mediaType) { return allMarketplaceTypes; diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts index 1584b30d341717..74ffacc926f04c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts @@ -17,21 +17,26 @@ import { INotificationService } from '../../../../../../platform/notification/co import { IRequestService } from '../../../../../../platform/request/common/request.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { EmbeddedMarketplaceDetail } from '../../../browser/aiCustomization/embeddedMarketplaceDetail.js'; -import { ICustomizationMarketplaceInstallService } from '../../../common/customizationMarketplaceInstallService.js'; +import { CustomizationMarketplaceInstallState, ICustomizationMarketplaceInstallService } from '../../../common/customizationMarketplaceInstallService.js'; suite('EmbeddedMarketplaceDetail', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function render(resource: ICustomizationMarketplaceResource, readmeContent?: string) { + function render(resource: ICustomizationMarketplaceResource, readmeContent?: string, installState: CustomizationMarketplaceInstallState = { kind: 'available' }) { const parent = DOM.append(document.body, DOM.$('.embedded-marketplace-detail-test')); store.add({ dispose: () => parent.remove() }); const instantiationService = workbenchInstantiationService(undefined, store); const installChangeEmitter = store.add(new Emitter()); let requestCount = 0; + let installCount = 0; + let repairCount = 0; + let uninstallCount = 0; instantiationService.stub(INotificationService, new class extends mock() { }()); instantiationService.stub(ICustomizationMarketplaceInstallService, new class extends mock() { override readonly onDidChange = installChangeEmitter.event; - override getInstallState() { return { kind: 'available' as const }; } + override getInstallState() { return installState; } + override async repair() { repairCount++; } + override async uninstall() { uninstallCount++; } }()); instantiationService.stub(IRequestService, new class extends mock() { override async request(): Promise { @@ -44,7 +49,7 @@ suite('EmbeddedMarketplaceDetail', () => { }()); const detail = store.add(instantiationService.createInstance(EmbeddedMarketplaceDetail, parent, { getSourceLabel: () => 'Marketplace', - install: async () => { }, + install: async () => { installCount++; }, openExternal: async () => { }, })); detail.setInput(resource); @@ -53,6 +58,7 @@ suite('EmbeddedMarketplaceDetail', () => { parent, fireInstallChange: () => installChangeEmitter.fire(), getRequestCount: () => requestCount, + getActionCounts: () => ({ installCount, repairCount, uninstallCount }), }; } @@ -75,6 +81,7 @@ suite('EmbeddedMarketplaceDetail', () => { }); assert.deepStrictEqual({ heading: parent.querySelector('h2')?.textContent, + publisher: parent.querySelector('.embedded-detail-publisher')?.textContent, icon: parent.querySelector('.marketplace-detail-icon img')?.getAttribute('src'), facts: [...parent.querySelectorAll('dt, dd')].map(element => element.textContent), queries: [...parent.querySelectorAll('.marketplace-detail-query-list li')].map(element => element.textContent), @@ -83,12 +90,13 @@ suite('EmbeddedMarketplaceDetail', () => { accessible: detail.getAccessibilityContent(), }, { heading: 'Repository review', + publisher: 'Example', icon: 'https://example.com/review.svg', facts: ['Type', 'Skill', 'Publisher', 'Example', 'Version', '1.2.0', 'Source', 'Marketplace', 'Tags', 'review', 'Repository', 'example/review'], queries: ['Review this change'], links: ['Marketplace', 'example/review'], actions: ['Install'], - accessible: 'Repository review\n\nAvailable to install\n\nReviews pull requests.\n\nTry this: Review this change\n\nType: Skill\n\nPublisher: Example\n\nVersion: 1.2.0\n\nSource: Marketplace\n\nTags: review\n\nRepository: example/review', + accessible: 'Repository review\n\nExample\n\nReviews pull requests.\n\nTry this: Review this change\n\nType: Skill\n\nPublisher: Example\n\nVersion: 1.2.0\n\nSource: Marketplace\n\nTags: review\n\nRepository: example/review', }); }); @@ -106,13 +114,48 @@ suite('EmbeddedMarketplaceDetail', () => { assert.deepStrictEqual({ facts: [...parent.querySelectorAll('dt, dd')].map(element => element.textContent), queries: parent.querySelectorAll('.marketplace-detail-query-list li').length, + publisherDisplay: parent.querySelector('.embedded-detail-publisher')?.style.display, actions: [...parent.querySelectorAll('.embedded-detail-title-actions .monaco-button')].map(element => element.textContent), accessible: detail.getAccessibilityContent(), }, { facts: ['Type', 'Skill', 'Source', 'Marketplace'], queries: 0, + publisherDisplay: 'none', actions: ['Install'], - accessible: 'Project notes\n\nAvailable to install\n\nType: Skill\n\nSource: Marketplace', + accessible: 'Project notes\n\nType: Skill\n\nSource: Marketplace', + }); + }); + + test('uses connection actions for Copilot connectors', async () => { + const resource: ICustomizationMarketplaceResource = { + sourceId: 'connectors', + identifier: 'mail', + displayName: 'Mail', + description: 'Search mail.', + mediaType: CustomizationMarketplaceMediaType.McpServer, + publisher: 'GitHub Copilot', + tags: [], + capabilities: [], + representativeQueries: [], + installation: { kind: 'copilotConnector', name: 'mail' }, + }; + const available = render(resource); + const installed = render(resource, undefined, { kind: 'installed', target: { kind: 'copilotConnector', name: 'mail' } }); + + available.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.click(); + installed.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.click(); + await timeout(0); + + assert.deepStrictEqual({ + availableAction: available.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.textContent, + installedAction: installed.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.textContent, + availableCounts: available.getActionCounts(), + installedCounts: installed.getActionCounts(), + }, { + availableAction: 'Connect', + installedAction: 'Disconnect', + availableCounts: { installCount: 1, repairCount: 0, uninstallCount: 0 }, + installedCounts: { installCount: 0, repairCount: 0, uninstallCount: 1 }, }); }); @@ -142,7 +185,7 @@ suite('EmbeddedMarketplaceDetail', () => { }, { contains: undefined, readme: 'Frontend Design\nUse the design system.', - accessible: 'Frontend Design\n\nAvailable to install\n\nDesign UI.\n\nType: Plugin\n\nSource: Marketplace\n\nPlugin README:\n# Frontend Design\n\nUse the design system.', + accessible: 'Frontend Design\n\nDesign UI.\n\nType: Plugin\n\nSource: Marketplace\n\nPlugin README:\n# Frontend Design\n\nUse the design system.', requestCount: 1, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts index 9b318faad1e0e1..d3f0829199c5ca 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts @@ -84,6 +84,7 @@ suite('PluginCustomizationMarketplaceProvider', () => { capabilities: [], representativeQueries: [], originLabel: customReference.displayLabel, + publisher: 'microsoft', version: '1.0', url: URI.parse('https://raw.githubusercontent.com/microsoft/plugins/stable/plugins/review/README.md'), readmeUri: URI.parse('https://raw.githubusercontent.com/microsoft/plugins/stable/plugins/review/README.md'), @@ -104,6 +105,22 @@ suite('PluginCustomizationMarketplaceProvider', () => { }); }); + test('uses a GitHub plugin source owner as the publisher', async () => { + const service = new TestPluginMarketplaceService(); + service.page = { + items: [{ + ...plugin, + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'octo-org/review-plugin' }, + }], + errors: [], + }; + const provider = new PluginCustomizationMarketplaceProvider('custom', service, new TestConfigurationService()); + + const page = await provider.query({}, CancellationToken.None); + + assert.strictEqual(page.items[0].publisher, 'octo-org'); + }); + test('default provider is suppressed while the public feed is enabled', async () => { const service = new TestPluginMarketplaceService(); const provider = new PluginCustomizationMarketplaceProvider('default', service, new TestConfigurationService({ From ac02d91af3385ee529a4819b70e00ed6e1844867 Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Thu, 1 Oct 2026 13:24:02 -0700 Subject: [PATCH 2/5] chat: Preserve marketplace detail states Show missing, error, and unavailable details using the existing MCP diagnostic banner, route missing items through Repair, and address publisher attribution, truncation, and connector accessibility feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../customizationMarketplace.contribution.ts | 2 +- .../embeddedMarketplaceDetail.ts | 115 ++++++++++++++++-- .../media/aiCustomizationManagement.css | 7 +- .../pluginCustomizationMarketplaceProvider.ts | 14 ++- .../embeddedMarketplaceDetail.test.ts | 66 ++++++++++ ...inCustomizationMarketplaceProvider.test.ts | 16 +++ 6 files changed, 202 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts index f25ae32afd3c34..eeb232412380f6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts @@ -65,7 +65,7 @@ class CustomizationDiscoveryAccessibleView implements IAccessibleViewImplementat localize('customizationDiscovery.help.browse', "Clear the search to browse. Show All on a section applies its type filter and moves to search results."), localize('customizationDiscovery.help.sources', "Use the source picker to search all available marketplace feeds or one feed. Configure Marketplaces opens the related settings."), localize('customizationDiscovery.help.navigation', "Use Tab and Shift+Tab between controls. In search results, use the arrow keys, Home, and End to navigate the list. Press Enter or Space on an available item to open its in-product details. Press Enter to open an installed item."), - localize('customizationDiscovery.help.install', "Review an available item's source, then choose Install. VS Code records the exact installed target instead of associating same-name local items. If recorded skill files are missing, choose Repair to restore only those files without overwriting existing files. Installed marketplace items provide an Uninstall action. MCP servers that require unsupported local prerequisites provide View Setup to open the publisher's instructions instead. VS Code continues to apply destination, trust, policy, and compatibility checks."), + localize('customizationDiscovery.help.install', "Review an available item's source, then choose Install. For connectors, choose Connect, Reconnect, or Disconnect to manage the connection. VS Code records the exact installed target instead of associating same-name local items. If recorded skill files are missing, choose Repair to restore only those files without overwriting existing files. Installed marketplace items provide an Uninstall action. MCP servers that require unsupported local prerequisites provide View Setup to open the publisher's instructions instead. VS Code continues to apply destination, trust, policy, and compatibility checks."), localize('customizationDiscovery.help.links', "Available customization names no longer open external resources. Open an item's details to review its metadata, then use the explicitly labeled resource or repository actions for external navigation. Back or Escape returns to Discover and restores focus."), localize('customizationDiscovery.help.paging', "Scrolling near the end of search results loads another page without removing loaded items. Retry repeats a failed marketplace request."), localize('customizationDiscovery.help.sourceFailures', "Unavailable sources show a warning and Retry action above the results. Scrolling continues healthy sources. Retrying a source reloads all sources from the first page to restore relevance order."), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts index a204a1d5b95ac3..b18cd9597d15d0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts @@ -14,10 +14,12 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../../b import { Codicon } from '../../../../../base/common/codicons.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isEqual } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { CustomizationMarketplaceMediaType, ICustomizationMarketplaceResource } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { asTextOrError, IRequestService } from '../../../../../platform/request/common/request.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -45,6 +47,11 @@ export class EmbeddedMarketplaceDetail extends Disposable { private readonly titleActionsEl: HTMLElement; private readonly descriptionEl: HTMLElement; private readonly publisherEl: HTMLElement; + private readonly installStateEl: HTMLElement; + private readonly installStateCardEl: HTMLElement; + private readonly installStateIconEl: HTMLElement; + private readonly installStateSummaryEl: HTMLElement; + private readonly installStateDetailsEl: HTMLElement; private readonly queriesEl: HTMLElement; private readonly factsEl: HTMLElement; private readonly readmeEl: HTMLElement; @@ -65,6 +72,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { @ICustomizationMarketplaceInstallService private readonly installService: ICustomizationMarketplaceInstallService, @INotificationService private readonly notificationService: INotificationService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IHoverService private readonly hoverService: IHoverService, @IThemeService private readonly themeService: IThemeService, @IRequestService private readonly requestService: IRequestService, ) { @@ -80,6 +88,17 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.titleActionsEl = DOM.append(header, $('.embedded-detail-title-actions')); this.descriptionEl = DOM.append(this.root, $('p.embedded-detail-description.marketplace-detail-description')); + this.installStateEl = DOM.append(this.root, $('section.mcp-detail-diagnostics')); + this.installStateEl.style.display = 'none'; + const installStateSection = DOM.append(this.installStateEl, $('section.mcp-detail-diagnostic-section')); + this.installStateCardEl = DOM.append(installStateSection, $('.mcp-detail-diagnostic-card')); + const installStateHeader = DOM.append(this.installStateCardEl, $('.mcp-detail-diagnostic-header')); + this.installStateIconEl = DOM.append(installStateHeader, $('.mcp-detail-diagnostic-icon')); + this.installStateIconEl.setAttribute('aria-hidden', 'true'); + this.installStateSummaryEl = DOM.append(installStateHeader, $('.mcp-detail-diagnostic-summary')); + this.installStateSummaryEl.setAttribute('aria-live', 'polite'); + this.installStateDetailsEl = DOM.append(this.installStateCardEl, $('.mcp-detail-diagnostic-details')); + const queriesSection = DOM.append(this.root, $('section.embedded-detail-section.marketplace-detail-queries')); DOM.append(queriesSection, $('h3.embedded-detail-section-title')).textContent = localize('marketplaceDetail.tryThis', "Try this"); this.queriesEl = DOM.append(queriesSection, $('ul.marketplace-detail-query-list')); @@ -125,6 +144,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.descriptionEl.textContent = ''; this.publisherEl.textContent = ''; this.publisherEl.style.display = 'none'; + this.installStateEl.style.display = 'none'; this.iconDisposables.clear(); DOM.clearNode(this.iconEl); DOM.clearNode(this.titleActionsEl); @@ -141,10 +161,12 @@ export class EmbeddedMarketplaceDetail extends Disposable { } const state = this.installService.getInstallState(resource); const target = getLocationTarget(state); + const statePresentation = getInstallStatePresentation(state); return [ resource.displayName, resource.publisher, resource.description, + statePresentation ? [statePresentation.summary, ...statePresentation.details].join('\n') : undefined, formatList(localize('marketplaceDetail.queries', "Try this"), resource.representativeQueries), localize('marketplaceDetail.typeAccessible', "Type: {0}", getMarketplaceTypeLabel(resource)), resource.publisher ? localize('marketplaceDetail.publisherAccessible', "Publisher: {0}", resource.publisher) : undefined, @@ -170,6 +192,9 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.titleEl.textContent = resource.displayName; this.publisherEl.textContent = resource.publisher ?? ''; this.publisherEl.style.display = resource.publisher ? '' : 'none'; + if (resource.publisher) { + this.renderDisposables.add(this.hoverService.setupDelayedHover(this.publisherEl, { content: resource.publisher })); + } this.renderIcon(); this.descriptionEl.textContent = resource.description || localize('marketplaceDetail.noDescription', "No description provided."); this.renderQueries(resource.representativeQueries); @@ -203,6 +228,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.locationFactEl = undefined; const state = this.installService.getInstallState(resource); + this.renderInstallStateBanner(state); this.renderInstallAction(resource, state); const location = getLocationTarget(state); @@ -214,6 +240,24 @@ export class EmbeddedMarketplaceDetail extends Disposable { } } + private renderInstallStateBanner(state: CustomizationMarketplaceInstallState): void { + const presentation = getInstallStatePresentation(state); + this.installStateEl.style.display = presentation ? '' : 'none'; + if (!presentation) { + return; + } + + this.installStateCardEl.className = `mcp-detail-diagnostic-card ${presentation.kind}`; + this.installStateIconEl.className = 'mcp-detail-diagnostic-icon'; + this.installStateIconEl.classList.add(...ThemeIcon.asClassNameArray(presentation.icon)); + this.installStateSummaryEl.textContent = presentation.summary; + DOM.clearNode(this.installStateDetailsEl); + this.installStateDetailsEl.style.display = presentation.details.length ? '' : 'none'; + for (const message of presentation.details) { + DOM.append(this.installStateDetailsEl, $('p.mcp-detail-diagnostic-message')).textContent = message; + } + } + private renderIcon(): void { const resource = this.current; this.iconDisposables.clear(); @@ -251,28 +295,47 @@ export class EmbeddedMarketplaceDetail extends Disposable { ? localize('marketplaceDetail.installed', "Installed") : state.kind === 'installing' ? localize('marketplaceDetail.installing', "Installing...") - : state.kind === 'uninstalling' - ? localize('marketplaceDetail.uninstalling', "Uninstalling...") - : setupUrl - ? localize('marketplaceDetail.viewSetup', "View Setup") - : localize('marketplaceDetail.install', "Install"); + : state.kind === 'repairing' + ? localize('marketplaceDetail.repairing', "Repairing...") + : state.kind === 'missing' + ? localize('marketplaceDetail.repair', "Repair") + : state.kind === 'uninstalling' + ? localize('marketplaceDetail.uninstalling', "Uninstalling...") + : setupUrl + ? localize('marketplaceDetail.viewSetup', "View Setup") + : state.kind === 'unavailable' + ? localize('marketplaceDetail.unavailable', "Unavailable") + : localize('marketplaceDetail.install', "Install"); + const action = state.kind === 'missing' && !state.repairUnavailableMessage + ? () => this.installService.repair(resource) + : state.kind === 'available' + ? () => this.options.install(resource) + : setupUrl + ? () => this.options.openExternal(setupUrl) + : undefined; const button = this.installActionDisposables.add(new Button(this.titleActionsEl, { ...defaultButtonStyles, ariaLabel: localize('marketplaceDetail.actionAria', "{0} {1}", label, resource.displayName), })); button.label = label; - button.enabled = state.kind === 'available' || !!setupUrl; - button.element.setAttribute('aria-busy', String(state.kind === 'installing')); + button.enabled = action !== undefined; + button.element.setAttribute('aria-busy', String(state.kind === 'installing' || state.kind === 'repairing')); + if (!action) { + return; + } this.installActionDisposables.add(button.onDidClick(async () => { try { - if (setupUrl) { - await this.options.openExternal(setupUrl); - } else { - await this.options.install(resource); + await action(); + if (state.kind === 'available') { status(localize('marketplaceDetail.installedStatus', "Installed {0}.", resource.displayName)); + } else if (state.kind === 'missing') { + status(localize('marketplaceDetail.repairedStatus', "Repaired {0}.", resource.displayName)); } } catch (error) { - this.notificationService.error(localize('marketplaceDetail.installError', "Could not install {0}. {1}", resource.displayName, getErrorMessage(error))); + const message = state.kind === 'missing' + ? localize('marketplaceDetail.repairError', "Could not repair {0}. {1}", resource.displayName, getErrorMessage(error)) + : localize('marketplaceDetail.installError', "Could not install {0}. {1}", resource.displayName, getErrorMessage(error)); + this.notificationService.error(message); } })); } @@ -453,6 +516,34 @@ function getLocationTarget(state: CustomizationMarketplaceInstallState): URI | u } } +function getInstallStatePresentation(state: CustomizationMarketplaceInstallState): { readonly kind: 'warning' | 'error'; readonly icon: ThemeIcon; readonly summary: string; readonly details: readonly string[] } | undefined { + switch (state.kind) { + case 'missing': + return { + kind: 'warning', + icon: Codicon.warning, + summary: localize('marketplaceDetail.missingSummary', "This customization needs repair"), + details: state.repairUnavailableMessage ? [state.repairUnavailableMessage] : [], + }; + case 'error': + return { + kind: 'error', + icon: Codicon.error, + summary: localize('marketplaceDetail.errorSummary', "This customization has an error"), + details: [state.message], + }; + case 'unavailable': + return { + kind: 'warning', + icon: Codicon.warning, + summary: localize('marketplaceDetail.unavailableSummary', "This customization is unavailable"), + details: [state.message], + }; + default: + return undefined; + } +} + function getRepositoryLabel(repository: URI): string { const path = repository.path.replace(/^\/|\/$/g, ''); return path || repository.authority || repository.toString(); 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 cf8263f81c3881..2d86d0d9ea8ba9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -2013,7 +2013,12 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .embedded-marketplace-detail .embedded-detail-publisher { align-self: flex-end; - flex-shrink: 0; + flex-shrink: 1; + min-width: 0; + max-width: 40%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; color: var(--vscode-descriptionForeground); font-size: var(--vscode-fontSize-label1); font-weight: var(--vscode-fontWeight-regular); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts index 146a619e45f84e..a3740d74a3ed1f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts @@ -127,10 +127,16 @@ function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'de } function getPluginPublisher(plugin: IMarketplacePlugin): string | undefined { - const repository = plugin.sourceDescriptor.kind === PluginSourceKind.GitHub - ? plugin.sourceDescriptor.repo - : plugin.marketplaceReference.githubRepo; - return repository?.split('/', 1)[0]; + if (plugin.sourceDescriptor.kind === PluginSourceKind.GitHub) { + return plugin.sourceDescriptor.repo.split('/', 1)[0]; + } + if (plugin.sourceDescriptor.kind === PluginSourceKind.GitUrl) { + const match = plugin.sourceDescriptor.url.match(/^https:\/\/github\.com\/(?[^/]+)\//i); + if (match?.groups) { + return match.groups.owner; + } + } + return plugin.marketplaceReference.githubRepo?.split('/', 1)[0]; } function getPluginMarketplaceTypes(mediaType: string | undefined): ReadonlySet | undefined { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts index 74ffacc926f04c..de983774840c23 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts @@ -159,6 +159,72 @@ suite('EmbeddedMarketplaceDetail', () => { }); }); + test('renders failure states and repairs missing customizations', async () => { + const resource: ICustomizationMarketplaceResource = { + sourceId: 'test', + identifier: 'review', + displayName: 'Repository review', + description: 'Reviews pull requests.', + mediaType: CustomizationMarketplaceMediaType.Skill, + tags: [], + capabilities: [], + representativeQueries: [], + }; + const target = { kind: 'skill' as const, uri: URI.file('C:\\skills\\review') }; + const missing = render(resource, undefined, { kind: 'missing', target }); + const blockedMissing = render(resource, undefined, { kind: 'missing', target, repairUnavailableMessage: 'The source is no longer available.' }); + const error = render(resource, undefined, { kind: 'error', target, message: 'Installation failed.' }); + const unavailable = render(resource, undefined, { kind: 'unavailable', message: 'This item requires a newer version.' }); + + missing.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.click(); + await timeout(0); + + const state = (result: ReturnType) => ({ + banner: result.parent.querySelector('.mcp-detail-diagnostic-card')?.textContent, + bannerClass: result.parent.querySelector('.mcp-detail-diagnostic-card')?.className, + action: result.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.textContent, + actionDisabled: result.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.classList.contains('disabled'), + accessible: result.detail.getAccessibilityContent(), + }); + assert.deepStrictEqual({ + missing: state(missing), + missingCounts: missing.getActionCounts(), + blockedMissing: state(blockedMissing), + error: state(error), + unavailable: state(unavailable), + }, { + missing: { + banner: 'This customization needs repair', + bannerClass: 'mcp-detail-diagnostic-card warning', + action: 'Repair', + actionDisabled: false, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization needs repair\n\nType: Skill\n\nSource: Marketplace\n\nLocation: c:\\skills\\review', + }, + missingCounts: { installCount: 0, repairCount: 1, uninstallCount: 0 }, + blockedMissing: { + banner: 'This customization needs repairThe source is no longer available.', + bannerClass: 'mcp-detail-diagnostic-card warning', + action: 'Repair', + actionDisabled: true, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization needs repair\nThe source is no longer available.\n\nType: Skill\n\nSource: Marketplace\n\nLocation: c:\\skills\\review', + }, + error: { + banner: 'This customization has an errorInstallation failed.', + bannerClass: 'mcp-detail-diagnostic-card error', + action: 'Install', + actionDisabled: true, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization has an error\nInstallation failed.\n\nType: Skill\n\nSource: Marketplace\n\nLocation: c:\\skills\\review', + }, + unavailable: { + banner: 'This customization is unavailableThis item requires a newer version.', + bannerClass: 'mcp-detail-diagnostic-card warning', + action: 'Unavailable', + actionDisabled: true, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization is unavailable\nThis item requires a newer version.\n\nType: Skill\n\nSource: Marketplace', + }, + }); + }); + test('fetches and renders the plugin README inline without a Contains section', async () => { const { detail, parent, fireInstallChange, getRequestCount } = render({ sourceId: 'test', diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts index d3f0829199c5ca..5f9262dad3afa7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts @@ -121,6 +121,22 @@ suite('PluginCustomizationMarketplaceProvider', () => { assert.strictEqual(page.items[0].publisher, 'octo-org'); }); + test('uses a GitHub URL plugin source owner as the publisher', async () => { + const service = new TestPluginMarketplaceService(); + service.page = { + items: [{ + ...plugin, + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://github.com/acme/monorepo.git', path: 'plugins/review' }, + }], + errors: [], + }; + const provider = new PluginCustomizationMarketplaceProvider('custom', service, new TestConfigurationService()); + + const page = await provider.query({}, CancellationToken.None); + + assert.strictEqual(page.items[0].publisher, 'acme'); + }); + test('default provider is suppressed while the public feed is enabled', async () => { const service = new TestPluginMarketplaceService(); const provider = new PluginCustomizationMarketplaceProvider('default', service, new TestConfigurationService({ From 62f32d99fd63ba694ed46cd298a97a5a9762536e Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Fri, 2 Oct 2026 09:35:33 -0700 Subject: [PATCH 3/5] chat: Add marketplace state fixtures Show missing, error, and unavailable marketplace details in Component Explorer and keep their diagnostic banners aligned and content-sized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../embeddedMarketplaceDetail.ts | 2 +- .../media/aiCustomizationManagement.css | 8 +++ ...aiCustomizationManagementEditor.fixture.ts | 59 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts index b18cd9597d15d0..f729c32c103367 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts @@ -88,7 +88,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.titleActionsEl = DOM.append(header, $('.embedded-detail-title-actions')); this.descriptionEl = DOM.append(this.root, $('p.embedded-detail-description.marketplace-detail-description')); - this.installStateEl = DOM.append(this.root, $('section.mcp-detail-diagnostics')); + this.installStateEl = DOM.append(this.root, $('section.mcp-detail-diagnostics.marketplace-detail-install-state')); this.installStateEl.style.display = 'none'; const installStateSection = DOM.append(this.installStateEl, $('section.mcp-detail-diagnostic-section')); this.installStateCardEl = DOM.append(installStateSection, $('.mcp-detail-diagnostic-card')); 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 2d86d0d9ea8ba9..72e6db1ad2cced 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1776,6 +1776,14 @@ per-word capitalization does not survive translation. */ margin: 0; } +.ai-customization-management-editor .marketplace-detail-install-state { + padding-inline-start: var(--vscode-spacing-size320); +} + +.ai-customization-management-editor .marketplace-detail-install-state .mcp-detail-diagnostic-card { + min-height: 0; +} + .ai-customization-management-editor .marketplace-detail-source-facts { display: flex; flex-direction: column; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index c4c78d251f2476..8f4c9e8a13643f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -989,6 +989,7 @@ interface IRenderEditorOptions { readonly toggleMarketplaceVisibility?: boolean; readonly customizationMarketplaceState?: 'ready' | 'empty' | 'error' | 'loading' | 'loadingMore'; readonly customizationMarketplaceInstallationState?: 'mixed' | 'missing' | 'error'; + readonly customizationMarketplaceDetailState?: 'missing' | 'error' | 'unavailable'; readonly discoveryQuery?: string; readonly clearDiscoveryQuery?: boolean; readonly selectDiscoveryResult?: boolean; @@ -1122,6 +1123,16 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } else if (options.customizationMarketplaceInstallationState === 'missing') { customizationMarketplaceInstallStates.set(getCustomizationMarketplaceResourceKey(customizationMarketplaceResources[0]), { kind: 'missing', target: getFixtureInstallationTarget(customizationMarketplaceResources[0]) }); } + if (options.customizationMarketplaceDetailState) { + const resource = customizationMarketplaceResources[0]; + const target = getFixtureInstallationTarget(resource); + const state: CustomizationMarketplaceInstallState = options.customizationMarketplaceDetailState === 'missing' + ? { kind: 'missing', target } + : options.customizationMarketplaceDetailState === 'error' + ? { kind: 'error', target, message: 'Installation failed because the selected destination is not writable.' } + : { kind: 'unavailable', message: 'This customization requires a runtime that is not installed.', setupUrl: URI.parse('https://example.com/setup') }; + customizationMarketplaceInstallStates.set(getCustomizationMarketplaceResourceKey(resource), state); + } const getCustomizationMarketplaceInstallations = () => createCustomizationMarketplaceInstallationSnapshot( customizationMarketplaceResources.flatMap(resource => { @@ -1880,6 +1891,18 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } } assert(detailContainer !== null && scrollHost !== null, 'Marketplace detail must render inside its page scroll host.'); + if (options.customizationMarketplaceDetailState) { + const diagnostic = detailContainer.querySelector('.mcp-detail-diagnostic-card'); + assert(Boolean(diagnostic?.textContent?.trim()), 'Marketplace failure detail must render its diagnostic banner.'); + const detailSection = detailContainer.querySelector('.marketplace-detail-query-list'); + assert( + !diagnostic || !detailSection + || Math.abs(diagnostic.getBoundingClientRect().left - detailSection.getBoundingClientRect().left) <= 1 + && Math.abs(diagnostic.getBoundingClientRect().right - detailSection.getBoundingClientRect().right) <= 1, + 'Marketplace failure detail must align with the page content.', + ); + assert(options.customizationMarketplaceDetailState !== 'missing' || !diagnostic || diagnostic.offsetHeight < 72, 'A summary-only marketplace diagnostic must size to its content.'); + } assert( scrollHost.getBoundingClientRect().bottom <= detailContainer.getBoundingClientRect().bottom + 1, 'Marketplace detail scroll host must remain inside the detail page bounds.', @@ -3385,6 +3408,42 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + DiscoverMissingDetail: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['A missing marketplace customization shows the MCP-style warning banner and a primary Repair action above its details.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + marketplaceVisibilityEnabled: true, + discoveryQuery: 'repository review', + selectDiscoveryResult: true, + customizationMarketplaceDetailState: 'missing', + }), + }), + + DiscoverInstallErrorDetail: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['A marketplace installation error shows the MCP-style error banner with its failure reason and a disabled Install action above its details.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + marketplaceVisibilityEnabled: true, + discoveryQuery: 'repository review', + selectDiscoveryResult: true, + customizationMarketplaceDetailState: 'error', + }), + }), + + DiscoverUnavailableDetail: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['An unavailable marketplace customization shows the MCP-style warning banner with its setup reason and a View Setup action above its details.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + marketplaceVisibilityEnabled: true, + discoveryQuery: 'repository review', + selectDiscoveryResult: true, + customizationMarketplaceDetailState: 'unavailable', + }), + }), + DiscoverMcpDetail: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, expectedVisualDescriptions: ['An MCP marketplace item without a safely resolved workbench server uses the generic detail presentation and preserves its setup/install state and metadata.'], From 5fbc1b2d6384973a926168b8efbcd614184f16ae Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Fri, 2 Oct 2026 10:26:52 -0700 Subject: [PATCH 4/5] chat: Link marketplace publishers Use trusted registry, repository owner, and connector destinations for publisher links in marketplace details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentFinderRestProvider.ts | 5 +++- .../common/agentFinderRestProvider.test.ts | 9 ++++-- .../common/customizationMarketplaceService.ts | 1 + .../common/mcpGalleryMarketplaceProvider.ts | 2 ++ .../mcpGalleryMarketplaceProvider.test.ts | 5 ++-- .../copilotConnectorsService.ts | 2 ++ .../embeddedMarketplaceDetail.ts | 28 +++++++++++++++++-- .../media/aiCustomizationManagement.css | 4 +++ .../pluginCustomizationMarketplaceProvider.ts | 19 ++++++++----- .../copilotConnectorsService.test.ts | 13 ++++++--- .../embeddedMarketplaceDetail.test.ts | 16 ++++++++--- ...inCustomizationMarketplaceProvider.test.ts | 11 ++++++-- 12 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts index 50677773550735..789f13b71499b9 100644 --- a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts +++ b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts @@ -167,7 +167,8 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide this.logService.warn(`[AgentFinderRestProvider] Ignoring an invalid MCP catalog icon for '${installation.name}'.`); } const publisher = server?.publisherDisplayName ?? server?.publisher; - return icon || publisher ? { ...item, ...(icon ? { icon } : {}), ...(publisher ? { publisher } : {}) } : item; + const publisherUrl = parseHttpUri(server?.publisherUrl); + return icon || publisher || publisherUrl ? { ...item, ...(icon ? { icon } : {}), ...(publisher ? { publisher } : {}), ...(publisherUrl ? { publisherUrl } : {}) } : item; } catch (error) { if (!queryToken.isCancellationRequested && !iconToken.isCancellationRequested) { this.logService.warn(`[AgentFinderRestProvider] Failed to resolve MCP catalog metadata for '${installation.name}'.`, error); @@ -302,6 +303,7 @@ function parseResource(value: unknown): ICustomizationMarketplaceEntry { const sourceSet = text(metadata?.sourceSet); const repository = (sourceSet && !sourceSet.includes('://') ? githubRepository(parseHttpUri(`https://github.com/${sourceSet}`), true) : undefined) ?? githubRepository(url); const publisher = repository?.path.split('/')[1]; + const publisherUrl = publisher ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${publisher}` }) : undefined; const installation = parseInstallation(mediaType, metadata, url, externalUrl); const readmeUri = getReadmeUri(installation); return { @@ -318,6 +320,7 @@ function parseResource(value: unknown): ICustomizationMarketplaceEntry { ...(readmeUri ? { readmeUri } : {}), icon: publisher ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${publisher}.png`, query: 'size=64' }) : undefined, publisher, + ...(publisherUrl ? { publisherUrl } : {}), version: text(value.version) ?? text(metadata?.version), score: value.score, installation, diff --git a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts index 716492e2af6310..b046511d6df1c8 100644 --- a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts +++ b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts @@ -96,6 +96,7 @@ function resourceSnapshot(resource: ICustomizationMarketplaceEntry) { url: resource.url?.toString(), repository: resource.repository?.toString(), icon: resource.icon?.toString(), + ...(resource.publisherUrl ? { publisherUrl: resource.publisherUrl.toString() } : {}), }; } @@ -141,6 +142,7 @@ suite('AgentFinderRestProvider', () => { repository: 'https://github.com/ChromeDevTools/chrome-devtools-mcp', icon: 'https://github.com/ChromeDevTools.png?size%3D64', publisher: 'ChromeDevTools', + publisherUrl: 'https://github.com/ChromeDevTools', version: undefined, score: undefined, installation: { kind: 'skill', repository: 'ChromeDevTools/chrome-devtools-mcp', ref: 'main', path: 'skills/a11y-debugging' }, @@ -176,6 +178,7 @@ suite('AgentFinderRestProvider', () => { }, publisher: 'pgEdge', publisherDisplayName: 'pgEdge, Inc.', + publisherUrl: 'https://registry.modelcontextprotocol.io/publishers/pgEdge', }); }, }); @@ -191,6 +194,7 @@ suite('AgentFinderRestProvider', () => { externalUrl: page.items[0].externalUrl, repository: page.items[0].repository, publisher: page.items[0].publisher, + publisherUrl: page.items[0].publisherUrl?.toString(), icon: page.items[0].icon?.toString(), stars: page.items[0].stars, }, @@ -204,6 +208,7 @@ suite('AgentFinderRestProvider', () => { externalUrl: mcpServer.url, repository: undefined, publisher: 'pgEdge, Inc.', + publisherUrl: 'https://registry.modelcontextprotocol.io/publishers/pgEdge', icon: 'https://avatars.githubusercontent.com/u/213697801', stars: undefined, }, @@ -753,8 +758,8 @@ suite('AgentFinderRestProvider', () => { const page = await service.query({}, CancellationToken.None); const item = page.items[0]; - assert.deepStrictEqual({ repository: item.repository?.toString(), icon: item.icon?.toString(), publisher: item.publisher, version: item.version }, { - repository: 'https://github.com/Owner/Repository', icon: 'https://github.com/Owner.png?size%3D64', publisher: 'Owner', version: '2.0', + assert.deepStrictEqual({ repository: item.repository?.toString(), icon: item.icon?.toString(), publisher: item.publisher, publisherUrl: item.publisherUrl?.toString(), version: item.version }, { + repository: 'https://github.com/Owner/Repository', icon: 'https://github.com/Owner.png?size%3D64', publisher: 'Owner', publisherUrl: 'https://github.com/Owner', version: '2.0', }); }); diff --git a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts index 900f1920f536ed..d0f5175461cdd9 100644 --- a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts +++ b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts @@ -83,6 +83,7 @@ export interface ICustomizationMarketplaceEntry { readonly readmeUri?: URI; readonly icon?: CustomizationMarketplaceIcon; readonly publisher?: string; + readonly publisherUrl?: URI; /** Source-supplied origin within a feed, distinct from the feed's display name. */ readonly originLabel?: string; readonly version?: string; diff --git a/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts b/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts index e99c0d4b499259..fdcdfea61c8bfe 100644 --- a/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts +++ b/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts @@ -71,6 +71,7 @@ function toMarketplaceEntry(server: IGalleryMcpServer, registry: 'custom' | 'def const url = webUrl ?? repository; const lightIcon = safeWebUri(server.icon?.light); const darkIcon = safeWebUri(server.icon?.dark); + const publisherUrl = safeWebUri(server.publisherUrl); const icon: CustomizationMarketplaceIcon | undefined = lightIcon && darkIcon ? { light: lightIcon, dark: darkIcon } : lightIcon ?? darkIcon; @@ -86,6 +87,7 @@ function toMarketplaceEntry(server: IGalleryMcpServer, registry: 'custom' | 'def ...(repository ? { repository } : {}), ...(icon ? { icon } : {}), publisher: server.publisherDisplayName ?? server.publisher, + ...(publisherUrl ? { publisherUrl } : {}), version: server.version, stars: server.starsCount, priority: registry === 'custom' ? 1 : 0, diff --git a/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts b/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts index ded9ea83f20c8b..7130d53bb4db33 100644 --- a/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts +++ b/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts @@ -26,6 +26,7 @@ suite('McpGalleryMarketplaceProvider', () => { isLatest: true, status: GalleryMcpServerStatus.Active, publisher: 'Owner', + publisherUrl: 'https://registry.test/publishers/owner', topics: ['database'], starsCount: 42, webUrl: 'https://registry.test/servers/io.github.owner%2Fserver', @@ -81,13 +82,13 @@ suite('McpGalleryMarketplaceProvider', () => { url: first.items[0].url, externalUrl: server.webUrl, repository: first.items[0].repository, icon: first.items[0].icon, - publisher: 'Owner', version: '1.0.0', stars: 42, priority: 1, + publisher: 'Owner', publisherUrl: first.items[0].publisherUrl, version: '1.0.0', stars: 42, priority: 1, installation: { kind: 'mcpGallery', name: server.name, registry: 'custom', registryUrl: customUrl }, }], total: 2, nextCursor: 'opaque+/=', }, lastCursor: undefined, - entryFields: ['identifier', 'displayName', 'description', 'mediaType', 'tags', 'capabilities', 'representativeQueries', 'url', 'externalUrl', 'repository', 'icon', 'publisher', 'version', 'stars', 'priority', 'installation'], + entryFields: ['identifier', 'displayName', 'description', 'mediaType', 'tags', 'capabilities', 'representativeQueries', 'url', 'externalUrl', 'repository', 'icon', 'publisher', 'publisherUrl', 'version', 'stars', 'priority', 'installation'], iconUris: { light: 'https://registry.test/icon-light.png', dark: 'https://registry.test/icon-dark.png', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts index df797e9b175b93..358ef98a32f913 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts @@ -775,6 +775,7 @@ function scoreConnectorField(word: string, value: string): number | undefined { } export function toCopilotConnectorMarketplaceEntry(connector: ICopilotConnector): ICustomizationMarketplaceEntry { + const publisherUrl = connector.homepage ?? connector.author?.url; return { identifier: connector.name, displayName: connector.displayName, @@ -787,6 +788,7 @@ export function toCopilotConnectorMarketplaceEntry(connector: ICopilotConnector) externalUrl: connector.documentation?.toString(true), icon: connector.icon, publisher: localize('copilotConnectors.publisher', "GitHub Copilot"), + ...(publisherUrl ? { publisherUrl } : {}), installation: { kind: 'copilotConnector', name: connector.name }, }; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts index f729c32c103367..75b3ae27fce22c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts @@ -46,7 +46,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { private readonly titleEl: HTMLElement; private readonly titleActionsEl: HTMLElement; private readonly descriptionEl: HTMLElement; - private readonly publisherEl: HTMLElement; + private readonly publisherEl: HTMLAnchorElement; private readonly installStateEl: HTMLElement; private readonly installStateCardEl: HTMLElement; private readonly installStateIconEl: HTMLElement; @@ -84,7 +84,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { const identity = DOM.append(header, $('.embedded-detail-header-text')); const nameRow = DOM.append(identity, $('.embedded-detail-name-row')); this.titleEl = DOM.append(nameRow, $('h2.embedded-detail-name')); - this.publisherEl = DOM.append(nameRow, $('.embedded-detail-publisher')); + this.publisherEl = DOM.append(nameRow, $('a.embedded-detail-publisher')) as HTMLAnchorElement; this.titleActionsEl = DOM.append(header, $('.embedded-detail-title-actions')); this.descriptionEl = DOM.append(this.root, $('p.embedded-detail-description.marketplace-detail-description')); @@ -144,6 +144,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.descriptionEl.textContent = ''; this.publisherEl.textContent = ''; this.publisherEl.style.display = 'none'; + this.publisherEl.removeAttribute('href'); this.installStateEl.style.display = 'none'; this.iconDisposables.clear(); DOM.clearNode(this.iconEl); @@ -192,6 +193,16 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.titleEl.textContent = resource.displayName; this.publisherEl.textContent = resource.publisher ?? ''; this.publisherEl.style.display = resource.publisher ? '' : 'none'; + const publisherUrl = getPublisherUrl(resource); + if (publisherUrl) { + this.publisherEl.href = publisherUrl.toString(true); + this.renderDisposables.add(DOM.addDisposableListener(this.publisherEl, DOM.EventType.CLICK, event => { + event.preventDefault(); + void this.options.openExternal(publisherUrl); + })); + } else { + this.publisherEl.removeAttribute('href'); + } if (resource.publisher) { this.renderDisposables.add(this.hoverService.setupDelayedHover(this.publisherEl, { content: resource.publisher })); } @@ -200,7 +211,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.renderQueries(resource.representativeQueries); this.appendFact(localize('marketplaceDetail.type', "Type"), getMarketplaceTypeLabel(resource)); if (resource.publisher) { - this.appendFact(localize('marketplaceDetail.publisher', "Publisher"), resource.publisher); + this.appendFact(localize('marketplaceDetail.publisher', "Publisher"), this.createLink(resource.publisher, publisherUrl)); } if (resource.version) { this.appendFact(localize('marketplaceDetail.version', "Version"), resource.version); @@ -544,6 +555,17 @@ function getInstallStatePresentation(state: CustomizationMarketplaceInstallState } } +function getPublisherUrl(resource: ICustomizationMarketplaceResource): URI | undefined { + if (resource.publisherUrl) { + return resource.publisherUrl; + } + if (resource.publisher && resource.repository?.scheme === Schemas.https && resource.repository.authority.toLowerCase() === 'github.com') { + const owner = resource.repository.path.split('/').filter(Boolean)[0]; + return owner ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${owner}` }) : undefined; + } + return undefined; +} + function getRepositoryLabel(repository: URI): string { const path = repository.path.replace(/^\/|\/$/g, ''); return path || repository.authority || repository.toString(); 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 72e6db1ad2cced..d60aaf276b2e4d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -2032,6 +2032,10 @@ per-word capitalization does not survive translation. */ font-weight: var(--vscode-fontWeight-regular); } +.ai-customization-management-editor .embedded-marketplace-detail .embedded-detail-publisher[href] { + color: var(--vscode-textLink-foreground); +} + .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .embedded-detail-disable-button.monaco-button { color: var(--vscode-descriptionForeground); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts index a3740d74a3ed1f..0cad2971fc84b7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { createLazyCustomizationMarketplaceProvider, CustomizationMarketplaceMediaType, ICustomizationMarketplaceProvider, ICustomizationMarketplaceSourceEntry, ICustomizationMarketplaceSourceInfo, ICustomizationMarketplaceSourcePage, ICustomizationMarketplaceSourceQuery } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; @@ -107,6 +109,7 @@ export class PluginCustomizationMarketplaceProvider implements ICustomizationMar } function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'default', search: boolean): ICustomizationMarketplaceSourceEntry { + const publisher = getPluginPublisher(plugin); return { identifier: getPluginMarketplaceIdentifier(plugin), displayName: plugin.name, @@ -116,7 +119,8 @@ function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'de capabilities: [], representativeQueries: [], originLabel: plugin.marketplace, - publisher: getPluginPublisher(plugin), + publisher: publisher?.name, + ...(publisher ? { publisherUrl: publisher.url } : {}), version: plugin.version, url: plugin.readmeUri, ...(plugin.readmeUri ? { readmeUri: plugin.readmeUri } : {}), @@ -126,17 +130,18 @@ function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'de }; } -function getPluginPublisher(plugin: IMarketplacePlugin): string | undefined { +function getPluginPublisher(plugin: IMarketplacePlugin): { readonly name: string; readonly url: URI } | undefined { + let publisher: string | undefined; if (plugin.sourceDescriptor.kind === PluginSourceKind.GitHub) { - return plugin.sourceDescriptor.repo.split('/', 1)[0]; - } - if (plugin.sourceDescriptor.kind === PluginSourceKind.GitUrl) { + publisher = plugin.sourceDescriptor.repo.split('/', 1)[0]; + } else if (plugin.sourceDescriptor.kind === PluginSourceKind.GitUrl) { const match = plugin.sourceDescriptor.url.match(/^https:\/\/github\.com\/(?[^/]+)\//i); if (match?.groups) { - return match.groups.owner; + publisher = match.groups.owner; } } - return plugin.marketplaceReference.githubRepo?.split('/', 1)[0]; + publisher ??= plugin.marketplaceReference.githubRepo?.split('/', 1)[0]; + return publisher ? { name: publisher, url: URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${publisher}` }) } : undefined; } function getPluginMarketplaceTypes(mediaType: string | undefined): ReadonlySet | undefined { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts index 24cd777c02d3cf..9391cf423a7755 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts @@ -30,7 +30,11 @@ import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticati import { CopilotConnectorsMarketplaceProvider, CopilotConnectorsService } from '../../../browser/aiCustomization/copilotConnectorsService.js'; import { CustomizationMarketplaceMediaType } from '../../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; -function catalogResponse(status: 'available' | 'connected', names = ['mail']): unknown { +function catalogResponse( + status: 'available' | 'connected', + names = ['mail'], + publisherMetadata?: { readonly homepage?: string; readonly author?: { readonly name?: string; readonly url?: string } }, +): unknown { return { plugins: names.map(name => ({ name, @@ -42,6 +46,7 @@ function catalogResponse(status: 'available' | 'connected', names = ['mail']): u representativeQueries: [`Search ${name}`], iconUrl: `https://example.com/${name}.png`, documentationUrl: `https://example.com/${name}`, + ...publisherMetadata, }, connection: { status, @@ -193,20 +198,20 @@ suite('CopilotConnectorsService', () => { } test('validates catalog metadata and exposes an MCP marketplace source', async () => { - const fixture = createFixture([{ body: catalogResponse('available', ['mail', 'calendar']) }]); + const fixture = createFixture([{ body: catalogResponse('available', ['mail', 'calendar'], { homepage: 'https://github.com/features/copilot' }) }]); const source = new CopilotConnectorsMarketplaceProvider(fixture.service, fixture.configurationService); const first = await source.query({ query: 'connector', mediaType: CustomizationMarketplaceMediaType.McpServer, pageSize: 1 }, CancellationToken.None); const second = await source.query({ query: 'connector', mediaType: CustomizationMarketplaceMediaType.McpServer, pageSize: 1, cursor: first.nextCursor }, CancellationToken.None); assert.deepStrictEqual({ - first: first.items.map(item => ({ identifier: item.identifier, installation: item.installation, publisher: item.publisher })), + first: first.items.map(item => ({ identifier: item.identifier, installation: item.installation, publisher: item.publisher, publisherUrl: item.publisherUrl?.toString() })), second: second.items.map(item => item.identifier), total: first.total, hasCursor: typeof first.nextCursor === 'string', requests: fixture.requests.map(request => request.url), }, { - first: [{ identifier: 'mail', installation: { kind: 'copilotConnector', name: 'mail' }, publisher: 'GitHub Copilot' }], + first: [{ identifier: 'mail', installation: { kind: 'copilotConnector', name: 'mail' }, publisher: 'GitHub Copilot', publisherUrl: 'https://github.com/features/copilot' }], second: ['calendar'], total: 2, hasCursor: true, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts index de983774840c23..e02c4150f63ca1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts @@ -31,6 +31,7 @@ suite('EmbeddedMarketplaceDetail', () => { let installCount = 0; let repairCount = 0; let uninstallCount = 0; + const openedExternal: Array = []; instantiationService.stub(INotificationService, new class extends mock() { }()); instantiationService.stub(ICustomizationMarketplaceInstallService, new class extends mock() { override readonly onDidChange = installChangeEmitter.event; @@ -50,7 +51,7 @@ suite('EmbeddedMarketplaceDetail', () => { const detail = store.add(instantiationService.createInstance(EmbeddedMarketplaceDetail, parent, { getSourceLabel: () => 'Marketplace', install: async () => { installCount++; }, - openExternal: async () => { }, + openExternal: async resource => { openedExternal.push(resource); }, })); detail.setInput(resource); return { @@ -59,11 +60,12 @@ suite('EmbeddedMarketplaceDetail', () => { fireInstallChange: () => installChangeEmitter.fire(), getRequestCount: () => requestCount, getActionCounts: () => ({ installCount, repairCount, uninstallCount }), + getOpenedExternal: () => openedExternal.map(resource => typeof resource === 'string' ? resource : resource.toString()), }; } - test('renders ordered metadata and representative queries', () => { - const { detail, parent } = render({ + test('renders ordered metadata and representative queries', async () => { + const { detail, parent, getOpenedExternal } = render({ sourceId: 'test', identifier: 'review', displayName: 'Repository review', @@ -79,23 +81,29 @@ suite('EmbeddedMarketplaceDetail', () => { repository: URI.parse('https://github.com/example/review'), icon: URI.parse('https://example.com/review.svg'), }); + parent.querySelector('.embedded-detail-publisher')?.click(); + await timeout(0); assert.deepStrictEqual({ heading: parent.querySelector('h2')?.textContent, publisher: parent.querySelector('.embedded-detail-publisher')?.textContent, + publisherHref: parent.querySelector('.embedded-detail-publisher')?.getAttribute('href'), icon: parent.querySelector('.marketplace-detail-icon img')?.getAttribute('src'), facts: [...parent.querySelectorAll('dt, dd')].map(element => element.textContent), queries: [...parent.querySelectorAll('.marketplace-detail-query-list li')].map(element => element.textContent), links: [...parent.querySelectorAll('.embedded-detail-fact-link')].map(element => element.textContent), actions: [...parent.querySelectorAll('.embedded-detail-title-actions .monaco-button')].map(element => element.textContent), + openedExternal: getOpenedExternal(), accessible: detail.getAccessibilityContent(), }, { heading: 'Repository review', publisher: 'Example', + publisherHref: 'https://github.com/example', icon: 'https://example.com/review.svg', facts: ['Type', 'Skill', 'Publisher', 'Example', 'Version', '1.2.0', 'Source', 'Marketplace', 'Tags', 'review', 'Repository', 'example/review'], queries: ['Review this change'], - links: ['Marketplace', 'example/review'], + links: ['Example', 'Marketplace', 'example/review'], actions: ['Install'], + openedExternal: ['https://github.com/example'], accessible: 'Repository review\n\nExample\n\nReviews pull requests.\n\nTry this: Review this change\n\nType: Skill\n\nPublisher: Example\n\nVersion: 1.2.0\n\nSource: Marketplace\n\nTags: review\n\nRepository: example/review', }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts index 5f9262dad3afa7..4c29708ddadf44 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts @@ -85,6 +85,7 @@ suite('PluginCustomizationMarketplaceProvider', () => { representativeQueries: [], originLabel: customReference.displayLabel, publisher: 'microsoft', + publisherUrl: URI.parse('https://github.com/microsoft'), version: '1.0', url: URI.parse('https://raw.githubusercontent.com/microsoft/plugins/stable/plugins/review/README.md'), readmeUri: URI.parse('https://raw.githubusercontent.com/microsoft/plugins/stable/plugins/review/README.md'), @@ -118,7 +119,10 @@ suite('PluginCustomizationMarketplaceProvider', () => { const page = await provider.query({}, CancellationToken.None); - assert.strictEqual(page.items[0].publisher, 'octo-org'); + assert.deepStrictEqual({ publisher: page.items[0].publisher, publisherUrl: page.items[0].publisherUrl?.toString() }, { + publisher: 'octo-org', + publisherUrl: 'https://github.com/octo-org', + }); }); test('uses a GitHub URL plugin source owner as the publisher', async () => { @@ -134,7 +138,10 @@ suite('PluginCustomizationMarketplaceProvider', () => { const page = await provider.query({}, CancellationToken.None); - assert.strictEqual(page.items[0].publisher, 'acme'); + assert.deepStrictEqual({ publisher: page.items[0].publisher, publisherUrl: page.items[0].publisherUrl?.toString() }, { + publisher: 'acme', + publisherUrl: 'https://github.com/acme', + }); }); test('default provider is suppressed while the public feed is enabled', async () => { From ecd9df55cf44c417a8919ef6f1d9604f8172091c Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Fri, 2 Oct 2026 11:18:03 -0700 Subject: [PATCH 5/5] chat: Derive MCP publisher links from repositories Preserve trusted MCP registry repository metadata and use its GitHub owner as the publisher destination when the registry has no publisher URL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentFinder/common/agentFinderRestProvider.ts | 8 ++++++-- .../test/common/agentFinderRestProvider.test.ts | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts index 789f13b71499b9..b4cdab99836c1f 100644 --- a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts +++ b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts @@ -167,8 +167,12 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide this.logService.warn(`[AgentFinderRestProvider] Ignoring an invalid MCP catalog icon for '${installation.name}'.`); } const publisher = server?.publisherDisplayName ?? server?.publisher; - const publisherUrl = parseHttpUri(server?.publisherUrl); - return icon || publisher || publisherUrl ? { ...item, ...(icon ? { icon } : {}), ...(publisher ? { publisher } : {}), ...(publisherUrl ? { publisherUrl } : {}) } : item; + const repository = githubRepository(parseHttpUri(server?.repositoryUrl)); + const repositoryOwner = repository?.path.split('/')[1]; + const publisherUrl = parseHttpUri(server?.publisherUrl) ?? (repositoryOwner ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${repositoryOwner}` }) : undefined); + return icon || publisher || publisherUrl || repository + ? { ...item, ...(icon ? { icon } : {}), ...(publisher ? { publisher } : {}), ...(publisherUrl ? { publisherUrl } : {}), ...(repository ? { repository } : {}) } + : item; } catch (error) { if (!queryToken.isCancellationRequested && !iconToken.isCancellationRequested) { this.logService.warn(`[AgentFinderRestProvider] Failed to resolve MCP catalog metadata for '${installation.name}'.`, error); diff --git a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts index b046511d6df1c8..43cb50abc7772f 100644 --- a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts +++ b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts @@ -178,7 +178,7 @@ suite('AgentFinderRestProvider', () => { }, publisher: 'pgEdge', publisherDisplayName: 'pgEdge, Inc.', - publisherUrl: 'https://registry.modelcontextprotocol.io/publishers/pgEdge', + repositoryUrl: 'https://github.com/pgEdge/postgres-mcp', }); }, }); @@ -192,7 +192,7 @@ suite('AgentFinderRestProvider', () => { version: page.items[0].version, url: page.items[0].url?.toString(), externalUrl: page.items[0].externalUrl, - repository: page.items[0].repository, + repository: page.items[0].repository?.toString(), publisher: page.items[0].publisher, publisherUrl: page.items[0].publisherUrl?.toString(), icon: page.items[0].icon?.toString(), @@ -206,9 +206,9 @@ suite('AgentFinderRestProvider', () => { version: '1.0.0', url: 'https://api.mcp.github.com/oss/v0.1/servers/io.github.pgEdge/postgres-mcp/versions/latest', externalUrl: mcpServer.url, - repository: undefined, + repository: 'https://github.com/pgEdge/postgres-mcp', publisher: 'pgEdge, Inc.', - publisherUrl: 'https://registry.modelcontextprotocol.io/publishers/pgEdge', + publisherUrl: 'https://github.com/pgEdge', icon: 'https://avatars.githubusercontent.com/u/213697801', stars: undefined, },