From 2adee83de16487766fffbeb45b253ce401e8e6ff Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:21:35 -0400 Subject: [PATCH] Customizations modal redesign (#322043) * Add prototype design for plugin page in customizations modal * More updates/tweaks * More updates/tweaks * More updates * Refactor AI customization components and tests for improved functionality - Enhance aiCustomizationListWidget and related management editors. - Introduce aiCustomizationPresentation for better UI handling. - Update styles in aiCustomizationManagement.css for consistency. - Improve tests for aiCustomization components to ensure reliability. * Refactor AI customization management and update related tests - Improve aiCustomizationManagementEditor and associated components - Enhance styling in aiCustomizationManagement and welcome prompt - Update tests for aiCustomizationManagementEditor and welcome page * Refactor AI customization components and update related tests * Refactor AI customization components and improve session handling - Update agent host sessions provider for better integration. - Enhance AI customization management editor and presentation. - Optimize embedded agent plugin and MCP server details. - Clean up CSS for AI customization management. - Adjust tests to reflect changes in AI customization components. * Refactor AI customization components and add new tests for coverage * Fix AgentService provider refactor compilation Route failed-turn resume through the provider service and use the existing provider registration helper in its tests.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address customization editor review feedback Preserve live MCP and plugin detail controls, map remote MCP resources correctly, defer marketplace and editor work until visible, scope hook discovery by storage, and make card-list metadata linear.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix customization CI regressions Keep Agent Host provider mapping compatible with lightweight providers and accept the reviewed component screenshot baselines generated by CI.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update customization screenshot baselines Accept the reviewed Agent Host migration and welcome-page hashes generated by the blocks-CI Ubuntu run.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentHostSessionsProvider.ts | 4 +- .../aiCustomizationWorkspaceService.ts | 8 +- .../customizationsToolbar.contribution.ts | 40 +- .../browser/agentHostCustomizationService.ts | 2 + .../chat/browser/agentPluginActions.ts | 2 +- .../agentPluginEditor/agentPluginEditor.ts | 3 +- .../agentPluginEditor/agentPluginItems.ts | 1 + .../contrib/chat/browser/agentPluginsView.ts | 3 +- .../agentHost/agentHostChatContribution.ts | 3 +- .../agentHostCustomizationService.ts | 40 +- .../aiCustomizationItemSource.ts | 11 +- .../aiCustomizationListWidget.ts | 595 ++++++- .../aiCustomizationManagement.contribution.ts | 1 + .../aiCustomizationManagementEditor.ts | 397 +++-- .../aiCustomizationManagementEditorInput.ts | 27 +- .../aiCustomizationPresentation.ts | 27 + ...CustomizationWelcomePagePromptLaunchers.ts | 142 +- .../aiCustomizationWorkspaceService.ts | 8 +- .../aiCustomization/customizationCardList.ts | 200 +++ .../customizationMigrationCategories.ts | 29 +- .../embeddedAgentPluginDetail.ts | 652 ++++++- .../embeddedMcpServerDetail.ts | 204 ++- .../aiCustomization/galleryItemRenderer.ts | 2 +- .../browser/aiCustomization/mcpListWidget.ts | 1262 +++++++------ .../media/aiCustomizationManagement.css | 1570 ++++++++++++++++- .../aiCustomizationWelcomePromptLaunchers.css | 84 +- .../aiCustomization/pluginListWidget.ts | 1450 +++++++++++---- ...promptsServiceCustomizationItemProvider.ts | 9 - .../aiCustomization/toolsListWidget.ts | 222 ++- .../chatManagement/chatModelsWidget.ts | 5 +- .../chatManagement/media/chatModelsWidget.css | 2 +- .../contrib/chat/browser/pluginUrlHandler.ts | 1 + .../chat/browser/promptSyntax/hookActions.ts | 17 +- .../chat/browser/promptSyntax/hookUtils.ts | 10 +- .../browser/widget/input/chatInputPart.ts | 2 +- .../chat/common/plugins/agentPluginService.ts | 2 + .../common/plugins/agentPluginServiceImpl.ts | 8 + .../plugins/pluginMarketplaceService.ts | 44 +- .../agentHostCustomizationService.test.ts | 18 + .../aiCustomizationListWidget.test.ts | 123 +- .../aiCustomizationManagementEditor.test.ts | 75 +- ...mizationWelcomePagePromptLaunchers.test.ts | 85 +- .../customizationMigration.test.ts | 12 +- .../embeddedAgentPluginDetail.test.ts | 140 ++ .../aiCustomization/mcpListWidget.test.ts | 62 +- .../aiCustomization/pluginListWidget.test.ts | 78 +- .../aiCustomization/toolsListWidget.test.ts | 26 + .../browser/promptSyntax/hookUtils.test.ts | 39 +- ...aiCustomizationManagementEditor.fixture.ts | 584 +++++- .../blocks-ci-screenshots.md | 92 +- 50 files changed, 6829 insertions(+), 1594 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationPresentation.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/toolsListWidget.test.ts diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index cebfb9c1135..6cd4059a5fa 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -10,7 +10,7 @@ import { URI } from '../../base/common/uri.js'; import { AuthenticateParams, AuthenticateResult, IAgentConnection } from '../../platform/agentHost/common/agentService.js'; import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentCustomization, Customization, McpServerStatus, RootConfigState, type CustomizationEnablement, type McpServerState, type RootState } from '../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, Customization, McpServerStatus, RootConfigState, type CustomizationEnablement, type McpServerState, type RootState, type TextRange } from '../../platform/agentHost/common/state/protocol/state.js'; import { type CustomizationDisabledReason } from '../../platform/agentHost/common/customizationEnablement.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; @@ -40,6 +40,8 @@ export interface IAgentHostMcpServer { readonly disabledReason?: CustomizationDisabledReason; readonly status: McpServerStatus; readonly state: McpServerState; + readonly sourceUri?: URI; + readonly sourceRange?: TextRange; readonly logOutputChannelId?: string; /** Starts or restarts the server. Providers that cannot control lifecycle may no-op. */ start(): Promise; diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index a58af881706..5c29bdca4c5 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -95,14 +95,14 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization } readonly managementSections: readonly AICustomizationManagementSection[] = [ - AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.McpServers, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks, - AICustomizationManagementSection.Automations, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, AICustomizationManagementSection.Tools, + AICustomizationManagementSection.Automations, AICustomizationManagementSection.HarnessSettings, ]; diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index 1728e0dbfec..bd0df7d233d 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -68,11 +68,18 @@ const CUSTOMIZATION_OVERVIEW_ITEM: ICustomizationItemConfig = { export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ { - id: 'sessions.customization.agents', - label: localize('agents', "Agents"), - icon: agentIcon, - section: AICustomizationManagementSection.Agents, - modelSection: AICustomizationManagementSection.Agents, + id: 'sessions.customization.plugins', + label: localize('plugins', "Plugins"), + icon: pluginIcon, + section: AICustomizationManagementSection.Plugins, + isPlugins: true, + }, + { + id: 'sessions.customization.mcpServers', + label: localize('mcpServers', "MCP Servers"), + icon: mcpServerIcon, + section: AICustomizationManagementSection.McpServers, + isMcp: true, }, { id: 'sessions.customization.skills', @@ -88,6 +95,13 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ section: AICustomizationManagementSection.Instructions, modelSection: AICustomizationManagementSection.Instructions, }, + { + id: 'sessions.customization.agents', + label: localize('agents', "Agents"), + icon: agentIcon, + section: AICustomizationManagementSection.Agents, + modelSection: AICustomizationManagementSection.Agents, + }, { id: 'sessions.customization.hooks', label: localize('hooks', "Hooks"), @@ -95,20 +109,6 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ section: AICustomizationManagementSection.Hooks, modelSection: AICustomizationManagementSection.Hooks, }, - { - id: 'sessions.customization.mcpServers', - label: localize('mcpServers', "MCP Servers"), - icon: mcpServerIcon, - section: AICustomizationManagementSection.McpServers, - isMcp: true, - }, - { - id: 'sessions.customization.plugins', - label: localize('plugins', "Plugins"), - icon: pluginIcon, - section: AICustomizationManagementSection.Plugins, - isPlugins: true, - }, { id: 'sessions.customization.tools', label: localize('tools', "Tools"), @@ -131,6 +131,7 @@ export async function openCustomizationOverviewPage(editorService: IEditorServic } const input = AICustomizationManagementEditorInput.getOrCreate(); + input.setTargetLabel(harnessService.getActiveDescriptor().label); const pane = await editorService.openEditor(input, { pinned: true }); if (pane instanceof AICustomizationManagementEditor) { pane.showWelcomePage(); @@ -144,6 +145,7 @@ async function openCustomizationSectionPage(editorService: IEditorService, harne } const input = AICustomizationManagementEditorInput.getOrCreate(); + input.setTargetLabel(harnessService.getActiveDescriptor().label); const pane = await editorService.openEditor(input, { pinned: true }); if (pane instanceof AICustomizationManagementEditor) { pane.selectSectionById(section); diff --git a/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts b/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts index c0a09012b33..58620a41f8b 100644 --- a/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts +++ b/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; +import { identityAgentHostResourceUriMapper } from '../../../../platform/agentHost/common/agentHostUri.js'; import { combinedDisposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -69,6 +70,7 @@ export class AgentHostCustomizationService extends AbstractAgentHostCustomizatio } return { customizations: provider.getCustomizations(session.sessionId), + resourceUris: provider.getFeedbackAnnotationsChannel?.(session.sessionId)?.connection.resourceUris ?? identityAgentHostResourceUriMapper, workingDirectory: provider.getWorkingDirectory(session.sessionId), workingDirectories: provider.getWorkingDirectories(session.sessionId), rootConfig: provider.getRootConfig(), diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts index e6c1a980a39..251e615db20 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts @@ -38,7 +38,7 @@ export class InstallPluginAction extends Action { () => pluginInstallService.installPlugin({ name: item.name, description: item.description, - version: '', + version: item.version ?? '', source: item.source, sourceDescriptor: item.sourceDescriptor, marketplace: item.marketplace, diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts index 7683dded76d..3b1d854fa2c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -224,7 +224,7 @@ export class AgentPluginEditor extends EditorPane { const expectedUri = this.pluginInstallService.getPluginInstallUri({ name: item.name, description: item.description, - version: '', + version: item.version ?? '', source: item.source, sourceDescriptor: item.sourceDescriptor, marketplace: item.marketplace, @@ -246,6 +246,7 @@ export class AgentPluginEditor extends EditorPane { kind: AgentPluginItemKind.Marketplace, name: item.name, description: mp.description, + version: mp.version, source: mp.source, sourceDescriptor: mp.sourceDescriptor, marketplace: mp.marketplace, diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts index 612cea9bb62..f6a527763ef 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts @@ -26,6 +26,7 @@ export interface IMarketplacePluginItem { readonly kind: AgentPluginItemKind.Marketplace; readonly name: string; readonly description: string; + readonly version?: string; readonly source: string; readonly sourceDescriptor: IPluginSourceDescriptor; readonly marketplace: string; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 89a1576675c..a0d60b8e02e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -69,6 +69,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin kind: AgentPluginItemKind.Marketplace, name: plugin.name, description: plugin.description, + version: plugin.version, source: plugin.source, sourceDescriptor: plugin.sourceDescriptor, marketplace: plugin.marketplace, @@ -466,7 +467,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView this._activeClientService.getOrigin(syncedUri))); itemProvider.setDraftCustomAgents(ambientScope.customAgents); itemProvider.setDraftCustomizations(ambientScope.customizations); - // `[Agent Host]` suffix disambiguates from the extension-host Copilot CLI harness, which uses the same displayName. store.add(this._customizationHarnessService.registerExternalHarness({ id: sessionType, - label: localize('agentHostHarnessLabel.local', "{0} [Agent Host]", agent.displayName), + label: agent.displayName, icon: ThemeIcon.fromId(Codicon.server.id), // The Tools section is surfaced for the Copilot CLI agent host only. hiddenSections: agent.provider === 'copilotcli' ? [AICustomizationManagementSection.Prompts] : [AICustomizationManagementSection.Tools, AICustomizationManagementSection.Prompts], diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 721ffc18871..5d013ce2ff3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -10,6 +10,7 @@ import { Disposable, DisposableResourceMap, IDisposable, toDisposable } from '.. import { ResourceSet } from '../../../../../../base/common/map.js'; import { AgentHostMcpServers, AgentHostMcpServersConfigKey } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; @@ -135,6 +136,7 @@ export class NullAgentHostCustomizationService implements IAgentHostCustomizatio export interface IAgentHostCustomizationTarget { readonly customizations: readonly Customization[]; + readonly resourceUris: IAgentHostResourceUriMapper; readonly folderPickerDecision?: ISessionFolderPickerDecision; readonly workingDirectory?: string; readonly workingDirectories?: readonly string[]; @@ -201,22 +203,27 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i return []; } return getPresentableMcpServerCustomizations(target.customizations) - .map(({ server, plugin }): IAgentHostMcpServer => ({ - id: this._scopedMcpServerId(sessionResource, server.id), - name: server.name, - enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), - enablement: server.enablement, - isPluginProvided: plugin !== undefined, - isClientBundled: plugin !== undefined && target.isBundledMcpServer(plugin.uri, server.name), - owningPluginClientId: plugin?.clientId, - disabledReason: getCustomizationDisabledReason(server, plugin), - status: server.state.kind, - state: server.state, - logOutputChannelId: channelIdForMcpServer(sessionResource.toString(), server.id), - setEnabled: (enabled: boolean) => target.setCustomizationEnablement(server.id, withCustomizationEnablement(server.enablement, CustomizationEnablementKind.Session, { kind: CustomizationEnablementKind.Session, enabled })), - start: () => target.startMcpServer(server.id), - stop: () => target.stopMcpServer(server.id), - })); + .map(({ server, plugin }): IAgentHostMcpServer => { + const source = URI.parse(server.uri); + return { + id: this._scopedMcpServerId(sessionResource, server.id), + name: server.name, + enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), + enablement: server.enablement, + isPluginProvided: plugin !== undefined, + isClientBundled: plugin !== undefined && target.isBundledMcpServer(plugin.uri, server.name), + owningPluginClientId: plugin?.clientId, + disabledReason: getCustomizationDisabledReason(server, plugin), + status: server.state.kind, + state: server.state, + sourceUri: source.scheme === 'mcp-top-level' ? undefined : target.resourceUris.fromAgentHost(source), + sourceRange: server.range, + logOutputChannelId: channelIdForMcpServer(sessionResource.toString(), server.id), + setEnabled: (enabled: boolean) => target.setCustomizationEnablement(server.id, withCustomizationEnablement(server.enablement, CustomizationEnablementKind.Session, { kind: CustomizationEnablementKind.Session, enabled })), + start: () => target.startMcpServer(server.id), + stop: () => target.stopMcpServer(server.id), + }; + }); } showMcpServerLog(sessionResource: URI, serverId: string, beforeShow?: () => Promise): Promise { @@ -481,6 +488,7 @@ class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizat const channel = target.backendSession.toString(); return { customizations: sessionState?.customizations ?? [], + resourceUris: target.connection.resourceUris, folderPickerDecision: readSessionFolderPickerDecision(sessionState?._meta), workingDirectory: sessionState?.workingDirectories?.[0], workingDirectories: sessionState?.workingDirectories, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 7c70765874e..29810ad2459 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -11,7 +11,7 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; -import { basename, dirname } from '../../../../../base/common/resources.js'; +import { basename } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; @@ -277,7 +277,7 @@ export async function mergeBuiltinSkills( items: readonly IAICustomizationListItem[], promptType: PromptsType, promptsService: IPromptsService, - workspaceService: IAICustomizationWorkspaceService, + _workspaceService: IAICustomizationWorkspaceService, itemNormalizer: AICustomizationItemNormalizer, ): Promise { const builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = await promptsService.listPromptFilesForStorage(PromptsType.skill, PromptsStorage.builtIn, CancellationToken.None); @@ -294,9 +294,6 @@ export async function mergeBuiltinSkills( // re-discovered the bundled copy by scanning disk). const deduped = items.filter(item => !builtinUris.has(item.uri)); - const uiIntegrations = workspaceService.getSkillUIIntegrations(); - const uiIntegrationBadge = localize('uiIntegrationBadge', "UI Integration"); - // Collect names of user/workspace skills so we can hide the built-in // copy once the user has added an override at either level. const overriddenNames = new Set(); @@ -321,8 +318,6 @@ export async function mergeBuiltinSkills( if (overriddenNames.has(name)) { continue; } - const folderName = basename(dirname(p.uri)); - const uiTooltip = uiIntegrations.get(folderName); const builtinItem: ICustomizationItem = { uri: p.uri, type: PromptsType.skill, @@ -331,8 +326,6 @@ export async function mergeBuiltinSkills( source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, enabled: !disabledPromptFiles.has(p.uri), - badge: uiTooltip ? uiIntegrationBadge : undefined, - badgeTooltip: uiTooltip, extensionId: undefined, pluginUri: undefined, userInvocable: true, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index e631223a7e2..c79aa9c30cc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -24,7 +24,7 @@ import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, sectionToPromptType } from './aiCustomizationManagement.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; -import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultInputBoxStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { Delayer } from '../../../../../base/common/async.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; @@ -36,7 +36,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { createActionViewItem, getContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { Action, Separator } from '../../../../../base/common/actions.js'; +import { Action, IAction, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -48,6 +48,7 @@ import { ICustomizationHarnessService } from '../../common/customizationHarnessS import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IAICustomizationListItem } from './aiCustomizationItemSource.js'; import { IAICustomizationItemsModel, ItemsModelSection } from './aiCustomizationItemsModel.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -499,6 +500,24 @@ function toItemsModelSection(section: AICustomizationManagementSection): ItemsMo } } +export function usesCustomizationCardLayout(section: AICustomizationManagementSection): boolean { + return section === AICustomizationManagementSection.Agents + || section === AICustomizationManagementSection.Skills + || section === AICustomizationManagementSection.Instructions + || section === AICustomizationManagementSection.Hooks + || section === AICustomizationManagementSection.Prompts; +} + +export function getAlwaysVisibleCustomizationGroupKeys(section: AICustomizationManagementSection, isFiltering: boolean): readonly string[] { + return usesCustomizationCardLayout(section) && !isFiltering + ? [PromptsStorage.local, PromptsStorage.user] + : []; +} + +export function getTargetedCreateActionLabel(label: string, compactLabel?: string): string { + return compactLabel ?? label.replace(/^\$\([^)]+\)\s*/, ''); +} + /** * Returns the ARIA status announcement string for a given section, item * count, and whether a search filter is active. Exported for testing. @@ -559,11 +578,22 @@ export function getCountAnnouncement(section: AICustomizationManagementSection, */ interface ICreateAction { readonly label: string; + readonly compactLabel?: string; readonly enabled: boolean; readonly tooltip?: string; + readonly kind?: 'generate'; + readonly target?: 'workspace' | 'user'; run(): void; } +interface ICustomizationItemGroup { + readonly groupKey: string; + readonly label: string; + readonly icon: ThemeIcon; + readonly description: string; + readonly items: IAICustomizationListItem[]; +} + /** * Widget that displays a searchable list of AI customization items. */ @@ -584,6 +614,14 @@ export class AICustomizationListWidget extends Disposable { private addButtonSimple!: Button; private listContainer!: HTMLElement; private list!: WorkbenchList; + private cardContainer!: HTMLElement; + private cardScrollElement: HTMLElement | undefined; + private firstCardFocusElement: HTMLElement | undefined; + private readonly cardRowsByUri = new Map(); + private readonly cardRowsById = new Map(); + private readonly cardMenuButtonsById = new Map(); + private cardMenuOpen = false; + private lastCardFocusItemId: string | undefined; private emptyStateContainer!: HTMLElement; private emptyStateText!: HTMLElement; private emptyStateSubtext!: HTMLElement; @@ -598,6 +636,7 @@ export class AICustomizationListWidget extends Disposable { private lastLayoutHeight = 0; private lastHeaderHeight = 0; private readonly dropdownActionDisposables = this._register(new DisposableStore()); + private readonly cardDisposables = this._register(new DisposableStore()); /** Monotonically increasing counter; guards the post-load announcement against stale calls. */ private _sectionLoadId = 0; @@ -639,7 +678,7 @@ export class AICustomizationListWidget extends Disposable { @IAgentPluginService private readonly agentPluginService: IAgentPluginService, ) { super(); - this.element = $('.ai-customization-list-widget'); + this.element = $('.ai-customization-list-widget.plugin-list-widget'); this.create(); // Re-render the add button when the active project root or harness changes. @@ -727,7 +766,6 @@ export class AICustomizationListWidget extends Disposable { // Simple button (for single-action case, no dropdown) this.addButtonSimple = this._register(new Button(this.addButtonContainer, { ...defaultButtonStyles, - supportIcons: true, })); this.addButtonSimple.element.classList.add('list-add-button'); this._register(this.addButtonSimple.onDidClick(() => this.executePrimaryCreateAction())); @@ -735,14 +773,15 @@ export class AICustomizationListWidget extends Disposable { // Button with dropdown (for multi-action case) this.addButton = this._register(new ButtonWithDropdown(this.addButtonContainer, { ...defaultButtonStyles, - supportIcons: true, contextMenuProvider: this.contextMenuService, addPrimaryActionToDropdown: false, actions: { getActions: () => this.getDropdownActions() }, })); this.addButton.element.classList.add('list-add-button'); this._register(this.addButton.onDidClick(() => this.executePrimaryCreateAction())); - this.updateAddButton(); + + this.cardContainer = DOM.append(this.element, $('.plugin-card-container.customization-card-container')); + this.cardContainer.style.display = 'none'; // List container this.listContainer = DOM.append(this.element, $('.list-container')); @@ -825,8 +864,13 @@ export class AICustomizationListWidget extends Disposable { } })); - // Handle context menu - this._register(this.list.onContextMenu(e => this.onContextMenu(e))); + // Prompts retain their existing list context menu. The redesigned sections + // expose all row actions through an explicit overflow button. + this._register(this.list.onContextMenu(e => { + if (!this.usesCardLayout()) { + this.onContextMenu(e); + } + })); // Refresh on file deletions so the list updates after inline delete actions this._register(this.fileService.onDidFilesChange(e => { @@ -836,6 +880,7 @@ export class AICustomizationListWidget extends Disposable { })); this.updateSectionHeader(); + this.updateAddButton(); } /** @@ -905,6 +950,76 @@ export class AICustomizationListWidget extends Disposable { }); } + private showCardItemActions(item: IAICustomizationListItem, anchor: HTMLElement): void { + this.cardMenuOpen = true; + this.lastCardFocusItemId = item.id; + const disposables = new DisposableStore(); + const context: Record = { + uri: item.uri.toString(), + name: item.name, + promptType: item.promptType, + source: item.source, + pluginUri: item.pluginUri?.toString(), + itemId: item.id, + }; + const overlayPairs: [string, string | boolean][] = [ + [AI_CUSTOMIZATION_ITEM_TYPE_KEY, item.promptType], + [AI_CUSTOMIZATION_ITEM_URI_KEY, item.uri.toString()], + [AI_CUSTOMIZATION_ITEM_DISABLED_KEY, item.disabled], + [AI_CUSTOMIZATION_ITEM_STORAGE_KEY, item.source], + ]; + if (item.pluginUri) { + overlayPairs.push([AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, item.pluginUri.toString()]); + } + const overlay = this.contextKeyService.createOverlay(overlayPairs); + const menu = disposables.add(this.menuService.createMenu(AICustomizationManagementItemMenuId, overlay)); + const groups = menu.getActions({ arg: context, shouldForwardArgs: true }); + const actions: IAction[] = []; + const addedActionIds = new Set(); + for (const [, groupActions] of groups) { + const uniqueGroupActions = groupActions.filter(action => { + if (addedActionIds.has(action.id)) { + return false; + } + addedActionIds.add(action.id); + return true; + }); + if (uniqueGroupActions.length === 0) { + continue; + } + if (actions.length > 0) { + actions.push(new Separator()); + } + actions.push(...uniqueGroupActions); + } + if (!item.isBuiltin) { + if (actions.length > 0) { + actions.push(new Separator()); + } + actions.push(disposables.add(new Action('copyRelativePath', localize('copyRelativePath', "Copy Relative Path"), undefined, true, async () => { + const basePath = this.workspaceService.getActiveProjectRoot(); + const relativePath = basePath && item.uri.fsPath.startsWith(basePath.fsPath) + ? item.uri.fsPath.substring(basePath.fsPath.length + 1) + : this.labelService.getUriLabel(item.uri, { relative: true }); + await this.clipboardService.writeText(relativePath); + }))); + } + if (actions.length === 0) { + this.cardMenuOpen = false; + disposables.dispose(); + return; + } + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions, + onHide: () => { + this.cardMenuOpen = false; + (this.cardMenuButtonsById.get(item.id) ?? this.cardRowsById.get(item.id) ?? this.firstCardFocusElement)?.focus(); + disposables.dispose(); + }, + }); + } + /** * Sets the current section and binds the list to the model's per-section * observable. Returns once the initial fetch for the section has resolved @@ -914,6 +1029,7 @@ export class AICustomizationListWidget extends Disposable { async setSection(section: AICustomizationManagementSection): Promise { const loadId = ++this._sectionLoadId; this.currentSection = section; + this.element.classList.toggle('plugin-list-widget', this.usesCardLayout()); this.updateSectionHeader(); const modelSection = toItemsModelSection(section); @@ -996,6 +1112,15 @@ export class AICustomizationListWidget extends Disposable { * The first action becomes the primary button; the rest go in the dropdown. */ private updateAddButton(): void { + if (this.usesCardLayout()) { + this.addButton.element.style.display = 'none'; + this.addButtonSimple.element.style.display = 'none'; + if (this.allItems.length > 0 || !this.searchQuery.trim()) { + this.filterItems(); + } + return; + } + const actions = this.buildCreateActions(); const [primary, ...dropdown] = actions; const hasDropdown = dropdown.length > 0; @@ -1036,7 +1161,7 @@ export class AICustomizationListWidget extends Disposable { // Full command override (e.g. Claude hooks) — single action, no dropdown if (override?.commandId) { return [{ - label: `$(${Codicon.add.id}) ${override.label}`, + label: override.label ?? localize('newCustomization', "New {0}", typeLabel), enabled: true, run: () => { this.commandService.executeCommand(override.commandId!); }, }]; @@ -1057,9 +1182,8 @@ export class AICustomizationListWidget extends Disposable { for (const [, group] of menuActions) { for (const menuItem of group) { if (menuItem instanceof MenuItemAction) { - const icon = ThemeIcon.isThemeIcon(menuItem.item.icon) ? menuItem.item.icon.id : Codicon.add.id; extensionCreateActions.push({ - label: `$(${icon}) ${typeof menuItem.item.title === 'string' ? menuItem.item.title : menuItem.item.title.value}`, + label: typeof menuItem.item.title === 'string' ? menuItem.item.title : menuItem.item.title.value, enabled: menuItem.enabled, run: () => { menuItem.run(); }, }); @@ -1079,8 +1203,9 @@ export class AICustomizationListWidget extends Disposable { // Without a workspace, user creation becomes primary and rootFile goes to dropdown. if (override?.rootFile && hasWorkspace) { actions.push({ - label: `$(${Codicon.add.id}) ${override.label}`, + label: override.label ?? localize('newCustomization', "New {0}", typeLabel), enabled: true, + target: 'workspace', run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root' }); }, }); addedTargets.add('workspace-root'); @@ -1089,28 +1214,30 @@ export class AICustomizationListWidget extends Disposable { // Hooks have a simplified action set if (promptType === PromptsType.hook) { if (!this.workspaceService.isSessionsWindow && !descriptor.hideGenerateButton) { - // Core Local: Generate is primary, configure hooks in dropdown actions.push({ - label: `$(${Codicon.sparkle.id}) Generate ${typeLabel}`, + label: localize('generateWithAI', "Generate with AI"), + tooltip: localize('generateCustomizationWithAI', "Generate {0} with AI", typeLabel), enabled: true, + kind: 'generate', run: () => { this._onDidRequestCreate.fire(promptType); }, }); - if (hasWorkspace) { - actions.push({ - label: `$(${Codicon.add.id}) ${localize('configureHooks', "Configure Hooks")}`, - enabled: true, - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, - }); - } - } else if (!override?.commandId) { - // Sessions / non-local: configure hooks (view + create) + } + if (hasWorkspace) { actions.push({ - label: `$(${Codicon.add.id}) ${localize('configureHooks', "Configure Hooks")}`, - enabled: hasWorkspace, - tooltip: hasWorkspace ? undefined : localize('configureHooksDisabled', "Open a workspace folder to configure hooks."), + label: localize('newHook', "New Hook"), + compactLabel: localize('newHook', "New Hook"), + enabled: true, + target: 'workspace', run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, }); } + actions.push({ + label: localize('newHook', "New Hook"), + compactLabel: localize('newHook', "New Hook"), + enabled: true, + target: 'user', + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); }, + }); return actions; } @@ -1119,25 +1246,31 @@ export class AICustomizationListWidget extends Disposable { if (!override?.rootFile) { // Determine the primary action (first in list) if (!this.workspaceService.isSessionsWindow && !descriptor.hideGenerateButton) { - // Core Local: Generate is primary + // Local exposes one non-storage-scoped AI generation action. actions.push({ - label: `$(${Codicon.sparkle.id}) Generate ${typeLabel}`, + label: localize('generateWithAI', "Generate with AI"), + tooltip: localize('generateCustomizationWithAI', "Generate {0} with AI", typeLabel), enabled: true, + kind: 'generate', run: () => { this._onDidRequestCreate.fire(promptType); }, }); } else if (hasWorkspace) { // Sessions or non-local harness with workspace: workspace is primary actions.push({ - label: `$(${Codicon.add.id}) New ${createTypeLabel} (Workspace)`, + label: localize('newWorkspaceCustomization', "New {0} (Workspace)", createTypeLabel), + compactLabel: localize('newCustomization', "New {0}", createTypeLabel), enabled: true, + target: 'workspace', run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, }); addedTargets.add('workspace'); } else { // No workspace: user is primary actions.push({ - label: `$(${Codicon.add.id}) New ${createTypeLabel} (User)`, + label: localize('newUserCustomization', "New {0} (User)", createTypeLabel), + compactLabel: localize('newCustomization', "New {0}", createTypeLabel), enabled: true, + target: 'user', run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); }, }); addedTargets.add('user'); @@ -1147,16 +1280,20 @@ export class AICustomizationListWidget extends Disposable { // Secondary actions (dropdown) — only add if not already present if (hasWorkspace && !addedTargets.has('workspace')) { actions.push({ - label: `$(${Codicon.folder.id}) New ${createTypeLabel} (Workspace)`, + label: localize('newWorkspaceCustomization', "New {0} (Workspace)", createTypeLabel), + compactLabel: localize('newCustomization', "New {0}", createTypeLabel), enabled: true, + target: 'workspace', run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, }); } if (!addedTargets.has('user')) { actions.push({ - label: `$(${Codicon.account.id}) New ${createTypeLabel} (User)`, + label: localize('newUserCustomization', "New {0} (User)", createTypeLabel), + compactLabel: localize('newCustomization', "New {0}", createTypeLabel), enabled: true, + target: 'user', run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); }, }); } @@ -1165,8 +1302,9 @@ export class AICustomizationListWidget extends Disposable { if (hasWorkspace && override?.rootFileShortcuts && !addedTargets.has('workspace-root')) { for (const fileName of override.rootFileShortcuts) { actions.push({ - label: `$(${Codicon.file.id}) New ${fileName}`, + label: localize('newCustomizationFile', "New {0}", fileName), enabled: true, + target: 'workspace', run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root', rootFileName: fileName }); }, }); } @@ -1215,7 +1353,7 @@ export class AICustomizationListWidget extends Disposable { case AICustomizationManagementSection.Skills: return localize('skill', "Skill"); case AICustomizationManagementSection.Instructions: - return localize('instructions', "Instructions"); + return localize('instruction', "Instruction"); case AICustomizationManagementSection.Hooks: return localize('hook', "Hook"); case AICustomizationManagementSection.Prompts: @@ -1306,7 +1444,7 @@ export class AICustomizationListWidget extends Disposable { * Builds grouped display entries from items assigned to groups. * Empty groups are omitted. Collapsed groups show only their header. */ - private buildGroupedEntries(groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[]): void { + private buildGroupedEntries(groups: ICustomizationItemGroup[]): void { // Sort items within each group for (const group of groups) { group.items.sort((a, b) => a.name.localeCompare(b.name)); @@ -1319,7 +1457,7 @@ export class AICustomizationListWidget extends Disposable { continue; } - const collapsed = this.collapsedGroups.has(group.groupKey); + const collapsed = !this.usesCardLayout() && this.collapsedGroups.has(group.groupKey); this.displayEntries.push({ type: 'group-header', @@ -1355,38 +1493,33 @@ export class AICustomizationListWidget extends Disposable { * Groups items by normalized storage/groupKey. */ private groupMatchedItems(matchedItems: IAICustomizationListItem[]): void { - // Standard provider layout: group by inferred storage/groupKey. - // Instructions use semantic categories (matching core path) so - // that provider-supplied groupKeys like 'context-instructions' - // are routed to the correct collapsible header. - const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = - this.currentSection === AICustomizationManagementSection.Instructions - ? [ - { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, - { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, - { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, - { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - { groupKey: PromptsStorage.builtIn, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, - ] - : [ - { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - { groupKey: PromptsStorage.builtIn, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, - ]; + const groups: ICustomizationItemGroup[] = [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: PromptsStorage.builtIn, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, + ]; for (const item of matchedItems) { - const key = item.groupKey ?? item.source ?? AICustomizationSources.local; + const key = this.currentSection === AICustomizationManagementSection.Instructions + ? item.source + : item.groupKey ?? item.source ?? AICustomizationSources.local; let group = groups.find(g => g.groupKey === key); if (!group) { // Dynamically create a group for unknown groupKeys from providers let label: string; + let description = ''; switch (key) { case 'remote-host': label = localize('remoteHostGroupShort', "Remote"); + if (this.currentSection === AICustomizationManagementSection.Skills) { + description = localize( + 'remoteSkillsGroupDescription', + "Skills available from {0}, the active remote agent environment.", + this.harnessService.getActiveDescriptor().label, + ); + } break; case 'remote-client': label = localize('remoteClientGroupShort', "Local"); @@ -1394,7 +1527,7 @@ export class AICustomizationListWidget extends Disposable { default: label = formatDisplayName(key); } - group = { groupKey: key, label, icon: Codicon.folder, description: '', items: [] }; + group = { groupKey: key, label, icon: Codicon.folder, description, items: [] }; // Insert dynamic groups before the built-in group so it always stays last. const builtinIdx = groups.findIndex(g => g.groupKey === PromptsStorage.builtIn); if (builtinIdx >= 0) { @@ -1407,8 +1540,316 @@ export class AICustomizationListWidget extends Disposable { } this.buildGroupedEntries(groups); + if (this.usesCardLayout()) { + this.renderCardGroups(groups); + } else { + this.commitDisplayEntries(); + } + } - this.commitDisplayEntries(); + private usesCardLayout(): boolean { + return usesCustomizationCardLayout(this.currentSection); + } + + private renderCardGroups(groups: ICustomizationItemGroup[]): void { + const activeElement = DOM.getActiveElement(); + const shouldRestoreFocus = this.cardMenuOpen || !!activeElement && this.cardContainer.contains(activeElement); + const focusItemId = this.lastCardFocusItemId; + const isFiltering = !!this.searchQuery.trim(); + const usesTargetedCreateActions = this.usesTargetedCreateActions(); + const createGroupKey = isFiltering || usesTargetedCreateActions ? undefined : this.getCreateActionGroupKey(); + const alwaysVisibleGroupKeys = new Set(getAlwaysVisibleCustomizationGroupKeys(this.currentSection, isFiltering)); + const visibleGroups = groups.filter(group => group.items.length > 0 || alwaysVisibleGroupKeys.has(group.groupKey) || group.groupKey === createGroupKey); + if (visibleGroups.length === 0) { + this.cardDisposables.clear(); + this.cardRowsByUri.clear(); + this.cardRowsById.clear(); + this.cardMenuButtonsById.clear(); + this.cardScrollElement = undefined; + this.firstCardFocusElement = undefined; + DOM.clearNode(this.cardContainer); + this.cardContainer.style.display = 'none'; + this.updateEmptyState(); + return; + } + if (createGroupKey) { + const createGroupIndex = visibleGroups.findIndex(group => group.groupKey === createGroupKey); + if (createGroupIndex > 0) { + visibleGroups.unshift(...visibleGroups.splice(createGroupIndex, 1)); + } + } + + this.cardDisposables.clear(); + this.cardRowsByUri.clear(); + this.cardRowsById.clear(); + this.cardMenuButtonsById.clear(); + this.firstCardFocusElement = undefined; + DOM.clearNode(this.cardContainer); + this.listContainer.style.display = 'none'; + this.emptyStateContainer.style.display = 'none'; + this.cardContainer.style.display = ''; + const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll.customization-card-scroll')); + + for (const group of visibleGroups) { + const section = DOM.append(content, $('.plugin-card-section.customization-card-section')); + const header = DOM.append(section, $('.plugin-card-section-header')); + const text = DOM.append(header, $('.plugin-card-section-text')); + const headingRow = DOM.append(text, $('.plugin-card-section-heading-row')); + const heading = DOM.append(headingRow, $('h3.plugin-card-section-title')); + heading.textContent = group.label; + const count = DOM.append(headingRow, $('.plugin-card-section-count')); + count.textContent = String(group.items.length); + if (group.description) { + const description = DOM.append(text, $('.plugin-card-section-description')); + description.textContent = group.description; + } + if (!isFiltering && usesTargetedCreateActions && (group.groupKey === PromptsStorage.local || group.groupKey === PromptsStorage.user)) { + this.renderTargetedCardCreateActions(header, group.groupKey); + } else if (group.groupKey === createGroupKey) { + this.renderCardCreateActions(header); + } + + const inventory = DOM.append(section, $('.plugin-card-grid.plugin-inventory-list.customization-inventory-list')); + const cardList = this.cardDisposables.add(new CustomizationCardListController(inventory, group.label)); + if (group.items.length === 0) { + const empty = DOM.append(inventory, $('.plugin-inventory-empty')); + empty.textContent = this.getEmptyGroupMessage(group.groupKey); + continue; + } + for (const item of group.items) { + this.appendCustomizationCardRow(inventory, item, group.label, cardList); + } + cardList.finalize(); + } + if (shouldRestoreFocus) { + DOM.getWindow(this.element).requestAnimationFrame(() => { + (this.cardMenuButtonsById.get(focusItemId ?? '') ?? this.cardRowsById.get(focusItemId ?? '') ?? this.firstCardFocusElement)?.focus(); + }); + } + } + + private usesTargetedCreateActions(): boolean { + return this.currentSection === AICustomizationManagementSection.Agents + || this.currentSection === AICustomizationManagementSection.Skills + || this.currentSection === AICustomizationManagementSection.Instructions + || this.currentSection === AICustomizationManagementSection.Hooks + || this.currentSection === AICustomizationManagementSection.Prompts; + } + + private getCreateActionGroupKey(): string | undefined { + if (this.buildCreateActions().length === 0) { + return undefined; + } + return this.hasActiveWorkspace() ? PromptsStorage.local : PromptsStorage.user; + } + + private renderTargetedCardCreateActions(header: HTMLElement, groupKey: string): void { + const target = groupKey === PromptsStorage.local ? 'workspace' : 'user'; + const hasWorkspace = this.hasActiveWorkspace(); + const actions = this.buildCreateActions().filter(action => + action.target === target + || action.target === undefined && (target === 'workspace' ? hasWorkspace : !hasWorkspace) + ); + const primary = actions.find(action => action.target === target) ?? actions[0]; + if (!primary) { + return; + } + + const container = DOM.append(header, $('.plugin-card-section-actions')); + const label = this.formatTargetedCreateActionLabel(primary); + const button = this.cardDisposables.add(new Button(container, { + ...defaultButtonStyles, + secondary: true, + title: primary.tooltip ?? label, + ariaLabel: primary.tooltip ?? label, + })); + button.element.classList.add('customization-create-action'); + button.label = label; + button.enabled = primary.enabled; + this.firstCardFocusElement ??= button.element; + this.cardDisposables.add(button.onDidClick(() => primary.run())); + + const generateAction = actions.find(action => action.kind === 'generate'); + if (generateAction && generateAction !== primary) { + const generateButton = this.cardDisposables.add(new Button(container, { + ...defaultButtonStyles, + secondary: true, + title: generateAction.tooltip ?? generateAction.label, + ariaLabel: generateAction.tooltip ?? generateAction.label, + })); + generateButton.element.classList.add('customization-generate-action'); + generateButton.label = generateAction.label; + generateButton.enabled = generateAction.enabled; + this.cardDisposables.add(generateButton.onDidClick(() => generateAction.run())); + } + + const secondaryActions = actions.filter(action => action !== primary && action !== generateAction); + if (secondaryActions.length > 0) { + const moreLabel = localize('moreCreateActions', "More creation actions for {0}", groupKey === PromptsStorage.local ? localize('workspace', "Workspace") : localize('user', "User")); + const more = this.cardDisposables.add(new Button(container, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + title: moreLabel, + ariaLabel: moreLabel, + })); + more.element.classList.add('plugin-card-icon-button', 'customization-create-more-action'); + more.label = `$(${Codicon.ellipsis.id})`; + this.cardDisposables.add(more.onDidClick(() => this.showCreateActionsMenu(secondaryActions, more.element))); + } + } + + private formatTargetedCreateActionLabel(action: ICreateAction): string { + return getTargetedCreateActionLabel(action.label, action.compactLabel); + } + + private showCreateActionsMenu(createActions: readonly ICreateAction[], anchor: HTMLElement): void { + const disposables = new DisposableStore(); + const actions = createActions.map((action, index) => disposables.add(new Action( + `customization.create.${index}`, + action.label.replace(/^\$\([^)]+\)\s*/, ''), + undefined, + action.enabled, + () => action.run(), + ))); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions, + onHide: () => disposables.dispose(), + }); + } + + private getEmptyGroupMessage(groupKey: string): string { + const workspace = groupKey === PromptsStorage.local; + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + return workspace ? localize('noWorkspaceAgents', "No workspace agents yet.") : localize('noUserAgents', "No user agents yet."); + case AICustomizationManagementSection.Skills: + return workspace ? localize('noWorkspaceSkills', "No workspace skills yet.") : localize('noUserSkills', "No user skills yet."); + case AICustomizationManagementSection.Instructions: + return workspace ? localize('noWorkspaceInstructions', "No workspace instructions yet.") : localize('noUserInstructions', "No user instructions yet."); + case AICustomizationManagementSection.Hooks: + return workspace ? localize('noWorkspaceHooks', "No workspace hooks yet.") : localize('noUserHooks', "No user hooks yet."); + case AICustomizationManagementSection.Prompts: + return workspace ? localize('noWorkspacePrompts', "No workspace prompts yet.") : localize('noUserPrompts', "No user prompts yet."); + default: + return localize('noCustomizationsInSection', "No customizations are available."); + } + } + + private renderCardCreateActions(header: HTMLElement): void { + const actions = this.buildCreateActions(); + const [primary, ...dropdown] = actions; + if (!primary) { + return; + } + const container = DOM.append(header, $('.plugin-card-section-actions')); + const accessibleLabel = primary.tooltip ?? primary.label.replace(/\$\([^)]+\)\s*/g, ''); + if (dropdown.length > 0) { + const button = this.cardDisposables.add(new ButtonWithDropdown(container, { + ...defaultButtonStyles, + secondary: true, + contextMenuProvider: this.contextMenuService, + addPrimaryActionToDropdown: false, + actions: { getActions: () => this.getDropdownActions() }, + title: accessibleLabel, + ariaLabel: accessibleLabel, + })); + button.element.classList.add('customization-create-action'); + button.label = primary.label; + button.enabled = primary.enabled; + this.firstCardFocusElement ??= button.element; + this.cardDisposables.add(button.onDidClick(() => this.executePrimaryCreateAction())); + return; + } + + const button = this.cardDisposables.add(new Button(container, { + ...defaultButtonStyles, + secondary: true, + title: accessibleLabel, + ariaLabel: accessibleLabel, + })); + button.element.classList.add('customization-create-action'); + button.label = primary.label; + button.enabled = primary.enabled; + this.firstCardFocusElement ??= button.element; + this.cardDisposables.add(button.onDidClick(() => this.executePrimaryCreateAction())); + } + + private appendCustomizationCardRow(parent: HTMLElement, item: IAICustomizationListItem, groupLabel: string, cardList: CustomizationCardListController): void { + const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.customization-home-row')); + row.classList.toggle('disabled', item.disabled); + const displayName = item.displayName ?? formatDisplayName(item.name); + const secondaryText = getCustomizationSecondaryText(item.description, item.filename, item.promptType); + const statusLabel = this.getItemStatusLabel(item); + const accessibleSecondaryText = [secondaryText, statusLabel].filter(Boolean).join('. '); + const accessibleLabel = item.disabled + ? localize('customizationCardAriaLabelDisabled', "{0}. {1}. Disabled", displayName, accessibleSecondaryText || groupLabel) + : localize('customizationCardAriaLabel', "{0}. {1}", displayName, accessibleSecondaryText || groupLabel); + const primary = createCustomizationCardPrimaryAction(row, accessibleLabel, 'customization-row-primary'); + this.firstCardFocusElement ??= primary; + if (!this.cardRowsByUri.has(item.uri.toString())) { + this.cardRowsByUri.set(item.uri.toString(), primary); + } + this.cardRowsById.set(item.id, primary); + this.cardDisposables.add(DOM.addDisposableListener(primary, 'focus', () => { + this.lastCardFocusItemId = item.id; + })); + this.cardDisposables.add(DOM.addDisposableListener(primary, 'click', () => this._onDidSelectItem.fire(item))); + this.cardDisposables.add(DOM.addDisposableListener(row, 'contextmenu', event => { + event.preventDefault(); + this.showCardItemActions(item, row); + })); + this.cardDisposables.add(this.hoverService.setupDelayedHover(row, () => ({ + content: `${displayName}\n${this.labelService.getUriLabel(item.uri, { relative: item.source === AICustomizationSources.local })}`, + appearance: { compact: true, skipFadeInAnimation: true }, + }))); + + const details = DOM.append(primary, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + name.textContent = displayName; + if (item.badge && item.promptType !== PromptsType.instructions) { + const badge = DOM.append(nameRow, $('.inline-badge.item-badge')); + badge.textContent = item.badge; + badge.title = item.badgeTooltip ?? item.badge; + } + const description = DOM.append(details, $('.plugin-list-item-description')); + description.textContent = secondaryText ?? localize('customizationNoDescription', "No description provided."); + + const actionContainer = DOM.append(row, $('.plugin-list-item-action')); + this.cardDisposables.add(DOM.addDisposableGenericMouseDownListener(actionContainer, e => e.stopPropagation())); + this.cardDisposables.add(DOM.addDisposableListener(actionContainer, 'click', e => e.stopPropagation())); + const more = this.cardDisposables.add(new Button(actionContainer, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('customizationMoreActionsAria', "More actions for {0}", displayName), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + this.cardMenuButtonsById.set(item.id, more.element); + this.cardDisposables.add(DOM.addDisposableListener(more.element, 'focus', () => { + this.lastCardFocusItemId = item.id; + })); + this.cardDisposables.add(more.onDidClick(() => this.showCardItemActions(item, more.element))); + cardList.addItem({ + row, + primaryAction: primary, + label: displayName, + actions: [more.element], + contextMenuAction: more.element, + }); + } + + private getItemStatusLabel(item: IAICustomizationListItem): string | undefined { + switch (item.status) { + case 'loading': return localize('customizationStatusLoading', "Loading"); + case 'loaded': return localize('customizationStatusLoaded', "Loaded"); + case 'degraded': return localize('customizationStatusDegraded', "Needs attention"); + case 'error': return localize('customizationStatusError', "Error"); + default: return undefined; + } } /** @@ -1436,6 +1877,7 @@ export class AICustomizationListWidget extends Disposable { private updateEmptyState(): void { const hasItems = this.displayEntries.length > 0; if (!hasItems) { + this.cardContainer.style.display = 'none'; this.emptyStateContainer.style.display = 'flex'; this.listContainer.style.display = 'none'; @@ -1451,7 +1893,8 @@ export class AICustomizationListWidget extends Disposable { } } else { this.emptyStateContainer.style.display = 'none'; - this.listContainer.style.display = ''; + this.listContainer.style.display = this.usesCardLayout() ? 'none' : ''; + this.cardContainer.style.display = this.usesCardLayout() ? '' : 'none'; } } @@ -1511,6 +1954,10 @@ export class AICustomizationListWidget extends Disposable { * Focuses the list. */ focusList(): void { + if (this.usesCardLayout()) { + this.firstCardFocusElement?.focus(); + return; + } this.list.domFocus(); if (this.displayEntries.length > 0) { this.list.setFocus([0]); @@ -1521,6 +1968,12 @@ export class AICustomizationListWidget extends Disposable { * Scrolls the list so the last item is visible. */ revealLastItem(): void { + if (this.usesCardLayout()) { + if (this.cardScrollElement) { + this.cardScrollElement.scrollTop = this.cardScrollElement.scrollHeight; + } + return; + } if (this.displayEntries.length > 0) { this.list.reveal(this.displayEntries.length - 1); } @@ -1530,6 +1983,17 @@ export class AICustomizationListWidget extends Disposable { * Reveals and selects the first list item whose URI matches one of the provided URIs. */ revealAndSelectFirstItemByUri(uris: readonly URI[]): boolean { + if (this.usesCardLayout()) { + for (const uri of uris) { + const row = this.cardRowsByUri.get(uri.toString()); + if (row) { + row.scrollIntoView({ block: 'nearest' }); + row.focus(); + return true; + } + } + return false; + } const entryIndex = this.displayEntries.findIndex(entry => { return entry.type === 'file-item' && uris.some(uri => isEqual(entry.item.uri, uri)); }); @@ -1550,6 +2014,8 @@ export class AICustomizationListWidget extends Disposable { layout(height: number, width: number): void { this.lastLayoutHeight = height; this.lastLayoutWidth = width; + this.element.classList.toggle('narrow-layout', width < 500); + this.element.classList.toggle('wide-layout', width >= 600); // Use the CSS-computed height within the padded parent. this.element.style.height = ''; this.searchInput.layout(); @@ -1576,8 +2042,11 @@ export class AICustomizationListWidget extends Disposable { const availableHeight = this.element.clientHeight || height; const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); + this.cardContainer.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; - this.list.layout(listHeight, width); + if (!this.usesCardLayout()) { + this.list.layout(listHeight, width); + } } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 32a428f5322..4fb9dce2d36 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -780,6 +780,7 @@ class AICustomizationManagementActionsContribution extends Disposable implements } const input = AICustomizationManagementEditorInput.getOrCreate(); + input.setTargetLabel(harnessService.getActiveDescriptor().label); const pane = await editorService.openEditor(input, { pinned: true }); if (section && pane instanceof AICustomizationManagementEditor) { pane.selectSectionById(section); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index ce37eea8834..db35b380a43 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -10,8 +10,9 @@ import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { RunOnceScheduler, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { getErrorMessage, onUnexpectedError } from '../../../../../base/common/errors.js'; import { DisposableStore, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Action } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; @@ -75,8 +76,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; -import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; -import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; +import { Checkbox, TriStateCheckbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { createTextBufferFactoryFromSnapshot } from '../../../../../editor/common/model/textModel.js'; @@ -86,18 +86,17 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; -import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; -import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultCheckboxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; -import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; import { IExtension } from '../../../extensions/common/extensions.js'; -import { EmbeddedMcpServerDetail } from './embeddedMcpServerDetail.js'; +import { EmbeddedMcpServerDetail, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js'; import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; @@ -335,14 +334,14 @@ export class AICustomizationManagementEditor extends EditorPane { private migrationListContainer: HTMLElement | undefined; private migrationListScrollable: DomScrollableElement | undefined; private migrationMigrateButton: Button | undefined; - private migrationSearchInput: InputBox | undefined; private migrationTitleElement: HTMLElement | undefined; private migrationDescriptionElement: HTMLElement | undefined; + private migrationDescriptionTextElement: HTMLElement | undefined; private migrationBannerContainer: HTMLElement | undefined; private migrationLinkElement: HTMLAnchorElement | undefined; - private migrationSearchQuery = ''; + private migrationSelectedCountElement: HTMLElement | undefined; + private migrationFirstFocusableElement: HTMLElement | undefined; private activeMigrationCategoryId: CustomizationMigrationCategoryId | undefined; - private readonly collapsedCustomizationMigrationGroups = new Set(); private selectedCustomizationMigrationItems = new ResourceMap>(); private readonly migrationPageDisposables = this._register(new DisposableStore()); @@ -373,6 +372,8 @@ export class AICustomizationManagementEditor extends EditorPane { private customizationsByMigrationCategory = new Map(); private customizationMigrationTargetFoldersByType = new Map(); private customizationMigrationRefreshSequence = 0; + private customizationMigrationLoading = false; + private customizationMigrationLoadError: string | undefined; private readonly editorDisposables = this._register(new DisposableStore()); private _editorContentChanged = false; @@ -406,7 +407,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, @IHoverService private readonly hoverService: IHoverService, - @IContextViewService private readonly contextViewService: IContextViewService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IModelService private readonly modelService: IModelService, @IQuickInputService private readonly quickInputService: IQuickInputService, @@ -544,8 +545,8 @@ export class AICustomizationManagementEditor extends EditorPane { }); } } - // Embedded MCP/plugin detail panes use a plain DOM widget that flows with - // the container; no explicit layout call is needed here. + // The MCP detail editor uses automatic layout; plugin details flow with + // their container, so neither requires an explicit layout call here. } }, }, Sizing.Distribute, undefined, true); @@ -571,8 +572,10 @@ export class AICustomizationManagementEditor extends EditorPane { private updateHarnessLabelPresentation(): void { const harnessLabel = this.getActiveHarnessLabel(); - AICustomizationManagementEditorInput.getOrCreate().setHarnessLabel(harnessLabel); this.welcomePage?.setHarnessLabel(harnessLabel); + if (this.input instanceof AICustomizationManagementEditorInput) { + this.input.setTargetLabel(harnessLabel); + } } /** @@ -860,40 +863,17 @@ export class AICustomizationManagementEditor extends EditorPane { const titleRow = DOM.append(header, $('.section-title-row')); this.migrationTitleElement = DOM.append(titleRow, $('h2.section-title')); this.migrationDescriptionElement = DOM.append(header, $('p.section-title-description')); - - this.migrationBannerContainer = DOM.append(this.migrationContentContainer, $('.customization-migration-banner')); - this.migrationBannerContainer.style.display = 'none'; - - const sectionLink = this.migrationLinkElement = DOM.append(this.migrationContentContainer, $('a.section-title-link.migration-learn-more-link')) as HTMLAnchorElement; + this.migrationDescriptionTextElement = DOM.append(this.migrationDescriptionElement, $('span.section-title-description-text')); + this.migrationDescriptionElement.appendChild(document.createTextNode(' ')); + const sectionLink = this.migrationLinkElement = DOM.append(this.migrationDescriptionElement, $('a.section-title-link')) as HTMLAnchorElement; + sectionLink.classList.add('migration-learn-more-link'); this.editorDisposables.add(DOM.addDisposableListener(sectionLink, 'click', e => { e.preventDefault(); this.openerService.open(URI.parse(sectionLink.href)); })); - const actions = DOM.append(this.migrationContentContainer, $('.list-search-and-button-container.prompt-migration-actions')); - const searchContainer = DOM.append(actions, $('.list-search-container')); - this.migrationSearchInput = this.editorDisposables.add(new InputBox(searchContainer, this.contextViewService, { - placeholder: localize('customizationMigrationSearchPlaceholder', "Type to search..."), - inputBoxStyles: defaultInputBoxStyles, - })); - this.editorDisposables.add(this.migrationSearchInput.onDidChange(() => { - this.migrationSearchQuery = this.migrationSearchInput?.value ?? ''; - this.renderCustomizationMigrationPage(); - })); - const actionButtonContainer = DOM.append(actions, $('.list-add-button-container')); - this.migrationMigrateButton = this.editorDisposables.add(new Button(actionButtonContainer, defaultButtonStyles)); - this.migrationMigrateButton.element.classList.add('list-add-button', 'prompt-migration-button'); - this.migrationMigrateButton.label = localize('customizationMigrationPageButton', "Migrate"); - this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this.migrationMigrateButton.element, () => this.getActiveMigrationCategory()?.migrateButtonTooltip ?? '')); - this.editorDisposables.add(this.migrationMigrateButton.onDidClick(() => { - const category = this.getActiveMigrationCategory(); - if (!category) { - return; - } - const selectedCustomizations = this.getMigrationCandidates(category) - .filter(customization => this.isCustomizationSelectedForMigration(customization)); - void this.migrateSelectedCustomizations(category, selectedCustomizations); - })); + this.migrationBannerContainer = DOM.append(this.migrationContentContainer, $('.customization-migration-banner')); + this.migrationBannerContainer.style.display = 'none'; this.migrationListContainer = $('.prompt-migration-list.list-container'); this.migrationListScrollable = this.editorDisposables.add(new DomScrollableElement(this.migrationListContainer, { @@ -911,6 +891,23 @@ export class AICustomizationManagementEditor extends EditorPane { targetWindow, )); this.editorDisposables.add(migrationResizeObserver.observe(migrationListScrollableNode)); + + const footer = DOM.append(this.migrationContentContainer, $('.prompt-migration-footer')); + this.migrationSelectedCountElement = DOM.append(footer, $('span.prompt-migration-selected-count')); + this.migrationSelectedCountElement.setAttribute('aria-live', 'polite'); + const actionButtonContainer = DOM.append(footer, $('.list-add-button-container')); + this.migrationMigrateButton = this.editorDisposables.add(new Button(actionButtonContainer, defaultButtonStyles)); + this.migrationMigrateButton.element.classList.add('list-add-button', 'prompt-migration-button'); + this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this.migrationMigrateButton.element, () => this.getActiveMigrationCategory()?.migrateButtonTooltip ?? '')); + this.editorDisposables.add(this.migrationMigrateButton.onDidClick(() => { + const category = this.getActiveMigrationCategory(); + if (!category) { + return; + } + const selectedCustomizations = this.getMigrationCandidates(category) + .filter(customization => this.isCustomizationSelectedForMigration(customization)); + void this.migrateSelectedCustomizations(category, selectedCustomizations); + })); this.renderCustomizationMigrationPage(); } @@ -983,16 +980,10 @@ export class AICustomizationManagementEditor extends EditorPane { if (hasSections.has(AICustomizationManagementSection.McpServers)) { this.mcpContentContainer = DOM.append(contentInner, $('.mcp-content-container')); this.mcpListWidget = this.editorDisposables.add(this.instantiationService.createInstance(McpListWidget)); - this.mcpListWidget.setCloseCustomizationEditor(async () => { - if (this.input) { - await this.group.closeEditor(this.input); - } - }); this.mcpContentContainer.appendChild(this.mcpListWidget.element); // Embedded MCP server detail view this.mcpDetailContainer = DOM.append(contentInner, $('.mcp-detail-container')); - this.createEmbeddedMcpDetail(); this.editorDisposables.add(this.mcpListWidget.onDidSelectServer(server => { this.showEmbeddedMcpDetail(server); @@ -1006,7 +997,7 @@ export class AICustomizationManagementEditor extends EditorPane { // Container for Plugins content if (hasSections.has(AICustomizationManagementSection.Plugins)) { this.pluginContentContainer = DOM.append(contentInner, $('.plugin-content-container')); - this.pluginListWidget = this.editorDisposables.add(this.instantiationService.createInstance(PluginListWidget)); + this.pluginListWidget = this.editorDisposables.add(this.instantiationService.createInstance(PluginListWidget, undefined)); this.pluginContentContainer.appendChild(this.pluginListWidget.element); // Embedded plugin detail view @@ -1110,8 +1101,12 @@ export class AICustomizationManagementEditor extends EditorPane { private async refreshCustomizationMigrationInfo(): Promise { const activeHarnessId = this.harnessService.activeHarness.get(); const refreshSequence = ++this.customizationMigrationRefreshSequence; + this.customizationMigrationLoading = true; + this.customizationMigrationLoadError = undefined; + this.renderCustomizationMigrationPage(); if (!isAgentHostTarget(activeHarnessId)) { + this.customizationMigrationLoading = false; this.setCustomizationsToMigrate(new Map(), new Map()); return; } @@ -1119,6 +1114,7 @@ export class AICustomizationManagementEditor extends EditorPane { try { const enabledCategories = this.getEnabledMigrationCategories(); if (enabledCategories.length === 0) { + this.customizationMigrationLoading = false; this.setCustomizationsToMigrate(new Map(), new Map()); return; } @@ -1150,10 +1146,13 @@ export class AICustomizationManagementEditor extends EditorPane { for (const [categoryId, candidates] of unfilteredCandidatesByCategory) { candidatesByCategory.set(categoryId, this.filterCustomizationMigrationCandidatesByTargetFolders(candidates, targetFoldersByType)); } + this.customizationMigrationLoading = false; this.setCustomizationsToMigrate(candidatesByCategory, targetFoldersByType); } catch (error) { if (refreshSequence === this.customizationMigrationRefreshSequence) { - this.setCustomizationsToMigrate(new Map(), new Map()); + this.customizationMigrationLoading = false; + this.customizationMigrationLoadError = getErrorMessage(error); + this.renderCustomizationMigrationPage(); } onUnexpectedError(error); } @@ -1362,11 +1361,31 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationPageDisposables.clear(); DOM.clearNode(this.migrationListContainer); + this.migrationFirstFocusableElement = undefined; const category = this.getActiveMigrationCategory() ?? CUSTOMIZATION_MIGRATION_CATEGORIES[0]; const candidates = this.getMigrationCandidates(category); this.updateCustomizationMigrationPageHeader(category, candidates); + if (this.customizationMigrationLoading) { + this.renderCustomizationMigrationState( + localize('customizationMigrationLoading', "Loading customizations..."), + localize('customizationMigrationLoadingDescription', "Checking the active harness and available destinations."), + ); + this.migrationMigrateButton.enabled = false; + return; + } + + if (this.customizationMigrationLoadError) { + this.renderCustomizationMigrationState( + localize('customizationMigrationLoadError', "Customizations could not be loaded"), + localize('customizationMigrationLoadErrorDescription', "Check the active agent connection, then try again."), + () => void this.refreshCustomizationMigrationInfo(), + ); + this.migrationMigrateButton.enabled = false; + return; + } + if (candidates.length === 0) { const emptyMessage = DOM.append(this.migrationListContainer, $('p.prompt-migration-empty')); emptyMessage.textContent = category.pageEmptyMessage; @@ -1375,23 +1394,6 @@ export class AICustomizationManagementEditor extends EditorPane { return; } - const query = this.migrationSearchQuery.trim().toLowerCase(); - const filteredCustomizations = candidates.filter(customization => { - if (!query) { - return true; - } - const displayName = (customization.name ?? basename(customization.uri)).toLowerCase(); - const relativePath = this.labelService.getUriLabel(customization.uri, { relative: true }).toLowerCase(); - return displayName.includes(query) || relativePath.includes(query); - }); - if (filteredCustomizations.length === 0) { - const emptyMessage = DOM.append(this.migrationListContainer, $('p.prompt-migration-empty')); - emptyMessage.textContent = category.searchEmptyMessage; - this.updateCustomizationMigrationActionState(); - this.migrationListScrollable?.scanDomNode(); - return; - } - const openCustomizationInEmbeddedEditor = (customization: IPromptPath): void => { const isWorkspaceFile = customization.storage === PromptsStorage.local; void this.showEmbeddedEditor( @@ -1407,6 +1409,7 @@ export class AICustomizationManagementEditor extends EditorPane { const checkboxTitle = localize('customizationMigrationSelectAriaLabel', "Select {0}", customization.name ?? basename(customization.uri)); const checkbox = this.migrationPageDisposables.add(new Checkbox(checkboxTitle, this.isCustomizationSelectedForMigration(customization), defaultCheckboxStyles)); checkboxContainer.replaceChildren(checkbox.domNode); + this.migrationFirstFocusableElement ??= checkbox.domNode; this.migrationPageDisposables.add(checkbox.onChange(() => { this.setCustomizationSelectedForMigration(customization, checkbox.checked); this.updateCustomizationMigrationActionState(); @@ -1438,83 +1441,97 @@ export class AICustomizationManagementEditor extends EditorPane { pathLabel.textContent = relativePath; const itemRight = DOM.append(row, $('span.item-right')); - const deleteButton = DOM.append(itemRight, $('button.icon-button', { + const moreButton = DOM.append(itemRight, $('button.icon-button.prompt-migration-more-action', { type: 'button', - 'aria-label': localize('deleteCustomizationFile', "Delete {0}", customization.name ?? basename(customization.uri)), + 'aria-label': localize('customizationMigrationMoreActions', "More actions for {0}", customization.name ?? basename(customization.uri)), })) as HTMLButtonElement; - deleteButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.trash)); - this.migrationPageDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), deleteButton, localize('deleteCustomizationFileTooltip', "Delete"))); - this.migrationPageDisposables.add(DOM.addDisposableListener(deleteButton, 'click', event => { + moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); + this.migrationPageDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), moreButton, localize('moreActions', "More Actions"))); + this.migrationPageDisposables.add(DOM.addDisposableListener(moreButton, 'click', event => { event.stopPropagation(); - void this.deleteCustomizationFile(customization); + const actions = new DisposableStore(); + const deleteAction = actions.add(new Action( + 'customizationMigration.delete', + localize('delete', "Delete"), + ThemeIcon.asClassName(Codicon.trash), + true, + () => this.deleteCustomizationFile(customization), + )); + this.contextMenuService.showContextMenu({ + getAnchor: () => moreButton, + getActions: () => [deleteAction], + onHide: () => actions.dispose(), + }); })); return checkbox; }; const renderGroup = (groupKey: string, groupLabel: string, customizations: readonly IPromptPath[]): void => { + const group = DOM.append(this.migrationListContainer!, $('.prompt-migration-group')); + const groupHeader = DOM.append(group, $('.prompt-migration-group-header')); + const groupHeading = DOM.append(groupHeader, $('.prompt-migration-group-heading')); + const label = DOM.append(groupHeading, $('h3.prompt-migration-group-title')); + label.textContent = groupLabel; if (customizations.length === 0) { + const count = DOM.append(groupHeading, $('span.prompt-migration-group-count')); + count.textContent = '0'; + const emptyItems = DOM.append(group, $('.prompt-migration-group-items')); + DOM.append(emptyItems, $('.plugin-inventory-empty.prompt-migration-group-empty')).textContent = localize( + 'customizationMigrationGroupEmpty', + "No customizations are available to migrate from {0}.", + groupLabel, + ); return; } - - const group = DOM.append(this.migrationListContainer!, $('.prompt-migration-group')); - const groupHeader = DOM.append(group, $('.ai-customization-group-header.prompt-migration-group-header')); - const groupCheckboxContainer = DOM.append(groupHeader, $('.item-sync-checkbox.prompt-migration-group-checkbox')); - const allInGroupSelected = customizations.every(customization => this.isCustomizationSelectedForMigration(customization)); + const selectedInGroup = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; + const initialGroupState: boolean | 'mixed' = selectedInGroup === customizations.length ? true : selectedInGroup === 0 ? false : 'mixed'; const groupCheckboxAriaLabel = localize('customizationMigrationSelectGroupAriaLabel', "Select all customizations in {0}", groupLabel); - const groupCheckbox = this.migrationPageDisposables.add(new Checkbox(groupCheckboxAriaLabel, allInGroupSelected, defaultCheckboxStyles)); + const groupCheckbox = this.migrationPageDisposables.add(new TriStateCheckbox(groupCheckboxAriaLabel, initialGroupState, defaultCheckboxStyles)); + const count = DOM.append(groupHeading, $('span.prompt-migration-group-count')); + count.textContent = String(customizations.length); + const groupControls = DOM.append(groupHeader, $('.prompt-migration-group-controls')); + const groupCheckboxContainer = DOM.append(groupControls, $('.item-sync-checkbox.prompt-migration-group-checkbox')); groupCheckboxContainer.replaceChildren(groupCheckbox.domNode); + this.migrationFirstFocusableElement ??= groupCheckbox.domNode; + const selectAllLabel = DOM.append(groupControls, $('span.prompt-migration-select-all-label')); + selectAllLabel.textContent = localize('customizationMigrationSelectAll', "Select all"); + const setGroupCheckboxState = (state: boolean | 'mixed'): void => { + groupCheckbox.checked = state; + groupCheckbox.domNode.setAttribute('aria-checked', String(state)); + }; + setGroupCheckboxState(initialGroupState); const itemCheckboxes: Checkbox[] = []; - this.migrationPageDisposables.add(groupCheckbox.onChange(() => { + const setGroupSelection = (selected: boolean): void => { for (const customization of customizations) { - this.setCustomizationSelectedForMigration(customization, groupCheckbox.checked); + this.setCustomizationSelectedForMigration(customization, selected); } for (const itemCheckbox of itemCheckboxes) { - itemCheckbox.checked = groupCheckbox.checked; + itemCheckbox.checked = selected; } this.updateCustomizationMigrationActionState(); + }; + this.migrationPageDisposables.add(groupCheckbox.onChange(() => setGroupSelection(groupCheckbox.checked === true))); + this.migrationPageDisposables.add(DOM.addDisposableListener(selectAllLabel, 'click', e => { + DOM.EventHelper.stop(e, true); + const selected = groupCheckbox.checked !== true; + setGroupCheckboxState(selected); + setGroupSelection(selected); + groupCheckbox.focus(); })); const updateGroupCheckboxState = (): void => { - groupCheckbox.checked = customizations.every(customization => this.isCustomizationSelectedForMigration(customization)); + const selectedCount = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; + setGroupCheckboxState(selectedCount === customizations.length ? true : selectedCount === 0 ? false : 'mixed'); }; - const groupToggle = DOM.append(groupHeader, $('button.prompt-migration-group-toggle')) as HTMLButtonElement; - groupToggle.type = 'button'; const groupId = `prompt-migration-group-${category.id}-${groupKey}`; - const collapsed = this.collapsedCustomizationMigrationGroups.has(groupId); - groupToggle.setAttribute('aria-controls', `${groupId}-items`); - groupToggle.setAttribute('aria-expanded', String(!collapsed)); - const chevron = DOM.append(groupToggle, $('span.group-chevron')); - chevron.setAttribute('aria-hidden', 'true'); - const groupLabelGroup = DOM.append(groupToggle, $('.group-label-group')); - const label = DOM.append(groupLabelGroup, $('span.group-label')); - label.textContent = groupLabel; - const count = DOM.append(groupToggle, $('span.group-count')); - count.textContent = String(customizations.length); const groupItems = DOM.append(group, $('.prompt-migration-group-items')); groupItems.id = `${groupId}-items`; - const setGroupCollapsed = (collapsed: boolean): void => { - groupItems.style.display = collapsed ? 'none' : ''; - chevron.className = 'group-chevron'; - chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRightCompact : Codicon.chevronDownCompact)); - groupToggle.setAttribute('aria-expanded', String(!collapsed)); - this.migrationListScrollable?.scanDomNode(); - }; - setGroupCollapsed(collapsed); - this.migrationPageDisposables.add(DOM.addDisposableListener(groupToggle, 'click', () => { - if (this.collapsedCustomizationMigrationGroups.has(groupId)) { - this.collapsedCustomizationMigrationGroups.delete(groupId); - setGroupCollapsed(false); - } else { - this.collapsedCustomizationMigrationGroups.add(groupId); - setGroupCollapsed(true); - } - })); for (const customization of customizations) { itemCheckboxes.push(renderItem(groupItems, customization, updateGroupCheckboxState)); } }; - const groups = category.group(filteredCustomizations); + const groups = category.group(candidates); const groupedUris = new ResourceSet(); for (const group of groups) { for (const customization of group.customizations) { @@ -1523,7 +1540,7 @@ export class AICustomizationManagementEditor extends EditorPane { renderGroup(group.key, group.label, group.customizations); } - for (const customization of filteredCustomizations.filter(item => !groupedUris.has(item.uri))) { + for (const customization of candidates.filter(item => !groupedUris.has(item.uri))) { renderItem(this.migrationListContainer, customization); } @@ -1531,6 +1548,19 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationListScrollable?.scanDomNode(); } + private renderCustomizationMigrationState(title: string, description: string, retry?: () => void): void { + const state = DOM.append(this.migrationListContainer!, $('.plugin-inventory-empty.prompt-migration-state')); + DOM.append(state, $('strong.prompt-migration-state-title')).textContent = title; + DOM.append(state, $('span.prompt-migration-state-description')).textContent = description; + if (retry) { + const retryButton = this.migrationPageDisposables.add(new Button(state, { ...defaultButtonStyles, secondary: true, ariaLabel: localize('retryCustomizationMigration', "Retry loading customizations") })); + retryButton.label = localize('retry', "Retry"); + this.migrationFirstFocusableElement ??= retryButton.element; + this.migrationPageDisposables.add(retryButton.onDidClick(retry)); + } + this.migrationListScrollable?.scanDomNode(); + } + private updateCustomizationMigrationPageHeader(category: ICustomizationMigrationCategory, candidates: readonly IPromptPath[]): void { if (this.migrationTitleElement) { this.migrationTitleElement.textContent = category.pageTitle; @@ -1551,7 +1581,12 @@ export class AICustomizationManagementEditor extends EditorPane { : undefined; this.renderCustomizationMigrationBanner(banner); if (this.migrationDescriptionElement) { - this.migrationDescriptionElement.textContent = banner ? '' : category.getPageDescription(candidates, this.getActiveHarnessLabel()); + const description = banner ? '' : category.getPageDescription(candidates, this.getActiveHarnessLabel()); + if (this.migrationDescriptionTextElement) { + this.migrationDescriptionTextElement.textContent = description; + } else { + this.migrationDescriptionElement.textContent = description; + } this.migrationDescriptionElement.style.display = banner ? 'none' : ''; } @@ -1569,24 +1604,22 @@ export class AICustomizationManagementEditor extends EditorPane { DOM.clearNode(container); if (!banner) { + if (this.migrationLinkElement && this.migrationDescriptionElement) { + this.migrationDescriptionElement.appendChild(this.migrationLinkElement); + } container.style.display = 'none'; return; } container.style.display = ''; - const icon = DOM.append(container, $('span.customization-migration-banner-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.warning)); - icon.setAttribute('aria-hidden', 'true'); - const content = DOM.append(container, $('.customization-migration-banner-content')); - DOM.append(content, $('h3.customization-migration-banner-title')).textContent = banner.title; DOM.append(content, $('p.customization-migration-banner-message')).textContent = banner.message; - - const consequence = DOM.append(content, $('p.customization-migration-banner-consequence')); - const consequenceIcon = DOM.append(consequence, $('span.customization-migration-banner-consequence-icon')); - consequenceIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.sync)); - consequenceIcon.setAttribute('aria-hidden', 'true'); - DOM.append(consequence, $('span')).textContent = banner.consequence; + if (banner.consequence) { + DOM.append(content, $('p.customization-migration-banner-consequence')).textContent = banner.consequence; + } + if (this.migrationLinkElement) { + content.appendChild(this.migrationLinkElement); + } } private updateCustomizationMigrationActionState(): void { @@ -1596,9 +1629,20 @@ export class AICustomizationManagementEditor extends EditorPane { const category = this.getActiveMigrationCategory() ?? CUSTOMIZATION_MIGRATION_CATEGORIES[0]; const selectedCount = this.getMigrationCandidates(category).filter(customization => this.isCustomizationSelectedForMigration(customization)).length; this.migrationMigrateButton.enabled = selectedCount > 0; - this.migrationMigrateButton.label = selectedCount > 0 - ? localize('customizationMigrationPageButtonWithCount', "Migrate ({0})", selectedCount) - : localize('customizationMigrationPageButton', "Migrate"); + if (this.migrationSelectedCountElement) { + this.migrationSelectedCountElement.textContent = selectedCount === 1 + ? localize('customizationMigrationOneSelected', "1 selected") + : localize('customizationMigrationSelectedCount', "{0} selected", selectedCount); + } + if (category.id === CustomizationMigrationCategoryId.PromptFiles) { + this.migrationMigrateButton.label = selectedCount > 0 + ? localize('customizationMigrationConvertWithCount', "Convert {0} to Skills", selectedCount) + : localize('customizationMigrationConvert', "Convert to Skills"); + } else { + this.migrationMigrateButton.label = selectedCount > 0 + ? localize('customizationMigrationPageButtonWithCount', "Migrate {0}", selectedCount) + : localize('customizationMigrationPageButton', "Migrate"); + } } private async deleteCustomizationFile(customization: IPromptPath): Promise { @@ -2000,12 +2044,14 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.mcpContentContainer) { this.mcpContentContainer.style.display = !isEditorMode && !isMigrationMode && !isDetailMode && isMcpSection ? '' : 'none'; } + this.mcpListWidget?.setVisible(!isEditorMode && !isMigrationMode && !isDetailMode && isMcpSection); if (this.mcpDetailContainer) { this.mcpDetailContainer.style.display = isMcpDetailMode ? '' : 'none'; } if (this.pluginContentContainer) { this.pluginContentContainer.style.display = !isEditorMode && !isMigrationMode && !isDetailMode && isPluginsSection ? '' : 'none'; } + this.pluginListWidget?.setVisible(!isEditorMode && !isMigrationMode && !isDetailMode && isPluginsSection); if (this.pluginDetailContainer) { this.pluginDetailContainer.style.display = isPluginDetailMode ? '' : 'none'; } @@ -2104,22 +2150,25 @@ export class AICustomizationManagementEditor extends EditorPane { } if (type === PromptsType.hook) { + const preferredStorage = target === 'user' ? PromptsStorage.user : PromptsStorage.local; if (this.workspaceService.isSessionsWindow) { // Sessions: show hooks filtered to Copilot CLI (GitHub Copilot) hook types await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { openEditor: async (resource) => { - await this.showEmbeddedEditor(resource, basename(resource), PromptsType.hook, PromptsStorage.local, true); + await this.showEmbeddedEditor(resource, basename(resource), PromptsType.hook, preferredStorage, preferredStorage === PromptsStorage.local); return; }, target: Target.GitHubCopilot, + preferredStorage, }); } else { // Core: use the default core behaviour await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { openEditor: async (resource) => { - await this.showEmbeddedEditor(resource, basename(resource), PromptsType.hook, PromptsStorage.local, true); + await this.showEmbeddedEditor(resource, basename(resource), PromptsType.hook, preferredStorage, preferredStorage === PromptsStorage.local); return; - } + }, + preferredStorage, }); } return; @@ -2191,6 +2240,7 @@ export class AICustomizationManagementEditor extends EditorPane { }); await super.setInput(input, options, context, token); + input.setTargetLabel(this.getActiveHarnessLabel()); if (this.dimension) { this.layout(this.dimension); @@ -2243,7 +2293,6 @@ export class AICustomizationManagementEditor extends EditorPane { for (const widget of this.contributedSectionWidgets.values()) { widget.layout?.(dimension); } - this.migrationSearchInput?.layout(); this.migrationListScrollable?.scanDomNode(); } @@ -2258,7 +2307,7 @@ export class AICustomizationManagementEditor extends EditorPane { return; } if (this.viewMode === 'migration') { - this.migrationSearchInput?.focus(); + this.focusCustomizationMigrationPage(); return; } if (this.selectedSection === undefined) { @@ -2347,13 +2396,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.goBackFromToolDetail(); } - if (this.activeMigrationCategoryId !== categoryId) { - this.activeMigrationCategoryId = categoryId; - this.migrationSearchQuery = ''; - if (this.migrationSearchInput) { - this.migrationSearchInput.value = ''; - } - } + this.activeMigrationCategoryId = categoryId; this.selectedSection = undefined; this.sectionContextKey.set(''); this.viewMode = 'migration'; @@ -2363,6 +2406,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.dimension) { this.layout(this.dimension); } + if (this.migrationContentContainer) { + DOM.getWindow(this.migrationContentContainer).requestAnimationFrame(() => this.focusCustomizationMigrationPage()); + } } /** @@ -2632,7 +2678,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.layout(this.dimension); } if (returnViewMode === 'migration') { - this.migrationSearchInput?.focus(); + this.focusCustomizationMigrationPage(); } else { this.listWidget?.focusSearch(); } @@ -2646,6 +2692,10 @@ export class AICustomizationManagementEditor extends EditorPane { } } + private focusCustomizationMigrationPage(): void { + (this.migrationFirstFocusableElement ?? this.migrationLinkElement ?? this.migrationMigrateButton?.element)?.focus(); + } + //#endregion private async getOrCreateBuiltinEditingSession(uri: URI): Promise<{ model: ITextModel; originalContent: string }> { @@ -3199,7 +3249,6 @@ export class AICustomizationManagementEditor extends EditorPane { return; } - // Container for the compact MCP detail component const detailBody = DOM.append(this.mcpDetailContainer, $('.mcp-detail-editor-container')); this.embeddedMcpDetail = this.editorDisposables.add(this.instantiationService.createInstance(EmbeddedMcpServerDetail, detailBody)); @@ -3209,14 +3258,17 @@ export class AICustomizationManagementEditor extends EditorPane { backButton.setAttribute('type', 'button'); backButton.setAttribute('aria-label', localize('backToMcpList', "Back to MCP servers")); this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), backButton, localize('backToMcpListTooltip', "Back to MCP servers"))); - const backIconEl = DOM.append(backButton, $(`.codicon.codicon-${Codicon.arrowLeft.id}`)); + const backIconEl = DOM.append(backButton, $(`.codicon.codicon-${Codicon.arrowLeft.id}.editor-action-button-icon`)); backIconEl.setAttribute('aria-hidden', 'true'); this.editorDisposables.add(DOM.addDisposableListener(backButton, 'click', () => { this.goBackFromMcpDetail(); })); } - private async showEmbeddedMcpDetail(server: IWorkbenchMcpServer): Promise { + private async showEmbeddedMcpDetail(server: IMcpServerDetailInput): Promise { + if (!this.embeddedMcpDetail) { + this.createEmbeddedMcpDetail(); + } if (!this.embeddedMcpDetail) { return; } @@ -3230,6 +3282,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.dimension) { this.layout(this.dimension); } + this.embeddedMcpDetail.focus(); } private goBackFromMcpDetail(): void { @@ -3257,6 +3310,18 @@ export class AICustomizationManagementEditor extends EditorPane { const detailBody = DOM.append(this.pluginDetailContainer, $('.plugin-detail-editor-container')); this.embeddedPluginDetail = this.editorDisposables.add(this.instantiationService.createInstance(EmbeddedAgentPluginDetail, detailBody)); + this.editorDisposables.add(this.embeddedPluginDetail.onDidRequestOpenSkill(uri => { + this.openSkillFromPluginDetail(uri); + })); + this.editorDisposables.add(this.embeddedPluginDetail.onDidRequestOpenAgent(uri => { + this.openPromptsItemFromPluginDetail(AICustomizationManagementSection.Agents, uri); + })); + this.editorDisposables.add(this.embeddedPluginDetail.onDidRequestOpenSection(section => { + this.openSectionFromPluginDetail(section); + })); + this.editorDisposables.add(this.embeddedPluginDetail.onDidUninstall(() => { + this.goBackFromPluginDetail(); + })); // Back button rendered into the detail's leading slot const backButton = DOM.append(this.embeddedPluginDetail.leadingSlot, $('button.editor-back-button')); @@ -3286,6 +3351,46 @@ export class AICustomizationManagementEditor extends EditorPane { } } + private async openSkillFromPluginDetail(uri: URI): Promise { + await this.openPromptsItemFromPluginDetail(AICustomizationManagementSection.Skills, uri); + } + + private async openPromptsItemFromPluginDetail(section: AICustomizationManagementSection, uri: URI): Promise { + this.pluginDetailDisposables.clear(); + this.embeddedPluginDetail?.clearInput(); + this.pluginDetailReturnSection = undefined; + this.viewMode = 'list'; + this.selectedSection = section; + this.sectionContextKey.set(section); + this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, section, StorageScope.PROFILE, StorageTarget.USER); + this.updateContentVisibility(); + await this.listWidget.setSection(section); + const modelSection = ITEMS_MODEL_SECTIONS.find(s => s === section); + if (modelSection) { + await this.itemsModel.whenSectionLoaded(modelSection); + const item = this.itemsModel.getItems(modelSection).get().find(item => isEqual(item.uri, uri)); + if (item) { + const source = item.source; + const isWorkspaceFile = source === AICustomizationSources.local; + const isReadOnly = !source || source === AICustomizationSources.extension || source === AICustomizationSources.plugin || source === AICustomizationSources.builtin; + this.showEmbeddedEditor(item.uri, item.name, item.promptType, source ?? AICustomizationSources.builtin, isWorkspaceFile, isReadOnly); + } + } + this.ensureSectionsListReflectsActiveSection(section); + if (this.dimension) { + this.layout(this.dimension); + } + } + + private openSectionFromPluginDetail(section: AICustomizationManagementSection): void { + this.pluginDetailDisposables.clear(); + this.embeddedPluginDetail?.clearInput(); + this.pluginDetailReturnSection = undefined; + this.viewMode = 'list'; + this.updateContentVisibility(); + this.selectSectionById(section); + } + /** * Public method to show a plugin detail from any section (e.g. from "Show Plugin" context menu). * Saves the current section so the back button returns the user to it. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts index 169b80a938f..56800039fe1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts @@ -21,9 +21,9 @@ export class AICustomizationManagementEditorInput extends EditorInput implements readonly resource = undefined; - private static _activeHarnessLabel = ''; private _isDirty = false; private _saveHandler?: () => Promise; + private _targetLabel: string | undefined; override get capabilities(): EditorInputCapabilities { return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.RequiresModal; @@ -36,7 +36,6 @@ export class AICustomizationManagementEditorInput extends EditorInput implements */ static getOrCreate(): AICustomizationManagementEditorInput { if (!AICustomizationManagementEditorInput._instance || AICustomizationManagementEditorInput._instance.isDisposed()) { - AICustomizationManagementEditorInput._activeHarnessLabel = ''; AICustomizationManagementEditorInput._instance = new AICustomizationManagementEditorInput(); } return AICustomizationManagementEditorInput._instance; @@ -55,10 +54,10 @@ export class AICustomizationManagementEditorInput extends EditorInput implements } override getName(): string { - const harnessLabel = AICustomizationManagementEditorInput._activeHarnessLabel; - return harnessLabel - ? localize('aiCustomizationManagementEditorNameWithHarness', "Agent Customizations for {0}", harnessLabel) - : localize('aiCustomizationManagementEditorName', "Agent Customizations"); + if (this._targetLabel) { + return localize('aiCustomizationManagementEditorNameWithTarget', "Agent Customizations - {0}", this._targetLabel); + } + return localize('aiCustomizationManagementEditorName', "Agent Customizations"); } override getIcon(): ThemeIcon { @@ -92,14 +91,6 @@ export class AICustomizationManagementEditorInput extends EditorInput implements this.setDirty(false); } - setHarnessLabel(label: string): void { - if (AICustomizationManagementEditorInput._activeHarnessLabel === label) { - return; - } - AICustomizationManagementEditorInput._activeHarnessLabel = label; - this._onDidChangeLabel.fire(); - } - setDirty(dirty: boolean): void { if (this._isDirty !== dirty) { this._isDirty = dirty; @@ -110,4 +101,12 @@ export class AICustomizationManagementEditorInput extends EditorInput implements setSaveHandler(handler: (() => Promise) | undefined): void { this._saveHandler = handler; } + + setTargetLabel(label: string | undefined): void { + if (this._targetLabel === label) { + return; + } + this._targetLabel = label; + this._onDidChangeLabel.fire(); + } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationPresentation.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationPresentation.ts new file mode 100644 index 00000000000..efa30c5d1ab --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationPresentation.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { ContributionEnablementState } from '../../common/enablement.js'; +import { IAgentPlugin } from '../../common/plugins/agentPluginService.js'; + +export function getPluginInclusionLabel(plugin: IAgentPlugin): string { + if (plugin.policyBlocked?.get() === true) { + return localize('pluginBlockedByOrganization', "Blocked by Organization"); + } + + switch (plugin.enablement.get()) { + case ContributionEnablementState.EnabledWorkspace: + return localize('pluginIncludedInWorkspace', "Included in Workspace"); + case ContributionEnablementState.DisabledWorkspace: + return localize('pluginExcludedFromWorkspace', "Excluded from Workspace"); + case ContributionEnablementState.EnabledProfile: + return localize('pluginIncludedForProfile', "Included for Profile"); + case ContributionEnablementState.DisabledProfile: + return localize('pluginExcludedFromProfile', "Excluded from Profile"); + default: + return localize('pluginUnknownInclusion', "Inclusion Unknown"); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts index e763cab9108..48fcb33f6bd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.ts @@ -60,47 +60,47 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem id: AICustomizationManagementSection.Agents, label: localize('agents', "Agents"), icon: agentIcon, - description: localize('agentsDesc', "Define custom agents with specialized personas, tool access, and instructions for specific tasks."), + description: localize('agentsDesc', "Create specialized agents for focused development tasks. Control their instructions, tools, and behavior."), promptType: PromptsType.agent, }, { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon, - description: localize('skillsDesc', "Create reusable skill files that provide domain-specific knowledge and workflows."), + description: localize('skillsDesc', "Add reusable knowledge and workflows for specialized tasks. Agents load relevant skills when needed."), promptType: PromptsType.skill, }, { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon, - description: localize('instructionsDesc', "Set always-on instructions that guide AI behavior across your workspace or user profile."), + description: localize('instructionsDesc', "Define guidance that shapes how agents work. Apply it across a workspace or keep it in your user profile."), promptType: PromptsType.instructions, }, { id: AICustomizationManagementSection.Hooks, label: localize('hooks', "Hooks"), icon: hookIcon, - description: localize('hooksDesc', "Configure automated actions triggered by events like saving files or running tasks."), + description: localize('hooksDesc', "Run automated commands at key points in the agent lifecycle. Use hooks to validate, format, or coordinate work."), promptType: PromptsType.hook, }, { id: AICustomizationManagementSection.McpServers, label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, - description: localize('mcpServersDesc', "Connect external tool servers that extend AI capabilities with custom tools and data sources."), + description: localize('mcpServersDesc', "Connect agents to external tools and data through MCP servers. Manage the servers available to your agent."), }, { id: AICustomizationManagementSection.Plugins, label: localize('plugins', "Plugins"), icon: pluginIcon, - description: localize('pluginsDesc', "Install and manage agent plugins that add additional tools, skills, and integrations."), + description: localize('pluginsDesc', "Install reusable packages that extend the agent. Plugins can add tools, skills, agents, hooks, and MCP servers."), }, { id: AICustomizationManagementSection.Tools, label: localize('tools', "Tools"), icon: toolsIcon, - description: localize('toolsDesc', "Enable or disable the tools available to chat."), + description: localize('toolsDesc', "Review the tools available to the active agent. Enable or disable configurable tool groups."), }, ]; @@ -208,6 +208,8 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem this.sentLabel.remove(); } this.sentLabel = DOM.append(inputRow, $('span.welcome-prompts-sent-label')); + this.sentLabel.setAttribute('role', 'status'); + this.sentLabel.setAttribute('aria-live', 'polite'); this.sentLabel.textContent = localize('sentToChat', "Sent to chat \u2713"); this.callbacks.prefillChat(query, { isPartialQuery: false, newChat: true }); @@ -258,14 +260,31 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem DOM.clearNode(this.cardsContainer); this.firstCard = undefined; - for (const category of this.categoryDescriptions) { - if (!visibleSectionIds.has(category.id)) { + if (this.migrationCategories.length > 0) { + const migrationGrid = this.renderOverviewSection( + localize('overviewNeedsAttention', "Needs Attention"), + localize('overviewNeedsAttentionDescription', "Review customizations that need an update for the active agent."), + 'welcome-prompts-attention-section', + ); + for (const category of this.migrationCategories) { + this.renderCustomizationMigrationCard(migrationGrid, category); + } + } + + const exploreGrid = this.renderOverviewSection( + localize('overviewExploreCustomizations', "Explore Customizations"), + localize('overviewExploreCustomizationsDescription', "Manage what the active agent knows and can do."), + 'welcome-prompts-explore-section', + ); + for (const section of this.workspaceService.managementSections) { + const category = this.categoryDescriptions.find(candidate => candidate.id === section); + if (!category || !visibleSectionIds.has(category.id)) { continue; } - const card = DOM.append(this.cardsContainer, $('.welcome-prompts-card')); - card.setAttribute('tabindex', '0'); - card.setAttribute('role', 'button'); + const card = DOM.append(exploreGrid, $('button.welcome-prompts-card.welcome-prompts-navigation-card')) as HTMLButtonElement; + card.type = 'button'; + card.setAttribute('aria-label', localize('openCustomizationCategory', "Open {0}", category.label)); if (!this.firstCard) { this.firstCard = card; } @@ -279,64 +298,40 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem const descEl = DOM.append(card, $('p.welcome-prompts-card-description')); descEl.textContent = category.description; - const footer = DOM.append(card, $('.welcome-prompts-card-footer')); - if (category.promptType) { - const generateBtn = DOM.append(footer, $('button.welcome-prompts-card-action')); - generateBtn.textContent = localize('new', "New..."); - generateBtn.setAttribute('aria-label', localize('newCategoryAriaLabel', "New {0}...", category.label)); - this.cardDisposables.add(DOM.addDisposableListener(generateBtn, 'click', e => { - e.stopPropagation(); - this.callbacks.closeEditor(); - if (this.workspaceService.isSessionsWindow) { - const typeLabel = category.label.toLowerCase().replace(/s$/, ''); - this.callbacks.prefillChat(`Create me a custom ${typeLabel} that `, { isPartialQuery: true, newChat: true }); - } else { - this.workspaceService.generateCustomization(category.promptType!); - } - })); - } else { - const browseBtn = DOM.append(footer, $('button.welcome-prompts-card-action')); - browseBtn.textContent = localize('browse', "Browse..."); - browseBtn.setAttribute('aria-label', localize('browseCategoryAriaLabel', "Browse {0}...", category.label)); - this.cardDisposables.add(DOM.addDisposableListener(browseBtn, 'click', e => { - e.stopPropagation(); - this.callbacks.selectSectionWithMarketplace(category.id); - })); - } - this.cardDisposables.add(DOM.addDisposableListener(card, 'click', () => { this.callbacks.selectSection(category.id); })); - this.cardDisposables.add(DOM.addDisposableListener(card, 'keydown', e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this.callbacks.selectSection(category.id); - } - })); } if (!this.workspaceService.isSessionsWindow) { + const otherGrid = this.renderOverviewSection( + localize('overviewOtherCustomizations', "Other Customizations"), + localize('overviewOtherCustomizationsDescription', "Configure specialized voice and dictation behavior."), + 'welcome-prompts-other-section', + ); for (const customization of this.standaloneCustomizations) { - this.renderStandaloneCustomization(customization); + this.renderStandaloneCustomization(otherGrid, customization); } } - for (const category of this.migrationCategories) { - this.renderCustomizationMigrationCard(category); - } - // Content changed — recompute scroll dimensions. this.scrollable.scanDomNode(); } - private renderStandaloneCustomization(customization: IStandaloneCustomizationDescription): void { - if (!this.cardsContainer) { - return; - } + private renderOverviewSection(title: string, description: string, className: string): HTMLElement { + const section = DOM.append(this.cardsContainer!, $('.welcome-prompts-overview-section')); + section.classList.add(className); + const heading = DOM.append(section, $('h3.welcome-prompts-overview-section-title')); + heading.textContent = title; + const descriptionElement = DOM.append(section, $('p.welcome-prompts-overview-section-description')); + descriptionElement.textContent = description; + return DOM.append(section, $('.welcome-prompts-overview-grid')); + } - const card = DOM.append(this.cardsContainer, $('.welcome-prompts-card')); - card.setAttribute('tabindex', '0'); - card.setAttribute('role', 'button'); + private renderStandaloneCustomization(parent: HTMLElement, customization: IStandaloneCustomizationDescription): void { + const card = DOM.append(parent, $('button.welcome-prompts-card.welcome-prompts-navigation-card')) as HTMLButtonElement; + card.type = 'button'; + card.setAttribute('aria-label', localize('configureCategoryAriaLabel', "Configure {0}", customization.label)); if (!this.firstCard) { this.firstCard = card; } @@ -350,25 +345,10 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem const descEl = DOM.append(card, $('p.welcome-prompts-card-description')); descEl.textContent = customization.description; - const footer = DOM.append(card, $('.welcome-prompts-card-footer')); - const configureButton = DOM.append(footer, $('button.welcome-prompts-card-action')); - configureButton.textContent = localize('configure', "Configure..."); - configureButton.setAttribute('aria-label', localize('configureCategoryAriaLabel', "Configure {0}...", customization.label)); - const configure = () => { void this.commandService.executeCommand(customization.commandId); }; - this.cardDisposables.add(DOM.addDisposableListener(configureButton, 'click', e => { - e.stopPropagation(); - configure(); - })); this.cardDisposables.add(DOM.addDisposableListener(card, 'click', configure)); - this.cardDisposables.add(DOM.addDisposableListener(card, 'keydown', e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - configure(); - } - })); } setMigrationCategories(categories: readonly ICustomizationMigrationCategorySummary[]): void { @@ -395,16 +375,14 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem private updateHeading(): void { if (this.heading) { - this.heading.textContent = localize('welcomeHeadingWithHarness', "Agent Customizations for {0}", this.harnessLabel); + this.heading.textContent = localize('welcomeHeading', "Agent Customizations"); } } - private renderCustomizationMigrationCard(category: ICustomizationMigrationCategorySummary): void { - if (!this.cardsContainer) { - return; - } - - const migrationCard = DOM.append(this.cardsContainer, $('.welcome-prompts-card.welcome-prompts-migration-card')); + private renderCustomizationMigrationCard(parent: HTMLElement, category: ICustomizationMigrationCategorySummary): void { + const migrationCard = DOM.append(parent, $('button.welcome-prompts-card.welcome-prompts-migration-card')) as HTMLButtonElement; + migrationCard.type = 'button'; + migrationCard.setAttribute('aria-label', category.actionAriaLabel); const cardHeader = DOM.append(migrationCard, $('.welcome-prompts-card-header')); const iconEl = DOM.append(cardHeader, $('.welcome-prompts-card-icon')); @@ -415,14 +393,12 @@ export class PromptLaunchersAICustomizationWelcomePage extends Disposable implem const descEl = DOM.append(migrationCard, $('p.welcome-prompts-card-description')); descEl.textContent = category.description; - const footer = DOM.append(migrationCard, $('.welcome-prompts-card-footer')); - const migrateBtn = DOM.append(footer, $('button.welcome-prompts-card-action')); - migrateBtn.textContent = category.actionLabel; - migrateBtn.setAttribute('aria-label', category.actionAriaLabel); if (!this.firstCard) { - this.firstCard = migrateBtn; + this.firstCard = migrationCard; } - this.cardDisposables.add(DOM.addDisposableListener(migrateBtn, 'click', () => this.callbacks.migrateCustomizations(category.id))); + const actionLabel = DOM.append(migrationCard, $('span.welcome-prompts-card-action-label')); + actionLabel.textContent = category.actionLabel; + this.cardDisposables.add(DOM.addDisposableListener(migrationCard, 'click', () => this.callbacks.migrateCustomizations(category.id))); } focus(): void { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 134e3a370d1..b9fbbd9bef5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -47,14 +47,14 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic } readonly managementSections: readonly AICustomizationManagementSection[] = [ - AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.McpServers, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Instructions, - AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, AICustomizationManagementSection.Tools, + AICustomizationManagementSection.Prompts, AICustomizationManagementSection.HarnessSettings, ]; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts new file mode 100644 index 00000000000..7cddeff5b59 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts @@ -0,0 +1,200 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; + +const $ = DOM.$; + +export interface ICustomizationCardListItem { + readonly row: HTMLElement; + readonly primaryAction: HTMLElement; + readonly label: string; + readonly actions?: readonly HTMLElement[]; + readonly contextMenuAction?: HTMLElement; +} + +interface ICardListItem extends ICustomizationCardListItem { + readonly actions: readonly HTMLElement[]; + actionsTabbable: boolean; +} + +export function createCustomizationCardPrimaryAction(parent: HTMLElement, ariaLabel: string, ...classNames: string[]): HTMLButtonElement { + const button = DOM.append(parent, $('button.customization-card-primary-action')) as HTMLButtonElement; + button.type = 'button'; + button.setAttribute('aria-label', ariaLabel); + button.classList.add(...classNames); + return button; +} + +export class CustomizationCardListController extends Disposable { + + private readonly items: ICardListItem[] = []; + private readonly typeAheadReset = this._register(new MutableDisposable()); + private typeAhead = ''; + + constructor( + container: HTMLElement, + ariaLabel: string, + ) { + super(); + container.setAttribute('role', 'list'); + container.setAttribute('aria-label', ariaLabel); + } + + addItem(item: ICustomizationCardListItem): void { + const entry: ICardListItem = { + ...item, + actions: item.actions ?? [], + actionsTabbable: false, + }; + this.items.push(entry); + entry.row.setAttribute('role', 'listitem'); + entry.row.setAttribute('aria-posinset', String(this.items.length)); + entry.primaryAction.tabIndex = this.items.length === 1 ? 0 : -1; + this.setActionsTabbable(entry, false); + + this._register(DOM.addDisposableListener(entry.primaryAction, 'focus', () => this.setActiveItem(entry))); + this._register(DOM.addDisposableListener(entry.primaryAction, 'keydown', event => this.onPrimaryActionKeyDown(entry, event))); + for (const action of entry.actions) { + const mutationDisposables = this._register(new DisposableStore()); + this._register(DOM.sharedMutationObserver.observe(action, mutationDisposables, { + attributes: true, + attributeFilter: ['aria-disabled', 'disabled', 'style', 'tabindex'], + })(() => this.updateActionTabIndex(entry, action))); + this._register(DOM.addDisposableListener(action, 'focus', () => { + this.setActiveItem(entry); + this.setActionsTabbable(entry, true); + })); + this._register(DOM.addDisposableListener(action, 'keydown', event => { + const focusableActions = this.getFocusableActions(entry); + if (event.key === 'Tab' && event.shiftKey && action === focusableActions[0]) { + event.preventDefault(); + this.setActionsTabbable(entry, false); + entry.primaryAction.focus(); + } else if (event.key === 'Tab' && !event.shiftKey && action === focusableActions.at(-1)) { + this.setActionsTabbable(entry, false); + } + })); + this._register(DOM.addDisposableListener(action, 'blur', () => { + DOM.getWindow(entry.row).queueMicrotask(() => { + if (!entry.row.contains(entry.row.ownerDocument.activeElement)) { + this.setActionsTabbable(entry, false); + } + }); + })); + } + + } + + finalize(): void { + const setSize = String(this.items.length); + for (const item of this.items) { + item.row.setAttribute('aria-setsize', setSize); + } + } + + private onPrimaryActionKeyDown(entry: ICardListItem, event: KeyboardEvent): void { + if (event.target !== entry.primaryAction) { + return; + } + const index = this.items.indexOf(entry); + switch (event.key) { + case 'ArrowDown': + this.focusItem(Math.min(index + 1, this.items.length - 1)); + break; + case 'ArrowUp': + this.focusItem(Math.max(index - 1, 0)); + break; + case 'Home': + this.focusItem(0); + break; + case 'End': + this.focusItem(this.items.length - 1); + break; + case 'Tab': { + const firstAction = this.getFocusableActions(entry)[0]; + if (!event.shiftKey && firstAction) { + event.preventDefault(); + this.setActionsTabbable(entry, true); + firstAction.focus(); + } + return; + } + case 'ContextMenu': + if (entry.contextMenuAction) { + event.preventDefault(); + entry.contextMenuAction.click(); + } + return; + case 'F10': + if (event.shiftKey && entry.contextMenuAction) { + event.preventDefault(); + entry.contextMenuAction.click(); + } + return; + default: + if (event.key !== ' ' && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) { + this.runTypeAhead(entry, event.key); + event.preventDefault(); + } + return; + } + event.preventDefault(); + } + + private runTypeAhead(current: ICardListItem, key: string): void { + this.typeAhead += key.toLocaleLowerCase(); + this.typeAheadReset.value = disposableTimeout(() => this.typeAhead = '', 800); + const start = this.items.indexOf(current) + 1; + const ordered = [...this.items.slice(start), ...this.items.slice(0, start)]; + const match = ordered.find(item => item.label.toLocaleLowerCase().startsWith(this.typeAhead)); + if (match) { + this.setActiveItem(match); + match.primaryAction.focus(); + } + } + + private focusItem(index: number): void { + const item = this.items[index]; + if (item) { + this.setActiveItem(item); + item.primaryAction.focus(); + } + } + + private setActiveItem(active: ICardListItem): void { + for (const item of this.items) { + item.primaryAction.tabIndex = item === active ? 0 : -1; + if (item !== active) { + this.setActionsTabbable(item, false); + } + } + } + + private setActionsTabbable(item: ICardListItem, tabbable: boolean): void { + item.actionsTabbable = tabbable; + for (const action of item.actions) { + this.updateActionTabIndex(item, action); + } + } + + private updateActionTabIndex(item: ICardListItem, action: HTMLElement): void { + const tabIndex = item.actionsTabbable && this.isFocusableAction(action) ? 0 : -1; + if (action.tabIndex !== tabIndex) { + action.tabIndex = tabIndex; + } + } + + private getFocusableActions(item: ICardListItem): readonly HTMLElement[] { + return item.actions.filter(action => this.isFocusableAction(action)); + } + + private isFocusableAction(action: HTMLElement): boolean { + return action.style.display !== 'none' && action.getAttribute('aria-disabled') !== 'true' && !action.matches(':disabled'); + } + +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts index a2690dc0511..261083fe00d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts @@ -27,14 +27,11 @@ export interface ICustomizationMigrationConfirmation { } /** - * Prominent explanation shown above the migration list, for migrations whose - * trade-off needs stating before the user commits. + * Prominent explanation shown above the migration list. */ export interface ICustomizationMigrationBanner { - readonly title: string; readonly message: string; - /** What the user gives up by migrating, so the choice is made knowingly. */ - readonly consequence: string; + readonly consequence?: string; } /** @@ -56,7 +53,6 @@ export interface ICustomizationMigrationCategory { readonly pageLinkLabel: string; readonly pageLinkUrl: string; readonly pageEmptyMessage: string; - readonly searchEmptyMessage: string; readonly migrateButtonTooltip: string; readonly backLabel: string; readonly noFilesMigratedMessage: string; @@ -93,7 +89,6 @@ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { pageLinkLabel: localize('promptMigrationLearnMore', "Learn more about agent skills"), pageLinkUrl: SKILLS_DOCUMENTATION_URL, pageEmptyMessage: localize('promptMigrationPageEmpty', "No prompt files are available to migrate."), - searchEmptyMessage: localize('promptMigrationSearchEmpty', "No prompt files match your search."), migrateButtonTooltip: localize('promptMigrationPageButtonTooltip', "Convert selected prompt files to skills"), backLabel: localize('backToPromptMigration', "Back to Migrate Prompt Files"), noFilesMigratedMessage: localize('promptMigrationNoFilesConverted', "No prompt files were converted."), @@ -171,6 +166,16 @@ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { ); }, + getBanner(_customizations, harnessLabel) { + return { + message: localize( + 'promptMigrationBannerMessage', + "Prompts are no longer supported by {0}. Convert them to skills to keep them available in both VS Code and this harness.", + harnessLabel, + ), + }; + }, + getConfirmation(customizations) { const { workspaceCount, userCount } = countPromptStorages(customizations); const detail = workspaceCount > 0 && userCount > 0 @@ -224,7 +229,6 @@ const userDataMigrationCategory: ICustomizationMigrationCategory = { pageLinkLabel: localize('userDataMigrationLearnMore', "Learn more about agent customizations"), pageLinkUrl: CUSTOMIZATION_DOCUMENTATION_URL, pageEmptyMessage: localize('userDataMigrationPageEmpty', "No user data customizations are available to migrate."), - searchEmptyMessage: localize('userDataMigrationSearchEmpty', "No user data customizations match your search."), migrateButtonTooltip: localize('userDataMigrationPageButtonTooltip', "Move the selected user data customizations to the active harness"), backLabel: localize('backToUserDataMigration', "Back to Migrate User Data Customizations"), noFilesMigratedMessage: localize('userDataMigrationNoFilesMigrated', "No user data customizations were migrated."), @@ -290,15 +294,8 @@ const userDataMigrationCategory: ICustomizationMigrationCategory = { ); }, - getBanner(customizations, harnessLabel, destinationLabel) { - const { totalCount } = countUserDataTypes(customizations); - + getBanner(_customizations, harnessLabel, destinationLabel) { return { - title: totalCount === 1 - ? localize('userDataMigrationBannerTitleSingle', "1 customization is not available to {0}", harnessLabel) - : localize('userDataMigrationBannerTitle', "{0} customizations are not available to {1}", totalCount, harnessLabel), - // The grouped list below already breaks these down by type, so the - // message explains the move rather than repeating the counts. message: destinationLabel ? localize( 'userDataMigrationBannerMessageWithDestination', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts index 65a86f43572..dd6a203da17 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts @@ -4,53 +4,212 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../../base/browser/dom.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { asTextOrError, IRequestService } from '../../../../../platform/request/common/request.js'; import { localize } from '../../../../../nls.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { IMarketplacePlugin } from '../../common/plugins/pluginMarketplaceService.js'; +import { IPluginInstallService } from '../../common/plugins/pluginInstallService.js'; +import { ContributionEnablementState, isContributionEnabled } from '../../common/enablement.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { defaultButtonStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { createPolicyBlockedEnableAction, createUninstallPluginAction, isPluginPolicyBlocked } from '../agentPluginActions.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; +import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { FileOperationError, FileOperationResult, IFileService } from '../../../../../platform/files/common/files.js'; +import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import type { IContextMenuProvider } from '../../../../../base/browser/contextmenu.js'; +import { AnchorAlignment } from '../../../../../base/browser/ui/contextview/contextview.js'; +import { getPluginInclusionLabel } from './aiCustomizationPresentation.js'; +import { getErrorMessage } from '../../../../../base/common/errors.js'; +import { autorun } from '../../../../../base/common/observable.js'; const $ = DOM.$; +export interface IPluginReadme { + readonly content: string; + readonly baseUri: URI; +} + +export class PluginReadmeRenderGuard { + + private generation = 0; + + begin(): number { + return ++this.generation; + } + + isCurrent(generation: number): boolean { + return this.generation === generation; + } +} + +export async function loadPluginReadme( + item: IAgentPluginItem, + fileService: Pick, + requestService: Pick, +): Promise { + const readmeUri = item.kind === AgentPluginItemKind.Installed + ? joinPath(item.plugin.uri, 'README.md') + : item.readmeUri; + if (!readmeUri) { + return undefined; + } + if (readmeUri.scheme === Schemas.file || readmeUri.scheme === Schemas.vscodeRemote) { + try { + const content = await fileService.readFile(readmeUri); + return { content: content.value.toString(), baseUri: readmeUri }; + } catch (error) { + if (error instanceof FileOperationError && error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; + } + } + if (readmeUri.scheme === Schemas.https) { + let fetchedUri = readmeUri; + const githubBlobMatch = readmeUri.toString().match(/^https:\/\/github\.com\/(?[^/]+)\/(?[^/]+)\/blob\/(?.+)$/); + if (githubBlobMatch?.groups) { + fetchedUri = URI.parse(`https://raw.githubusercontent.com/${githubBlobMatch.groups['owner']}/${githubBlobMatch.groups['repo']}/${githubBlobMatch.groups['rest']}`); + } + const context = await requestService.request({ type: 'GET', url: fetchedUri.toString(), callSite: 'aiCustomizationPluginDetail.fetchReadme' }, CancellationToken.None); + return { content: await asTextOrError(context) ?? '', baseUri: fetchedUri }; + } + throw new Error(`Unsupported plugin README URI scheme: ${readmeUri.scheme}`); +} + /** * Compact detail view for an agent plugin inside the AI Customizations management editor's - * split-pane host. Renders identity (icon + name + source) and description. - * - * Advanced actions (enable / disable / uninstall) remain accessible via the row's existing - * context menu, so this component intentionally stays small. + * split-pane host. Renders identity, provenance, contribution summary, and description while + * keeping management actions in the list/context-menu surfaces. */ export class EmbeddedAgentPluginDetail extends Disposable { + private readonly _onDidRequestOpenSkill = this._register(new Emitter()); + readonly onDidRequestOpenSkill = this._onDidRequestOpenSkill.event; + private readonly _onDidRequestOpenAgent = this._register(new Emitter()); + readonly onDidRequestOpenAgent = this._onDidRequestOpenAgent.event; + private readonly _onDidRequestOpenSection = this._register(new Emitter()); + readonly onDidRequestOpenSection = this._onDidRequestOpenSection.event; + private readonly _onDidUninstall = this._register(new Emitter()); + readonly onDidUninstall = this._onDidUninstall.event; + private readonly root: HTMLElement; private readonly headerEl: HTMLElement; private readonly leadingSlotEl: HTMLElement; + private readonly nameRowEl: HTMLElement; private readonly nameEl: HTMLElement; - private readonly sourceEl: HTMLElement; + private readonly statusBadgeEl: HTMLElement; + private readonly titleActionsEl: HTMLElement; private readonly descriptionEl: HTMLElement; + private readonly sourceFactsEl: HTMLElement; + private readonly factsEl: HTMLElement; + private readonly contributionsEl: HTMLElement; + private readonly contributionsListEl: HTMLElement; + private readonly readmeEl: HTMLElement; + private readonly readmeContentEl: HTMLElement; private readonly emptyEl: HTMLElement; + private readonly renderDisposables = this._register(new DisposableStore()); + private readonly copyStateReset = this._register(new MutableDisposable()); + private readonly narrowLayoutUpdate = this._register(new MutableDisposable()); + private readonly inputStateAutorun = this._register(new MutableDisposable()); private current: IAgentPluginItem | undefined; + private narrowLayout = false; + private readonly readmeRenderGuard = new PluginReadmeRenderGuard(); + private updateEnablementAction: (() => void) | undefined; + private pluginVersionRowEl: HTMLElement | undefined; + private pluginVersionValueEl: HTMLElement | undefined; + private renderedPolicyBlocked = false; constructor( parent: HTMLElement, + @ILabelService private readonly labelService: ILabelService, + @IClipboardService private readonly clipboardService: IClipboardService, + @IOpenerService private readonly openerService: IOpenerService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + @INotificationService private readonly notificationService: INotificationService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @ICommandService private readonly commandService: ICommandService, + @IHoverService private readonly hoverService: IHoverService, + @IFileService private readonly fileService: IFileService, + @IRequestService private readonly requestService: IRequestService, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, ) { super(); this.root = DOM.append(parent, $('.ai-customization-embedded-detail.embedded-plugin-detail')); + const targetWindow = DOM.getWindow(this.root); + const resizeObserver = this._register(new DOM.DisposableResizeObserver( + 'EmbeddedAgentPluginDetail', + () => { + const narrow = this.root.offsetWidth < 520; + if (this.narrowLayout !== narrow) { + this.narrowLayoutUpdate.value = DOM.scheduleAtNextAnimationFrame(targetWindow, () => this.updateNarrowLayout(narrow)); + } + }, + targetWindow, + )); + this._register(resizeObserver.observe(this.root)); this.headerEl = DOM.append(this.root, $('.embedded-detail-header')); // Slot at the start of the header for callers to append leading chrome // (e.g. a back button) without reaching into private DOM structure. this.leadingSlotEl = DOM.append(this.headerEl, $('.embedded-detail-leading-slot')); const headerText = DOM.append(this.headerEl, $('.embedded-detail-header-text')); - this.nameEl = DOM.append(headerText, $('h2.embedded-detail-name')); + this.nameRowEl = DOM.append(headerText, $('.embedded-detail-name-row')); + this.nameEl = DOM.append(this.nameRowEl, $('h2.embedded-detail-name')); this.nameEl.setAttribute('role', 'heading'); - this.sourceEl = DOM.append(headerText, $('.embedded-detail-scope')); + this.statusBadgeEl = DOM.append(this.nameRowEl, $('.inline-badge.embedded-detail-status-badge')); + this.titleActionsEl = DOM.append(this.headerEl, $('.embedded-detail-title-actions')); this.descriptionEl = DOM.append(this.root, $('.embedded-detail-description')); + this.sourceFactsEl = DOM.append(this.root, $('.embedded-detail-section.plugin-detail-source-facts')); + const sourceFactsTitle = DOM.append(this.sourceFactsEl, $('h3.embedded-detail-section-title')); + sourceFactsTitle.textContent = localize('pluginSourceFactsTitle', "Details"); + this.factsEl = DOM.append(this.sourceFactsEl, $('.embedded-detail-facts.plugin-detail-flat-list')); + + this.contributionsEl = DOM.append(this.root, $('.embedded-detail-section.plugin-detail-contributions')); + const contributionsTitle = DOM.append(this.contributionsEl, $('h3.embedded-detail-section-title')); + contributionsTitle.textContent = localize('pluginContributionsTitle', "Contains"); + this.contributionsListEl = DOM.append(this.contributionsEl, $('.embedded-detail-chip-list.plugin-detail-flat-list')); + this.readmeEl = DOM.append(this.root, $('.embedded-detail-section.plugin-detail-readme')); + const readmeTitle = DOM.append(this.readmeEl, $('h3.plugin-detail-contribution-group-title')); + const readmeLabel = DOM.append(readmeTitle, $('span.plugin-detail-contribution-title-label')); + readmeLabel.textContent = localize('pluginReadmeTitle', "Plugin README"); + this.readmeContentEl = DOM.append(this.readmeEl, $('.plugin-detail-readme-content')); + this.emptyEl = DOM.append(this.root, $('.embedded-detail-empty')); this.emptyEl.textContent = localize('pluginDetailEmpty', "No plugin selected."); - this.renderItem(); + } + + private updateNarrowLayout(narrow: boolean): void { + if (this.narrowLayout === narrow) { + return; + } + this.narrowLayout = narrow; + this.root.classList.toggle('narrow-layout', narrow); } get element(): HTMLElement { @@ -72,40 +231,489 @@ export class EmbeddedAgentPluginDetail extends Disposable { setInput(item: IAgentPluginItem): void { this.current = item; this.renderItem(); + if (item.kind === AgentPluginItemKind.Installed) { + this.renderedPolicyBlocked = isPluginPolicyBlocked(item.plugin); + this.inputStateAutorun.value = autorun(reader => { + item.plugin.enablement.read(reader); + item.plugin.policyBlocked?.read(reader); + item.plugin.version?.read(reader); + if (this._store.isDisposed || this.current !== item) { + return; + } + const policyBlocked = isPluginPolicyBlocked(item.plugin); + if (policyBlocked !== this.renderedPolicyBlocked) { + this.renderedPolicyBlocked = policyBlocked; + this.renderItem(); + return; + } + this.updateInstalledState(item); + }); + } else { + this.inputStateAutorun.clear(); + } } clearInput(): void { this.current = undefined; + this.inputStateAutorun.clear(); this.renderItem(); } private renderItem(): void { + const readmeRenderGeneration = this.readmeRenderGuard.begin(); + this.renderDisposables.clear(); + this.updateEnablementAction = undefined; + this.pluginVersionRowEl = undefined; + this.pluginVersionValueEl = undefined; const item = this.current; const hasItem = !!item; this.emptyEl.style.display = hasItem ? 'none' : ''; this.root.classList.toggle('is-empty', !hasItem); if (!item) { this.nameEl.textContent = ''; - this.sourceEl.textContent = ''; + this.statusBadgeEl.textContent = ''; + this.statusBadgeEl.style.display = 'none'; + DOM.clearNode(this.titleActionsEl); this.descriptionEl.textContent = ''; + DOM.clearNode(this.factsEl); + this.sourceFactsEl.style.display = 'none'; + DOM.clearNode(this.contributionsListEl); + this.contributionsEl.style.display = 'none'; + DOM.clearNode(this.readmeContentEl); + this.readmeEl.style.display = 'none'; return; } this.nameEl.textContent = item.name; + if (item.kind === AgentPluginItemKind.Installed && (!isContributionEnabled(item.plugin.enablement.get()) || isPluginPolicyBlocked(item.plugin))) { + this.statusBadgeEl.textContent = getPluginInclusionLabel(item.plugin); + this.statusBadgeEl.style.display = ''; + } else { + this.statusBadgeEl.textContent = ''; + this.statusBadgeEl.style.display = 'none'; + } + DOM.clearNode(this.titleActionsEl); + DOM.clearNode(this.factsEl); + DOM.clearNode(this.contributionsListEl); - const isMarketplace = item.kind === AgentPluginItemKind.Marketplace; - - const sourceLabel = item.marketplace - ? (isMarketplace - ? localize('pluginSourceMarketplace', "From {0}", item.marketplace) - : localize('pluginSourceInstalled', "Installed from {0}", item.marketplace)) - : (isMarketplace - ? localize('pluginSourceMarketplaceUnknown', "Marketplace plugin") - : localize('pluginSourceLocal', "Installed plugin")); - this.sourceEl.textContent = sourceLabel; + this.renderTitleActions(item); + this.renderFacts(item); + this.renderContributions(item); + this.renderReadme(item, readmeRenderGeneration); const description = (item.description || '').trim(); - this.descriptionEl.textContent = description; - this.descriptionEl.style.display = description ? '' : 'none'; + this.descriptionEl.textContent = description || localize('pluginNoDescription', "No description provided."); + this.descriptionEl.style.display = ''; + } + + private updateInstalledState(item: Extract): void { + if (!isContributionEnabled(item.plugin.enablement.get()) || isPluginPolicyBlocked(item.plugin)) { + this.statusBadgeEl.textContent = getPluginInclusionLabel(item.plugin); + this.statusBadgeEl.style.display = ''; + } else { + this.statusBadgeEl.textContent = ''; + this.statusBadgeEl.style.display = 'none'; + } + this.updateEnablementAction?.(); + this.updatePluginVersionFact(item); + } + + private renderTitleActions(item: IAgentPluginItem): void { + if (item.kind === AgentPluginItemKind.Marketplace) { + const installButton = this.renderDisposables.add(new Button(this.titleActionsEl, { ...defaultButtonStyles, ariaLabel: localize('installPluginAria', "Install {0}", item.name) })); + installButton.label = localize('install', "Install"); + this.renderDisposables.add(installButton.onDidClick(async () => { + installButton.label = localize('installing', "Installing..."); + installButton.enabled = false; + try { + await this.pluginInstallService.installPlugin({ + name: item.name, + description: item.description, + version: item.version ?? '', + source: item.source, + sourceDescriptor: item.sourceDescriptor, + marketplace: item.marketplace, + marketplaceReference: item.marketplaceReference, + marketplaceType: item.marketplaceType, + readmeUri: item.readmeUri, + }); + if (this._store.isDisposed || this.current !== item) { + return; + } + const installed = this.getInstalledPluginForMarketplaceItem(item); + if (installed) { + installButton.label = localize('installed', "Installed"); + this.setInput(installed); + } + } catch (error) { + if (this._store.isDisposed || this.current !== item) { + return; + } + installButton.label = localize('install', "Install"); + installButton.enabled = true; + this.notificationService.error(localize('pluginInstallFailed', "Unable to install plugin: {0}", getErrorMessage(error))); + } + })); + return; + } + + const uninstallAction = createUninstallPluginAction(item.plugin); + if (uninstallAction) { + this.renderDisposables.add(uninstallAction); + const uninstallButton = this.renderDisposables.add(new Button(this.titleActionsEl, { + ...getButtonStyles({ + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryBorder: undefined, + }), + secondary: true, + supportIcons: true, + ariaLabel: uninstallAction.label, + })); + uninstallButton.element.classList.add('embedded-detail-uninstall-button'); + uninstallButton.label = uninstallAction.label; + uninstallButton.enabled = uninstallAction.enabled; + this.renderDisposables.add(uninstallButton.onDidClick(async () => { + await uninstallAction.run(); + if (!this._store.isDisposed && this.current === item) { + this._onDidUninstall.fire(); + } + })); + } + + this.renderEnablementSplitButton(item); + } + + private renderEnablementSplitButton(item: Extract): void { + if (isPluginPolicyBlocked(item.plugin)) { + const action = createPolicyBlockedEnableAction(item.plugin, this.notificationService); + const policyLabel = localize('pluginManagedByOrganization', "Managed by Organization"); + const button = this.renderDisposables.add(new Button(this.titleActionsEl, { ...defaultButtonStyles, secondary: true, supportIcons: true, ariaLabel: policyLabel })); + button.label = policyLabel; + this.renderDisposables.add(button.onDidClick(() => action.run())); + this.renderDisposables.add(action); + return; + } + + const key = item.plugin.uri.toString(); + const setEnablement = (state: ContributionEnablementState) => { + this.agentPluginService.enablementModel.setEnabled(key, state); + status(localize('pluginInclusionChanged', "{0}. {1}.", item.name, getPluginInclusionLabel(item.plugin))); + }; + const contextMenuProvider: IContextMenuProvider = { + showContextMenu: delegate => this.contextMenuService.showContextMenu({ + ...delegate, + anchorAlignment: AnchorAlignment.RIGHT, + }), + }; + const splitButton = this.renderDisposables.add(new ButtonWithDropdown(this.titleActionsEl, { + ...defaultButtonStyles, + secondary: true, + supportIcons: true, + contextMenuProvider, + addPrimaryActionToDropdown: false, + actions: { + getActions: () => { + const state = getPluginEnablementActionState(item.plugin.enablement.get()); + return [ + this.renderDisposables.add(new Action(`plugin.${state.isEnabled ? 'exclude' : 'include'}AlternateScope`, state.alternateLabel, undefined, true, async () => setEnablement(state.alternateState))) + ]; + }, + }, + ariaLabel: '', + })); + this.updateEnablementAction = () => { + const state = getPluginEnablementActionState(item.plugin.enablement.get()); + splitButton.element.classList.toggle('embedded-detail-disable-button', state.isEnabled); + splitButton.element.classList.toggle('embedded-detail-enable-button', !state.isEnabled); + splitButton.label = state.primaryLabel; + splitButton.element.setAttribute('aria-label', state.primaryLabel); + }; + this.updateEnablementAction(); + this.renderDisposables.add(splitButton.onDidClick(() => setEnablement(getPluginEnablementActionState(item.plugin.enablement.get()).primaryState))); + } + + private getInstalledPluginForMarketplaceItem(item: Extract): IAgentPluginItem | undefined { + const expectedUri = this.pluginInstallService.getPluginInstallUri({ + name: item.name, + description: item.description, + version: item.version ?? '', + source: item.source, + sourceDescriptor: item.sourceDescriptor, + marketplace: item.marketplace, + marketplaceReference: item.marketplaceReference, + marketplaceType: item.marketplaceType, + readmeUri: item.readmeUri, + }); + const plugin = this.agentPluginService.plugins.get().find(plugin => plugin.uri.toString() === expectedUri.toString()); + if (!plugin) { + return undefined; + } + return { + kind: AgentPluginItemKind.Installed, + name: plugin.label || basename(plugin.uri), + description: plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(plugin.uri, { relative: true }), + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + } + + private renderMarketplaceLink(label: string, uri: URI | undefined): HTMLElement { + if (uri) { + const link = $('a.embedded-detail-fact-link') as HTMLAnchorElement; + link.href = uri.toString(); + link.textContent = label; + this.renderDisposables.add(DOM.addDisposableListener(link, 'click', e => { + e.preventDefault(); + this.openerService.open(uri); + })); + return link; + } else { + const value = $('span'); + value.textContent = label; + return value; + } + } + + private renderFacts(item: IAgentPluginItem): void { + this.sourceFactsEl.style.display = ''; + if (item.kind === AgentPluginItemKind.Marketplace) { + this.appendPluginVersionFact(item); + this.appendFact(this.factsEl, localize('pluginDetailMarketplace', "Marketplace"), this.renderMarketplaceLink(item.marketplace, getMarketplaceUri(item))); + return; + } + + this.appendPluginVersionFact(item); + if (item.marketplace) { + this.appendFact(this.factsEl, localize('pluginDetailMarketplace', "Marketplace"), this.renderMarketplaceLink(item.marketplace, item.plugin.fromMarketplace ? getMarketplaceUri(item.plugin.fromMarketplace) : undefined)); + } + this.appendFact(this.factsEl, localize('pluginDetailLocation', "Location"), this.createLocationValue(item.plugin.uri)); + } + + private appendPluginVersionFact(item: IAgentPluginItem): void { + const row = DOM.append(this.factsEl, $('.embedded-detail-fact-row')); + DOM.append(row, $('.embedded-detail-fact-label')).textContent = localize('pluginDetailVersion', "Version"); + this.pluginVersionValueEl = DOM.append(row, $('.embedded-detail-fact-value')); + this.pluginVersionRowEl = row; + this.updatePluginVersionFact(item); + } + + private updatePluginVersionFact(item: IAgentPluginItem): void { + const version = getPluginVersion(item); + if (this.pluginVersionRowEl && this.pluginVersionValueEl) { + this.pluginVersionRowEl.style.display = version ? '' : 'none'; + this.pluginVersionValueEl.textContent = version ?? ''; + } + } + + private appendFact(parent: HTMLElement, label: string, value: string | HTMLElement): void { + const row = DOM.append(parent, $('.embedded-detail-fact-row')); + const labelEl = DOM.append(row, $('.embedded-detail-fact-label')); + labelEl.textContent = label; + const valueEl = DOM.append(row, $('.embedded-detail-fact-value')); + if (typeof value === 'string') { + valueEl.textContent = value; + } else { + valueEl.classList.add('has-actions'); + valueEl.appendChild(value); + } + } + + private createLocationValue(uri: URI): HTMLElement { + const container = $('.embedded-detail-location-value'); + const label = DOM.append(container, $('span.embedded-detail-location-label')); + label.textContent = this.labelService.getUriLabel(uri, { relative: true }); + label.title = uri.fsPath || uri.toString(); + const copyPluginPathLabel = localize('copyPluginPath', "Copy Plugin Path"); + let copyPluginPathTooltip = copyPluginPathLabel; + const inlineButtonStyles = getButtonStyles({ + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryBorder: undefined, + }); + const copyButton = this.renderDisposables.add(new Button(container, { ...inlineButtonStyles, secondary: true, supportIcons: true, title: copyPluginPathLabel, ariaLabel: copyPluginPathLabel })); + copyButton.element.classList.add('embedded-detail-copy-button'); + copyButton.label = `$(${Codicon.copy.id})`; + this.renderDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), copyButton.element, () => copyPluginPathTooltip)); + this.renderDisposables.add(copyButton.onDidClick(async () => { + await this.clipboardService.writeText(uri.fsPath || uri.toString()); + copyButton.label = `$(${Codicon.check.id})`; + copyPluginPathTooltip = localize('copiedPluginPath', "Copied"); + copyButton.setTitle(copyPluginPathTooltip); + status(localize('copiedPluginPathStatus', "Copied plugin path to clipboard")); + this.copyStateReset.value = disposableTimeout(() => { + copyButton.label = `$(${Codicon.copy.id})`; + copyPluginPathTooltip = copyPluginPathLabel; + copyButton.setTitle(copyPluginPathTooltip); + }, 1200); + })); + const openPluginFolderLabel = localize('openPluginFolder', "Open Plugin Folder"); + const openButton = this.renderDisposables.add(new Button(container, { ...inlineButtonStyles, secondary: true, supportIcons: true, title: openPluginFolderLabel, ariaLabel: openPluginFolderLabel })); + openButton.element.classList.add('embedded-detail-copy-button'); + openButton.label = `$(${Codicon.folderOpened.id})`; + this.renderDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), openButton.element, openPluginFolderLabel)); + this.renderDisposables.add(openButton.onDidClick(async () => { + try { + await this.commandService.executeCommand('revealFileInOS', uri); + } catch { + await this.openerService.open(dirname(uri)); + } + })); + return container; + } + + private async renderReadme(item: IAgentPluginItem, renderGeneration: number): Promise { + DOM.clearNode(this.readmeContentEl); + this.readmeEl.style.display = ''; + let readme: IPluginReadme | undefined; + try { + readme = await loadPluginReadme(item, this.fileService, this.requestService); + } catch { + if (!this._store.isDisposed && this.current === item && this.readmeRenderGuard.isCurrent(renderGeneration)) { + const message = DOM.append(this.readmeContentEl, $('.plugin-detail-readme-message')); + message.textContent = localize('pluginReadmeLoadError', "The plugin README could not be loaded."); + } + return; + } + if (this._store.isDisposed || this.current !== item || !this.readmeRenderGuard.isCurrent(renderGeneration)) { + return; + } + if (readme === undefined) { + const message = DOM.append(this.readmeContentEl, $('.plugin-detail-readme-message')); + message.textContent = localize('pluginReadmeMissing', "No README was provided for this plugin."); + return; + } + if (!readme.content.trim()) { + const message = DOM.append(this.readmeContentEl, $('.plugin-detail-readme-message')); + message.textContent = localize('pluginReadmeEmpty', "The plugin README is empty."); + return; + } + const markdown = new MarkdownString(readme.content, { supportHtml: false }); + markdown.baseUri = readme.baseUri; + const rendered = this.renderDisposables.add(this.markdownRendererService.render(markdown)); + this.readmeContentEl.appendChild(rendered.element); + } + + override dispose(): void { + this.current = undefined; + this.readmeRenderGuard.begin(); + super.dispose(); + } + + private renderContributions(item: IAgentPluginItem): void { + if (item.kind === AgentPluginItemKind.Marketplace) { + this.contributionsEl.style.display = ''; + const empty = DOM.append(this.contributionsListEl, $('.plugin-detail-contribution-empty')); + empty.textContent = localize('pluginMarketplaceContributionsUnavailable', "Contribution details are available after install when the plugin can be inspected locally."); + return; + } + + const entries = getInstalledPluginContributionEntries(item); + + this.contributionsEl.style.display = entries.length > 0 ? '' : 'none'; + for (const entry of entries) { + const section = DOM.append(this.contributionsListEl, $('.plugin-detail-contribution-section')); + const header = DOM.append(section, $('.plugin-detail-contribution-group-title')); + const label = DOM.append(header, $('span.plugin-detail-contribution-title-label')); + label.textContent = entry.label; + const count = DOM.append(header, $('span.plugin-detail-contribution-title-count')); + count.textContent = String(entry.items.length); + const group = DOM.append(section, $('.plugin-detail-contribution-group')); + const list = DOM.append(group, $('.plugin-detail-contribution-list')); + for (const contribution of entry.items) { + const row = DOM.append(list, $('.plugin-detail-contribution-row')); + if (entry.kind === 'skills' && contribution.uri) { + const button = DOM.append(row, $('button.plugin-detail-contribution-name.plugin-detail-contribution-link')) as HTMLButtonElement; + button.type = 'button'; + button.textContent = contribution.name; + button.setAttribute('aria-label', localize('openSkillContribution', "Open skill {0}", contribution.name)); + this.renderDisposables.add(DOM.addDisposableListener(button, 'click', () => this._onDidRequestOpenSkill.fire(contribution.uri!))); + } else if (entry.kind === 'agents' && contribution.uri) { + const button = DOM.append(row, $('button.plugin-detail-contribution-name.plugin-detail-contribution-link')) as HTMLButtonElement; + button.type = 'button'; + button.textContent = contribution.name; + button.setAttribute('aria-label', localize('openAgentContribution', "Open agent {0}", contribution.name)); + this.renderDisposables.add(DOM.addDisposableListener(button, 'click', () => this._onDidRequestOpenAgent.fire(contribution.uri!))); + } else if (entry.kind === 'mcp') { + const button = DOM.append(row, $('button.plugin-detail-contribution-name.plugin-detail-contribution-link')) as HTMLButtonElement; + button.type = 'button'; + button.textContent = contribution.name; + button.setAttribute('aria-label', localize('openMcpSectionForContribution', "Open MCP Servers")); + this.renderDisposables.add(DOM.addDisposableListener(button, 'click', () => this._onDidRequestOpenSection.fire(AICustomizationManagementSection.McpServers))); + } else { + const name = DOM.append(row, $('.plugin-detail-contribution-name')); + name.textContent = contribution.name; + } + if (contribution.description && entry.kind !== 'skills') { + const description = DOM.append(row, $('.plugin-detail-contribution-description')); + description.textContent = contribution.description; + } + } + } } } + +interface IPluginContributionEntry { + readonly kind: string; + readonly label: string; + readonly items: readonly { name: string; description?: string; uri?: URI }[]; +} + +function getInstalledPluginContributionEntries(item: Extract): IPluginContributionEntry[] { + const plugin = item.plugin; + const entries: IPluginContributionEntry[] = []; + appendContributionEntry(entries, 'agents', localize('pluginDetailAgents', "Agents"), plugin.agents.get()); + appendContributionEntry(entries, 'skills', localize('pluginDetailSkills', "Skills"), plugin.skills.get()); + appendContributionEntry(entries, 'commands', localize('pluginDetailCommands', "Commands"), plugin.commands.get()); + appendContributionEntry(entries, 'instructions', localize('pluginDetailInstructions', "Instructions"), plugin.instructions.get()); + appendContributionEntry(entries, 'mcp', localize('pluginDetailMcpServers', "MCP Servers"), plugin.mcpServerDefinitions.get().map(server => ({ name: server.name }))); + appendContributionEntry(entries, 'hooks', localize('pluginDetailHooks', "Hooks"), plugin.hooks.get().map(hook => ({ name: hook.originalId, description: localize('pluginDetailHookCommands', "{0} commands", hook.hooks.length) }))); + return entries; +} + +function appendContributionEntry(entries: IPluginContributionEntry[], kind: string, label: string | undefined, items: readonly { name: string; description?: string; uri?: URI }[]): void { + if (label && items.length > 0) { + entries.push({ kind, label, items }); + } +} + +export function getPluginVersion(item: IAgentPluginItem): string | undefined { + const version = item.kind === AgentPluginItemKind.Marketplace + ? item.version + : item.plugin.version?.get() ?? item.plugin.fromMarketplace?.version; + return version?.trim() || undefined; +} + +function getPluginEnablementActionState(current: ContributionEnablementState): { + readonly isEnabled: boolean; + readonly primaryLabel: string; + readonly primaryState: ContributionEnablementState; + readonly alternateLabel: string; + readonly alternateState: ContributionEnablementState; +} { + const isEnabled = isContributionEnabled(current); + const isWorkspaceScope = current === ContributionEnablementState.EnabledWorkspace || current === ContributionEnablementState.DisabledWorkspace; + const profileLabel = isEnabled ? localize('disablePlugin', "Disable") : localize('enablePlugin', "Enable"); + const workspaceLabel = isEnabled ? localize('disablePluginWorkspace', "Disable (Workspace)") : localize('enablePluginWorkspace', "Enable (Workspace)"); + const profileState = isEnabled ? ContributionEnablementState.DisabledProfile : ContributionEnablementState.EnabledProfile; + const workspaceState = isEnabled ? ContributionEnablementState.DisabledWorkspace : ContributionEnablementState.EnabledWorkspace; + return { + isEnabled, + primaryLabel: isWorkspaceScope ? workspaceLabel : profileLabel, + primaryState: isWorkspaceScope ? workspaceState : profileState, + alternateLabel: isWorkspaceScope ? profileLabel : workspaceLabel, + alternateState: isWorkspaceScope ? profileState : workspaceState, + }; +} + +function getMarketplaceUri(item: Pick, 'marketplaceReference'>): URI | undefined { + if (item.marketplaceReference.githubRepo) { + return URI.parse(`https://github.com/${item.marketplaceReference.githubRepo}`); + } + if (item.marketplaceReference.cloneUrl) { + return URI.parse(item.marketplaceReference.cloneUrl.replace(/\.git$/, '')); + } + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts index 5cb83790537..323dd2b71dd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts @@ -4,21 +4,47 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../../base/browser/dom.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { basename } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; import { localize } from '../../../../../nls.js'; -import { LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; -import { userIcon, workspaceIcon } from './aiCustomizationIcons.js'; const $ = DOM.$; +export interface IMcpServerDetailInput { + readonly id: string; + readonly name: string; + readonly label: string; + readonly config?: IMcpServerConfiguration; + readonly source?: { + readonly uri: URI; + readonly range?: IRange; + }; +} + +export function createWorkbenchMcpServerDetailInput(server: IWorkbenchMcpServer): IMcpServerDetailInput { + return { + id: server.id, + name: server.name, + label: server.label, + config: server.config, + source: server.local?.mcpResource ? { uri: server.local.mcpResource } : undefined, + }; +} + /** - * Compact detail view for an MCP server inside the AI Customizations management editor's - * split-pane host. Renders identity (icon + name + scope) and description. - * - * Advanced actions (enable / disable / uninstall / configure) remain accessible via the - * row's existing context menu, so this component intentionally stays small. + * Detail view for an MCP server inside the AI Customizations management editor. */ export class EmbeddedMcpServerDetail extends Disposable { @@ -26,30 +52,40 @@ export class EmbeddedMcpServerDetail extends Disposable { private readonly headerEl: HTMLElement; private readonly leadingSlotEl: HTMLElement; private readonly nameEl: HTMLElement; - private readonly scopeEl: HTMLElement; - private readonly descriptionEl: HTMLElement; + private readonly pathEl: HTMLElement; + private readonly definitionEditorContainer: HTMLElement; + private readonly definitionEmptyEl: HTMLElement; + private definitionEditor: CodeEditorWidget | undefined; + private readonly definitionModel = this._register(new MutableDisposable()); private readonly emptyEl: HTMLElement; - private current: IWorkbenchMcpServer | undefined; + private current: IMcpServerDetailInput | undefined; + private currentDefinition: string | undefined; + private renderGeneration = 0; constructor( parent: HTMLElement, @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IModelService private readonly modelService: IModelService, + @ILanguageService private readonly languageService: ILanguageService, + @IFileService private readonly fileService: IFileService, ) { super(); - this.root = DOM.append(parent, $('.ai-customization-embedded-detail.embedded-mcp-detail')); + this.root = DOM.append(parent, $('.editor-content-container.ai-customization-embedded-detail.embedded-mcp-detail')); - this.headerEl = DOM.append(this.root, $('.embedded-detail-header')); - // Slot at the start of the header for callers to append leading chrome - // (e.g. a back button) without reaching into private DOM structure. + this.headerEl = DOM.append(this.root, $('.editor-header.mcp-detail-header')); this.leadingSlotEl = DOM.append(this.headerEl, $('.embedded-detail-leading-slot')); - const headerText = DOM.append(this.headerEl, $('.embedded-detail-header-text')); - this.nameEl = DOM.append(headerText, $('h2.embedded-detail-name')); - this.nameEl.setAttribute('role', 'heading'); - this.scopeEl = DOM.append(headerText, $('.embedded-detail-scope')); + const headerText = DOM.append(this.headerEl, $('.editor-item-info')); + this.nameEl = DOM.append(headerText, $('.editor-item-name')); + this.pathEl = DOM.append(headerText, $('.editor-item-path')); - this.descriptionEl = DOM.append(this.root, $('.embedded-detail-description')); + this.definitionEditorContainer = DOM.append(this.root, $('.embedded-editor-container.mcp-detail-definition-editor')); + this.definitionEmptyEl = DOM.append(this.root, $('.embedded-detail-empty.mcp-detail-definition-empty')); + this.definitionEmptyEl.tabIndex = -1; + this.definitionEmptyEl.textContent = localize('mcpDefinitionUnavailable', "No definition is available for this MCP server."); this.emptyEl = DOM.append(this.root, $('.embedded-detail-empty')); this.emptyEl.textContent = localize('mcpDetailEmpty', "No MCP server selected."); @@ -57,7 +93,7 @@ export class EmbeddedMcpServerDetail extends Disposable { // Refresh when the underlying server changes (install state, enablement, etc.). this._register(this.mcpWorkbenchService.onChange(server => { if (this.current && server && server.id === this.current.id) { - this.current = server; + this.current = createWorkbenchMcpServerDetailInput(server); this.renderItem(); } })); @@ -81,7 +117,7 @@ export class EmbeddedMcpServerDetail extends Disposable { return this.leadingSlotEl; } - setInput(server: IWorkbenchMcpServer): void { + setInput(server: IMcpServerDetailInput): void { this.current = server; this.renderItem(); } @@ -91,46 +127,120 @@ export class EmbeddedMcpServerDetail extends Disposable { this.renderItem(); } + focus(): void { + if (this.currentDefinition !== undefined) { + this.ensureDefinitionEditor().focus(); + return; + } + this.definitionEmptyEl.focus(); + } + private renderItem(): void { + const renderGeneration = ++this.renderGeneration; const server = this.current; const hasItem = !!server; this.emptyEl.style.display = hasItem ? 'none' : ''; this.root.classList.toggle('is-empty', !hasItem); if (!server) { this.nameEl.textContent = ''; - this.scopeEl.textContent = ''; - this.descriptionEl.textContent = ''; + this.pathEl.textContent = ''; + this.setDefinition(undefined); + this.definitionEmptyEl.style.display = 'none'; return; } this.nameEl.textContent = server.label || server.name; - - // Scope label - const scope = server.local?.scope; - const scopeInfo = describeMcpScope(scope); - if (scopeInfo) { - this.scopeEl.textContent = scopeInfo.label; - this.scopeEl.style.display = ''; + this.pathEl.textContent = server.source ? basename(server.source.uri) : 'mcp.json'; + if (server.config) { + this.setDefinition(`${JSON.stringify({ servers: { [server.name]: server.config } }, null, '\t')}\n`); + } else if (server.source) { + this.setDefinition(undefined, localize('mcpDefinitionLoading', "Loading MCP server definition...")); + void this.loadSourceDefinition(server, server.source, renderGeneration); } else { - this.scopeEl.replaceChildren(); - this.scopeEl.style.display = 'none'; + this.setDefinition(undefined); + } + } + + private async loadSourceDefinition(server: IMcpServerDetailInput, source: NonNullable, renderGeneration: number): Promise { + try { + const content = (await this.fileService.readFile(source.uri)).value.toString(); + if (this.current !== server || this.renderGeneration !== renderGeneration) { + return; + } + this.setDefinition(source.range ? getTextInRange(content, source.range) : content); + } catch { + if (this.current === server && this.renderGeneration === renderGeneration) { + this.setDefinition(undefined, localize('mcpDefinitionLoadFailed', "The MCP server definition could not be loaded.")); + } + } + } + + private setDefinition(definition: string | undefined, emptyMessage = localize('mcpDefinitionUnavailable', "No definition is available for this MCP server.")): void { + const hasDefinition = definition !== undefined; + this.definitionEditorContainer.style.display = hasDefinition ? '' : 'none'; + this.definitionEmptyEl.style.display = hasDefinition ? 'none' : ''; + this.definitionEmptyEl.textContent = emptyMessage; + + if (this.currentDefinition === definition) { + return; } - // Description (single line, but allow wrapping in CSS) - const description = (server.description || '').trim(); - this.descriptionEl.textContent = description; - this.descriptionEl.style.display = description ? '' : 'none'; + this.currentDefinition = definition; + + if (!hasDefinition) { + this.definitionEditor?.setModel(null); + this.definitionModel.clear(); + return; + } + + const definitionEditor = this.ensureDefinitionEditor(); + definitionEditor.updateOptions({ + ariaLabel: localize('mcpDefinitionEditorAriaLabelWithName', "MCP server definition for {0}", this.current?.label || this.current?.name || ''), + }); + const model = this.modelService.createModel(definition, this.languageService.createById('jsonc'), undefined, true); + definitionEditor.setModel(model); + this.definitionModel.value = model; + } + + private ensureDefinitionEditor(): CodeEditorWidget { + if (!this.definitionEditor) { + this.definitionEditor = this._register(this.instantiationService.createInstance( + CodeEditorWidget, + this.definitionEditorContainer, + { + ...getSimpleEditorOptions(this.configurationService), + readOnly: true, + domReadOnly: true, + minimap: { enabled: false }, + lineNumbers: 'on', + wordWrap: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + folding: true, + renderLineHighlight: 'all', + scrollbar: { vertical: 'auto', horizontal: 'auto' }, + ariaLabel: localize('mcpDefinitionEditorAriaLabel', "MCP server definition"), + }, + { isSimpleWidget: false } + )); + } + return this.definitionEditor; } } -function describeMcpScope(scope: LocalMcpServerScope | undefined): { label: string; icon: ThemeIcon } | undefined { - switch (scope) { - case LocalMcpServerScope.Workspace: - return { label: localize('mcpScopeWorkspace', "Workspace"), icon: workspaceIcon }; - case LocalMcpServerScope.User: - case LocalMcpServerScope.RemoteUser: - return { label: localize('mcpScopeUser', "User"), icon: userIcon }; - default: - return undefined; +function getTextInRange(content: string, range: IRange): string { + const lines = content.split(/\r\n|\r|\n/); + const startLineIndex = range.startLineNumber - 1; + const endLineIndex = range.endLineNumber - 1; + if (startLineIndex < 0 || endLineIndex >= lines.length || startLineIndex > endLineIndex) { + throw new Error('MCP server source range is outside the source document.'); } + if (startLineIndex === endLineIndex) { + return lines[startLineIndex].slice(range.startColumn - 1, range.endColumn - 1); + } + return [ + lines[startLineIndex].slice(range.startColumn - 1), + ...lines.slice(startLineIndex + 1, endLineIndex), + lines[endLineIndex].slice(0, range.endColumn - 1), + ].join('\n'); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/galleryItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/galleryItemRenderer.ts index 81821327ba6..c8b4cbbf56a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/galleryItemRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/galleryItemRenderer.ts @@ -62,7 +62,7 @@ export class GalleryItemRenderer implements IListRenderer { - getHeight(element: IMcpListEntry): number { - if (element.type === 'group-header') { - return element.isFirst ? CUSTOMIZATION_GROUP_HEADER_HEIGHT : CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR; - } - if (element.type === 'server-item' && element.server.gallery && (element.marketplace || !element.server.local)) { - return 62; - } - if (element.type === 'server-item' && element.server.description?.trim()) { - return MCP_ITEM_WITH_DESCRIPTION_HEIGHT; - } - if (element.type === 'builtin-item' && element.description) { - return MCP_ITEM_WITH_DESCRIPTION_HEIGHT; - } - return MCP_ITEM_HEIGHT; - } - - getTemplateId(element: IMcpListEntry): string { - if (element.type === 'group-header') { - return 'mcpGroupHeader'; - } - if (element.type === 'builtin-item') { - return 'mcpServerItem'; - } - if (element.type === 'session-server-item') { - return 'mcpServerItem'; - } - const server = element.server; - return server.gallery && (element.marketplace || !server.local) ? MCP_GALLERY_ITEM_TEMPLATE_ID : 'mcpServerItem'; +export function getToggledMcpEnablementState(state: ContributionEnablementState): ContributionEnablementState { + switch (state) { + case ContributionEnablementState.EnabledWorkspace: + return ContributionEnablementState.DisabledWorkspace; + case ContributionEnablementState.DisabledWorkspace: + return ContributionEnablementState.EnabledWorkspace; + case ContributionEnablementState.EnabledProfile: + return ContributionEnablementState.DisabledProfile; + case ContributionEnablementState.DisabledProfile: + return ContributionEnablementState.EnabledProfile; } } @@ -376,16 +352,7 @@ export class McpServerItemRenderer implements IListRenderer Promise) => this.agentHostCustomizationService.showMcpServerLog(activeSessionResource, activeSessionServer.id, beforeShow) : undefined; if (state === McpServerStatus.AuthRequired && activeSessionServer !== undefined) { - const signInLabel = localize('signInToMcpServer', "Sign in to {0}", label); - const signInButton = templateData.actionDisposables.add(new Button(templateData.actions, { - ...defaultButtonStyles, - secondary: true, - small: true, - title: signInLabel, - ariaLabel: signInLabel, - })); - signInButton.label = localize('signIn', "Sign In"); - signInButton.element.classList.add('mcp-server-sign-in'); + const signInButton = createMcpSignInButton(templateData.actions, templateData.actionDisposables, label); registerMcpInlineButtonAction(templateData.actionDisposables, signInButton, async () => { signInButton.enabled = false; try { @@ -427,6 +394,20 @@ export class McpServerItemRenderer implements IListRenderer, serverLabel: string): Button { + const signInLabel = localize('signInToMcpServer', "Sign in to {0}", serverLabel); + const signInButton = store.add(new Button(parent, { + ...defaultButtonStyles, + secondary: true, + small: true, + title: signInLabel, + ariaLabel: signInLabel, + })); + signInButton.label = localize('signIn', "Sign In"); + signInButton.element.classList.add('mcp-server-sign-in'); + return signInButton; +} + /** Registers an inline MCP button without allowing its pointer or click events to open the containing list row. */ export function registerMcpInlineButtonAction(store: Pick, button: Button, action: () => void | Promise): void { store.add(DOM.addDisposableGenericMouseDownListener(button.element, event => DOM.EventHelper.stop(event, true))); @@ -580,10 +561,7 @@ function getMcpStatusKind(entry: IMcpServerItemEntry | IMcpSessionServerItemEntr return undefined; } -function getMcpEntryAriaLabel(element: IMcpListEntry, isSessionsWindow: boolean): string { - if (element.type === 'group-header') { - return localize('mcpGroupAriaLabel', "{0}, {1} items, {2}", element.label, element.count, element.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); - } +function getMcpEntryAriaLabel(element: IMcpInstalledEntry, isSessionsWindow: boolean): string { const label = getMcpEntryLabel(element); const statusKind = getMcpStatusKind(element, isSessionsWindow); const disabledReason = statusKind === 'disabled' ? getMcpDisabledReason(element) : undefined; @@ -702,6 +680,34 @@ export function getActiveSessionServerPresentation(server: AgentHostMcpServer): }; } +export function updateMcpCardRuntimePresentation( + statusBadge: HTMLElement, + primaryAction: HTMLElement, + description: HTMLElement, + statusKind: McpStatusKind | undefined, + disabledReason: CustomizationDisabledReason | undefined, + ariaLabel: string, + descriptionText: string, +): void { + const statusPresentation = getMcpStatusPresentation(statusKind, disabledReason); + statusBadge.className = 'plugin-list-item-status mcp-runtime-status-badge'; + statusBadge.style.display = statusPresentation ? '' : 'none'; + statusBadge.textContent = statusPresentation?.label ?? ''; + if (statusPresentation) { + statusBadge.classList.add(statusPresentation.className); + } + primaryAction.setAttribute('aria-label', ariaLabel); + description.textContent = descriptionText; +} + +export function shouldLoadMcpGallerySnapshot(visible: boolean, query: string, itemCount: number, failed: boolean, loading: boolean): boolean { + return visible && !query.trim() && itemCount === 0 && !failed && !loading; +} + +export function hasSameMcpMembership(previous: string, current: string): boolean { + return previous === current; +} + export function getActiveSessionServerLifecycleAction(server: AgentHostMcpServer): Action | undefined { if (!getActiveSessionServerPresentation(server).enabled) { return undefined; @@ -918,48 +924,59 @@ function createBuiltinEntry(server: IMcpServer, activeSessionServer?: AgentHostM }; } -const MCP_GALLERY_ITEM_TEMPLATE_ID = 'mcpGalleryItem'; - -/** Adapts a gallery MCP server entry to the shared gallery row renderer. */ -class McpGalleryItemProvider implements IGalleryItemProvider { - - constructor(private readonly mcpWorkbenchService: IMcpWorkbenchService) { } - - getLabel(element: IMcpServerItemEntry): string { - return element.server.label; +function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry): IMcpServerDetailInput { + if (entry.type === 'server-item') { + return createWorkbenchMcpServerDetailInput(entry.server); } - getPublisherDisplayName(element: IMcpServerItemEntry): string | undefined { - return element.server.publisherDisplayName; - } - - getDescription(element: IMcpServerItemEntry): string | undefined { - return element.server.description; - } - - getInstallState(element: IMcpServerItemEntry): GalleryItemInstallState { - switch (element.server.installState) { - case McpServerInstallState.Installed: return GalleryItemInstallState.Installed; - case McpServerInstallState.Installing: return GalleryItemInstallState.Installing; - default: return GalleryItemInstallState.Uninstalled; + const activeSessionServer = getActiveSessionServer(entry); + const localServer = entry.type === 'session-server-item' ? undefined : entry.localServer; + const localDefinition = localServer?.readDefinitions().get().server; + const localSource = localDefinition?.presentation?.origin; + const activeSessionSource = activeSessionServer?.sourceUri + ? { + uri: activeSessionServer.sourceUri, + range: activeSessionServer.sourceRange + ? new Range( + activeSessionServer.sourceRange.start.line + 1, + activeSessionServer.sourceRange.start.character + 1, + activeSessionServer.sourceRange.end.line + 1, + activeSessionServer.sourceRange.end.character + 1, + ) + : undefined, } - } + : undefined; - canInstall(element: IMcpServerItemEntry): boolean { - return this.mcpWorkbenchService.canInstall(element.server) === true; - } + return { + id: getMcpRowKey(entry), + name: getMcpEntryLabel(entry), + label: getMcpEntryLabel(entry), + config: localDefinition ? getMcpServerConfiguration(localDefinition) : undefined, + source: localSource ?? activeSessionSource, + }; +} - async install(element: IMcpServerItemEntry): Promise { - await this.mcpWorkbenchService.install(element.server); - } - - onDidChangeInstallState(element: IMcpServerItemEntry, listener: () => void) { - return this.mcpWorkbenchService.onChange(changed => { - if (!changed || changed.id === element.server.id) { - listener(); - } - }); +function getMcpServerConfiguration(definition: McpServerDefinition): IMcpServerConfiguration { + const launch = definition.launch; + if (launch.type === McpServerTransportType.HTTP) { + return { + type: McpServerType.REMOTE, + url: launch.uri.toString(true), + headers: launch.headers.length > 0 ? Object.fromEntries(launch.headers) : undefined, + oauth: launch.oauth, + dev: definition.devMode, + }; } + return { + type: McpServerType.LOCAL, + command: launch.command, + args: launch.args, + env: launch.env, + envFile: launch.envFile, + cwd: launch.cwd, + sandboxEnabled: definition.sandboxEnabled, + dev: definition.devMode, + }; } /** @@ -969,7 +986,7 @@ export class McpListWidget extends Disposable { readonly element: HTMLElement; - private readonly _onDidSelectServer = this._register(new Emitter()); + private readonly _onDidSelectServer = this._register(new Emitter()); readonly onDidSelectServer = this._onDidSelectServer.event; private readonly _onDidChangeItemCount = this._register(new Emitter()); @@ -982,8 +999,7 @@ export class McpListWidget extends Disposable { private sectionLink!: HTMLAnchorElement; private searchAndButtonContainer!: HTMLElement; private searchInput!: InputBox; - private listContainer!: HTMLElement; - private list!: WorkbenchList; + private cardContainer!: HTMLElement; private emptyContainer!: HTMLElement; private emptyText!: HTMLElement; private emptySubtext!: HTMLElement; @@ -991,26 +1007,33 @@ export class McpListWidget extends Disposable { private disabledIcon!: HTMLElement; private disabledMessage!: HTMLElement; private readonly disabledLinkListener = this._register(new MutableDisposable()); - private browseButton!: Button; - private backButton!: Button; - private addButton!: Button; + private installedAddButton!: Button | undefined; private filteredServers: IWorkbenchMcpServer[] = []; private filteredBuiltinCount = 0; private filteredActiveSessionCount = 0; - private displayEntries: IMcpListEntry[] = []; + private installedEntries: IMcpInstalledPresentation[] = []; + private gallerySnapshotServers: IWorkbenchMcpServer[] = []; private galleryServers: IWorkbenchMcpServer[] = []; private searchQuery: string = ''; - private browseMode: boolean = false; + private gallerySnapshotFailed = false; + private gallerySnapshotLoading = false; + private gallerySearchLoading = false; + private visible = false; + private firstCardFocusElement: HTMLElement | undefined; + private cardScrollElement: HTMLElement | undefined; + private availableSection: HTMLElement | undefined; + private narrowLayout = false; + private wideLayout = false; private lastHeight: number = 0; private lastWidth: number = 0; private lastHeaderHeight = 0; private _layoutDeferred = false; - private readonly collapsedGroups = new Set(); private galleryCts: CancellationTokenSource | undefined; + private readonly cardDisposables = this._register(new DisposableStore()); + private readonly cardListControllers = new WeakMap(); private readonly delayedFilter = new Delayer(200); private readonly delayedGallerySearch = new Delayer(400); - private _closeCustomizationEditor: () => Promise = () => Promise.resolve(); constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -1021,17 +1044,23 @@ export class McpListWidget extends Disposable { @IOpenerService private readonly openerService: IOpenerService, @IContextViewService private readonly contextViewService: IContextViewService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IHoverService private readonly hoverService: IHoverService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IDialogService private readonly dialogService: IDialogService, @IConfigurationService private readonly configurationService: IConfigurationService, @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + @INotificationService private readonly notificationService: INotificationService, ) { super(); - this.element = $('.mcp-list-widget'); + this.element = $('.mcp-list-widget.plugin-list-widget'); this.create(); + const resizeObserver = this._register(new DOM.DisposableResizeObserver( + 'McpListWidget', + () => this.updateResponsiveLayout(this.element.offsetWidth), + DOM.getWindow(this.element), + )); + this._register(resizeObserver.observe(this.element)); this.updateAccessState(); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(mcpAccessConfig)) { @@ -1045,10 +1074,6 @@ export class McpListWidget extends Disposable { }); } - setCloseCustomizationEditor(closeCustomizationEditor: () => Promise): void { - this._closeCustomizationEditor = closeCustomizationEditor; - } - private create(): void { // Section title header (title + description with inline learn more) at the top. this.sectionTitleHeader = DOM.append(this.element, $('.section-title-header')); @@ -1105,55 +1130,25 @@ export class McpListWidget extends Disposable { this._register(this.searchInput.onDidChange(() => { this.searchQuery = this.searchInput.value; - if (this.browseMode) { - this.delayedGallerySearch.trigger(() => this.queryGallery()); + this.galleryCts?.dispose(true); + this.searchInput.hideMessage(); + const query = this.searchQuery.toLowerCase().trim(); + this.galleryServers = query + ? this.gallerySnapshotServers.filter(server => this.matchesGalleryServerQuery(server, query)) + : [...this.gallerySnapshotServers]; + this.delayedFilter.trigger(() => this.filterServers()); + if (query) { + this.gallerySearchLoading = true; + this.delayedGallerySearch.trigger(() => this.queryMcpSearch()); } else { - this.delayedFilter.trigger(() => this.filterServers()); + this.gallerySearchLoading = false; + this.delayedGallerySearch.cancel(); + if (this.visible && this.gallerySnapshotServers.length === 0) { + this.delayedGallerySearch.trigger(() => this.queryGallerySnapshot()); + } } })); - // Button container (Browse Marketplace + Add Server) - const buttonContainer = DOM.append(this.searchAndButtonContainer, $('.list-button-group')); - - // Back button (visible only in marketplace browse mode) - const backButtonContainer = DOM.append(buttonContainer, $('.list-add-button-container')); - this.backButton = this._register(new Button(backButtonContainer, { - ...defaultButtonStyles, - secondary: true, - supportIcons: true, - title: localize('backToInstalled', "Back to installed servers"), - ariaLabel: localize('backToInstalled', "Back to installed servers") - })); - this.backButton.label = `$(${Codicon.arrowLeft.id}) ${localize('mcpBrowseBack', "Back")}`; - this.backButton.element.classList.add('list-add-button'); - backButtonContainer.style.display = 'none'; - this._register(this.backButton.onDidClick(() => { - this.toggleBrowseMode(false); - })); - - // Browse Marketplace button - const browseButtonContainer = DOM.append(buttonContainer, $('.list-add-button-container')); - this.browseButton = this._register(new Button(browseButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - this.browseButton.label = `$(${Codicon.library.id}) ${localize('browseMarketplace', "Browse Marketplace")}`; - this.browseButton.element.classList.add('list-add-button'); - this._register(this.browseButton.onDidClick(() => { - this.toggleBrowseMode(!this.browseMode); - })); - - this.addButton = this._register(new Button(buttonContainer, { - ...defaultButtonStyles, - secondary: true, - supportIcons: true, - title: localize('addServer', "Add Server"), - ariaLabel: localize('addServer', "Add Server") - })); - this.addButton.label = `$(${Codicon.add.id})`; - this.addButton.element.classList.add('list-icon-button'); - this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this.addButton.element, localize('addServerTooltip', "Add Server"))); - this._register(this.addButton.onDidClick(() => { - this.commandService.executeCommand(McpCommandIds.AddConfiguration); - })); - // Empty state this.emptyContainer = DOM.append(this.element, $('.mcp-empty-state')); const emptyHeader = DOM.append(this.emptyContainer, $('.empty-state-header')); @@ -1169,94 +1164,29 @@ export class McpListWidget extends Disposable { disabledText.textContent = localize('mcpAccessDisabledTitle', "MCP servers are disabled"); this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); - // List container - this.listContainer = DOM.append(this.element, $('.mcp-list-container')); - - // Create list - const delegate = new McpServerItemDelegate(); - const groupHeaderRenderer = new CustomizationGroupHeaderRenderer('mcpGroupHeader', this.hoverService); - const localRenderer = this.instantiationService.createInstance(McpServerItemRenderer, () => this._closeCustomizationEditor()); - const galleryRenderer = new GalleryItemRenderer(MCP_GALLERY_ITEM_TEMPLATE_ID, new McpGalleryItemProvider(this.mcpWorkbenchService)); - - this.list = this._register(this.instantiationService.createInstance( - WorkbenchList, - 'McpManagementList', - this.listContainer, - delegate, - [groupHeaderRenderer, localRenderer, galleryRenderer], - { - multipleSelectionSupport: false, - setRowLineHeight: false, - horizontalScrolling: false, - accessibilityProvider: { - getAriaLabel: (element: IMcpListEntry) => { - return getMcpEntryAriaLabel(element, this.workspaceService.isSessionsWindow); - }, - getWidgetAriaLabel() { - return localize('mcpServersListAriaLabel', "MCP Servers"); - } - }, - openOnSingleClick: true, - identityProvider: { - getId(element: IMcpListEntry) { - if (element.type === 'group-header') { - return element.id; - } - if (element.type === 'builtin-item') { - return element.id; - } - return element.server.id; - }, - getGroupId(element: IMcpListEntry) { - return element.type === 'group-header' ? NotSelectableGroupId : 0; - } - } - } - )); - - this._register(this.list.onDidOpen(e => { - if (e.element) { - if (e.element.type === 'group-header') { - this.toggleGroup(e.element); - } else if (e.element.type === 'server-item') { - // Marketplace entries are always selectable; installed rows only open - // detail when there is something extra to show beyond the row. - const server = e.element.server; - const isGallery = e.element.marketplace || !server.local; - if (isGallery || server.description) { - this._onDidSelectServer.fire(server); - } - } else if (e.element.type === 'session-server-item') { - this.openActiveSessionServerOptions(e.element.server); - } - // builtin-item: no action on click (read-only) - } - })); - - // Handle context menu - this._register(this.list.onContextMenu(e => this.onContextMenu(e as IListContextMenuEvent))); + this.cardContainer = DOM.append(this.element, $('.plugin-card-container')); + this.cardContainer.style.display = 'none'; // Listen to MCP service changes this._register(this.mcpWorkbenchService.onChange(() => { - if (!this.browseMode) { - this.refresh(); - } + this.refresh(); })); this._register(autorun(reader => { - this.mcpService.servers.read(reader); - if (!this.browseMode) { - this.refresh(); + const servers = this.mcpService.servers.read(reader); + for (const server of servers) { + server.enablement.read(reader); } + this.refresh(); })); this._register(autorun(reader => { this.customizationHarnessService.activeSessionResource.read(reader); - if (!this.browseMode) { - this.refresh(); - } + this.refresh(); })); this._register(this.agentHostCustomizationService.onDidChangeCustomizations(() => { - if (!this.browseMode) { - this.refresh(); + const previousMembership = this.getInstalledEntryMembershipSignature(); + this.filterServers(false); + if (!hasSameMcpMembership(previousMembership, this.getInstalledEntryMembershipSignature())) { + this.renderFilteredServers(); } })); @@ -1265,10 +1195,19 @@ export class McpListWidget extends Disposable { } private async refresh(): Promise { - if (this.browseMode) { - await this.queryGallery(); - } else { - this.filterServers(); + this.filterServers(); + if (shouldLoadMcpGallerySnapshot(this.visible, this.searchQuery, this.gallerySnapshotServers.length, this.gallerySnapshotFailed, this.gallerySnapshotLoading)) { + await this.queryGallerySnapshot(); + } + } + + setVisible(visible: boolean): void { + if (this.visible === visible) { + return; + } + this.visible = visible; + if (visible) { + void this.refresh(); } } @@ -1303,94 +1242,525 @@ export class McpListWidget extends Disposable { } public showBrowseMarketplace(): void { - if (!this.browseMode) { - this.toggleBrowseMode(true); - } - } - - private toggleBrowseMode(browse: boolean): void { - this.browseMode = browse; this.searchInput.value = ''; this.searchQuery = ''; - - // Update UI for browse vs installed mode - this.addButton.element.style.display = browse ? 'none' : ''; - this.browseButton.element.parentElement!.style.display = browse ? 'none' : ''; - this.backButton.element.parentElement!.style.display = browse ? '' : 'none'; - - this.searchInput.setPlaceHolder(browse - ? localize('searchGalleryPlaceholder', "Search MCP marketplace...") - : localize('searchMcpPlaceholder', "Type to search...") - ); - - if (browse) { - void this.queryGallery(); - } else { - this.galleryCts?.dispose(true); - this.galleryServers = []; - this.filterServers(); - } - - // Re-layout to account for the back link height change - if (this.lastHeight > 0) { - this.layout(this.lastHeight, this.lastWidth); - } + void this.queryGallerySnapshot(true); } - private async queryGallery(): Promise { + private async queryGallerySnapshot(revealMarketplace = false): Promise { this.galleryCts?.dispose(true); const cts = this.galleryCts = new CancellationTokenSource(); - - // Show loading state - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; - this.emptyText.textContent = localize('loadingGallery', "Loading marketplace..."); - this.emptySubtext.textContent = ''; + this.gallerySnapshotLoading = true; try { - const pager = await this.mcpWorkbenchService.queryGallery( - { text: this.searchQuery.trim() || undefined }, - cts.token, - ); - - if (cts.token.isCancellationRequested) { + const pager = await this.mcpWorkbenchService.queryGallery(undefined, cts.token); + if (cts.token.isCancellationRequested || this.searchQuery.trim()) { return; } - this.galleryServers = pager.firstPage.items; - this.updateGalleryList(); + this.gallerySnapshotServers = pager.firstPage.items; + this.galleryServers = [...this.gallerySnapshotServers]; + this.gallerySnapshotFailed = false; + this.gallerySnapshotLoading = false; + this.renderMcpHome(); + if (revealMarketplace) { + this.availableSection?.scrollIntoView({ block: 'start' }); + } } catch { if (!cts.token.isCancellationRequested) { + this.gallerySnapshotServers = []; this.galleryServers = []; - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; - this.emptyText.textContent = localize('galleryError', "Unable to load marketplace"); - this.emptySubtext.textContent = localize('tryAgainLater', "Check your connection and try again"); + this.gallerySnapshotFailed = true; + this.gallerySnapshotLoading = false; + this.renderMcpHome(); + } + } finally { + if (this.galleryCts === cts) { + this.gallerySnapshotLoading = false; } } } - private updateGalleryList(): void { - if (this.galleryServers.length === 0) { - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; - if (this.searchQuery.trim()) { - this.emptyText.textContent = localize('noGalleryResults', "No servers match '{0}'", this.searchQuery); - this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); - } else { - this.emptyText.textContent = localize('emptyGallery', "No MCP servers available"); - this.emptySubtext.textContent = ''; + private async queryMcpSearch(): Promise { + const query = this.searchQuery.trim(); + if (!query) { + return; + } + + this.galleryCts?.dispose(true); + const cts = this.galleryCts = new CancellationTokenSource(); + this.gallerySearchLoading = true; + try { + const pager = await this.mcpWorkbenchService.queryGallery({ text: query }, cts.token); + if (cts.token.isCancellationRequested || this.searchQuery.trim() !== query) { + return; } + this.galleryServers = pager.firstPage.items; + this.searchInput.hideMessage(); + } catch { + if (!cts.token.isCancellationRequested) { + this.galleryServers = this.gallerySnapshotServers.filter(server => this.matchesGalleryServerQuery(server, query.toLowerCase())); + this.searchInput.showMessage({ + content: localize('mcpSearchMarketplaceUnavailable', "Marketplace results are unavailable. Showing installed MCP servers only."), + type: MessageType.WARNING, + }); + } + } finally { + if (this.galleryCts === cts) { + this.gallerySearchLoading = false; + this.filterServers(); + } + } + } + + private showCardSurface(): void { + this.emptyContainer.style.display = 'none'; + this.cardContainer.style.display = ''; + } + + private showEmptySurface(message: string, detail: string): void { + this.cardContainer.style.display = 'none'; + this.emptyContainer.style.display = 'flex'; + this.emptyText.textContent = message; + this.emptySubtext.textContent = detail; + } + + private addSurfaceActivation(surface: HTMLElement, label: string, callback: () => void, ...classNames: string[]): HTMLButtonElement { + const primaryAction = createCustomizationCardPrimaryAction(surface, label, ...classNames); + this.firstCardFocusElement ??= primaryAction; + this.cardDisposables.add(DOM.addDisposableListener(primaryAction, 'click', callback)); + return primaryAction; + } + + private renderCardSection(parent: HTMLElement, title: string, description: string | undefined, className: string, count?: number, renderActions?: (header: HTMLElement) => void): HTMLElement { + const section = DOM.append(parent, $('.plugin-card-section')); + section.classList.add(className); + const header = DOM.append(section, $('.plugin-card-section-header')); + const text = DOM.append(header, $('.plugin-card-section-text')); + const headingRow = DOM.append(text, $('.plugin-card-section-heading-row')); + const heading = DOM.append(headingRow, $('h3.plugin-card-section-title')); + heading.textContent = title; + if (count !== undefined) { + const countElement = DOM.append(headingRow, $('.plugin-card-section-count')); + countElement.textContent = String(count); + } + if (description) { + const descriptionElement = DOM.append(text, $('.plugin-card-section-description')); + descriptionElement.textContent = description; + } + renderActions?.(header); + const list = DOM.append(section, $('.plugin-card-grid')); + this.cardListControllers.set(list, this.cardDisposables.add(new CustomizationCardListController(list, title))); + return list; + } + + private renderMcpHome(): void { + if (this.searchQuery.trim()) { + return; + } + + this.cardDisposables.clear(); + this.installedAddButton = undefined; + this.firstCardFocusElement = undefined; + this.availableSection = undefined; + DOM.clearNode(this.cardContainer); + this.showCardSurface(); + + const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + this.renderFeaturedServers(content); + + const installedList = this.renderCardSection( + content, + localize('installedMcpServersSection', "Installed"), + undefined, + 'installed-mcp-servers-section', + this.installedEntries.length, + header => this.renderInstalledSectionActions(header), + ); + installedList.classList.add('plugin-inventory-list'); + if (this.installedEntries.length === 0) { + const empty = DOM.append(installedList, $('.plugin-inventory-empty')); + empty.textContent = localize('noInstalledMcpServers', "No MCP servers are installed."); } else { - this.emptyContainer.style.display = 'none'; - this.listContainer.style.display = ''; + for (const presentation of this.installedEntries) { + this.appendInstalledServerRow(installedList, presentation); + } } + this.cardListControllers.get(installedList)?.finalize(); - const entries: IMcpListEntry[] = this.galleryServers.map(server => ({ type: 'server-item' as const, server, marketplace: true })); - this.list.splice(0, this.list.length, entries); + this.renderAvailableServers(content, this.getAvailableGalleryServers(), true); } - private filterServers(): void { + private renderInstalledSectionActions(header: HTMLElement): void { + const actions = DOM.append(header, $('.plugin-card-section-actions')); + const addLabel = localize('addServer', "Add Server"); + const add = this.installedAddButton = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, ariaLabel: addLabel })); + add.element.classList.add('plugin-installed-action'); + add.label = this.narrowLayout ? localize('addServerNarrow', "Add") : addLabel; + this.firstCardFocusElement ??= add.element; + this.cardDisposables.add(add.onDidClick(() => this.commandService.executeCommand(McpCommandIds.AddConfiguration))); + } + + private renderFeaturedServers(parent: HTMLElement): void { + const featured = this.getAvailableGalleryServers().slice(0, 3); + if (featured.length === 0) { + if (this.gallerySnapshotFailed) { + const grid = this.renderCardSection( + parent, + localize('mcpMarketplaceUnavailable', "Featured MCP servers could not be loaded"), + localize('mcpMarketplaceUnavailableDescription', "Check your connection, then try loading marketplace results again."), + 'plugin-discovery-section', + ); + const retry = this.cardDisposables.add(new Button(grid, { ...defaultButtonStyles, secondary: true, ariaLabel: localize('retryMcpMarketplace', "Retry Loading MCP Servers") })); + retry.label = localize('retry', "Retry"); + this.cardDisposables.add(retry.onDidClick(() => { + this.gallerySnapshotFailed = false; + void this.queryGallerySnapshot(); + })); + } + return; + } + + const grid = this.renderCardSection( + parent, + localize('featuredMcpServers', "Featured"), + localize('featuredMcpServersDescription', "Discover MCP servers that connect agents to popular tools and services."), + 'plugin-discovery-section', + ); + for (const server of featured) { + this.appendMarketplaceServerCard(grid, server); + } + this.cardListControllers.get(grid)?.finalize(); + } + + private renderAvailableServers(parent: HTMLElement, servers: readonly IWorkbenchMcpServer[], showDescription: boolean): void { + const availableList = this.renderCardSection( + parent, + localize('availableMcpServersSection', "Available"), + showDescription ? localize('availableMcpServersSectionDescription', "Browse and install MCP servers from the marketplace.") : undefined, + 'available-mcp-servers-section', + servers.length, + ); + this.availableSection = availableList.parentElement ?? undefined; + availableList.classList.add('plugin-inventory-list'); + if (servers.length === 0) { + const empty = DOM.append(availableList, $('.plugin-inventory-empty')); + empty.textContent = this.gallerySnapshotLoading + ? localize('loadingMcpMarketplace', "Loading marketplace MCP servers...") + : localize('noAvailableMcpServers', "No marketplace MCP servers are available."); + this.cardListControllers.get(availableList)?.finalize(); + return; + } + for (const server of servers) { + this.appendMarketplaceServerRow(availableList, server); + } + this.cardListControllers.get(availableList)?.finalize(); + } + + private appendInstalledServerRow(parent: HTMLElement, presentation: IMcpInstalledPresentation): void { + let entry = presentation.entry; + const rowKey = getMcpRowKey(entry); + const label = getMcpEntryLabel(entry); + const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.mcp-installed-home-row')); + const enabled = this.isInstalledEntryEnabled(entry); + row.classList.toggle('disabled', !enabled); + + const primaryAction = this.addSurfaceActivation(row, getMcpEntryAriaLabel(entry, this.workspaceService.isSessionsWindow), () => this._onDidSelectServer.fire(createInstalledMcpServerDetailInput(entry))); + + const details = DOM.append(primaryAction, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + name.textContent = formatDisplayName(label); + name.title = label; + const statusBadge = DOM.append(nameRow, $('.plugin-list-item-status.mcp-runtime-status-badge')); + const description = DOM.append(details, $('.plugin-list-item-description')); + + const actions = DOM.append(row, $('.plugin-list-item-action')); + const getEntry = () => entry; + const signIn = this.appendInstalledServerSignIn(actions, getEntry); + const toggle = this.appendInstalledServerToggle(actions, getEntry); + const more = this.cardDisposables.add(new Button(actions, { ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), secondary: true, supportIcons: true, ariaLabel: localize('mcpMoreActionsAria', "More actions for {0}", label) })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + this.cardDisposables.add(more.onDidClick(() => this.showMcpServerActions(entry, more.element))); + this.cardListControllers.get(parent)?.addItem({ + row, + primaryAction, + label, + actions: [signIn?.element, toggle.element, more.element].filter((action): action is HTMLElement => action !== undefined), + contextMenuAction: more.element, + }); + + this.cardDisposables.add(autorun(reader => { + if (entry.type !== 'session-server-item') { + entry.localServer?.connectionState.read(reader); + } + updateMcpCardRuntimePresentation( + statusBadge, + primaryAction, + description, + getMcpStatusKind(entry, this.workspaceService.isSessionsWindow), + getMcpDisabledReason(entry), + getMcpEntryAriaLabel(entry, this.workspaceService.isSessionsWindow), + this.getInstalledEntryDescription(entry), + ); + })); + this.cardDisposables.add(this.agentHostCustomizationService.onDidChangeCustomizations(() => { + const updated = this.installedEntries.find(candidate => getMcpRowKey(candidate.entry) === rowKey)?.entry; + if (!updated) { + return; + } + entry = updated; + updateMcpCardRuntimePresentation( + statusBadge, + primaryAction, + description, + getMcpStatusKind(entry, this.workspaceService.isSessionsWindow), + getMcpDisabledReason(entry), + getMcpEntryAriaLabel(entry, this.workspaceService.isSessionsWindow), + this.getInstalledEntryDescription(entry), + ); + signIn?.update(); + toggle.update(); + row.classList.toggle('disabled', !this.isInstalledEntryEnabled(entry)); + })); + } + + private appendInstalledServerSignIn(parent: HTMLElement, getEntry: () => IMcpInstalledEntry): { readonly element: HTMLElement; update(): void } | undefined { + if (getActiveSessionServer(getEntry()) === undefined) { + return undefined; + } + + const label = getMcpEntryLabel(getEntry()); + const signInButton = createMcpSignInButton(parent, this.cardDisposables, label); + registerMcpInlineButtonAction(this.cardDisposables, signInButton, async () => { + const activeSessionServer = getActiveSessionServer(getEntry()); + if (!activeSessionServer) { + return; + } + signInButton.enabled = false; + try { + await authenticateMcpServer(this.agentHostCustomizationService, this.customizationHarnessService.activeSessionResource.get(), activeSessionServer.id); + } catch (error) { + this.notificationService.error(localize('mcpAuthenticationFailed', "Unable to sign in to {0}: {1}", label, getErrorMessage(error))); + } finally { + signInButton.enabled = true; + } + }); + const update = () => { + signInButton.element.style.display = getMcpStatusKind(getEntry(), this.workspaceService.isSessionsWindow) === McpServerStatus.AuthRequired ? '' : 'none'; + }; + update(); + return { element: signInButton.element, update }; + } + + private appendInstalledServerToggle(parent: HTMLElement, getEntry: () => IMcpInstalledEntry): { readonly element: HTMLButtonElement; update(): void } { + const label = getMcpEntryLabel(getEntry()); + let enabled = this.isInstalledEntryEnabled(getEntry()); + const switchElement = DOM.append(parent, $('button.plugin-enable-switch')) as HTMLButtonElement; + switchElement.type = 'button'; + switchElement.setAttribute('role', 'switch'); + switchElement.setAttribute('aria-checked', String(enabled)); + const updateLabel = () => { + const blockedByPlugin = getMcpDisabledReason(getEntry())?.source === 'plugin'; + const toggleLabel = enabled + ? localize('disableMcpServerAria', "Disable {0}", label) + : localize('enableMcpServerAria', "Enable {0}", label); + const accessibleLabel = blockedByPlugin + ? localize('mcpServerManagedByPluginAria', "{0} is disabled by its plugin", label) + : toggleLabel; + switchElement.setAttribute('aria-label', accessibleLabel); + switchElement.title = accessibleLabel; + }; + switchElement.classList.toggle('checked', enabled); + updateLabel(); + DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + this.cardDisposables.add(DOM.addDisposableListener(switchElement, 'click', () => { + enabled = !enabled; + switchElement.classList.toggle('checked', enabled); + switchElement.setAttribute('aria-checked', String(enabled)); + updateLabel(); + this.setInstalledEntryEnabled(getEntry(), enabled); + status(enabled + ? localize('mcpServerEnabledStatus', "{0} enabled.", label) + : localize('mcpServerDisabledStatus', "{0} disabled.", label)); + })); + const update = () => { + enabled = this.isInstalledEntryEnabled(getEntry()); + switchElement.disabled = getMcpDisabledReason(getEntry())?.source === 'plugin'; + switchElement.classList.toggle('checked', enabled); + switchElement.setAttribute('aria-checked', String(enabled)); + updateLabel(); + }; + update(); + return { element: switchElement, update }; + } + + private appendMarketplaceServerRow(parent: HTMLElement, server: IWorkbenchMcpServer): void { + const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-marketplace-home-row')); + const primaryAction = this.addSurfaceActivation(row, localize('marketplaceMcpServerRowAriaLabel', "{0}. Available to install from the MCP marketplace.", server.label), () => this._onDidSelectServer.fire(createWorkbenchMcpServerDetailInput(server))); + const details = DOM.append(primaryAction, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + name.textContent = server.label; + name.title = server.label; + const description = DOM.append(details, $('.plugin-list-item-description')); + description.textContent = truncateToFirstLine(server.description || localize('mcpNoDescription', "No description provided.")); + const actions = DOM.append(row, $('.plugin-list-item-action')); + const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installMcpServerAria', "Install {0}", server.label) })); + install.element.classList.add('plugin-list-item-install-button'); + install.label = localize('install', "Install"); + this.cardDisposables.add(install.onDidClick(() => this.installMarketplaceServer(server, install))); + this.cardListControllers.get(parent)?.addItem({ + row, + primaryAction, + label: server.label, + actions: [install.element], + }); + } + + private appendMarketplaceServerCard(parent: HTMLElement, server: IWorkbenchMcpServer): void { + const card = DOM.append(parent, $('.plugin-card.plugin-marketplace-card')); + const header = DOM.append(card, $('.plugin-card-header')); + const titleBlock = this.addSurfaceActivation(header, localize('marketplaceMcpServerCardAriaLabel', "{0}. Featured MCP server available to install.", server.label), () => this._onDidSelectServer.fire(createWorkbenchMcpServerDetailInput(server)), 'plugin-card-title-block'); + const name = DOM.append(titleBlock, $('.plugin-card-title')); + name.textContent = server.label; + name.title = server.label; + const description = DOM.append(titleBlock, $('.plugin-card-subtitle')); + description.textContent = truncateToFirstLine(server.description || localize('mcpNoDescription', "No description provided.")); + const actions = DOM.append(header, $('.plugin-card-actions')); + const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installMcpServerAria', "Install {0}", server.label) })); + install.label = localize('install', "Install"); + this.cardDisposables.add(install.onDidClick(() => this.installMarketplaceServer(server, install))); + this.cardListControllers.get(parent)?.addItem({ + row: card, + primaryAction: titleBlock, + label: server.label, + actions: [install.element], + }); + } + + private async installMarketplaceServer(server: IWorkbenchMcpServer, button: Button): Promise { + button.label = localize('installing', "Installing..."); + button.enabled = false; + try { + await this.mcpWorkbenchService.install(server); + status(localize('mcpServerInstalledStatus', "{0} installed.", server.label)); + await this.refresh(); + } catch (error) { + button.label = localize('install', "Install"); + button.enabled = true; + this.notificationService.error(localize('mcpInstallFailed', "Unable to install MCP server: {0}", getErrorMessage(error))); + } + } + + private getAvailableGalleryServers(): IWorkbenchMcpServer[] { + const installedKeys = new Set(); + for (const presentation of this.installedEntries) { + const entry = presentation.entry; + if (entry.type === 'server-item') { + for (const key of getWorkbenchServerMatchKeys(entry.server)) { + installedKeys.add(key.toLowerCase()); + } + } else if (entry.type === 'builtin-item') { + installedKeys.add(entry.label.toLowerCase()); + if (entry.localServer) { + for (const key of getRuntimeServerMatchKeys(entry.localServer)) { + installedKeys.add(key.toLowerCase()); + } + } + } else { + installedKeys.add(entry.server.name.toLowerCase()); + } + } + return this.galleryServers.filter(server => + server.installState === McpServerInstallState.Uninstalled + && !getWorkbenchServerMatchKeys(server).some(key => installedKeys.has(key.toLowerCase())) + ); + } + + private matchesGalleryServerQuery(server: IWorkbenchMcpServer, query: string): boolean { + return server.label.toLowerCase().includes(query) + || server.description.toLowerCase().includes(query) + || server.publisherDisplayName?.toLowerCase().includes(query) === true; + } + + private getInstalledEntryDescription(entry: IMcpInstalledEntry): string { + const description = entry.type === 'server-item' + ? entry.server.description + : entry.type === 'builtin-item' + ? entry.description + : ''; + return truncateToFirstLine(description || localize('mcpNoDescription', "No description provided.")); + } + + private isInstalledEntryEnabled(entry: IMcpInstalledEntry): boolean { + const activeSessionServer = getActiveSessionServer(entry); + if (activeSessionServer) { + return activeSessionServer.enabled; + } + const localServer = entry.type === 'session-server-item' ? undefined : entry.localServer; + if (localServer) { + return isContributionEnabled(localServer.enablement.get()); + } + if (entry.type === 'server-item') { + return isContributionEnabled(this.mcpService.enablementModel.readEnabled(entry.server.id)); + } + return true; + } + + private setInstalledEntryEnabled(entry: IMcpInstalledEntry, enabled: boolean): void { + const activeSessionServer = getActiveSessionServer(entry); + if (activeSessionServer) { + activeSessionServer.setEnabled(enabled); + return; + } + const localServer = entry.type === 'session-server-item' ? undefined : entry.localServer; + const serverId = localServer?.definition.id ?? (entry.type === 'server-item' ? entry.server.id : undefined); + if (!serverId) { + return; + } + const current = this.mcpService.enablementModel.readEnabled(serverId); + const next = getToggledMcpEnablementState(current); + if (isContributionEnabled(next) !== enabled) { + throw new Error(`Unexpected MCP enablement transition for ${serverId}.`); + } + this.mcpService.enablementModel.setEnabled(serverId, next); + } + + private updateSearchResults(): void { + const available = this.getAvailableGalleryServers(); + if (this.installedEntries.length === 0 && available.length === 0) { + this.showEmptySurface( + this.gallerySearchLoading + ? localize('searchingMcpMarketplace', "Searching the MCP marketplace...") + : localize('noMatchingServers', "No servers match '{0}'", this.searchQuery), + this.gallerySearchLoading ? '' : localize('tryDifferentSearch', "Try a different search term"), + ); + return; + } + + this.cardDisposables.clear(); + this.installedAddButton = undefined; + this.firstCardFocusElement = undefined; + this.availableSection = undefined; + DOM.clearNode(this.cardContainer); + this.showCardSurface(); + const content = this.cardScrollElement = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-search-results')); + if (this.installedEntries.length > 0) { + const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-mcp-servers-section', this.installedEntries.length); + installedList.classList.add('plugin-inventory-list'); + for (const presentation of this.installedEntries) { + this.appendInstalledServerRow(installedList, presentation); + } + this.cardListControllers.get(installedList)?.finalize(); + } + if (available.length > 0) { + this.renderAvailableServers(content, available, false); + } + } + + private filterServers(render = true): void { const query = this.searchQuery.toLowerCase().trim(); const activeSessionResource = this.customizationHarnessService.activeSessionResource.get(); const activeSessionMatcher = new ActiveSessionMcpServerMatcher(this.agentHostCustomizationService.getMcpServers(activeSessionResource)); @@ -1413,9 +1783,9 @@ export class McpListWidget extends Disposable { .filter(s => isMcpServerCollectionVisible(s.collection.id, hiddenCollectionIds)) .filter(s => !query || s.definition.label.toLowerCase().includes(query)); - const groups: { scope: LocalMcpServerScope; label: string; icon: ThemeIcon; description: string; entries: Array }[] = [ - { scope: LocalMcpServerScope.Workspace, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "MCP servers configured in your workspace or reported by the active session."), entries: [] }, - { scope: LocalMcpServerScope.User, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "MCP servers configured in your user settings. Private to you and available across all projects."), entries: [] }, + const groups: { entries: Array }[] = [ + { entries: [] }, + { entries: [] }, ]; for (const server of this.filteredServers) { @@ -1454,121 +1824,37 @@ export class McpListWidget extends Disposable { } const activeSessionOnlyServers = activeSessionMatcher.unmatched(query); const activeSessionBuiltinEntries = createBuiltinActiveSessionMcpEntries(activeSessionOnlyServers); - - // Show empty state only when there are no servers at all (not when filtered to empty) - if (this.filteredServers.length === 0 && builtinServers.length === 0 && activeSessionOnlyServers.length === 0) { - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; - - if (this.searchQuery.trim()) { - // Search with no results - this.emptyText.textContent = localize('noMatchingServers', "No servers match '{0}'", this.searchQuery); - this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); - } else { - // No servers configured - this.emptyText.textContent = localize('noMcpServers', "No MCP servers configured"); - this.emptySubtext.textContent = localize('addMcpServer', "Add an MCP server configuration to get started"); - } - } else { - this.emptyContainer.style.display = 'none'; - this.listContainer.style.display = ''; - } - - const entries: IMcpListEntry[] = []; - let isFirst = true; - for (const group of groups) { - if (group.entries.length === 0) { - continue; - } - const collapsed = this.collapsedGroups.has(group.scope); - entries.push({ - type: 'group-header', - id: `mcp-group-${group.scope}`, - scope: group.scope, - label: group.label, - icon: group.icon, - count: group.entries.length, - isFirst, - description: group.description, - collapsed, - }); - if (!collapsed) { - entries.push(...group.entries); - } - isFirst = false; - } - - if (pluginServers.length > 0) { - const collapsed = this.collapsedGroups.has('plugin'); - entries.push({ - type: 'group-header', - id: 'mcp-group-plugin', - scope: 'plugin', - label: localize('pluginGroup', "Plugins"), - icon: pluginIcon, - count: pluginServers.length, - isFirst, - description: localize('pluginGroupDescription', "MCP servers provided by installed plugins."), - collapsed, - }); - if (!collapsed) { - for (const { server, activeSessionServer } of pluginServers) { - entries.push(createBuiltinEntry(server, activeSessionServer)); - } - } - isFirst = false; - } - - if (extensionServers.length > 0) { - const collapsed = this.collapsedGroups.has('extension'); - entries.push({ - type: 'group-header', - id: 'mcp-group-extension', - scope: 'extension', - label: localize('extensionGroup', "Extensions"), - icon: extensionIcon, - count: extensionServers.length, - isFirst, - description: localize('extensionGroupDescription', "MCP servers contributed by installed VS Code extensions."), - collapsed, - }); - if (!collapsed) { - for (const { server, activeSessionServer } of extensionServers) { - entries.push(createBuiltinEntry(server, activeSessionServer)); - } - } - isFirst = false; - } - - if (otherBuiltinServers.length > 0 || activeSessionBuiltinEntries.length > 0) { - const collapsed = this.collapsedGroups.has('builtin'); - entries.push({ - type: 'group-header', - id: 'mcp-group-builtin', - scope: 'builtin', - label: localize('builtInGroup', "Built-in"), - icon: builtinIcon, - count: otherBuiltinServers.length + activeSessionBuiltinEntries.length, - isFirst, - description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), - collapsed, - }); - if (!collapsed) { - for (const { server, activeSessionServer } of otherBuiltinServers) { - entries.push(createBuiltinEntry(server, activeSessionServer)); - } - entries.push(...activeSessionBuiltinEntries); - } - isFirst = false; - } - - this.displayEntries = entries; - this.list.splice(0, this.list.length, this.displayEntries); + this.installedEntries = [ + ...groups.flatMap(group => group.entries.map(entry => ({ entry }))), + ...pluginServers.map(({ server, activeSessionServer }) => ({ entry: createBuiltinEntry(server, activeSessionServer) })), + ...extensionServers.map(({ server, activeSessionServer }) => ({ entry: createBuiltinEntry(server, activeSessionServer) })), + ...otherBuiltinServers.map(({ server, activeSessionServer }) => ({ entry: createBuiltinEntry(server, activeSessionServer) })), + ...activeSessionBuiltinEntries.map(entry => ({ entry })), + ]; // Compute sidebar badge directly from the data arrays (same source as group headers) this.filteredBuiltinCount = builtinServers.length; this.filteredActiveSessionCount = activeSessionOnlyServers.length; this._onDidChangeItemCount.fire(this.itemCount); + if (render) { + this.renderFilteredServers(); + } + } + + private renderFilteredServers(): void { + if (this.searchQuery.trim()) { + this.updateSearchResults(); + } else { + this.renderMcpHome(); + } + } + + private getInstalledEntryMembershipSignature(): string { + return this.installedEntries.map(({ entry }) => [ + getMcpRowKey(entry), + getActiveSessionServer(entry) ? 'session' : '', + entry.type !== 'session-server-item' && entry.localServer ? 'local' : '', + ].join(':')).join('|'); } /** @@ -1587,33 +1873,11 @@ export class McpListWidget extends Disposable { this._onDidChangeItemCount.fire(this.itemCount); } - /** - * Toggles the collapsed state of a group. - */ - private toggleGroup(entry: IMcpGroupHeaderEntry): void { - if (this.collapsedGroups.has(entry.scope)) { - this.collapsedGroups.delete(entry.scope); - } else { - this.collapsedGroups.add(entry.scope); - } - this.filterServers(); - } - - /** - * Whether the widget is currently in marketplace browse mode. - */ isInBrowseMode(): boolean { - return this.browseMode; + return false; } - /** - * Exits marketplace browse mode and returns to the installed servers list. - */ - exitBrowseMode(): void { - if (this.browseMode) { - this.toggleBrowseMode(false); - } - } + exitBrowseMode(): void { } /** * Layouts the widget. @@ -1622,9 +1886,9 @@ export class McpListWidget extends Disposable { this.lastHeight = height; this.lastWidth = width; - this.element.style.height = ''; + this.element.style.height = `${height}px`; + this.updateResponsiveLayout(width); const availableHeight = this.element.clientHeight || height; - const availableWidth = this.element.clientWidth || width; // Measure sibling elements to calculate the list height. // When offsetHeight returns 0 the container may have just become visible @@ -1647,8 +1911,7 @@ export class McpListWidget extends Disposable { this.lastHeaderHeight = headerHeight; const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); - this.listContainer.style.height = `${listHeight}px`; - this.list.layout(listHeight, availableWidth); + this.cardContainer.style.height = `${listHeight}px`; } /** @@ -1662,8 +1925,8 @@ export class McpListWidget extends Disposable { * Scrolls the list so the last item is visible. */ revealLastItem(): void { - if (this.list.length > 0) { - this.list.reveal(this.list.length - 1); + if (this.cardScrollElement) { + this.cardScrollElement.scrollTop = this.cardScrollElement.scrollHeight; } } @@ -1671,60 +1934,68 @@ export class McpListWidget extends Disposable { * Focuses the list. */ focus(): void { - this.list.domFocus(); - const servers = this.list.length; - if (servers > 0) { - this.list.setFocus([0]); + if (this.cardContainer.style.display !== 'none') { + this.firstCardFocusElement?.focus(); } } - private openActiveSessionServerOptions(server: AgentHostMcpServer): void { - void this.commandService.executeCommand(McpCommandIds.AgentHostServerOptions, this.customizationHarnessService.activeSessionResource.get(), server.id); + private updateResponsiveLayout(width: number): void { + const narrow = width < 500; + const wide = width >= 600; + if (this.narrowLayout === narrow && this.wideLayout === wide) { + return; + } + this.narrowLayout = narrow; + this.wideLayout = wide; + this.element.classList.toggle('narrow-layout', narrow); + this.element.classList.toggle('wide-layout', wide); + if (this.installedAddButton) { + this.installedAddButton.label = narrow ? localize('addServerNarrow', "Add") : localize('addServer', "Add Server"); + } } - /** - * Handles context menu for MCP server items. - */ - private onContextMenu(e: IListContextMenuEvent): void { - if (!e.element) { + private showMcpServerActions(entry: IMcpInstalledEntry, anchor: HTMLElement): void { + const disposables = new DisposableStore(); + const actions = this.getMcpServerActions(entry, disposables); + if (actions.length === 0) { + disposables.dispose(); return; } + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions, + onHide: () => disposables.dispose(), + }); + } - if (e.element.type === 'session-server-item') { - const disposables = new DisposableStore(); - const activeSessionActions = getActiveSessionServerOptionsActions(this.commandService, this.agentHostCustomizationService, this.agentPluginService, this.customizationHarnessService.activeSessionResource.get(), e.element.server); - activeSessionActions.forEach(action => isDisposable(action) && disposables.add(action)); - this.contextMenuService.showContextMenu({ - getAnchor: () => e.anchor, - getActions: () => activeSessionActions, - onHide: () => disposables.dispose(), - }); - return; + private getMcpServerActions(entry: IMcpInstalledEntry, disposables: DisposableStore): IAction[] { + if (entry.type === 'session-server-item') { + const actions = getActiveSessionServerOptionsActions(this.commandService, this.agentHostCustomizationService, this.agentPluginService, this.customizationHarnessService.activeSessionResource.get(), entry.server); + actions.forEach(action => isDisposable(action) && disposables.add(action)); + return actions; } - // Built-in rows use IMcpService for durable enablement and the agent host for session enablement. - if (e.element.type === 'builtin-item') { - const collectionId = e.element.collectionId; + if (entry.type === 'builtin-item') { + const collectionId = entry.collectionId; const pluginUriStr = getPluginUriFromCollectionId(collectionId); const plugin = pluginUriStr ? this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr) : undefined; - const disposables = new DisposableStore(); const actions: IAction[] = []; - const lifecycleAction = e.element.activeSessionServer !== undefined ? getActiveSessionServerLifecycleAction(e.element.activeSessionServer) : undefined; + const lifecycleAction = entry.activeSessionServer !== undefined ? getActiveSessionServerLifecycleAction(entry.activeSessionServer) : undefined; if (lifecycleAction) { actions.push(disposables.add(lifecycleAction)); } - if (e.element.localServer) { + if (entry.localServer) { const isEmptyWorkbench = this.workspaceService.getActiveProjectRoot() === undefined; const enablementActions = getBuiltinMcpServerEnablementActions( this.mcpService, - e.element.localServer.definition.id, + entry.localServer.definition.id, isEmptyWorkbench, this.agentHostCustomizationService, this.agentPluginService, this.customizationHarnessService.activeSessionResource.get(), - e.element.activeSessionServer, + entry.activeSessionServer, ); if (enablementActions.length > 0) { if (actions.length > 0) { @@ -1777,30 +2048,13 @@ export class McpListWidget extends Disposable { } ))); } - if (actions.length === 0) { - disposables.dispose(); - return; - } - - this.contextMenuService.showContextMenu({ - getAnchor: () => e.anchor, - getActions: () => actions, - onHide: () => disposables.dispose(), - }); - return; + return actions; } - if (e.element.type !== 'server-item') { - return; - } + const mcpServer = this.mcpWorkbenchService.local.find(local => local.id === entry.server.id) || entry.server; - const serverEntry = e.element; - const disposables = new DisposableStore(); - const mcpServer = this.mcpWorkbenchService.local.find(local => local.id === serverEntry.server.id) || serverEntry.server; - - // Local server actions include VS Code-owned profile/workspace enablement. const groups: IAction[][] = getContextMenuActions(mcpServer, false, this.instantiationService); - const activeSessionServer = serverEntry.activeSessionServer; + const activeSessionServer = entry.activeSessionServer; const activeSessionLifecycleAction = activeSessionServer !== undefined ? getActiveSessionServerLifecycleAction(activeSessionServer) : undefined; const agentHostEnablementActions = activeSessionServer !== undefined ? getAgentHostMcpServerEnablementActions(this.agentHostCustomizationService, this.agentPluginService, this.customizationHarnessService.activeSessionResource.get(), activeSessionServer, ['workspace', 'session']) @@ -1817,12 +2071,6 @@ export class McpListWidget extends Disposable { disposables.add(action); } } - const actions = getServerItemContextMenuActions(groups, activeSessionServer, activeSessionLifecycleAction, agentHostEnablementActions); - - this.contextMenuService.showContextMenu({ - getAnchor: () => e.anchor, - getActions: () => actions, - onHide: () => disposables.dispose() - }); + return getServerItemContextMenuActions(groups, activeSessionServer, activeSessionLifecycleAction, agentHostEnablementActions); } } 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 7e49c552b6f..6ebfd17ba5d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -11,6 +11,19 @@ overflow: hidden; } +.plugin-list-widget .plugin-card-section-count { + min-width: var(--vscode-spacing-size160); + padding: 0 var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-label3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 16px; + text-align: center; + box-sizing: border-box; +} + /* Inset button focus rings so they aren't clipped by a widget's overflow:hidden edge. !important overrides button.css. */ .ai-customization-management-editor .monaco-button:focus, .ai-customization-list-widget .monaco-button:focus, @@ -237,6 +250,15 @@ box-sizing: border-box; } +.plugin-list-widget > .section-title-header, +.plugin-list-widget > .plugin-marketplace-back-container, +.plugin-list-widget > .list-search-and-button-container, +.plugin-list-widget > .mcp-list-container { + width: min(100%, 840px); + margin-inline: auto; + box-sizing: border-box; +} + .ai-customization-management-editor .contributed-section-container { flex: 1 1 auto; min-height: 0; @@ -345,9 +367,50 @@ .mcp-list-widget .list-search-and-button-container { display: flex; align-items: center; - gap: 8px; flex-shrink: 0; - padding: 16px 0; + padding: var(--vscode-spacing-size160) 0 0; + margin-bottom: var(--vscode-spacing-size160); +} + +.ai-customization-list-widget .list-search-and-button-container { + gap: var(--vscode-spacing-size80); +} + +.mcp-list-widget .list-search-and-button-container { + gap: var(--vscode-spacing-size40); +} + +.plugin-list-widget.browse-mode .list-search-and-button-container { + gap: var(--vscode-spacing-sizeNone); +} + +.plugin-list-widget.narrow-layout .list-search-and-button-container { + align-items: stretch; + flex-wrap: wrap; +} + +.plugin-list-widget.narrow-layout .list-search-container { + flex-basis: 100%; +} + +.plugin-list-widget.narrow-layout .list-button-group { + width: 100%; + flex-wrap: nowrap; +} + +.plugin-list-widget.narrow-layout .list-button-group > .list-add-button-container, +.plugin-list-widget.narrow-layout .list-button-group > .list-add-button { + flex: 1 1 auto; + min-width: 0; +} + +.plugin-list-widget.narrow-layout .list-button-group > .list-add-button { + width: auto; +} + +.plugin-list-widget.narrow-layout .list-button-group > .list-icon-button { + flex: 0 0 var(--vscode-spacing-size280); + width: var(--vscode-spacing-size280); } .ai-customization-list-widget .list-search-container, @@ -889,43 +952,76 @@ per-word capitalization does not survive translation. */ min-height: 0; } +.ai-customization-management-editor .prompt-migration-footer { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--vscode-spacing-size120); + padding-top: var(--vscode-spacing-size120); + border-top: var(--vscode-strokeThickness) solid var(--vscode-widget-border); +} + +.ai-customization-management-editor .prompt-migration-selected-count { + font-size: var(--vscode-agents-fontSize-body2); + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .prompt-migration-state { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--vscode-spacing-size60); + margin-top: var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); +} + +.ai-customization-management-editor .prompt-migration-state-title { + color: var(--vscode-foreground); + font-size: var(--vscode-agents-fontSize-body1); + font-weight: var(--vscode-agents-fontWeight-semiBold); +} + +.ai-customization-management-editor .prompt-migration-state-description { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-body2); +} + +.ai-customization-management-editor .prompt-migration-state .monaco-button { + width: auto; + margin-top: var(--vscode-spacing-size40); +} + +.ai-customization-management-editor .prompt-migration-footer .monaco-button { + width: auto; +} + /* Migration copy is dynamic; don't reserve the two-line section-header height. */ .ai-customization-management-editor .prompt-migration-content-container .section-title-description { min-height: 0; margin-bottom: 0; } -/* - * Migration banner — an Inner-tier callout that states the trade-off before the - * user commits to a migration that deletes the originals by default. - */ +/* Migration banner — an Inner-tier callout for customizations needing attention. */ .ai-customization-management-editor .customization-migration-banner { flex-shrink: 0; - display: flex; - align-items: flex-start; - gap: var(--vscode-spacing-size100); - margin: 0 0 var(--vscode-spacing-size120) 0; + margin: var(--vscode-spacing-size120) 0; padding: var(--vscode-spacing-size120); - background-color: var(--vscode-textBlockQuote-background, transparent); - border: var(--vscode-strokeThickness) solid var(--vscode-widget-border, transparent); + background: color-mix(in srgb, var(--vscode-inputValidation-warningBackground) 35%, var(--vscode-agentsPanel-background)); + border: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--vscode-inputValidation-warningBorder) 70%, var(--vscode-widget-border)); border-radius: var(--vscode-cornerRadius-medium); box-sizing: border-box; } -.ai-customization-management-editor .customization-migration-banner-icon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - color: var(--vscode-list-warningForeground, var(--vscode-editorWarning-foreground, var(--vscode-descriptionForeground))); +.vscode-high-contrast .ai-customization-management-editor .customization-migration-banner { + border-color: var(--vscode-contrastBorder); } .ai-customization-management-editor .customization-migration-banner-content { display: flex; flex-direction: column; - gap: var(--vscode-spacing-size60); + gap: var(--vscode-spacing-size80); min-width: 0; } @@ -944,27 +1040,15 @@ per-word capitalization does not survive translation. */ } .ai-customization-management-editor .customization-migration-banner-consequence { - display: flex; - align-items: flex-start; - gap: var(--vscode-spacing-size60); margin: 0; - font-size: var(--vscode-fontSize-body1); + font-size: var(--vscode-fontSize-body2); line-height: 1.45; color: var(--vscode-descriptionForeground); } -.ai-customization-management-editor .customization-migration-banner-consequence-icon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; -} - .ai-customization-management-editor .migration-learn-more-link { align-self: flex-start; - margin-top: var(--vscode-spacing-size80); + margin-top: 0; } .ai-customization-management-editor .prompt-migration-list { @@ -979,54 +1063,90 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .prompt-migration-group { display: flex; flex-direction: column; + margin-bottom: var(--vscode-spacing-size200); } .ai-customization-management-editor .prompt-migration-group + .prompt-migration-group { - margin-top: 8px; + margin-top: var(--vscode-spacing-size40); } .ai-customization-management-editor .prompt-migration-group-header { - cursor: default; - margin-bottom: 2px; -} - -.ai-customization-management-editor .prompt-migration-group-header:focus-within { - outline: var(--vscode-strokeThickness) solid var(--vscode-list-focusOutline); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); - background: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); -} - -.ai-customization-management-editor .prompt-migration-group-checkbox { - margin-right: 8px; -} - -.ai-customization-management-editor .prompt-migration-group-toggle { display: flex; align-items: center; - gap: 8px; - flex: 1; - min-width: 0; + justify-content: space-between; + gap: var(--vscode-spacing-size120); + margin-bottom: var(--vscode-spacing-size80); padding: 0; - border: none; background: transparent; - color: inherit; - font: inherit; - text-align: left; +} + +.ai-customization-management-editor .prompt-migration-group-heading { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + min-width: 0; +} + +.ai-customization-management-editor .prompt-migration-group-title { + margin: 0; + font-size: var(--vscode-agents-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.ai-customization-management-editor .prompt-migration-group-count { + font-size: var(--vscode-agents-fontSize-label2); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .prompt-migration-group-controls { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size40); + flex: 0 0 auto; +} + +.ai-customization-management-editor .prompt-migration-group-controls .monaco-checkbox { + margin-right: 0; +} + +.ai-customization-management-editor .prompt-migration-select-all-label { + font-size: var(--vscode-agents-fontSize-body2); + color: var(--vscode-descriptionForeground); cursor: pointer; } -.ai-customization-management-editor .prompt-migration-group-toggle:focus { - outline: none; +.ai-customization-management-editor .prompt-migration-group-checkbox { + margin: 0; } .ai-customization-management-editor .prompt-migration-group-items { display: flex; flex-direction: column; + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; +} + +.ai-customization-management-editor .prompt-migration-group-empty { + padding: var(--vscode-spacing-size160); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; } .ai-customization-management-editor .prompt-migration-item { gap: 8px; cursor: default; + min-height: 56px; + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + border-radius: 0; + box-sizing: border-box; +} + +.ai-customization-management-editor .prompt-migration-item:not(:last-child) { + box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); } .ai-customization-management-editor .prompt-migration-checkbox { @@ -1074,6 +1194,10 @@ per-word capitalization does not survive translation. */ font-size: 16px; } +.ai-customization-management-editor .prompt-migration-more-action { + color: var(--vscode-descriptionForeground); +} + .ai-customization-management-editor .prompt-migration-item .icon-button:hover { background-color: var(--vscode-toolbar-hoverBackground); } @@ -1209,7 +1333,107 @@ per-word capitalization does not survive translation. */ overflow: hidden; display: flex; flex-direction: column; - gap: 2px; + gap: var(--vscode-spacing-size240); + padding-bottom: var(--vscode-spacing-size200); + box-sizing: border-box; +} + +.ai-customization-management-editor .tools-inventory-section { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); +} + +.ai-customization-management-editor .tools-inventory-section-header { + display: flex; + align-items: flex-end; + gap: var(--vscode-spacing-size80); + min-width: 0; +} + +.ai-customization-management-editor .tools-inventory-section-text { + flex: 1; + min-width: 0; +} + +.ai-customization-management-editor .tools-inventory-section-heading-row { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); +} + +.ai-customization-management-editor .tools-inventory-section-title, +.ai-customization-management-editor .tools-marketplace-title { + margin: 0; + font-size: var(--vscode-agents-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.ai-customization-management-editor .tools-inventory-section-count { + min-width: var(--vscode-spacing-size160); + padding: 0 var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-toolbar-hoverBackground); + font-size: var(--vscode-agents-fontSize-label3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-descriptionForeground); + line-height: 16px; + text-align: center; + box-sizing: border-box; +} + +.ai-customization-management-editor .tools-inventory-section-description, +.ai-customization-management-editor .tools-marketplace-description { + margin: var(--vscode-spacing-size20) 0 0; + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .tools-inventory-section-actions { + flex-shrink: 0; +} + +.ai-customization-management-editor .tools-inventory-section-actions .monaco-button { + width: auto; +} + +.ai-customization-management-editor .tools-list-widget.narrow-layout .tools-inventory-section-header { + align-items: stretch; + flex-wrap: wrap; +} + +.ai-customization-management-editor .tools-list-widget.narrow-layout .tools-inventory-section-actions { + width: 100%; +} + +.ai-customization-management-editor .tools-list-widget.narrow-layout .tools-inventory-section-actions .monaco-button { + width: 100%; +} + +.ai-customization-management-editor .tools-list-widget.narrow-layout .tools-list-toolrow { + padding-inline-start: var(--vscode-spacing-size240); +} + +.ai-customization-management-editor .tools-inventory-list { + display: flex; + flex-direction: column; + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; +} + +.ai-customization-management-editor .tools-inventory-list .plugin-inventory-empty { + padding: var(--vscode-spacing-size160); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; +} + +.ai-customization-management-editor .tools-marketplace-header { + flex-shrink: 0; + margin-bottom: var(--vscode-spacing-size120); } /* Top-level tool-set row */ @@ -1217,11 +1441,16 @@ per-word capitalization does not survive translation. */ display: flex; align-items: center; gap: 8px; - padding: 6px 12px; - border-radius: 4px; + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + border-radius: 0; outline-offset: -1px; } +.ai-customization-management-editor .tools-list-setrow:not(:last-child), +.ai-customization-management-editor .tools-list-children:not(:last-child) .tools-list-toolrow:last-child { + box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); +} + .ai-customization-management-editor .tools-list-setrow:hover, .ai-customization-management-editor .tools-list-toolrow:hover { background: var(--vscode-list-hoverBackground); @@ -1261,12 +1490,43 @@ per-word capitalization does not survive translation. */ display: flex; align-items: center; gap: 8px; - padding: 4px 12px 4px 40px; - border-radius: 4px; + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size120) var(--vscode-spacing-size60) var(--vscode-spacing-size400); + border-radius: 0; cursor: pointer; outline-offset: -1px; } +.ai-customization-management-editor .tools-list-more-action { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); + padding: 0; + border: none; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.ai-customization-management-editor .tools-list-more-action:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.ai-customization-management-editor .tools-list-more-action:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ai-customization-management-editor .tools-list-always-available { + flex: 0 0 auto; + font-size: var(--vscode-agents-fontSize-label2); + color: var(--vscode-descriptionForeground); +} + .ai-customization-management-editor .tools-list-toolrow.readonly { cursor: default; } @@ -1331,17 +1591,13 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; min-height: 0; - padding: 16px; - background: var(--vscode-agentsPanel-background); - border: 1px solid var(--vscode-agentsPanel-border); - border-radius: 6px; overflow: hidden; - box-sizing: border-box; } .ai-customization-management-editor .mcp-detail-editor-container { flex: 1; - overflow: auto; + display: flex; + overflow: hidden; min-height: 0; } @@ -1353,10 +1609,10 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; min-height: 0; - padding: 16px; + padding: var(--vscode-spacing-size160); background: var(--vscode-agentsPanel-background); - border: 1px solid var(--vscode-agentsPanel-border); - border-radius: 6px; + border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); + border-radius: var(--vscode-cornerRadius-medium); overflow: hidden; box-sizing: border-box; } @@ -1379,7 +1635,7 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .tools-detail-container .ai-customization-embedded-detail .embedded-detail-leading-slot:not(:empty) { display: flex; align-items: center; - margin-inline-end: 4px; + margin-inline-end: var(--vscode-spacing-size200); } .ai-customization-management-editor .plugin-detail-editor-container, @@ -1387,16 +1643,49 @@ per-word capitalization does not survive translation. */ flex: 1; overflow: auto; min-height: 0; + display: flex; + justify-content: center; } -/* Compact embedded detail component (shared by MCP / plugin) */ +/* Shared embedded detail component foundation */ .ai-customization-management-editor .ai-customization-embedded-detail { display: flex; flex-direction: column; - gap: 12px; - padding: 16px 24px 24px 24px; + gap: var(--vscode-spacing-size200); + padding: var(--vscode-spacing-size160) var(--vscode-spacing-size240) var(--vscode-spacing-size240); max-width: 720px; + width: 100%; min-width: 0; + box-sizing: border-box; +} + +.ai-customization-management-editor .ai-customization-embedded-detail.embedded-mcp-detail { + flex: 1; + gap: 0; + max-width: none; + min-height: 0; + padding: 0; +} + +.ai-customization-management-editor .embedded-mcp-detail.is-empty .mcp-detail-header { + display: none; +} + +.ai-customization-management-editor .mcp-detail-container .embedded-mcp-detail .embedded-detail-leading-slot:not(:empty) { + display: contents; + margin: 0; +} + +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-editor { + flex: 1; + overflow: hidden; +} + +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-empty { + margin: var(--vscode-spacing-size80) var(--vscode-spacing-size160) var(--vscode-spacing-size160); + padding: var(--vscode-spacing-size160); + border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); + border-radius: var(--vscode-cornerRadius-medium); } /* The tools detail lists many short tool descriptions, so let it use the full editor width. */ @@ -1413,8 +1702,9 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-header { display: flex; align-items: flex-start; - gap: 4px; + gap: 0; min-width: 0; + margin-bottom: var(--vscode-spacing-size40); } .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-icon { @@ -1439,6 +1729,90 @@ per-word capitalization does not survive translation. */ gap: 4px; } +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--vscode-spacing-size60); + margin-left: auto; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .monaco-button { + width: auto; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .monaco-button-dropdown:focus-within { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; + border-radius: var(--vscode-cornerRadius-small); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .monaco-button-dropdown > .monaco-button:focus { + outline: 0; +} + +.ai-customization-management-editor .embedded-plugin-detail.narrow-layout .embedded-detail-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + row-gap: var(--vscode-spacing-size80); +} + +.ai-customization-management-editor .embedded-plugin-detail.narrow-layout .embedded-detail-leading-slot { + grid-column: 1; + grid-row: 1; +} + +.ai-customization-management-editor .embedded-plugin-detail.narrow-layout .embedded-detail-leading-slot:empty { + display: none; +} + +.ai-customization-management-editor .embedded-plugin-detail.narrow-layout .embedded-detail-header-text { + grid-column: 2; + grid-row: 1; +} + +.ai-customization-management-editor .embedded-plugin-detail.narrow-layout .embedded-detail-leading-slot:empty + .embedded-detail-header-text { + grid-column: 1 / -1; +} + +.ai-customization-management-editor .embedded-plugin-detail.narrow-layout .embedded-detail-title-actions { + grid-column: 2; + grid-row: 2; + justify-content: flex-start; + margin-left: 0; +} + +.ai-customization-management-editor .embedded-plugin-detail.narrow-layout .embedded-detail-leading-slot:empty ~ .embedded-detail-title-actions { + grid-column: 1 / -1; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-name-row { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + min-width: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-status-badge { + flex-shrink: 0; + margin-top: 1px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .embedded-detail-disable-button.monaco-button { + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .embedded-detail-uninstall-button.monaco-button { + color: var(--vscode-errorForeground); + border-color: transparent; + background: transparent; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .embedded-detail-uninstall-button.monaco-button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-name { margin: 0; font-size: var(--vscode-fontSize-heading2); @@ -1452,13 +1826,20 @@ per-word capitalization does not survive translation. */ align-items: center; gap: 4px; color: var(--vscode-descriptionForeground); - font-size: 12px; + font-size: var(--vscode-agents-fontSize-label1); } .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-scope .codicon { font-size: var(--vscode-codiconFontSize-compact); } +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--vscode-spacing-size40); +} + .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-description { color: var(--vscode-foreground); font-size: var(--vscode-fontSize-body1); @@ -1466,6 +1847,252 @@ per-word capitalization does not survive translation. */ white-space: pre-wrap; word-break: break-word; padding-inline-start: 32px; + margin-bottom: var(--vscode-spacing-size40); +} + +.ai-customization-management-editor .ai-customization-embedded-detail.embedded-plugin-detail .embedded-detail-description, +.ai-customization-management-editor .ai-customization-embedded-detail.embedded-plugin-detail .embedded-detail-section { + padding-inline-start: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail.embedded-plugin-detail .embedded-detail-facts { + padding-inline-start: 0; + margin-bottom: 0; +} + +.ai-customization-management-editor .embedded-plugin-detail .plugin-detail-source-facts { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size40); +} + +.ai-customization-management-editor .embedded-plugin-detail .plugin-detail-readme-message { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; +} + +.ai-customization-management-editor .embedded-plugin-detail .plugin-detail-readme > .plugin-detail-contribution-group-title { + font-weight: var(--vscode-fontWeight-semiBold); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-facts { + display: flex; + flex-direction: column; + gap: 0; + padding-inline-start: 32px; + margin-bottom: var(--vscode-spacing-size80); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-row { + display: grid; + grid-template-columns: minmax(96px, max-content) minmax(0, 1fr); + gap: var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + min-width: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-row:not(:last-child) { + box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-label, +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-value { + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; + min-width: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-label { + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-value { + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-value.has-actions { + overflow: visible; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-fact-link:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-location-value { + display: inline-flex; + align-items: center; + gap: var(--vscode-spacing-size40); + min-width: 0; + max-width: 100%; + line-height: 20px; + padding: var(--vscode-spacing-size20); + margin: calc(-1 * var(--vscode-spacing-size20)); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-location-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-copy-button.monaco-button { + flex-shrink: 0; + width: 20px; + min-width: 20px; + height: 20px; + padding: 0; + background: transparent; + border-color: transparent; + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-copy-button.monaco-button:hover { + background: transparent; + color: var(--vscode-textLink-activeForeground); +} + +.ai-customization-management-editor .mcp-detail-definition-empty:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-section { + padding-inline-start: 32px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-section-title { + margin: 0 0 var(--vscode-spacing-size60) 0; + font-size: var(--vscode-agents-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 18px; + color: var(--vscode-foreground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-chip-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-flat-list { + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-section { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size60); + min-width: 0; + padding: var(--vscode-spacing-size100) var(--vscode-spacing-size120); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-section + .plugin-detail-contribution-section { + box-shadow: inset 0 var(--vscode-strokeThickness) var(--vscode-widget-border); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-group { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size60); + min-width: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-group-title { + display: flex; + align-items: baseline; + gap: var(--vscode-spacing-size60); + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-regular); + color: var(--vscode-foreground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-title-count { + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-list { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size60); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-row { + display: flex; + flex-direction: column; + min-width: 0; + gap: var(--vscode-spacing-size20); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-link { + padding: 0; + border: none; + background: transparent; + color: var(--vscode-textLink-foreground); + text-align: left; + cursor: pointer; + font-family: inherit; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-link:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-link:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-name { + font-size: var(--vscode-agents-fontSize-body1); + line-height: 18px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-link.plugin-detail-contribution-name { + color: var(--vscode-textLink-foreground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-link.plugin-detail-contribution-name:hover { + color: var(--vscode-textLink-activeForeground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-description, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-more, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-empty { + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-chip-list > .plugin-detail-contribution-empty { + padding: var(--vscode-spacing-size100) var(--vscode-spacing-size120); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-contribution-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vscode-high-contrast .ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-flat-list { + border-color: var(--vscode-contrastBorder); } .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-empty { @@ -1751,7 +2378,8 @@ per-word capitalization does not survive translation. */ gap: var(--vscode-spacing-size40); } -.mcp-server-item .mcp-server-sign-in.monaco-button { +.mcp-server-item .mcp-server-sign-in.monaco-button, +.plugin-list-widget .mcp-installed-home-row .mcp-server-sign-in.monaco-button { min-width: 0; min-height: var(--vscode-spacing-size240); padding: 0 var(--vscode-spacing-size80); @@ -1806,6 +2434,638 @@ per-word capitalization does not survive translation. */ color: var(--vscode-disabledForeground); } +.plugin-list-widget .plugin-search-header { + display: flex; + align-items: flex-end; + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120) var(--vscode-spacing-size40); + -webkit-user-select: none; + user-select: none; + pointer-events: none; +} + +.plugin-list-widget .plugin-search-header.available-to-install { + padding-top: var(--vscode-spacing-size160); +} + +.plugin-list-widget .monaco-list-row.plugin-search-header:hover { + background: transparent; +} + +.plugin-list-widget .plugin-search-header-label { + font-size: var(--vscode-agents-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 18px; + color: var(--vscode-foreground); +} + +/* Plugin list item */ +.plugin-list-widget .plugin-list-item { + display: flex; + align-items: center; + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + border-radius: var(--vscode-cornerRadius-small); + gap: var(--vscode-spacing-size120); + min-height: 44px; + overflow: hidden; + box-sizing: border-box; +} + +.plugin-list-widget .plugin-installed-item:not(.plugin-home-row), +.plugin-list-widget .plugin-marketplace-item { + cursor: pointer; +} + +.plugin-list-widget .plugin-card-grid.plugin-inventory-list { + display: flex; + flex-direction: column; + gap: 0; + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; +} + +.plugin-list-widget .plugin-home-row { + width: 100%; + min-height: 64px; + background: transparent; + border-radius: 0; + cursor: pointer; +} + +.plugin-list-widget .plugin-home-row.customization-home-row.disabled { + opacity: 0.6; +} + +.plugin-list-widget .customization-card-primary-action { + appearance: none; + background: transparent; + border: 0; + color: inherit; + font: inherit; + padding: 0; + text-align: start; + min-width: 0; + cursor: pointer; +} + +.plugin-list-widget .plugin-home-row > .customization-card-primary-action { + display: flex; + align-items: center; + align-self: stretch; + flex: 1; +} + +.plugin-list-widget .customization-row-primary { + display: flex; + align-items: center; + align-self: stretch; + flex: 1; + min-width: 0; + border-radius: var(--vscode-cornerRadius-small); + cursor: pointer; +} + +.plugin-list-widget .customization-card-primary-action:focus-visible, +.plugin-list-widget .plugin-remote-item:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.plugin-list-widget .plugin-home-row:not(:last-child) { + box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); +} + +.plugin-list-widget .plugin-home-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.plugin-list-widget .plugin-home-row .plugin-list-item-action { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size40); + margin-left: auto; +} + +.plugin-list-widget .plugin-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.plugin-list-widget .plugin-list-item.disabled { + opacity: 0.6; +} + +.plugin-list-widget .plugin-home-row.disabled { + opacity: 1; +} + +.plugin-list-widget .plugin-list-item .item-sync-checkbox { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.plugin-list-widget .plugin-list-item-details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size20); + overflow: hidden; +} + +.plugin-list-widget .plugin-list-item-name-row { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + min-width: 0; + overflow: hidden; +} + +.plugin-list-widget .plugin-list-item-name { + font-size: var(--vscode-agents-fontSize-body1); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 18px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plugin-list-widget .plugin-list-item-description, +.plugin-list-widget .plugin-list-item-source, +.plugin-list-widget .plugin-list-item-metadata { + font-size: var(--vscode-agents-fontSize-body2); + line-height: 14px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plugin-list-widget .plugin-list-item-source { + color: var(--vscode-textPreformat-foreground, var(--vscode-descriptionForeground)); +} + +.plugin-list-widget .plugin-list-item-status { + flex-shrink: 0; + font-size: var(--vscode-agents-fontSize-label2); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 18px; + padding: 0 var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-badge-foreground); + background-color: var(--vscode-badge-background); +} + +.plugin-list-widget .plugin-list-item-status.running { + color: var(--vscode-editor-background); + background-color: color-mix(in srgb, var(--vscode-terminal-ansiGreen) 50%, transparent); +} + +.plugin-list-widget .plugin-list-item-status.disabled { + color: var(--vscode-badge-foreground); + background-color: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent); +} + +.plugin-list-widget .mcp-runtime-status-badge { + color: var(--vscode-foreground); + background-color: var(--vscode-toolbar-hoverBackground); +} + +.plugin-list-widget .mcp-runtime-status-badge.running { + background-color: color-mix(in srgb, var(--vscode-charts-green) 18%, transparent); +} + +.plugin-list-widget .mcp-runtime-status-badge.starting { + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 18%, transparent); +} + +.plugin-list-widget .mcp-runtime-status-badge.auth-required { + background-color: color-mix(in srgb, var(--vscode-list-warningForeground, var(--vscode-editorWarning-foreground)) 18%, transparent); +} + +.plugin-list-widget .mcp-runtime-status-badge.error { + background-color: color-mix(in srgb, var(--vscode-errorForeground) 18%, transparent); +} + +.plugin-list-widget .plugin-list-item-action { + flex-shrink: 0; +} + +.plugin-list-widget .plugin-home-row .plugin-card-icon-button.monaco-button { + width: var(--vscode-spacing-size240); + min-width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); + padding: 0; + border-color: transparent; + background: transparent; + color: var(--vscode-descriptionForeground); +} + +.plugin-list-widget .plugin-home-row .plugin-card-icon-button.monaco-button:hover { + background: transparent; + border-color: var(--vscode-widget-border); + color: var(--vscode-textLink-activeForeground); +} + +.plugin-list-widget .customization-home-row:hover { + background: color-mix(in srgb, var(--vscode-list-hoverBackground) 65%, transparent); +} + +.plugin-list-widget .plugin-enable-switch { + position: relative; + flex: 0 0 auto; + width: var(--vscode-spacing-size280); + height: var(--vscode-spacing-size160); + padding: 0; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-circle); + background: color-mix(in srgb, var(--vscode-descriptionForeground) 36%, transparent); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.plugin-list-widget .plugin-enable-switch.checked { + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.plugin-list-widget .plugin-enable-switch:disabled { + cursor: default; + opacity: 0.5; +} + +.plugin-list-widget .plugin-enable-switch:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.plugin-list-widget .plugin-enable-switch-thumb { + position: absolute; + top: var(--vscode-strokeThickness); + left: var(--vscode-strokeThickness); + width: var(--vscode-spacing-size120); + height: var(--vscode-spacing-size120); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-button-foreground); + transition: transform 120ms ease; +} + +.plugin-list-widget .plugin-enable-switch.checked .plugin-enable-switch-thumb { + transform: translateX(var(--vscode-spacing-size120)); +} + +.vscode-high-contrast .plugin-list-widget .plugin-enable-switch { + border-color: var(--vscode-contrastBorder); +} + +.monaco-workbench.monaco-reduce-motion .plugin-list-widget .plugin-enable-switch, +.monaco-workbench.monaco-reduce-motion .plugin-list-widget .plugin-enable-switch-thumb { + transition: none; +} + +.plugin-list-widget .plugin-list-item-install-button { + font-size: var(--vscode-agents-fontSize-body2); + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); +} + +.plugin-list-widget .plugin-list-item-install-button:focus-visible { + outline-offset: -1px; +} + +.plugin-list-widget .plugin-card-container { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.plugin-list-widget .plugin-card-scroll { + height: 100%; + overflow: auto; + padding: 0 max(var(--vscode-spacing-size20), calc((100% - 840px) / 2)) var(--vscode-spacing-size200); + box-sizing: border-box; +} + +.plugin-list-widget .plugin-marketplace-back-container { + flex-shrink: 0; + display: flex; + align-items: center; + padding: var(--vscode-spacing-size120) 0 0; +} + +.plugin-list-widget .plugin-card-section { + margin-bottom: var(--vscode-spacing-size240); +} + +.plugin-list-widget .plugin-discovery-section { + margin-bottom: var(--vscode-spacing-size320); +} + +.plugin-list-widget .plugin-card-section-header { + display: flex; + align-items: flex-end; + justify-content: flex-start; + gap: var(--vscode-spacing-size60); + margin-bottom: var(--vscode-spacing-size120); + min-width: 0; +} + +.plugin-list-widget .plugin-card-section-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: var(--vscode-spacing-size40); +} + +.plugin-list-widget .plugin-card-section-actions .monaco-button { + width: auto; +} + +.plugin-list-widget .plugin-card-section-actions .plugin-update-available-button.monaco-button { + width: var(--vscode-spacing-size280); + min-width: var(--vscode-spacing-size280); + padding: var(--vscode-spacing-size40) 0; +} + +.plugin-list-widget.narrow-layout .available-plugins-section .plugin-card-section-header, +.plugin-list-widget.narrow-layout .installed-plugins-section .plugin-card-section-header, +.plugin-list-widget.narrow-layout .available-mcp-servers-section .plugin-card-section-header, +.plugin-list-widget.narrow-layout .installed-mcp-servers-section .plugin-card-section-header, +.plugin-list-widget.narrow-layout .customization-card-section .plugin-card-section-header { + align-items: stretch; + flex-wrap: wrap; +} + +.plugin-list-widget.narrow-layout .available-plugins-section .plugin-card-section-actions, +.plugin-list-widget.narrow-layout .installed-plugins-section .plugin-card-section-actions, +.plugin-list-widget.narrow-layout .available-mcp-servers-section .plugin-card-section-actions, +.plugin-list-widget.narrow-layout .installed-mcp-servers-section .plugin-card-section-actions, +.plugin-list-widget.narrow-layout .customization-card-section .plugin-card-section-actions { + width: 100%; +} + +.plugin-list-widget.narrow-layout .available-plugins-section .plugin-available-action, +.plugin-list-widget.narrow-layout .installed-plugins-section .plugin-installed-action, +.plugin-list-widget.narrow-layout .installed-mcp-servers-section .plugin-installed-action, +.plugin-list-widget.narrow-layout .customization-card-section .customization-create-action, +.plugin-list-widget.narrow-layout .customization-card-section .customization-generate-action { + flex: 1 1 auto; + min-width: 0; +} + +.plugin-list-widget.narrow-layout .customization-card-section .customization-create-action > .monaco-button { + flex: 1 1 auto; + min-width: 0; +} + +.plugin-list-widget .plugin-card-section-heading-row { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); +} + +.plugin-list-widget .plugin-card-section-text { + flex: 1; + min-width: 0; +} + +.plugin-list-widget .plugin-card-section-title { + margin: 0; + font-size: var(--vscode-agents-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 18px; + color: var(--vscode-foreground); +} + +.plugin-list-widget .plugin-card-section-description { + margin-top: var(--vscode-spacing-size20); + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; + color: var(--vscode-descriptionForeground); +} + +.plugin-list-widget .plugin-card-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--vscode-spacing-size80); +} + +.plugin-list-widget .plugin-inventory-empty { + padding: var(--vscode-spacing-size160); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; +} + +.plugin-list-widget .plugin-marketplace-home-row .plugin-list-item-install-button.monaco-button { + width: auto; + flex: 0 0 auto; +} + +.plugin-list-widget .plugin-search-results .plugin-card-section:last-child { + margin-bottom: 0; +} + +.plugin-list-widget.wide-layout .plugin-discovery-section .plugin-card-grid, +.plugin-list-widget.wide-layout .plugin-marketplace-recommended-section .plugin-card-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.plugin-list-widget .plugin-card { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + min-width: 0; + min-height: 112px; + padding: var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); + border-radius: var(--vscode-cornerRadius-large); + background: var(--vscode-editorWidget-background); + color: var(--vscode-foreground); + box-sizing: border-box; + cursor: pointer; +} + +.plugin-list-widget .plugin-marketplace-card { + justify-content: space-between; + gap: var(--vscode-spacing-size120); + min-height: 0; + padding-bottom: var(--vscode-spacing-size120); +} + +.plugin-list-widget .plugin-marketplace-card .plugin-card-header { + flex: 1; + align-items: stretch; + flex-direction: column; +} + +.plugin-list-widget .plugin-marketplace-card .plugin-card-title-block.customization-card-primary-action { + width: 100%; +} + +.plugin-list-widget .plugin-marketplace-card .plugin-card-header .plugin-card-actions { + justify-content: flex-start; + margin-top: auto; + margin-left: 0; + padding-top: var(--vscode-spacing-size80); +} + +.plugin-list-widget .plugin-marketplace-card .plugin-card-subtitle { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; +} + +.plugin-list-widget .plugin-card:hover { + background: var(--vscode-list-hoverBackground); +} + +.plugin-list-widget .plugin-discovery-section .plugin-card { + background: transparent; +} + +.plugin-list-widget .plugin-discovery-section .plugin-card:hover { + background: var(--vscode-list-hoverBackground); +} + +.plugin-list-widget .plugin-card.disabled { + opacity: 0.65; +} + +.plugin-list-widget .plugin-card-header { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size100); + min-width: 0; +} + +.plugin-list-widget .plugin-card-header .plugin-card-actions { + margin-left: auto; + padding-top: var(--vscode-spacing-size20); +} + +.plugin-list-widget .plugin-card-title-block { + flex: 1; + min-width: 0; +} + +.plugin-list-widget .plugin-card-title { + font-size: var(--vscode-agents-fontSize-body1); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 18px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plugin-list-widget .plugin-card-subtitle, +.plugin-list-widget .plugin-card-description, +.plugin-list-widget .plugin-card-meta { + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plugin-list-widget .plugin-card-description { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.plugin-list-widget .plugin-card-badges, +.plugin-list-widget .plugin-card-actions { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--vscode-spacing-size40); +} + +.plugin-list-widget .plugin-card-badges { + justify-content: flex-start; +} + +.plugin-list-widget .plugin-card-actions { + justify-content: flex-end; +} + +.plugin-list-widget .plugin-card-actions .monaco-button { + width: auto; + flex: 0 0 auto; + font-size: var(--vscode-agents-fontSize-body2); + padding: 0 var(--vscode-spacing-size80); + min-height: 24px; +} + +.plugin-list-widget .plugin-card-actions .plugin-card-icon-button.monaco-button { + width: 24px; + min-width: 24px; + padding: 0; +} + +.plugin-list-widget .plugin-card-actions .plugin-card-ghost-button.monaco-button, +.plugin-list-widget .plugin-card-section-header .plugin-card-ghost-button.monaco-button, +.plugin-list-widget .plugin-marketplace-page-header .plugin-card-ghost-button.monaco-button { + background: transparent; + color: var(--vscode-foreground); + border-color: transparent; + width: auto; + flex: 0 0 auto; +} + +.plugin-list-widget .plugin-card-actions .plugin-card-ghost-button.monaco-button:hover, +.plugin-list-widget .plugin-card-section-header .plugin-card-ghost-button.monaco-button:hover, +.plugin-list-widget .plugin-marketplace-page-header .plugin-card-ghost-button.monaco-button:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.plugin-list-widget .plugin-home-empty-card { + display: flex; + flex-direction: column; + grid-column: 1 / -1; + align-items: center; + justify-content: center; + gap: var(--vscode-spacing-size120); + min-height: 160px; + padding: var(--vscode-spacing-size360) var(--vscode-spacing-size160); + margin: var(--vscode-spacing-size40) 0 var(--vscode-spacing-size240); + text-align: center; + box-sizing: border-box; +} + +.plugin-list-widget .plugin-home-empty-title { + margin: 0; + font-size: var(--vscode-agents-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.plugin-list-widget .plugin-home-empty-description { + max-width: 520px; + font-size: var(--vscode-agents-fontSize-body2); + line-height: 1.45; + color: var(--vscode-descriptionForeground); +} + +.plugin-list-widget .plugin-home-empty-card .plugin-card-actions { + justify-content: center; + flex-wrap: wrap; +} + +.plugin-list-widget .plugin-browse-all-button.monaco-button { + width: fit-content; + flex: 0 0 auto; +} + +.vscode-high-contrast .plugin-list-widget .plugin-card, +.vscode-high-contrast .plugin-list-widget .plugin-home-empty-card { + border-color: var(--vscode-contrastBorder); +} + /* Button group for Add Server + Browse Marketplace */ .mcp-list-widget .list-button-group { display: flex; @@ -1842,7 +3102,8 @@ per-word capitalization does not survive translation. */ justify-content: center; width: 26px; height: 26px; - border-radius: 4px; + padding: 0; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; background: transparent; border: none; @@ -1851,6 +3112,16 @@ per-word capitalization does not survive translation. */ transition: background-color 0.1s ease, opacity 0.1s ease; } +.ai-customization-management-editor .editor-back-button .codicon { + font-size: var(--vscode-codiconFontSize); + line-height: 1; +} + +.ai-customization-management-editor .editor-back-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .ai-customization-management-editor .editor-back-button:hover:not(:disabled) { background-color: var(--vscode-toolbar-hoverBackground); opacity: 1; @@ -1887,6 +3158,141 @@ per-word capitalization does not survive translation. */ white-space: nowrap; } +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content { + color: var(--vscode-foreground); + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.5; + overflow-wrap: anywhere; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown > :first-child { + margin-top: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown > :last-child { + margin-bottom: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown p { + margin: 0 0 var(--vscode-spacing-size120) 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h1, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h2, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h3, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h4, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h5, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h6 { + margin: var(--vscode-spacing-size160) 0 var(--vscode-spacing-size80); + color: var(--vscode-foreground); + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 1.35; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h1 { + font-size: var(--vscode-agents-fontSize-heading2); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h2, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h3 { + font-size: var(--vscode-agents-fontSize-heading3); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h4, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h5, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown h6 { + font-size: var(--vscode-agents-fontSize-label1); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown a, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown a code { + color: var(--vscode-textLink-foreground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown a:hover, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown a:active { + color: var(--vscode-textLink-activeForeground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown ul, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown ol { + margin: 0 0 var(--vscode-spacing-size120) 0; + padding-inline-start: var(--vscode-spacing-size240); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown li { + margin: var(--vscode-spacing-size40) 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown li > p { + margin: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown code { + font-family: var(--monaco-monospace-font); + font-size: var(--vscode-agents-fontSize-label2); + color: var(--vscode-textPreformat-foreground); + background: var(--vscode-textCodeBlock-background); + border-radius: var(--vscode-cornerRadius-small); + padding: 0 var(--vscode-spacing-size40); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown pre, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown div[data-code] { + margin: 0 0 var(--vscode-spacing-size120) 0; + padding: var(--vscode-spacing-size100) var(--vscode-spacing-size120); + overflow: auto; + white-space: pre; + background: var(--vscode-textCodeBlock-background); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + box-sizing: border-box; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown pre code, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown div[data-code] code { + display: block; + padding: 0; + background: transparent; + border-radius: 0; + white-space: pre; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown .monaco-tokenized-source { + white-space: pre; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown blockquote { + margin: 0 0 var(--vscode-spacing-size120) 0; + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120) var(--vscode-spacing-size40); + color: var(--vscode-foreground); + background: var(--vscode-textBlockQuote-background); + border-left: 5px solid var(--vscode-textBlockQuote-border); + border-radius: var(--vscode-cornerRadius-small); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown blockquote > :first-child { + margin-top: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown blockquote > :last-child { + margin-bottom: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown table { + width: 100%; + margin: 0 0 var(--vscode-spacing-size120) 0; + border-collapse: collapse; + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown th, +.ai-customization-management-editor .ai-customization-embedded-detail .plugin-detail-readme-content .rendered-markdown td { + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size60); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + text-align: left; +} + .ai-customization-management-editor .editor-item-path { font-size: var(--vscode-fontSize-body2); color: var(--vscode-descriptionForeground); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css index 8fd21900ea9..12affff664e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css @@ -21,6 +21,9 @@ .ai-customization-management-editor .welcome-prompts-inner { margin: 0 auto; + max-width: 840px; + padding: var(--vscode-spacing-size240) var(--vscode-spacing-size200) var(--vscode-spacing-size320); + box-sizing: border-box; } .ai-customization-management-editor .welcome-prompts-heading { @@ -42,14 +45,15 @@ .ai-customization-management-editor .welcome-prompts-primary { display: flex; flex-direction: column; - gap: 6px; + gap: var(--vscode-spacing-size60); /* Match card grid width — no extra horizontal margin */ width: 100%; box-sizing: border-box; - padding: 12px 8px 8px; - margin: 16px 0; - border: 1px solid color-mix(in srgb, var(--vscode-editorWidget-border) 85%, transparent); - border-radius: 6px; + padding: var(--vscode-spacing-size160); + margin: var(--vscode-spacing-size200) 0 var(--vscode-spacing-size280); + border: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--vscode-editorWidget-border) 85%, transparent); + border-radius: var(--vscode-cornerRadius-large); + background: var(--vscode-editorWidget-background); } .ai-customization-management-editor .welcome-prompts-section-label { @@ -69,8 +73,8 @@ min-height: 36px; height: 36px; border-radius: 6px; - background: var(--vscode-agentsChatInput-background); - border: 1px solid var(--vscode-agentsChatInput-border, var(--vscode-input-border, transparent)); + background: var(--vscode-input-background); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); padding: 0 4px 0 12px; margin-top: 6px; } @@ -90,7 +94,7 @@ outline: none; box-shadow: none; background: transparent; - color: var(--vscode-agentsChatInput-foreground); + color: var(--vscode-input-foreground); -webkit-appearance: none; appearance: none; } @@ -108,7 +112,7 @@ } .ai-customization-management-editor .welcome-prompts-input::placeholder { - color: var(--vscode-agentsChatInput-placeholderForeground); + color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground)); } .ai-customization-management-editor .welcome-prompts-input-submit { @@ -151,7 +155,7 @@ } .ai-customization-management-editor .welcome-prompts-sent-label { - font-size: 12px; + font-size: var(--vscode-agents-fontSize-label1); color: var(--vscode-descriptionForeground); padding: 0 8px; white-space: nowrap; @@ -172,18 +176,48 @@ } .ai-customization-management-editor .welcome-prompts-cards { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size280); +} + +.ai-customization-management-editor .welcome-prompts-overview-section { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); +} + +.ai-customization-management-editor .welcome-prompts-overview-section-title { + margin: 0; + font-size: var(--vscode-agents-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.ai-customization-management-editor .welcome-prompts-overview-section-description { + margin: calc(-1 * var(--vscode-spacing-size60)) 0 0; + font-size: var(--vscode-agents-fontSize-body2); + line-height: 16px; + color: var(--vscode-descriptionForeground); +} + +.ai-customization-management-editor .welcome-prompts-overview-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--vscode-spacing-size100); } .ai-customization-management-editor .welcome-prompts-card { display: flex; flex-direction: column; - padding: 10px 12px 12px; - border: 1px solid var(--vscode-widget-border); - border-radius: 6px; - background: var(--vscode-sideBar-background); + min-width: 0; + padding: var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-large); + background: var(--vscode-editorWidget-background); + color: var(--vscode-foreground); + font: inherit; + text-align: left; cursor: pointer; transition: background-color 0.1s ease; } @@ -198,14 +232,14 @@ } .ai-customization-management-editor .welcome-prompts-migration-card { - grid-column: span 2; + grid-column: 1 / -1; background: color-mix(in srgb, var(--vscode-inputValidation-warningBackground) 35%, var(--vscode-sideBar-background)); border-color: color-mix(in srgb, var(--vscode-inputValidation-warningBorder) 70%, var(--vscode-widget-border)); - cursor: default; + cursor: pointer; } .ai-customization-management-editor .welcome-prompts-migration-card:hover { - background: color-mix(in srgb, var(--vscode-inputValidation-warningBackground) 35%, var(--vscode-sideBar-background)); + background: color-mix(in srgb, var(--vscode-inputValidation-warningBackground) 55%, var(--vscode-list-hoverBackground)); } .ai-customization-management-editor .welcome-prompts-card-header { @@ -216,7 +250,7 @@ } .ai-customization-management-editor .welcome-prompts-card-icon { - font-size: 14px; + font-size: var(--vscode-codiconFontSize); color: var(--vscode-icon-foreground); } @@ -227,13 +261,21 @@ } .ai-customization-management-editor .welcome-prompts-card-description { - font-size: 11px; + font-size: var(--vscode-agents-fontSize-body2); color: var(--vscode-descriptionForeground); line-height: 1.45; margin: 0; flex: 1; } +.ai-customization-management-editor .welcome-prompts-card-action-label { + align-self: flex-start; + margin-top: var(--vscode-spacing-size100); + font-size: var(--vscode-agents-fontSize-body2); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-textLink-foreground); +} + .ai-customization-management-editor .welcome-prompts-card-footer { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index d573f2c143c..0e52cb8258a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -14,11 +14,11 @@ import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../. import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; -import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; -import { autorun, runOnChange } from '../../../../../base/common/observable.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { autorun } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; -import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; +import { InputBox, MessageType } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; @@ -28,12 +28,11 @@ import { basename, dirname, isEqual } from '../../../../../base/common/resources import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { isWeb } from '../../../../../base/common/platform.js'; import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js'; -import { isContributionEnabled } from '../../common/enablement.js'; -import { getInstalledPluginContextMenuActions } from '../agentPluginActions.js'; +import { ContributionEnablementState, isContributionEnabled } from '../../common/enablement.js'; +import { getInstalledPluginContextMenuActions, isPluginPolicyBlocked } from '../agentPluginActions.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from '../../common/plugins/pluginMarketplaceService.js'; import { IPluginInstallService } from '../../common/plugins/pluginInstallService.js'; import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem, IMarketplacePluginItem } from '../agentPluginEditor/agentPluginItems.js'; -import { pluginIcon } from './aiCustomizationIcons.js'; import { formatDisplayName, truncateToFirstLine } from './aiCustomizationListWidget.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; @@ -41,12 +40,61 @@ import { getCustomizationDisabledLabel, ICustomizationHarnessService, isPluginCu import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IAICustomizationItemsModel } from './aiCustomizationItemsModel.js'; -import { GalleryItemInstallState, GalleryItemRenderer, IGalleryItemProvider } from './galleryItemRenderer.js'; import { UpdateAgentPluginsCommandId } from '../chat.js'; +import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { getErrorMessage } from '../../../../../base/common/errors.js'; +import { getPluginInclusionLabel } from './aiCustomizationPresentation.js'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; const $ = DOM.$; -const PLUGIN_ITEM_HEIGHT = 36; +const PLUGIN_ITEM_HEIGHT = 66; +const PLUGIN_MARKETPLACE_ITEM_HEIGHT = 68; + +type PluginMarketplaceSnapshotState = 'uninitialized' | 'loading' | 'loaded' | 'failed'; + +export class PluginMarketplaceSnapshotModel { + + private _state: PluginMarketplaceSnapshotState = 'uninitialized'; + private _items: readonly IMarketplacePluginItem[] = []; + + get state(): PluginMarketplaceSnapshotState { + return this._state; + } + + get items(): readonly IMarketplacePluginItem[] { + return this._items; + } + + beginLoading(): boolean { + if (this._state !== 'uninitialized') { + return false; + } + this._state = 'loading'; + return true; + } + + complete(items: readonly IMarketplacePluginItem[]): void { + this._items = items; + this._state = 'loaded'; + } + + fail(): void { + this._items = []; + this._state = 'failed'; + } + + reset(): void { + this._items = []; + this._state = 'uninitialized'; + } +} + +export function shouldLoadPluginMarketplaceSnapshot(visible: boolean, state: PluginMarketplaceSnapshotState, marketplaceAvailable: boolean): boolean { + return visible && state === 'uninitialized' && marketplaceAvailable; +} //#region Entry types @@ -78,7 +126,13 @@ interface IPluginRemoteItemEntry { readonly item: ICustomizationItem; } -type IPluginListEntry = IPluginGroupHeaderEntry | IPluginInstalledItemEntry | IPluginMarketplaceItemEntry | IPluginRemoteItemEntry; +interface IPluginSearchHeaderEntry { + readonly type: 'search-header'; + readonly id: string; + readonly label: string; +} + +type IPluginListEntry = IPluginGroupHeaderEntry | IPluginSearchHeaderEntry | IPluginInstalledItemEntry | IPluginMarketplaceItemEntry | IPluginRemoteItemEntry; //#endregion @@ -89,8 +143,11 @@ class PluginItemDelegate implements IListVirtualDelegate { if (element.type === 'group-header') { return element.isFirst ? CUSTOMIZATION_GROUP_HEADER_HEIGHT : CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR; } + if (element.type === 'search-header') { + return 32; + } if (element.type === 'marketplace-item') { - return 62; + return PLUGIN_MARKETPLACE_ITEM_HEIGHT; } return PLUGIN_ITEM_HEIGHT; } @@ -99,6 +156,9 @@ class PluginItemDelegate implements IListVirtualDelegate { if (element.type === 'group-header') { return 'pluginGroupHeader'; } + if (element.type === 'search-header') { + return 'pluginSearchHeader'; + } if (element.type === 'marketplace-item') { return PLUGIN_MARKETPLACE_ITEM_TEMPLATE_ID; } @@ -113,36 +173,72 @@ class PluginItemDelegate implements IListVirtualDelegate { //#endregion +//#region Search Header Renderer + +interface IPluginSearchHeaderTemplateData { + readonly container: HTMLElement; + readonly label: HTMLElement; +} + +class PluginSearchHeaderRenderer implements IListRenderer { + readonly templateId = 'pluginSearchHeader'; + + renderTemplate(container: HTMLElement): IPluginSearchHeaderTemplateData { + container.classList.add('plugin-search-header'); + const label = DOM.append(container, $('.plugin-search-header-label')); + return { container, label }; + } + + renderElement(element: IPluginSearchHeaderEntry, _index: number, templateData: IPluginSearchHeaderTemplateData): void { + templateData.label.textContent = element.label; + templateData.container.classList.toggle('available-to-install', element.id === 'plugin-search-available'); + } + + disposeTemplate(): void { } +} + +//#endregion + //#region Installed Plugin Renderer (reuses .mcp-server-item CSS) interface IPluginInstalledItemTemplateData { readonly container: HTMLElement; - readonly typeIcon: HTMLElement; + readonly syncCheckboxContainer: HTMLElement; readonly name: HTMLElement; + readonly source: HTMLElement; readonly description: HTMLElement; + readonly metadata: HTMLElement; readonly disposables: DisposableStore; } class PluginInstalledItemRenderer implements IListRenderer { readonly templateId = 'pluginInstalledItem'; + constructor( + private readonly _harnessService: ICustomizationHarnessService, + ) { } + renderTemplate(container: HTMLElement): IPluginInstalledItemTemplateData { - container.classList.add('mcp-server-item'); + container.classList.add('plugin-list-item', 'plugin-installed-item'); - const typeIcon = DOM.append(container, $('.mcp-server-icon')); - typeIcon.classList.add(...ThemeIcon.asClassNameArray(pluginIcon)); + const syncCheckboxContainer = DOM.append(container, $('.item-sync-checkbox')); + const details = DOM.append(container, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + const source = DOM.append(nameRow, $('.inline-badge.plugin-source-badge')); + const description = DOM.append(details, $('.plugin-list-item-description')); + const metadata = DOM.append(details, $('.plugin-list-item-metadata')); - const details = DOM.append(container, $('.mcp-server-details')); - const name = DOM.append(details, $('.mcp-server-name')); - const description = DOM.append(details, $('.mcp-server-description')); - - return { container, typeIcon, name, description, disposables: new DisposableStore() }; + return { container, syncCheckboxContainer, name, source, description, metadata, disposables: new DisposableStore() }; } renderElement(element: IPluginInstalledItemEntry, _index: number, templateData: IPluginInstalledItemTemplateData): void { templateData.disposables.clear(); templateData.name.textContent = formatDisplayName(element.item.name); + templateData.source.textContent = element.item.marketplace ? '' : localize('pluginLocalSourceBadge', "Local"); + templateData.source.title = element.item.marketplace ? '' : localize('pluginLocalSourceTooltip', "Installed from a local source"); + templateData.source.style.display = element.item.marketplace ? 'none' : ''; if (element.item.description) { templateData.description.textContent = truncateToFirstLine(element.item.description); @@ -150,6 +246,8 @@ class PluginInstalledItemRenderer implements IListRenderer { + syncProvider.setDisabled(pluginUri, !checkbox.checked); + })); + } else { + templateData.syncCheckboxContainer.style.display = 'none'; + templateData.syncCheckboxContainer.replaceChildren(); + } } disposeTemplate(templateData: IPluginInstalledItemTemplateData): void { @@ -173,10 +288,10 @@ class PluginInstalledItemRenderer implements IListRenderer { +class PluginMarketplaceItemRenderer implements IListRenderer { + readonly templateId = PLUGIN_MARKETPLACE_ITEM_TEMPLATE_ID; constructor( private readonly pluginInstallService: IPluginInstallService, private readonly agentPluginService: IAgentPluginService, + private readonly pluginMarketplaceService: IPluginMarketplaceService, + private readonly notificationService: INotificationService, ) { } - getLabel(element: IPluginMarketplaceItemEntry): string { - return element.item.name; + renderTemplate(container: HTMLElement): IPluginMarketplaceItemTemplateData { + container.classList.add('plugin-list-item', 'plugin-marketplace-item'); + const details = DOM.append(container, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + const recommendedBadge = DOM.append(nameRow, $('.inline-badge.plugin-recommended-badge')); + recommendedBadge.textContent = localize('recommendedBadge', "Recommended"); + const description = DOM.append(details, $('.plugin-list-item-description')); + const publisher = DOM.append(details, $('.plugin-list-item-source')); + const metadata = DOM.append(details, $('.plugin-list-item-metadata')); + const actionContainer = DOM.append(container, $('.plugin-list-item-action')); + const installButton = new Button(actionContainer, defaultButtonStyles); + installButton.element.classList.add('plugin-list-item-install-button'); + + const templateDisposables = new DisposableStore(); + templateDisposables.add(installButton); + + return { container, name, recommendedBadge, publisher, description, metadata, installButton, elementDisposables: new DisposableStore(), templateDisposables }; } - getPublisherDisplayName(element: IPluginMarketplaceItemEntry): string | undefined { - return element.item.marketplace; + renderElement(element: IPluginMarketplaceItemEntry, _index: number, templateData: IPluginMarketplaceItemTemplateData): void { + templateData.elementDisposables.clear(); + + templateData.name.textContent = element.item.name; + templateData.recommendedBadge.style.display = this.isRecommended(element.item) ? '' : 'none'; + templateData.publisher.textContent = ''; + templateData.publisher.style.display = 'none'; + templateData.description.textContent = element.item.description || ''; + templateData.metadata.textContent = ''; + templateData.metadata.style.display = 'none'; + + const installUri = this.pluginInstallService.getPluginInstallUri({ + name: element.item.name, + description: element.item.description, + version: element.item.version ?? '', + sourceDescriptor: element.item.sourceDescriptor, + source: element.item.source, + marketplace: element.item.marketplace, + marketplaceReference: element.item.marketplaceReference, + marketplaceType: element.item.marketplaceType, + }); + const isAlreadyInstalled = this.agentPluginService.plugins.get().some(p => isEqual(p.uri, installUri)); + + if (isAlreadyInstalled) { + templateData.installButton.label = localize('installed', "Installed"); + templateData.installButton.enabled = false; + return; + } + + templateData.installButton.label = localize('install', "Install"); + templateData.installButton.enabled = true; + + templateData.elementDisposables.add(templateData.installButton.onDidClick(async () => { + templateData.installButton.label = localize('installing', "Installing..."); + templateData.installButton.enabled = false; + try { + await this.pluginInstallService.installPlugin({ + name: element.item.name, + description: element.item.description, + version: element.item.version ?? '', + sourceDescriptor: element.item.sourceDescriptor, + source: element.item.source, + marketplace: element.item.marketplace, + marketplaceReference: element.item.marketplaceReference, + marketplaceType: element.item.marketplaceType, + readmeUri: element.item.readmeUri, + }); + templateData.installButton.label = localize('installed', "Installed"); + } catch (error) { + templateData.installButton.label = localize('install', "Install"); + templateData.installButton.enabled = true; + this.notificationService.error(localize('pluginInstallFailed', "Unable to install plugin: {0}", getErrorMessage(error))); + } + })); } - getDescription(element: IPluginMarketplaceItemEntry): string | undefined { - return element.item.description; + private isRecommended(item: IMarketplacePluginItem): boolean { + return this.pluginMarketplaceService.recommendedPlugins.get().has(getMarketplaceRecommendationKey(item)); } - getInstallState(element: IPluginMarketplaceItemEntry): GalleryItemInstallState { - const installUri = this.pluginInstallService.getPluginInstallUri(this._toInstallable(element.item)); - const isInstalled = this.agentPluginService.plugins.get().some(p => isEqual(p.uri, installUri)); - return isInstalled ? GalleryItemInstallState.Installed : GalleryItemInstallState.Uninstalled; - } - - async install(element: IPluginMarketplaceItemEntry): Promise { - await this.pluginInstallService.installPlugin({ ...this._toInstallable(element.item), readmeUri: element.item.readmeUri }); - } - - onDidChangeInstallState(_element: IPluginMarketplaceItemEntry, listener: () => void) { - return runOnChange(this.agentPluginService.plugins, () => listener()); - } - - private _toInstallable(item: IMarketplacePluginItem) { - return { - name: item.name, - description: item.description, - version: '', - sourceDescriptor: item.sourceDescriptor, - source: item.source, - marketplace: item.marketplace, - marketplaceReference: item.marketplaceReference, - marketplaceType: item.marketplaceType, - }; + disposeTemplate(templateData: IPluginMarketplaceItemTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.templateDisposables.dispose(); } } @@ -331,6 +505,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin kind: AgentPluginItemKind.Marketplace, name: plugin.name, description: plugin.description, + version: plugin.version, source: plugin.source, sourceDescriptor: plugin.sourceDescriptor, marketplace: plugin.marketplace, @@ -340,6 +515,89 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin }; } +function getMarketplaceRecommendationKey(plugin: Pick): string { + return `${plugin.name}@${plugin.marketplace}`; +} + +function compareInstalledPluginItems(a: IInstalledPluginItem, b: IInstalledPluginItem): number { + return formatDisplayName(a.name).localeCompare(formatDisplayName(b.name)); +} + +export function getInstalledPluginMetadata(item: IInstalledPluginItem): string { + const metadata: string[] = []; + const contributionSummary = getInstalledPluginContributionSummary(item); + if (contributionSummary) { + metadata.push(contributionSummary); + } + return metadata.join(' • '); +} + +interface IPluginContributionEntry { + readonly label: string; + readonly items: readonly { name: string; description?: string }[]; +} + +function getInstalledPluginContributionEntries(item: IInstalledPluginItem): IPluginContributionEntry[] { + const plugin = item.plugin; + const entries: IPluginContributionEntry[] = []; + appendContributionEntry(entries, formatContributionLabel(plugin.agents.get().length, localize('oneAgentContribution', "1 agent"), localize('manyAgentContributions', "{0} agents", plugin.agents.get().length)), plugin.agents.get()); + appendContributionEntry(entries, formatContributionLabel(plugin.skills.get().length, localize('oneSkillContribution', "1 skill"), localize('manySkillContributions', "{0} skills", plugin.skills.get().length)), plugin.skills.get()); + appendContributionEntry(entries, formatContributionLabel(plugin.commands.get().length, localize('oneCommandContribution', "1 command"), localize('manyCommandContributions', "{0} commands", plugin.commands.get().length)), plugin.commands.get()); + appendContributionEntry(entries, formatContributionLabel(plugin.instructions.get().length, localize('oneInstructionContribution', "1 instruction"), localize('manyInstructionContributions', "{0} instructions", plugin.instructions.get().length)), plugin.instructions.get()); + appendContributionEntry(entries, formatContributionLabel(plugin.mcpServerDefinitions.get().length, localize('oneMcpContribution', "1 MCP server"), localize('manyMcpContributions', "{0} MCP servers", plugin.mcpServerDefinitions.get().length)), plugin.mcpServerDefinitions.get().map(server => ({ name: server.name }))); + appendContributionEntry(entries, formatContributionLabel(plugin.hooks.get().length, localize('oneHookContribution', "1 hook"), localize('manyHookContributions', "{0} hooks", plugin.hooks.get().length)), plugin.hooks.get().map(hook => ({ name: hook.originalId, description: localize('hookCommandCount', "{0} commands", hook.hooks.length) }))); + return entries; +} + +function appendContributionEntry(entries: IPluginContributionEntry[], label: string | undefined, items: readonly { name: string; description?: string }[]): void { + if (label && items.length > 0) { + entries.push({ label, items }); + } +} + +function formatContributionLabel(count: number, singular: string, plural: string): string | undefined { + if (count === 0) { + return undefined; + } + return count === 1 ? singular : plural; +} + +function getRemotePluginStatusLabel(item: ICustomizationItem): string { + if (item.enabled === false) { + return getRemotePluginDisabledLabel(item); + } + + switch (item.status) { + case 'loading': + return localize('remotePluginLoading', "Loading"); + case 'loaded': + return localize('remotePluginLoaded', "Loaded"); + case 'degraded': + return localize('remotePluginDegraded', "Warning"); + case 'error': + return localize('remotePluginError', "Error"); + default: + return ''; + } +} + +function getInstalledPluginContributionSummary(item: IInstalledPluginItem): string | undefined { + return getInstalledPluginContributionEntries(item).map(entry => entry.label).slice(0, 2).join(' • '); +} + +export function getToggledPluginEnablementState(state: ContributionEnablementState): ContributionEnablementState { + switch (state) { + case ContributionEnablementState.EnabledWorkspace: + return ContributionEnablementState.DisabledWorkspace; + case ContributionEnablementState.DisabledWorkspace: + return ContributionEnablementState.EnabledWorkspace; + case ContributionEnablementState.EnabledProfile: + return ContributionEnablementState.DisabledProfile; + case ContributionEnablementState.DisabledProfile: + return ContributionEnablementState.EnabledProfile; + } +} + //#endregion /** @@ -358,8 +616,10 @@ export class PluginListWidget extends Disposable { private sectionTitleHeader!: HTMLElement; private sectionLink!: HTMLAnchorElement; + private marketplaceBackContainer!: HTMLElement; private searchAndButtonContainer!: HTMLElement; private searchInput!: InputBox; + private cardContainer!: HTMLElement; private listContainer!: HTMLElement; private list!: WorkbenchList; private emptyContainer!: HTMLElement; @@ -370,31 +630,40 @@ export class PluginListWidget extends Disposable { private disabledMessage!: HTMLElement; private readonly disabledLinkListener = this._register(new MutableDisposable()); private buttonContainer!: HTMLElement; - private browseButton!: Button; + private backButtonContainer!: HTMLElement; private backButton!: Button; + private browseButton!: Button; private addButtonContainer!: HTMLElement; private addButtonSimple!: Button; private addButton!: ButtonWithDropdown; - private createPluginButton!: Button; + private installedCreateButton: Button | undefined; private updatePluginsButton!: Button; private readonly addDropdownActions = this._register(new DisposableStore()); + private readonly cardDisposables = this._register(new DisposableStore()); + private readonly cardListControllers = new WeakMap(); private installedItems: IInstalledPluginItem[] = []; private remoteItems: ICustomizationItem[] = []; - private displayEntries: IPluginListEntry[] = []; private marketplaceItems: IMarketplacePluginItem[] = []; + private readonly marketplaceSnapshot = new PluginMarketplaceSnapshotModel(); private searchQuery: string = ''; private browseMode: boolean = false; + private visible = false; + private firstCardFocusElement: HTMLElement | undefined; + private narrowLayout = false; + private wideLayout = false; private lastHeight: number = 0; private lastWidth: number = 0; private lastHeaderHeight = 0; private _layoutDeferred = false; private readonly collapsedGroups = new Set(); private marketplaceCts: CancellationTokenSource | undefined; + private marketplaceSnapshotCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); private readonly delayedMarketplaceSearch = new Delayer(400); constructor( + private readonly marketplaceBrowsingAvailable = !isWeb, @IInstantiationService private readonly instantiationService: IInstantiationService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IPluginMarketplaceService private readonly pluginMarketplaceService: IPluginMarketplaceService, @@ -408,10 +677,17 @@ export class PluginListWidget extends Disposable { @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, @IConfigurationService private readonly configurationService: IConfigurationService, + @INotificationService private readonly notificationService: INotificationService, ) { super(); - this.element = $('.mcp-list-widget'); // reuse MCP list widget CSS + this.element = $('.mcp-list-widget.plugin-list-widget'); // reuse MCP shell, add plugin-specific row styling this.create(); + const resizeObserver = this._register(new DOM.DisposableResizeObserver( + 'PluginListWidget', + () => this.updateResponsiveLayout(this.element.offsetWidth), + DOM.getWindow(this.element), + )); + this._register(resizeObserver.observe(this.element)); this.updateAccessState(); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ChatConfiguration.PluginsEnabled)) { @@ -420,7 +696,10 @@ export class PluginListWidget extends Disposable { })); this._register({ dispose: () => { - this.marketplaceCts?.dispose(); + this.marketplaceCts?.dispose(true); + this.marketplaceCts = undefined; + this.marketplaceSnapshotCts?.dispose(true); + this.marketplaceSnapshotCts = undefined; } }); } @@ -447,7 +726,6 @@ export class PluginListWidget extends Disposable { this.openerService.open(URI.parse(href)); } })); - // Re-layout when the header height changes so the list's allotted // height stays in sync with the actual on-screen header size. Only // relayout when the header height actually changed to avoid redundant @@ -469,6 +747,26 @@ export class PluginListWidget extends Disposable { )); this._register(headerObserver.observe(this.sectionTitleHeader)); + this.marketplaceBackContainer = DOM.append(this.element, $('.plugin-marketplace-back-container')); + this.marketplaceBackContainer.style.display = 'none'; + const backToInstalledLabel = localize('backToInstalledPlugins', "Back to Installed"); + this.backButtonContainer = DOM.append(this.marketplaceBackContainer, $('.list-add-button-container')); + this.backButton = this._register(new Button(this.backButtonContainer, { + ...getButtonStyles({ + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryBorder: undefined, + }), + secondary: true, + supportIcons: true, + title: backToInstalledLabel, + ariaLabel: backToInstalledLabel, + })); + this.backButton.element.classList.add('list-add-button', 'plugin-card-ghost-button'); + this.backButton.label = `$(${Codicon.arrowLeft.id}) ${backToInstalledLabel}`; + this._register(this.backButton.onDidClick(() => this.toggleBrowseMode(false))); + // Search and button container this.searchAndButtonContainer = DOM.append(this.element, $('.list-search-and-button-container')); @@ -483,7 +781,10 @@ export class PluginListWidget extends Disposable { this.searchQuery = this.searchInput.value; if (this.browseMode) { this.delayedMarketplaceSearch.trigger(() => this.queryMarketplace()); + } else if (this.searchQuery.trim()) { + this.delayedMarketplaceSearch.trigger(() => this.queryPluginSearch()); } else { + this.searchInput.hideMessage(); this.delayedFilter.trigger(() => this.filterPlugins()); } })); @@ -491,31 +792,21 @@ export class PluginListWidget extends Disposable { // Button container (Browse Marketplace + Add actions + Create Plugin + Update Plugins) this.buttonContainer = DOM.append(this.searchAndButtonContainer, $('.list-button-group')); - // Back button (visible only in marketplace browse mode) - const backButtonContainer = DOM.append(this.buttonContainer, $('.list-add-button-container')); - const backToInstalledLabel = localize('backToInstalledPlugins', "Back to Installed Plugins"); - this.backButton = this._register(new Button(backButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: backToInstalledLabel, ariaLabel: backToInstalledLabel })); - this.backButton.label = `$(${Codicon.arrowLeft.id}) ${localize('pluginBrowseBack', "Back")}`; - this.backButton.element.classList.add('list-add-button'); - backButtonContainer.style.display = 'none'; - this._register(this.backButton.onDidClick(() => this.toggleBrowseMode(false))); - const browseButtonContainer = DOM.append(this.buttonContainer, $('.list-add-button-container')); const browseMarketplaceLabel = localize('browseMarketplace', "Browse Marketplace"); this.browseButton = this._register(new Button(browseButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: browseMarketplaceLabel, ariaLabel: browseMarketplaceLabel })); this.browseButton.element.classList.add('list-add-button'); - this._register(this.browseButton.onDidClick(() => this.runPrimaryButtonAction())); + browseButtonContainer.style.display = 'none'; this.addButtonContainer = DOM.append(this.buttonContainer, $('.list-add-button-container')); const addPluginLabel = localize('addPlugin', "Add Plugin"); - this.addButtonSimple = this._register(new Button(this.addButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: addPluginLabel, ariaLabel: addPluginLabel })); + this.addButtonSimple = this._register(new Button(this.addButtonContainer, { ...defaultButtonStyles, secondary: true, title: addPluginLabel, ariaLabel: addPluginLabel })); this.addButtonSimple.element.classList.add('list-add-button'); this._register(this.addButtonSimple.onDidClick(() => this.runPrimaryAddAction())); this.addButton = this._register(new ButtonWithDropdown(this.addButtonContainer, { ...defaultButtonStyles, secondary: true, - supportIcons: true, contextMenuProvider: this.contextMenuService, addPrimaryActionToDropdown: false, actions: { getActions: () => this.getAddDropdownActions() }, @@ -525,12 +816,6 @@ export class PluginListWidget extends Disposable { this.addButton.element.classList.add('list-add-button'); this._register(this.addButton.onDidClick(() => this.runPrimaryAddAction())); - const createPluginLabel = localize('createPlugin', "Create Plugin"); - this.createPluginButton = this._register(new Button(this.buttonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: createPluginLabel, ariaLabel: createPluginLabel })); - this.createPluginButton.element.classList.add('list-icon-button'); - this.createPluginButton.label = `$(${Codicon.newFile.id})`; - this._register(this.createPluginButton.onDidClick(() => this.runCreatePluginAction())); - const updatePluginsLabel = localize('updatePlugins', "Update Plugins"); this.updatePluginsButton = this._register(new Button(this.buttonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: updatePluginsLabel, ariaLabel: updatePluginsLabel })); this.updatePluginsButton.element.classList.add('list-icon-button'); @@ -552,6 +837,9 @@ export class PluginListWidget extends Disposable { disabledText.textContent = localize('pluginsDisabledTitle', "Plugins are disabled"); this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); + this.cardContainer = DOM.append(this.element, $('.plugin-card-container')); + this.cardContainer.style.display = 'none'; + // List container this.listContainer = DOM.append(this.element, $('.mcp-list-container')); @@ -560,25 +848,29 @@ export class PluginListWidget extends Disposable { // Create list const delegate = new PluginItemDelegate(); const groupHeaderRenderer = new CustomizationGroupHeaderRenderer('pluginGroupHeader', this.hoverService); - const installedRenderer = new PluginInstalledItemRenderer(); + const searchHeaderRenderer = new PluginSearchHeaderRenderer(); + const installedRenderer = new PluginInstalledItemRenderer(this.harnessService); const remoteRenderer = new PluginRemoteItemRenderer(); - const marketplaceRenderer = new GalleryItemRenderer(PLUGIN_MARKETPLACE_ITEM_TEMPLATE_ID, new PluginMarketplaceItemProvider(this.pluginInstallService, this.agentPluginService)); + const marketplaceRenderer = new PluginMarketplaceItemRenderer(this.pluginInstallService, this.agentPluginService, this.pluginMarketplaceService, this.notificationService); this.list = this._register(this.instantiationService.createInstance( WorkbenchList, 'PluginManagementList', this.listContainer, delegate, - [groupHeaderRenderer, installedRenderer, remoteRenderer, marketplaceRenderer], + [groupHeaderRenderer, searchHeaderRenderer, installedRenderer, remoteRenderer, marketplaceRenderer], { multipleSelectionSupport: false, setRowLineHeight: false, horizontalScrolling: false, accessibilityProvider: { - getAriaLabel(element: IPluginListEntry) { + getAriaLabel: (element: IPluginListEntry) => { if (element.type === 'group-header') { return localize('pluginGroupAriaLabel', "{0}, {1} items, {2}", element.label, element.count, element.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); } + if (element.type === 'search-header') { + return element.label; + } const name = formatDisplayName(element.item.name); const description = element.item.description ? truncateToFirstLine(element.item.description) : undefined; const nameAndDesc = description @@ -586,13 +878,30 @@ export class PluginListWidget extends Disposable { : name; if (element.type === 'plugin-item') { const enabled = isContributionEnabled(element.item.plugin.enablement.get()); + const metadata = getInstalledPluginMetadata(element.item); + const withMetadata = metadata + ? localize('pluginInstalledItemAriaLabelWithMetadata', "{0}. {1}", nameAndDesc, metadata) + : nameAndDesc; return enabled - ? localize('pluginInstalledItemAriaLabelEnabled', "{0}. Enabled", nameAndDesc) - : localize('pluginInstalledItemAriaLabelDisabled', "{0}. Disabled", nameAndDesc); + ? localize('pluginInstalledItemAriaLabelEnabled', "{0}. Enabled", withMetadata) + : localize('pluginInstalledItemAriaLabelDisabled', "{0}. Disabled", withMetadata); + } + if (element.type === 'remote-item') { + const status = getRemotePluginStatusLabel(element.item); + return status + ? localize('pluginRemoteItemAriaLabelWithStatus', "{0}. Remote agent host. Status: {1}", nameAndDesc, status) + : localize('pluginRemoteItemAriaLabel', "{0}. Remote agent host", nameAndDesc); + } + if (element.type === 'marketplace-item') { + const recommended = this.pluginMarketplaceService.recommendedPlugins.get().has(getMarketplaceRecommendationKey(element.item)); + const label = localize('pluginMarketplaceItemAriaLabel', "{0}. From {1}", nameAndDesc, element.item.marketplace); + return recommended + ? localize('pluginMarketplaceItemAriaLabelRecommended', "{0}. Recommended for this workspace", label) + : label; } return nameAndDesc; }, - getWidgetAriaLabel() { + getWidgetAriaLabel: () => { return localize('pluginsListAriaLabel', "Plugins"); } }, @@ -602,6 +911,9 @@ export class PluginListWidget extends Disposable { if (element.type === 'group-header') { return element.id; } + if (element.type === 'search-header') { + return element.id; + } if (element.type === 'marketplace-item') { return `marketplace-${element.item.marketplaceReference.canonicalId}/${element.item.source}`; } @@ -618,6 +930,8 @@ export class PluginListWidget extends Disposable { if (e.element) { if (e.element.type === 'group-header') { this.toggleGroup(e.element); + } else if (e.element.type === 'search-header') { + // Section label only. } else if (e.element.type === 'plugin-item') { this._onDidSelectPlugin.fire(e.element.item); } else if (e.element.type === 'remote-item') { @@ -638,13 +952,20 @@ export class PluginListWidget extends Disposable { for (const plugin of plugins) { plugin.enablement.read(reader); } - if (!this.browseMode) { - void this.refresh(); - } + void this.refresh(); })); this._register(this.pluginMarketplaceService.onDidChangeMarketplaces(() => { - if (!this.browseMode) { - void this.refresh(); + this.marketplaceItems = []; + this.marketplaceSnapshotCts?.dispose(true); + this.marketplaceSnapshot.reset(); + void this.refresh(); + })); + this._register(autorun(reader => { + this.pluginMarketplaceService.recommendedPlugins.read(reader); + if (this.browseMode) { + this.updateMarketplaceList(); + } else if (!this.searchQuery.trim()) { + this.renderPluginHome(); } })); @@ -682,6 +1003,8 @@ export class PluginListWidget extends Disposable { private async refresh(): Promise { if (this.browseMode) { await this.queryMarketplace(); + } else if (this.searchQuery.trim()) { + await this.queryPluginSearch(); } else { this.filterPlugins(); } @@ -697,7 +1020,7 @@ export class PluginListWidget extends Disposable { if (disabled) { this.disabledIcon.className = 'empty-icon'; - this.disabledIcon.classList.add(...ThemeIcon.asClassNameArray(policyLocked ? Codicon.shield : pluginIcon)); + this.disabledIcon.classList.add(...ThemeIcon.asClassNameArray(policyLocked ? Codicon.shield : Codicon.plug)); DOM.clearNode(this.disabledMessage); this.disabledLinkListener.clear(); @@ -737,7 +1060,8 @@ export class PluginListWidget extends Disposable { this.toggleBrowseMode(false); } - this.browseButton.element.parentElement!.style.display = this.browseMode ? 'none' : ''; + this.marketplaceBackContainer.style.display = this.browseMode ? '' : 'none'; + this.browseButton.element.parentElement!.style.display = 'none'; this.browseButton.label = `$(${Codicon.library.id}) ${localize('browseMarketplace', "Browse Marketplace")}`; this.browseButton.enabled = browseMarketplaceAvailable; const browseTitle = browseMarketplaceAvailable @@ -746,44 +1070,39 @@ export class PluginListWidget extends Disposable { this.browseButton.setTitle(browseTitle); this.browseButton.element.setAttribute('aria-label', browseTitle); - this.updateAddButton(); - this.createPluginButton.enabled = true; + this.addButton.element.style.display = 'none'; + this.addButtonSimple.element.style.display = 'none'; + this.updatePluginsButton.element.style.display = 'none'; + this.updateInstalledCreateButtonLabel(); + } + + private updateInstalledCreateButtonLabel(): void { + if (this.installedCreateButton) { + this.installedCreateButton.label = this.narrowLayout + ? localize('createPluginNarrow', "Create") + : localize('createPlugin', "Create Plugin"); + } + } + + private updateResponsiveLayout(width: number): void { + const narrow = width < 500; + const wide = width >= 600; + if (this.narrowLayout === narrow) { + if (this.wideLayout !== wide) { + this.wideLayout = wide; + this.element.classList.toggle('wide-layout', wide); + } + return; + } + this.narrowLayout = narrow; + this.wideLayout = wide; + this.element.classList.toggle('narrow-layout', narrow); + this.element.classList.toggle('wide-layout', wide); + this.updateToolbarActions(); } private isBrowseMarketplaceAvailable(): boolean { - return !isWeb; - } - - private updateAddButton(): void { - const actions = this.buildAddActions(); - const [primary, ...dropdown] = actions; - const hasDropdown = dropdown.length > 0; - - this.addButton.element.style.display = hasDropdown ? '' : 'none'; - this.addButtonSimple.element.style.display = hasDropdown ? 'none' : ''; - - if (!primary) { - this.addButton.element.style.display = 'none'; - this.addButtonSimple.element.style.display = 'none'; - return; - } - - if (hasDropdown) { - this.addButton.label = this.formatActionLabel(primary); - this.addButton.enabled = primary.enabled !== false; - const addPrimaryTitle = primary.tooltip ?? primary.label; - this.addButton.primaryButton.setTitle(addPrimaryTitle); - this.addButton.primaryButton.element.setAttribute('aria-label', addPrimaryTitle); - const moreLabel = localize('morePluginAddActions', "More Plugin Add Actions..."); - this.addButton.dropdownButton.setTitle(moreLabel); - this.addButton.dropdownButton.element.setAttribute('aria-label', moreLabel); - } else { - this.addButtonSimple.label = this.formatActionLabel(primary); - this.addButtonSimple.enabled = primary.enabled !== false; - const addSimpleTitle = primary.tooltip ?? primary.label; - this.addButtonSimple.setTitle(addSimpleTitle); - this.addButtonSimple.element.setAttribute('aria-label', addSimpleTitle); - } + return this.marketplaceBrowsingAvailable; } private buildAddActions(): readonly ICustomizationItemAction[] { @@ -791,9 +1110,8 @@ export class PluginListWidget extends Disposable { ...this.pluginActions, { id: 'plugin.installFromSource', - label: localize('installFromSource', "Install Plugin from Source"), + label: localize('installFromSourceShort', "Install from Source"), tooltip: localize('installFromSource', "Install Plugin from Source"), - icon: Codicon.add, run: async () => { const installed = await this.commandService.executeCommand('workbench.action.chat.installPluginFromSource', { skipReveal: true }); // Return to the installed list so the newly installed plugin is @@ -811,14 +1129,6 @@ export class PluginListWidget extends Disposable { return this.buildAddActions().slice(1).map((action, index) => this.addDropdownActions.add(new Action(`plugin_add_${index}`, this.formatActionLabel(action), undefined, action.enabled !== false, () => this.runPluginAction(action)))); } - private async runPrimaryButtonAction(): Promise { - if (!this.isBrowseMarketplaceAvailable()) { - return; - } - - this.toggleBrowseMode(!this.browseMode); - } - private async runPrimaryAddAction(): Promise { const [primary] = this.buildAddActions(); if (primary) { @@ -830,12 +1140,16 @@ export class PluginListWidget extends Disposable { await this.commandService.executeCommand('workbench.action.chat.createPlugin'); } - private async runUpdatePluginsAction(): Promise { - this.updatePluginsButton.enabled = false; + private async runInstallFromSourceAction(): Promise { + await this.commandService.executeCommand('workbench.action.chat.installPluginFromSource'); + } + + private async runUpdatePluginsAction(button = this.updatePluginsButton): Promise { + button.enabled = false; try { await this.commandService.executeCommand(UpdateAgentPluginsCommandId); } finally { - this.updatePluginsButton.enabled = true; + button.enabled = true; } } @@ -845,6 +1159,505 @@ export class PluginListWidget extends Disposable { } } + private showCardSurface(): void { + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = 'none'; + this.cardContainer.style.display = ''; + } + + private showEmptySurface(): void { + this.cardContainer.style.display = 'none'; + this.listContainer.style.display = 'none'; + this.emptyContainer.style.display = 'flex'; + } + + private addSurfaceActivation(surface: HTMLElement, label: string, callback: () => void, ...classNames: string[]): HTMLButtonElement { + const primaryAction = createCustomizationCardPrimaryAction(surface, label, ...classNames); + this.rememberCardFocusElement(primaryAction); + this.cardDisposables.add(DOM.addDisposableListener(primaryAction, 'click', callback)); + return primaryAction; + } + + private renderCardSection(parent: HTMLElement, title: string, description: string | undefined, className?: string, count?: number, renderActions?: (header: HTMLElement) => void): HTMLElement { + const section = DOM.append(parent, $('.plugin-card-section')); + if (className) { + section.classList.add(className); + } + const header = DOM.append(section, $('.plugin-card-section-header')); + const text = DOM.append(header, $('.plugin-card-section-text')); + const headingRow = DOM.append(text, $('.plugin-card-section-heading-row')); + const heading = DOM.append(headingRow, $('h3.plugin-card-section-title')); + heading.textContent = title; + if (count !== undefined) { + const countEl = DOM.append(headingRow, $('.plugin-card-section-count')); + countEl.textContent = String(count); + } + if (description) { + const descriptionEl = DOM.append(text, $('.plugin-card-section-description')); + descriptionEl.textContent = description; + } + renderActions?.(header); + const list = DOM.append(section, $('.plugin-card-grid')); + this.cardListControllers.set(list, this.cardDisposables.add(new CustomizationCardListController(list, title))); + return list; + } + + private renderPluginHome(): void { + if (this.browseMode || this.searchQuery.trim()) { + return; + } + + this.cardDisposables.clear(); + this.installedCreateButton = undefined; + this.firstCardFocusElement = undefined; + DOM.clearNode(this.cardContainer); + this.showCardSurface(); + + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const installedPlugins = this.installedItems; + + this.renderDiscoverySnapshot(content); + if (shouldLoadPluginMarketplaceSnapshot(this.visible, this.marketplaceSnapshot.state, this.isBrowseMarketplaceAvailable())) { + void this.queryMarketplaceSnapshot(); + } + + const installedList = this.renderCardSection( + content, + localize('installedPluginsSection', "Installed"), + undefined, + 'installed-plugins-section', + installedPlugins.length, + header => this.renderInstalledSectionActions(header), + ); + installedList.classList.add('plugin-inventory-list'); + if (installedPlugins.length === 0) { + const empty = DOM.append(installedList, $('.plugin-inventory-empty')); + empty.textContent = localize('noInstalledPlugins', "No plugins are installed."); + } else { + for (const item of installedPlugins) { + this.appendInstalledPluginRow(installedList, item); + } + } + this.cardListControllers.get(installedList)?.finalize(); + + const installedNames = new Set(this.installedItems.map(item => item.name.toLowerCase())); + const remoteItems = this.remoteItems.filter(item => item.groupKey !== 'remote-client' && (!item.name || !installedNames.has(item.name.toLowerCase()))); + if (remoteItems.length > 0) { + const remoteList = this.renderCardSection( + content, + localize('remotePluginsSection', "Remote session plugins"), + localize('remotePluginsSectionDescription', "Plugins configured directly on the active remote agent host."), + 'remote-plugins-section', + remoteItems.length, + ); + remoteList.classList.add('plugin-inventory-list'); + for (const item of remoteItems) { + this.appendRemotePluginRow(remoteList, item); + } + this.cardListControllers.get(remoteList)?.finalize(); + } + + this.renderAvailablePlugins(content, this.getUninstalledMarketplaceItems(this.marketplaceSnapshot.items), true); + } + + private renderInstalledSectionActions(header: HTMLElement): void { + const actions = DOM.append(header, $('.plugin-card-section-actions')); + const createLabel = localize('createPlugin', "Create Plugin"); + const create = this.installedCreateButton = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, ariaLabel: createLabel })); + create.element.classList.add('plugin-installed-action'); + this.updateInstalledCreateButtonLabel(); + this.rememberCardFocusElement(create.element); + this.cardDisposables.add(create.onDidClick(() => this.runCreatePluginAction())); + } + + private renderAvailablePlugins( + parent: HTMLElement, + items: readonly IMarketplacePluginItem[], + showActions: boolean, + title = localize('availablePluginsSection', "Available"), + description: string | undefined = localize('availablePluginsSectionDescription', "Browse and install plugins from your marketplaces."), + ): void { + const availableList = this.renderCardSection( + parent, + title, + description, + 'available-plugins-section', + items.length, + showActions ? header => this.renderAvailableSectionActions(header) : undefined, + ); + availableList.classList.add('plugin-inventory-list'); + if (items.length === 0) { + const empty = DOM.append(availableList, $('.plugin-inventory-empty')); + empty.textContent = localize('noAvailablePlugins', "No marketplace plugins are available."); + this.cardListControllers.get(availableList)?.finalize(); + return; + } + for (const item of items) { + this.appendMarketplacePluginRow(availableList, item); + } + this.cardListControllers.get(availableList)?.finalize(); + } + + private renderAvailableSectionActions(header: HTMLElement): void { + const actions = DOM.append(header, $('.plugin-card-section-actions')); + const installLabel = localize('installFromSourceShort', "Install from Source"); + const installTooltip = localize('installFromSource', "Install Plugin from Source"); + if (this.pluginActions.length > 0) { + const install = this.cardDisposables.add(new ButtonWithDropdown(actions, { + ...defaultButtonStyles, + secondary: true, + contextMenuProvider: this.contextMenuService, + addPrimaryActionToDropdown: false, + actions: { + getActions: () => { + this.addDropdownActions.clear(); + return this.pluginActions.map((action, index) => this.addDropdownActions.add(new Action(`plugin_provider_add_${index}`, this.formatActionLabel(action), undefined, action.enabled !== false, () => this.runPluginAction(action)))); + } + }, + title: installTooltip, + ariaLabel: installTooltip, + })); + install.element.classList.add('plugin-available-action'); + install.label = installLabel; + this.cardDisposables.add(install.onDidClick(() => this.runInstallFromSourceAction())); + } else { + const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, title: installTooltip, ariaLabel: installTooltip })); + install.element.classList.add('plugin-available-action'); + install.label = installLabel; + this.cardDisposables.add(install.onDidClick(() => this.runInstallFromSourceAction())); + } + + const updateLabel = localize('updatePlugins', "Update Plugins"); + const update = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: updateLabel, ariaLabel: updateLabel })); + update.element.classList.add('plugin-card-icon-button', 'plugin-update-available-button'); + update.label = `$(${Codicon.refresh.id})`; + this.cardDisposables.add(update.onDidClick(() => this.runUpdatePluginsAction(update))); + } + + private appendInstalledPluginRow(parent: HTMLElement, item: IInstalledPluginItem): void { + const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-installed-item')); + const enabled = isContributionEnabled(item.plugin.enablement.get()); + row.classList.toggle('disabled', !enabled || item.plugin.policyBlocked?.get() === true); + const primaryAction = this.addSurfaceActivation(row, localize('installedPluginRowAriaLabel', "{0}. {1}", item.name, getPluginInclusionLabel(item.plugin)), () => this._onDidSelectPlugin.fire(item)); + + const details = DOM.append(primaryAction, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + name.textContent = formatDisplayName(item.name); + name.title = item.name; + if (!item.marketplace) { + const source = DOM.append(nameRow, $('.inline-badge.plugin-source-badge')); + source.textContent = localize('pluginLocalSourceBadge', "Local"); + source.title = localize('pluginLocalSourceTooltip', "Installed from a local source"); + } + + const description = DOM.append(details, $('.plugin-list-item-description')); + description.textContent = truncateToFirstLine(item.description || localize('pluginNoDescription', "No description provided.")); + const metadata = DOM.append(details, $('.plugin-list-item-metadata')); + metadata.textContent = getInstalledPluginMetadata(item); + metadata.style.display = metadata.textContent ? '' : 'none'; + + const actions = DOM.append(row, $('.plugin-list-item-action')); + const toggle = this.appendInstalledPluginToggle(actions, item); + const more = this.cardDisposables.add(new Button(actions, { ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), secondary: true, supportIcons: true, ariaLabel: localize('pluginMoreActionsAria', "More actions for {0}", item.name) })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + this.cardDisposables.add(more.onDidClick(() => this.showInstalledPluginActions(item, more.element))); + this.cardListControllers.get(parent)?.addItem({ + row, + primaryAction, + label: item.name, + actions: [toggle, more.element], + contextMenuAction: more.element, + }); + } + + private appendInstalledPluginToggle(parent: HTMLElement, item: IInstalledPluginItem): HTMLButtonElement { + const current = item.plugin.enablement.get(); + const checked = isContributionEnabled(current); + const workspaceScope = current === ContributionEnablementState.EnabledWorkspace || current === ContributionEnablementState.DisabledWorkspace; + const blocked = isPluginPolicyBlocked(item.plugin); + const toggleLabel = checked + ? (workspaceScope ? localize('excludePluginWorkspaceAria', "Exclude {0} from Workspace", item.name) : localize('excludePluginProfileAria', "Exclude {0} from Profile", item.name)) + : (workspaceScope ? localize('includePluginWorkspaceAria', "Include {0} in Workspace", item.name) : localize('includePluginProfileAria', "Include {0} for Profile", item.name)); + const switchElement = DOM.append(parent, $('button.plugin-enable-switch')) as HTMLButtonElement; + switchElement.type = 'button'; + switchElement.disabled = blocked; + switchElement.setAttribute('role', 'switch'); + switchElement.setAttribute('aria-checked', String(checked)); + switchElement.setAttribute('aria-label', blocked ? localize('pluginManagedByOrganizationAria', "{0} is managed by your organization", item.name) : toggleLabel); + switchElement.classList.toggle('checked', checked); + switchElement.title = blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel; + DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + this.cardDisposables.add(DOM.addDisposableListener(switchElement, 'click', () => { + const nextState = getToggledPluginEnablementState(current); + this.agentPluginService.enablementModel.setEnabled(item.plugin.uri.toString(), nextState); + status(localize('pluginInclusionChanged', "{0}. {1}.", item.name, getPluginInclusionLabel(item.plugin))); + })); + return switchElement; + } + + private appendRemotePluginRow(parent: HTMLElement, item: ICustomizationItem): void { + const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-remote-item')); + row.setAttribute('role', 'listitem'); + row.setAttribute('aria-label', localize('pluginRemoteCardAria', "{0}. Remote plugin", item.name)); + row.classList.toggle('disabled', item.enabled === false); + + const details = DOM.append(row, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + name.textContent = formatDisplayName(item.name); + const source = DOM.append(nameRow, $('.inline-badge.plugin-source-badge')); + source.textContent = localize('remotePluginSource', "Remote"); + source.title = localize('remotePluginMetadata', "Remote agent host"); + const description = DOM.append(details, $('.plugin-list-item-description')); + description.textContent = item.description || localize('pluginNoDescription', "No description provided."); + const metadata = DOM.append(details, $('.plugin-list-item-metadata')); + metadata.textContent = localize('remotePluginConfigurationSource', "Configured on the active remote agent host"); + + const status = DOM.append(row, $('.plugin-list-item-status')); + const statusLabel = getRemotePluginStatusLabel(item); + if (statusLabel) { + status.textContent = statusLabel; + status.classList.toggle('disabled', item.enabled === false || item.status === 'degraded' || item.status === 'error'); + } else { + status.style.display = 'none'; + } + let more: Button | undefined; + if (item.actions?.length) { + const actions = DOM.append(row, $('.plugin-list-item-action')); + const moreButton = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, secondary: true, supportIcons: true, ariaLabel: localize('pluginMoreActionsAria', "More actions for {0}", item.name) })); + moreButton.element.classList.add('plugin-card-icon-button'); + moreButton.label = `$(${Codicon.ellipsis.id})`; + this.rememberCardFocusElement(moreButton.element); + this.cardDisposables.add(moreButton.onDidClick(() => this.showRemotePluginActions(item, moreButton.element))); + more = moreButton; + } + this.cardListControllers.get(parent)?.addItem({ + row, + primaryAction: row, + label: item.name, + actions: more ? [more.element] : [], + contextMenuAction: more?.element, + }); + } + + private appendMarketplacePluginRow(parent: HTMLElement, item: IMarketplacePluginItem): void { + const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-marketplace-home-row')); + const primaryAction = this.addSurfaceActivation(row, localize('marketplacePluginRowAriaLabel', "{0}. Available to install from {1}.", item.name, item.marketplace), () => this._onDidSelectPlugin.fire(item)); + + const details = DOM.append(primaryAction, $('.plugin-list-item-details')); + const nameRow = DOM.append(details, $('.plugin-list-item-name-row')); + const name = DOM.append(nameRow, $('.plugin-list-item-name')); + name.textContent = item.name; + name.title = item.name; + const description = DOM.append(details, $('.plugin-list-item-description')); + description.textContent = truncateToFirstLine(item.description || localize('pluginNoDescription', "No description provided.")); + + const actions = DOM.append(row, $('.plugin-list-item-action')); + const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installPluginAria', "Install {0}", item.name) })); + install.element.classList.add('plugin-list-item-install-button'); + install.label = localize('install', "Install"); + this.cardDisposables.add(install.onDidClick(() => this.installMarketplacePlugin(item, install))); + this.cardListControllers.get(parent)?.addItem({ + row, + primaryAction, + label: item.name, + actions: [install.element], + }); + } + + private appendMarketplacePluginCard(parent: HTMLElement, item: IMarketplacePluginItem, showRecommendedBadge = true): void { + const card = DOM.append(parent, $('.plugin-card.plugin-marketplace-card')); + const header = DOM.append(card, $('.plugin-card-header')); + const titleBlock = this.addSurfaceActivation(header, localize('marketplacePluginCardAriaLabel', "{0}. Available to install from {1}.", item.name, item.marketplace), () => this._onDidSelectPlugin.fire(item), 'plugin-card-title-block'); + const name = DOM.append(titleBlock, $('.plugin-card-title')); + name.textContent = item.name; + name.title = item.name; + const descriptionLine = DOM.append(titleBlock, $('.plugin-card-subtitle')); + descriptionLine.textContent = truncateToFirstLine(item.description || localize('pluginNoDescription', "No description provided.")); + const actions = DOM.append(header, $('.plugin-card-actions')); + const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installPluginAria', "Install {0}", item.name) })); + install.label = localize('install', "Install"); + this.cardDisposables.add(install.onDidClick(() => this.installMarketplacePlugin(item, install))); + if (showRecommendedBadge && this.pluginMarketplaceService.recommendedPlugins.get().has(getMarketplaceRecommendationKey(item))) { + const badges = DOM.append(card, $('.plugin-card-badges')); + this.appendCardBadge(badges, localize('recommendedBadge', "Recommended")); + } + this.cardListControllers.get(parent)?.addItem({ + row: card, + primaryAction: titleBlock, + label: item.name, + actions: [install.element], + }); + } + + private rememberCardFocusElement(element: HTMLElement): void { + this.firstCardFocusElement ??= element; + } + + private appendCardBadge(parent: HTMLElement, label: string): void { + const badge = DOM.append(parent, $('.inline-badge.plugin-card-badge')); + badge.textContent = label; + } + + private renderDiscoverySnapshot(parent: HTMLElement): void { + const marketplaceItems = this.getUninstalledMarketplaceItems(this.marketplaceSnapshot.items); + if (marketplaceItems.length === 0) { + if (this.marketplaceSnapshot.state === 'failed') { + this.renderDiscoveryError(parent); + } + return; + } + const recommendedKeys = this.pluginMarketplaceService.recommendedPlugins.get(); + const recommended = marketplaceItems.filter(item => recommendedKeys.has(getMarketplaceRecommendationKey(item))); + const snapshotItems = [ + ...recommended, + ...marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))), + ].slice(0, 3); + const section = DOM.append(parent, $('.plugin-card-section.plugin-discovery-section')); + const header = DOM.append(section, $('.plugin-card-section-header')); + const text = DOM.append(header, $('.plugin-card-section-text')); + const title = DOM.append(text, $('h3.plugin-card-section-title')); + title.textContent = localize('featuredPlugins', "Featured"); + const description = DOM.append(text, $('.plugin-card-section-description')); + description.textContent = localize('discoverMorePluginsDescription', "Curated plugins that add tools and expertise."); + const grid = DOM.append(section, $('.plugin-card-grid')); + this.cardListControllers.set(grid, this.cardDisposables.add(new CustomizationCardListController(grid, localize('featuredPlugins', "Featured")))); + for (const item of snapshotItems) { + this.appendMarketplacePluginCard(grid, item, false); + } + this.cardListControllers.get(grid)?.finalize(); + } + + private renderDiscoveryError(parent: HTMLElement): void { + const section = DOM.append(parent, $('.plugin-card-section.plugin-discovery-section')); + const header = DOM.append(section, $('.plugin-card-section-header')); + const text = DOM.append(header, $('.plugin-card-section-text')); + const title = DOM.append(text, $('h3.plugin-card-section-title')); + title.textContent = localize('pluginDiscoveryUnavailable', "Available plugins could not be loaded"); + const description = DOM.append(text, $('.plugin-card-section-description')); + description.textContent = localize('pluginDiscoveryUnavailableDescription', "Check your connection, then try loading results from the configured marketplaces again."); + const retry = this.cardDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, ariaLabel: localize('retryPluginDiscovery', "Retry Loading Plugins") })); + retry.label = localize('retry', "Retry"); + this.cardDisposables.add(retry.onDidClick(() => { + this.marketplaceSnapshot.reset(); + void this.queryMarketplaceSnapshot(); + })); + } + + private renderBrowseMarketplaceCards(): void { + this.cardDisposables.clear(); + this.installedCreateButton = undefined; + this.firstCardFocusElement = undefined; + DOM.clearNode(this.cardContainer); + const marketplaceItems = this.getUninstalledMarketplaceItems(); + if (marketplaceItems.length === 0) { + this.showEmptySurface(); + this.emptyText.textContent = localize('emptyMarketplace', "No plugins available"); + this.emptySubtext.textContent = ''; + return; + } + + this.showCardSurface(); + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll')); + const recommendedKeys = this.pluginMarketplaceService.recommendedPlugins.get(); + const recommended = marketplaceItems.filter(item => recommendedKeys.has(getMarketplaceRecommendationKey(item))); + const allPlugins = marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))); + if (recommended.length > 0) { + const recommendedGrid = this.renderCardSection( + content, + localize('recommendedGroup', "Recommended for this workspace"), + localize('recommendedGroupDescription', "Plugins recommended by workspace configuration."), + 'plugin-marketplace-recommended-section' + ); + for (const item of recommended) { + this.appendMarketplacePluginCard(recommendedGrid, item); + } + this.cardListControllers.get(recommendedGrid)?.finalize(); + } + const allGrid = this.renderCardSection( + content, + localize('allMarketplaceGroup', "All plugins"), + localize('allMarketplaceGroupDescription', "Plugins available from configured marketplaces."), + 'plugin-marketplace-all-section' + ); + for (const item of allPlugins) { + this.appendMarketplacePluginCard(allGrid, item); + } + this.cardListControllers.get(allGrid)?.finalize(); + } + + private getUninstalledMarketplaceItems(items: readonly IMarketplacePluginItem[] = this.marketplaceItems): IMarketplacePluginItem[] { + const installedUris = new Set(this.agentPluginService.plugins.get().map(p => p.uri.toString())); + return items.filter(item => { + const expectedUri = this.pluginInstallService.getPluginInstallUri({ + name: item.name, + description: item.description, + version: item.version ?? '', + source: item.source, + sourceDescriptor: item.sourceDescriptor, + marketplace: item.marketplace, + marketplaceReference: item.marketplaceReference, + marketplaceType: item.marketplaceType, + }); + return !installedUris.has(expectedUri.toString()); + }); + } + + private async installMarketplacePlugin(item: IMarketplacePluginItem, button: Button): Promise { + button.label = localize('installing', "Installing..."); + button.enabled = false; + try { + await this.pluginInstallService.installPlugin({ + name: item.name, + description: item.description, + version: item.version ?? '', + sourceDescriptor: item.sourceDescriptor, + source: item.source, + marketplace: item.marketplace, + marketplaceReference: item.marketplaceReference, + marketplaceType: item.marketplaceType, + readmeUri: item.readmeUri, + }); + button.label = localize('installed', "Installed"); + void this.refresh(); + } catch (error) { + button.label = localize('install', "Install"); + button.enabled = true; + this.notificationService.error(localize('pluginInstallFailed', "Unable to install plugin: {0}", getErrorMessage(error))); + } + } + + private async queryMarketplaceSnapshot(): Promise { + if (!this.marketplaceSnapshot.beginLoading()) { + return; + } + this.marketplaceSnapshotCts?.dispose(true); + const cts = this.marketplaceSnapshotCts = new CancellationTokenSource(); + try { + const plugins = await this.pluginMarketplaceService.fetchMarketplacePlugins(cts.token); + if (this.marketplaceSnapshotCts !== cts) { + return; + } + if (cts.token.isCancellationRequested) { + this.marketplaceSnapshot.reset(); + return; + } + this.marketplaceSnapshot.complete(plugins.map(marketplacePluginToItem)); + if (!this.browseMode && !this.searchQuery.trim()) { + this.renderPluginHome(); + } + } catch { + if (this.marketplaceSnapshotCts === cts && !cts.token.isCancellationRequested) { + this.marketplaceSnapshot.fail(); + if (!this.browseMode && !this.searchQuery.trim()) { + this.renderPluginHome(); + } + } + } + } + public showBrowseMarketplace(): void { if (!this.isBrowseMarketplaceAvailable()) { return; @@ -854,13 +1667,23 @@ export class PluginListWidget extends Disposable { } } + setVisible(visible: boolean): void { + if (this.visible === visible) { + return; + } + this.visible = visible; + if (visible) { + void this.refresh(); + } + } + private toggleBrowseMode(browse: boolean): void { this.browseMode = browse; + this.element.classList.toggle('browse-mode', browse); this.searchInput.value = ''; this.searchQuery = ''; - this.browseButton.element.parentElement!.style.display = browse ? 'none' : ''; - this.backButton.element.parentElement!.style.display = browse ? '' : 'none'; + this.updateToolbarActions(); this.searchInput.setPlaceHolder(browse ? localize('searchMarketplacePlaceholder', "Search plugin marketplace...") @@ -886,8 +1709,7 @@ export class PluginListWidget extends Disposable { const cts = this.marketplaceCts = new CancellationTokenSource(); // Show loading state - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; + this.showEmptySurface(); this.emptyText.textContent = localize('loadingMarketplace', "Loading marketplace..."); this.emptySubtext.textContent = ''; @@ -899,8 +1721,16 @@ export class PluginListWidget extends Disposable { } const query = this.searchQuery.toLowerCase().trim(); + if (query) { + const allPlugins = this.agentPluginService.plugins.get(); + this.installedItems = allPlugins + .map(p => installedPluginToItem(p, this.labelService)) + .filter(item => item.name.toLowerCase().includes(query) || item.description.toLowerCase().includes(query)) + .sort(compareInstalledPluginItems); + this.remoteItems = [...await this.getRemotePluginItems(query)]; + } const filtered = query - ? plugins.filter(p => p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query)) + ? plugins.filter(p => p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.marketplace.toLowerCase().includes(query)) : plugins; // Filter out already-installed plugins @@ -912,22 +1742,60 @@ export class PluginListWidget extends Disposable { }) .map(marketplacePluginToItem); - this.updateMarketplaceList(); + if (query) { + this.updateSearchResultsList(); + } else { + this.updateMarketplaceList(); + } } catch { if (!cts.token.isCancellationRequested) { this.marketplaceItems = []; - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; + this.showEmptySurface(); this.emptyText.textContent = localize('marketplaceError', "Unable to load marketplace"); this.emptySubtext.textContent = localize('tryAgainLater', "Check your connection and try again"); } } } + private async queryPluginSearch(): Promise { + if (!this.isBrowseMarketplaceAvailable()) { + this.marketplaceItems = []; + await this.filterPlugins(); + return; + } + + this.marketplaceCts?.dispose(true); + const cts = this.marketplaceCts = new CancellationTokenSource(); + try { + const plugins = await this.pluginMarketplaceService.fetchMarketplacePlugins(cts.token); + if (cts.token.isCancellationRequested || this.browseMode) { + return; + } + const query = this.searchQuery.toLowerCase().trim(); + const filtered = query + ? plugins.filter(p => p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.marketplace.toLowerCase().includes(query)) + : plugins; + const installedUris = new Set(this.agentPluginService.plugins.get().map(p => p.uri.toString())); + this.marketplaceItems = filtered + .filter(p => { + const expectedUri = this.pluginInstallService.getPluginInstallUri(p); + return !installedUris.has(expectedUri.toString()); + }) + .map(marketplacePluginToItem); + this.searchInput.hideMessage(); + } catch { + this.marketplaceItems = []; + this.searchInput.showMessage({ + content: localize('pluginSearchMarketplaceUnavailable', "Marketplace results are unavailable. Showing installed plugins only."), + type: MessageType.WARNING, + }); + } + await this.filterPlugins(); + } + private updateMarketplaceList(): void { if (this.marketplaceItems.length === 0) { - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; + this.showEmptySurface(); if (this.searchQuery.trim()) { this.emptyText.textContent = localize('noMarketplaceResults', "No plugins match '{0}'", this.searchQuery); this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); @@ -935,13 +1803,53 @@ export class PluginListWidget extends Disposable { this.emptyText.textContent = localize('emptyMarketplace', "No plugins available"); this.emptySubtext.textContent = ''; } - } else { - this.emptyContainer.style.display = 'none'; - this.listContainer.style.display = ''; + this.list.splice(0, this.list.length, []); + return; } - const entries: IPluginListEntry[] = this.marketplaceItems.map(item => ({ type: 'marketplace-item' as const, item })); - this.list.splice(0, this.list.length, entries); + const query = this.searchQuery.trim(); + if (!query) { + this.renderBrowseMarketplaceCards(); + this.list.splice(0, this.list.length, []); + return; + } else { + this.updateSearchResultsList(); + } + } + + private updateSearchResultsList(): void { + const installedNames = new Set(this.installedItems.map(item => item.name.toLowerCase())); + const remoteItems = this.remoteItems.filter(item => item.groupKey !== 'remote-client' && (!item.name || !installedNames.has(item.name.toLowerCase()))); + const installedCount = this.installedItems.length + remoteItems.length; + if (installedCount === 0 && this.marketplaceItems.length === 0) { + this.showEmptySurface(); + this.emptyText.textContent = localize('noMatchingPlugins', "No plugins match '{0}'", this.searchQuery); + this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + this.list.splice(0, this.list.length, []); + return; + } + + this.cardDisposables.clear(); + this.installedCreateButton = undefined; + this.firstCardFocusElement = undefined; + DOM.clearNode(this.cardContainer); + this.showCardSurface(); + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-search-results')); + if (installedCount > 0) { + const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-plugins-section', installedCount); + installedList.classList.add('plugin-inventory-list'); + for (const item of this.installedItems) { + this.appendInstalledPluginRow(installedList, item); + } + for (const item of remoteItems) { + this.appendRemotePluginRow(installedList, item); + } + this.cardListControllers.get(installedList)?.finalize(); + } + if (this.marketplaceItems.length > 0) { + this.renderAvailablePlugins(content, this.marketplaceItems, false, localize('availableSearchHeader', "Available to install"), undefined); + } + this.list.splice(0, this.list.length, []); } private async getRemotePluginItems(query: string): Promise { @@ -963,37 +1871,6 @@ export class PluginListWidget extends Disposable { } } - private getRemoteGroupMetadata(groupKey: string | undefined): { group: string; label: string; description: string } { - return { - group: groupKey ?? 'remote-host', - label: localize('remoteHostGroup', "Remote"), - description: localize('remoteHostGroupDescription', "Plugins configured directly on the remote agent host and available without local sync."), - }; - } - - private appendGroup(entries: IPluginListEntry[], header: { group: string; label: string; description: string }, items: readonly IPluginListEntry[], isFirst: boolean): boolean { - if (items.length === 0) { - return isFirst; - } - - const collapsed = this.collapsedGroups.has(header.group); - entries.push({ - type: 'group-header', - id: `plugin-group-${header.group}`, - group: header.group, - label: header.label, - icon: pluginIcon, - count: items.length, - isFirst, - description: header.description, - collapsed, - }); - if (!collapsed) { - entries.push(...items); - } - return false; - } - private async filterPlugins(): Promise { const query = this.searchQuery.toLowerCase().trim(); const allPlugins = this.agentPluginService.plugins.get(); @@ -1004,83 +1881,16 @@ export class PluginListWidget extends Disposable { .filter(item => !query || item.name.toLowerCase().includes(query) || item.description.toLowerCase().includes(query) - ); + ) + .sort(compareInstalledPluginItems); - if (this.remoteItems.length === 0 && this.installedItems.length === 0) { - this.emptyContainer.style.display = 'flex'; - this.listContainer.style.display = 'none'; - - if (this.searchQuery.trim()) { - this.emptyText.textContent = localize('noMatchingPlugins', "No plugins match '{0}'", this.searchQuery); - this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); - } else if (this.harnessService.getActiveDescriptor().itemProvider) { - this.emptyText.textContent = localize('noRemotePlugins', "No plugins configured"); - this.emptySubtext.textContent = localize('addRemotePlugins', "Use the toolbar to add remote plugins or install plugins from a source."); - } else { - this.emptyText.textContent = localize('noPlugins', "No plugins installed"); - this.emptySubtext.textContent = localize('browseToAdd', "Browse the marketplace to discover and install plugins"); - } - } else { - this.emptyContainer.style.display = 'none'; - this.listContainer.style.display = ''; + if (!query) { + this.renderPluginHome(); + this._onDidChangeItemCount.fire(this.itemCount); + return; } - // Group plugins: enabled vs disabled - const enabledPlugins = this.installedItems.filter(item => isContributionEnabled(item.plugin.enablement.get())); - const disabledPlugins = this.installedItems.filter(item => !isContributionEnabled(item.plugin.enablement.get())); - - const entries: IPluginListEntry[] = []; - let isFirst = true; - - const installedNames = new Set(this.installedItems.map(item => item.name.toLowerCase())); - const remoteGroups = new Map(); - for (const item of this.remoteItems) { - const key = item.groupKey ?? 'remote-host'; - if (key === 'remote-client') { - continue; // client-synced items are already shown in "Enabled Locally" - } - if (item.name && installedNames.has(item.name.toLowerCase())) { - continue; // plugin is also locally installed; show it once in "Enabled Locally" - } - let group = remoteGroups.get(key); - if (!group) { - group = []; - remoteGroups.set(key, group); - } - group.push({ type: 'remote-item', item }); - } - for (const [groupKey, items] of remoteGroups) { - isFirst = this.appendGroup(entries, this.getRemoteGroupMetadata(groupKey), items, isFirst); - } - - if (enabledPlugins.length > 0) { - isFirst = this.appendGroup( - entries, - { - group: 'enabled', - label: localize('enabledGroup', "Enabled Locally"), - description: localize('enabledGroupDescription', "Plugins installed in this client and available for syncing to the remote session."), - }, - enabledPlugins.map(item => ({ type: 'plugin-item' as const, item })), - isFirst, - ); - } - - if (disabledPlugins.length > 0) { - this.appendGroup( - entries, - { - group: 'disabled', - label: localize('disabledGroup', "Disabled Locally"), - description: localize('disabledGroupDescription', "Plugins installed in this client but currently disabled."), - }, - disabledPlugins.map(item => ({ type: 'plugin-item' as const, item })), - isFirst, - ); - } - - this.displayEntries = entries; - this.list.splice(0, this.list.length, this.displayEntries); + this.updateSearchResultsList(); // Compute sidebar badge directly from the data array (same source as group headers) this._onDidChangeItemCount.fire(this.itemCount); @@ -1142,6 +1952,7 @@ export class PluginListWidget extends Disposable { this.lastWidth = width; this.element.style.height = `${height}px`; + this.updateResponsiveLayout(width); // Measure sibling elements to calculate the list height. // When offsetHeight returns 0 the container may have just become visible @@ -1162,8 +1973,10 @@ export class PluginListWidget extends Disposable { } const headerHeight = this.sectionTitleHeader.offsetHeight; this.lastHeaderHeight = headerHeight; - const listHeight = Math.max(0, height - searchBarHeight - headerHeight); + const backHeight = this.marketplaceBackContainer.offsetHeight; + const listHeight = Math.max(0, height - searchBarHeight - headerHeight - backHeight); + this.cardContainer.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; this.list.layout(listHeight, width); } @@ -1179,47 +1992,72 @@ export class PluginListWidget extends Disposable { } focus(): void { - this.list.domFocus(); - if (this.list.length > 0) { + if (this.cardContainer.style.display !== 'none') { + this.firstCardFocusElement?.focus(); + } else if (this.list.length > 0) { + this.list.domFocus(); this.list.setFocus([0]); } } + private getInstalledPluginActions(item: IInstalledPluginItem, disposables: DisposableStore): IAction[] { + const actions: IAction[] = []; + const groups = getInstalledPluginContextMenuActions(item.plugin, this.instantiationService); + for (const menuActions of groups) { + for (const menuAction of menuActions) { + actions.push(menuAction); + if (isDisposable(menuAction)) { + disposables.add(menuAction); + } + } + actions.push(new Separator()); + } + if (actions.length > 0 && actions[actions.length - 1] instanceof Separator) { + actions.pop(); + } + return actions; + } + + private getRemotePluginActions(item: ICustomizationItem): IAction[] { + const actions: IAction[] = []; + for (const itemAction of item.actions ?? []) { + actions.push(new Action( + itemAction.id, + itemAction.label, + itemAction.icon ? ThemeIcon.asClassName(itemAction.icon) : undefined, + itemAction.enabled !== false, + () => itemAction.run(), + )); + } + return actions; + } + + private showInstalledPluginActions(item: IInstalledPluginItem, anchor: HTMLElement): void { + const disposables = new DisposableStore(); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this.getInstalledPluginActions(item, disposables), + onHide: () => disposables.dispose(), + }); + } + + private showRemotePluginActions(item: ICustomizationItem, anchor: HTMLElement): void { + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this.getRemotePluginActions(item), + }); + } + private onContextMenu(e: IListContextMenuEvent): void { - if (!e.element || e.element.type === 'group-header' || e.element.type === 'marketplace-item') { + if (!e.element || e.element.type === 'group-header' || e.element.type === 'search-header' || e.element.type === 'marketplace-item') { return; } const entry = e.element; const disposables = new DisposableStore(); - const actions: IAction[] = []; - - if (entry.type === 'plugin-item') { - const groups = getInstalledPluginContextMenuActions(entry.item.plugin, this.instantiationService); - for (const menuActions of groups) { - for (const menuAction of menuActions) { - actions.push(menuAction); - if (isDisposable(menuAction)) { - disposables.add(menuAction); - } - } - actions.push(new Separator()); - } - if (actions.length > 0 && actions[actions.length - 1] instanceof Separator) { - actions.pop(); - } - } else { - const itemActions = entry.item.actions ?? []; - for (const itemAction of itemActions) { - actions.push(new Action( - itemAction.id, - itemAction.label, - itemAction.icon ? ThemeIcon.asClassName(itemAction.icon) : undefined, - itemAction.enabled !== false, - () => itemAction.run(), - )); - } - } + const actions = entry.type === 'plugin-item' + ? this.getInstalledPluginActions(entry.item, disposables) + : this.getRemotePluginActions(entry.item); this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 0b14b05407f..57c594184b4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -112,13 +112,10 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); } } - const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); const seenUris = new ResourceSet(); for (const skill of skills || []) { const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); seenUris.add(skill.uri); - const skillFolderName = basename(dirname(skill.uri)); - const uiTooltip = uiIntegrations.get(skillFolderName); items.push({ uri: skill.uri, type: promptType, @@ -126,8 +123,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description: skill.description, source: skill.storage, enabled: true, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, extensionId: skill.extension?.identifier.value, pluginUri: skill.pluginUri, pluginLabel: skill.pluginLabel, @@ -138,8 +133,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt for (const file of allSkillFiles) { if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { const disabledName = file.name || basename(dirname(file.uri)) || basename(file.uri); - const disabledFolderName = basename(dirname(file.uri)); - const uiTooltip = uiIntegrations.get(disabledFolderName); items.push({ uri: file.uri, type: promptType, @@ -147,8 +140,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt description: file.description, source: file.storage, enabled: false, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, extensionId: file.extension?.identifier.value, pluginUri: file.pluginUri, pluginLabel: file.pluginLabel, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts index e8853ac2da1..099bd609359 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts @@ -23,6 +23,7 @@ import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; @@ -42,6 +43,10 @@ import './media/aiCustomizationManagement.css'; const $ = DOM.$; +export function isToolsTreeKeyboardTarget(target: HTMLElement, row: HTMLElement): boolean { + return target === row; +} + interface ITreeRow { readonly kind: 'set' | 'tool'; readonly rowId: string; @@ -51,6 +56,7 @@ interface ITreeRow { readonly group?: HTMLElement; readonly children?: ITreeRow[]; readonly parent?: ITreeRow; + readonly readOnly?: boolean; } interface IToolViewModel { @@ -146,7 +152,6 @@ export class ToolsListWidget extends Disposable { private _searchRow!: HTMLElement; private _treeContainer!: HTMLElement; private _treeScrollable!: DomScrollableElement; - private _browseButtonContainer: HTMLElement | undefined; private _backButtonContainer!: HTMLElement; private _galleryContainer!: HTMLElement; private _galleryEmpty!: HTMLElement; @@ -262,14 +267,6 @@ export class ToolsListWidget extends Disposable { }).catch(() => { /* delayer disposed */ }); })); - if (!this._environmentService.isSessionsWindow) { - const browseLabel = localize('toolsBrowseMarketplace', "Browse Marketplace"); - this._browseButtonContainer = DOM.append(this._searchRow, $('.tools-list-browse-button-container')); - const browseButton = this._register(new Button(this._browseButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: browseLabel, ariaLabel: browseLabel })); - browseButton.label = `$(${Codicon.library.id}) ${browseLabel}`; - this._register(browseButton.onDidClick(() => this._setBrowseMode(true))); - } - const backLabel = localize('toolsBrowseBack', "Back"); this._backButtonContainer = DOM.append(this._searchRow, $('.tools-list-browse-button-container')); this._backButtonContainer.style.display = 'none'; @@ -281,6 +278,9 @@ export class ToolsListWidget extends Disposable { private _createGallery(): void { this._galleryContainer = DOM.append(this.element, $('.tools-gallery-container')); this._galleryContainer.style.display = 'none'; + const header = DOM.append(this._galleryContainer, $('.tools-marketplace-header')); + DOM.append(header, $('h3.tools-marketplace-title')).textContent = localize('toolsMarketplaceTitle', "Marketplace Tools"); + DOM.append(header, $('p.tools-marketplace-description')).textContent = localize('toolsMarketplaceDescription', "Install extensions that contribute additional tools."); this._galleryEmpty = DOM.append(this._galleryContainer, $('.list-empty-state')); this._galleryEmpty.style.display = 'none'; this._galleryListContainer = DOM.append(this._galleryContainer, $('.tools-gallery-list')); @@ -404,6 +404,7 @@ export class ToolsListWidget extends Disposable { layout(height: number, width: number): void { this._lastHeight = height; this._lastWidth = width; + this.element.classList.toggle('narrow-layout', width < 500); this._searchInput.layout(); this._treeScrollable.scanDomNode(); @@ -423,9 +424,6 @@ export class ToolsListWidget extends Disposable { this._treeScrollable.getDomNode().style.display = browse ? 'none' : ''; this._galleryContainer.style.display = browse ? '' : 'none'; - if (this._browseButtonContainer) { - this._browseButtonContainer.style.display = browse ? 'none' : ''; - } this._backButtonContainer.style.display = browse ? '' : 'none'; this._searchInput.setPlaceHolder(browse @@ -546,31 +544,90 @@ export class ToolsListWidget extends Disposable { this._rowByElement.clear(); DOM.clearNode(this._treeContainer); - if (model.length === 0) { + const query = this._searchQuery.get().trim(); + if (model.length === 0 && query) { const emptyState = DOM.append(this._treeContainer, $('.list-empty-state')); const header = DOM.append(emptyState, $('.empty-state-header')); const text = DOM.append(header, $('.empty-state-text')); const subtext = DOM.append(emptyState, $('.empty-state-subtext')); - const query = this._searchQuery.get().trim(); - if (query) { - text.textContent = localize('noMatchingTools', "No tools match '{0}'", query); - subtext.textContent = localize('tryDifferentSearch', "Try a different search term"); - } else { - text.textContent = localize('toolsNoMatches', "No tools available."); - } + text.textContent = localize('noMatchingTools', "No tools match '{0}'", query); + subtext.textContent = localize('tryDifferentSearch', "Try a different search term"); this._treeScrollable.scanDomNode(); return; } + const builtIn = model.filter(vm => vm.toolSet.source.type === 'internal' || vm.toolSet.source.type === 'external'); + const connected = model.filter(vm => vm.toolSet.source.type === 'mcp' || vm.toolSet.source.type === 'user'); + const installed = model.filter(vm => vm.toolSet.source.type === 'extension'); + this._renderToolSection( + localize('builtInToolsSection', "Built-in Tools"), + localize('builtInToolsSectionDescription', "Tools provided by the active agent and VS Code."), + localize('builtInToolsSectionEmpty', "No built-in tool sets are available."), + builtIn, + query, + ); + this._renderToolSection( + localize('connectedToolsSection', "Connected Sources"), + localize('connectedToolsSectionDescription', "Tool sets provided by MCP servers and user configuration."), + localize('connectedToolsSectionEmpty', "No connected tool sources are available."), + connected, + query, + undefined, + false, + ); + this._renderToolSection( + localize('installedToolExtensionsSection', "Extension Tools"), + localize('installedToolExtensionsSectionDescription', "Tool sets contributed by installed extensions."), + localize('extensionToolsSectionEmpty', "No extension tools are installed."), + installed, + query, + !this._environmentService.isSessionsWindow ? sectionHeader => { + const actions = DOM.append(sectionHeader, $('.tools-inventory-section-actions')); + const browseLabel = localize('toolsBrowseMarketplace', "Browse Marketplace"); + const browseButton = this._rowStore.add(new Button(actions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: browseLabel, ariaLabel: browseLabel })); + browseButton.label = `$(${Codicon.library.id}) ${browseLabel}`; + this._rowStore.add(browseButton.onDidClick(() => this._setBrowseMode(true))); + } : undefined, + ); + this._initRovingTabIndex(hadFocus); + this._treeScrollable.scanDomNode(); + } + + private _renderToolSection( + title: string, + description: string, + emptyMessage: string, + model: readonly IToolSetViewModel[], + query: string, + renderActions?: (header: HTMLElement) => void, + showWhenEmpty = true, + ): void { + if (model.length === 0 && (query || !showWhenEmpty)) { + return; + } + const section = DOM.append(this._treeContainer, $('.tools-inventory-section')); + const header = DOM.append(section, $('.tools-inventory-section-header')); + const text = DOM.append(header, $('.tools-inventory-section-text')); + const headingRow = DOM.append(text, $('.tools-inventory-section-heading-row')); + DOM.append(headingRow, $('h3.tools-inventory-section-title')).textContent = title; + DOM.append(headingRow, $('span.tools-inventory-section-count')).textContent = String(model.length); + DOM.append(text, $('p.tools-inventory-section-description')).textContent = description; + renderActions?.(header); + + const inventory = DOM.append(section, $('.tools-inventory-list')); + inventory.setAttribute('role', 'group'); + inventory.setAttribute('aria-label', title); + if (model.length === 0) { + DOM.append(inventory, $('.plugin-inventory-empty')).textContent = emptyMessage; + return; + } for (const vm of model) { - const setRow = this._renderToolSet(vm); + const setRow = this._renderToolSet(inventory, vm); this._addRow(setRow); for (const child of setRow.children!) { this._addRow(child); } } - this._initRovingTabIndex(hadFocus); - this._treeScrollable.scanDomNode(); } private _addRow(row: ITreeRow): void { @@ -578,9 +635,9 @@ export class ToolsListWidget extends Disposable { this._rowByElement.set(row.element, row); } - private _renderToolSet(vm: IToolSetViewModel): ITreeRow { + private _renderToolSet(container: HTMLElement, vm: IToolSetViewModel): ITreeRow { const ts = vm.toolSet; - const row = DOM.append(this._treeContainer, $('.tools-list-setrow')); + const row = DOM.append(container, $('.tools-list-setrow')); // Tree item with a roving tabIndex: navigated with arrows, toggled with Space; not a Tab stop. row.setAttribute('role', 'treeitem'); row.setAttribute('aria-level', '1'); @@ -589,19 +646,17 @@ export class ToolsListWidget extends Disposable { const setName = ts.description ?? ts.referenceName; const toggleExpand = () => this._toggleCollapsed(ts.id); - const checkbox = this._rowStore.add(new TriStateCheckbox( - localize('toolsSetCheckbox', "Enable {0}", setName), - getToolSetTriState(this._currentState(), ts.id, vm.allToolIds), - defaultCheckboxStyles, - )); - checkbox.domNode.tabIndex = -1; - row.appendChild(checkbox.domNode); - if (vm.readOnly) { - checkbox.disable(); - checkbox.setTitle(localize('toolsSetReadOnly', "These are the agent's built-in tools and cannot be changed.")); - } else { + let checkbox: TriStateCheckbox | undefined; + if (!vm.readOnly) { + checkbox = this._rowStore.add(new TriStateCheckbox( + localize('toolsSetCheckbox', "Enable {0}", setName), + getToolSetTriState(this._currentState(), ts.id, vm.allToolIds), + defaultCheckboxStyles, + )); + checkbox.domNode.tabIndex = -1; + row.prepend(checkbox.domNode); this._rowStore.add(checkbox.onChange(() => { - const enabled = checkbox.checked === true; + const enabled = checkbox!.checked === true; this._enablementService.setToolSetEnabled(this._sessionType, ts.id, vm.allToolIds, enabled); })); } @@ -617,31 +672,37 @@ export class ToolsListWidget extends Disposable { } const count = DOM.append(row, $('span.tools-list-row-count')); + if (vm.readOnly) { + DOM.append(row, $('span.tools-list-always-available')).textContent = localize('toolsAlwaysAvailable', "Always Available"); + } + const extension = this._resolveExtensionForToolSet(ts); + let moreButton: HTMLButtonElement | undefined; + if (extension) { + const moreLabel = localize('toolsSetMoreActions', "More actions for {0}", setName); + moreButton = DOM.append(row, $('button.tools-list-more-action')) as HTMLButtonElement; + moreButton.type = 'button'; + moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); + moreButton.setAttribute('aria-label', moreLabel); + moreButton.title = moreLabel; + this._rowStore.add(DOM.addDisposableListener(moreButton, 'click', e => { + DOM.EventHelper.stop(e, true); + this._showExtensionContextMenu(moreButton!, extension); + })); + } // Decorative chevron: expand state is on the row (aria-expanded); toggled by row click or arrows. const chevron = DOM.append(row, $('a.tools-list-chevron.codicon')) as HTMLAnchorElement; chevron.setAttribute('aria-hidden', 'true'); this._rowStore.add(DOM.addDisposableListener(row, 'click', e => { - if (checkbox.domNode.contains(e.target as Node)) { + if (checkbox?.domNode.contains(e.target as Node) || moreButton?.contains(e.target as Node)) { return; } row.focus(); toggleExpand(); })); - // Extension-provided tool sets can be uninstalled via the context menu. - this._rowStore.add(DOM.addDisposableListener(row, 'contextmenu', e => { - const extension = this._resolveExtensionForToolSet(ts); - if (!extension) { - return; - } - DOM.EventHelper.stop(e, true); - const anchor: HTMLElement | StandardMouseEvent = e.button === 2 ? new StandardMouseEvent(DOM.getWindow(row), e) : row; - this._showExtensionContextMenu(anchor, extension); - })); - - const group = DOM.append(this._treeContainer, $('.tools-list-children')); + const group = DOM.append(container, $('.tools-list-children')); group.id = `tools-group-${ts.id}`; group.setAttribute('role', 'group'); group.setAttribute('aria-label', setName); @@ -653,9 +714,10 @@ export class ToolsListWidget extends Disposable { rowId: `set:${ts.id}`, toolSetId: ts.id, element: row, - toggleNode: checkbox.domNode, + toggleNode: checkbox?.domNode ?? row, group, children: [], + readOnly: vm.readOnly, }; for (const tool of vm.visibleTools) { setRow.children!.push(this._renderTool(group, setRow, vm, tool)); @@ -665,8 +727,12 @@ export class ToolsListWidget extends Disposable { this._rowStore.add(autorun(reader => { const state = this._readState(reader); const triState = getToolSetTriState(state, ts.id, vm.allToolIds); - checkbox.checked = triState; - this._updateRowAriaChecked(row, triState); + if (checkbox) { + checkbox.checked = triState; + this._updateRowAriaChecked(row, triState); + } else { + row.removeAttribute('aria-checked'); + } const enabledCount = vm.allToolIds.reduce((n, id) => n + (isToolEnabledInSet(state, ts.id, id) ? 1 : 0), 0); count.textContent = `${enabledCount}/${vm.allToolIds.length}`; count.setAttribute('aria-label', localize('toolsRowEnabledOfTotal', "{0} of {1} tools enabled", enabledCount, vm.allToolIds.length)); @@ -697,34 +763,32 @@ export class ToolsListWidget extends Disposable { row.setAttribute('aria-level', '2'); row.tabIndex = -1; - const checkbox = this._rowStore.add(new Checkbox( - localize('toolsToolCheckbox', "Enable {0}", toolName), - enabled, - defaultCheckboxStyles, - )); - checkbox.domNode.tabIndex = -1; - row.appendChild(checkbox.domNode); - this._updateRowAriaChecked(row, enabled); - if (vm.readOnly) { - checkbox.disable(); - checkbox.setTitle(localize('toolsSetReadOnly', "These are the agent's built-in tools and cannot be changed.")); - } else { + let checkbox: Checkbox | undefined; + if (!vm.readOnly) { + checkbox = this._rowStore.add(new Checkbox( + localize('toolsToolCheckbox', "Enable {0}", toolName), + enabled, + defaultCheckboxStyles, + )); + checkbox.domNode.tabIndex = -1; + row.prepend(checkbox.domNode); + this._updateRowAriaChecked(row, enabled); this._rowStore.add(checkbox.onChange(() => { - this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, checkbox.checked); + this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, checkbox!.checked); })); this._rowStore.add(DOM.addDisposableListener(row, 'click', e => { - if (checkbox.domNode.contains(e.target as Node)) { + if (checkbox!.domNode.contains(e.target as Node)) { return; } row.focus(); - this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, !checkbox.checked); + this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, !checkbox!.checked); })); // Keep the checkbox and the treeitem's aria-checked in sync (e.g. when the parent set is toggled). this._rowStore.add(autorun(reader => { const toolEnabled = isToolEnabledInSet(this._readState(reader), vm.toolSet.id, tool.id); - checkbox.checked = toolEnabled; + checkbox!.checked = toolEnabled; this._updateRowAriaChecked(row, toolEnabled); })); } @@ -738,14 +802,18 @@ export class ToolsListWidget extends Disposable { const subtext = DOM.append(text, $('span.tools-list-row-subtext')); subtext.textContent = description; } + if (vm.readOnly) { + DOM.append(row, $('span.tools-list-always-available')).textContent = localize('toolsAlwaysAvailable', "Always Available"); + } return { kind: 'tool', rowId: `tool:${vm.toolSet.id}:${tool.id}`, toolSetId: vm.toolSet.id, element: row, - toggleNode: checkbox.domNode, + toggleNode: checkbox?.domNode ?? row, parent, + readOnly: vm.readOnly, }; } @@ -845,7 +913,7 @@ export class ToolsListWidget extends Disposable { private _onTreeKeyDown(e: IKeyboardEvent): void { const row = this._rowFromTarget(e.target); - if (!row) { + if (!row || !isToolsTreeKeyboardTarget(e.target, row.element)) { return; } let handled = true; @@ -869,10 +937,18 @@ export class ToolsListWidget extends Disposable { this._focusEdge(false); break; case KeyCode.Space: - case KeyCode.Enter: - // Reuse the row's checkbox wiring; disabled (read-only) checkboxes ignore the click. + if (row.readOnly) { + break; + } row.toggleNode.click(); break; + case KeyCode.Enter: + if (row.kind === 'set' && row.readOnly) { + this._toggleCollapsed(row.toolSetId); + } else if (!row.readOnly) { + row.toggleNode.click(); + } + break; default: handled = false; } diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index b4e6cd7758b..4ce4691ae03 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -1281,11 +1281,10 @@ export class ChatModelsWidget extends Disposable { this.addButtonContainer = DOM.append(searchAndButtonContainer, $('.section-title-actions')); const buttonOptions: IButtonOptions = { ...defaultButtonStyles, - supportIcons: true, }; this.addButton = this._register(new Button(this.addButtonContainer, buttonOptions)); - this.addButton.label = `$(${Codicon.add.id}) ${localize('models.enableModelProvider', 'Add Models')}`; + this.addButton.label = localize('models.enableModelProvider', 'Add Models'); this.addButton.element.classList.add('models-add-model-button'); this.updateAddModelsButton(); this._register(this.addButton.onDidClick((e) => { @@ -1304,7 +1303,7 @@ export class ChatModelsWidget extends Disposable { ...buttonOptions, secondary: true, })); - browseMarketplaceButton.label = `$(${Codicon.extensions.id}) ${localize('models.installProviderExtensions', "Install Model Providers")}`; + browseMarketplaceButton.label = localize('models.installProviderExtensions', "Install Model Providers"); browseMarketplaceButton.element.classList.add('models-browse-marketplace-button'); this._register(browseMarketplaceButton.onDidClick(() => this.openLanguageModelProviderExtensionsSearch())); } diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css index 31adbda4067..5d46873a62f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css @@ -13,7 +13,7 @@ display: flex; align-items: center; gap: 8px; - margin-bottom: 12px; + margin-bottom: var(--vscode-spacing-size160); } .models-widget .models-search-container { diff --git a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts index b14379fa33b..7db90c55f1d 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts @@ -146,6 +146,7 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi kind: AgentPluginItemKind.Marketplace, name: plugin.name, description: plugin.description, + version: plugin.version, source: plugin.source, sourceDescriptor: plugin.sourceDescriptor, marketplace: plugin.marketplace, diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index cd7572506a1..7dedb04c77d 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -307,6 +307,8 @@ export interface IHookQuickPickOptions { readonly onHookFileCreated?: (uri: URI) => void; /** Filter the displayed hook types to those supported by the given target. */ readonly target?: Target; + /** Restrict hook files and creation destinations to one storage scope. */ + readonly preferredStorage?: PromptsStorage; } /** @@ -347,7 +349,7 @@ export async function showConfigureHooksQuickPick( userHome, targetOS, CancellationToken.None, - { includeAgentHooks: true } + { includeAgentHooks: true, preferredStorage: options?.preferredStorage } ); // Count hooks per type @@ -573,9 +575,9 @@ export async function showConfigureHooksQuickPick( } case Step.SelectFile: { - // Step 3: Handle "Add new hook" - show create new file + existing hook files - // Get existing hook files (local storage only, not User Data) - const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); + // Step 3: Handle "Add new hook" - show create new file + existing hook files. + const hookStorage = options?.preferredStorage ?? PromptsStorage.local; + const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, hookStorage, CancellationToken.None); const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; @@ -647,10 +649,13 @@ export async function showConfigureHooksQuickPick( case Step.SelectFolder: { // Get source folders for hooks (uses getSourceFolders which // excludes Claude paths and normalizes to directories) - const allFolders = await promptsService.getSourceFolders(PromptsType.hook); + const allFolders = (await promptsService.getSourceFolders(PromptsType.hook)) + .filter(folder => options?.preferredStorage === undefined || folder.storage === options.preferredStorage); if (allFolders.length === 0) { - notificationService.error(localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); + notificationService.error(options?.preferredStorage === PromptsStorage.user + ? localize('commands.hook.noUserFolders', "No user hooks folder is available.") + : localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); return; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index 0f7619583e2..43a0f146c8f 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -6,7 +6,7 @@ import { findNodeAtLocation, Node, parse as parseJSONC, parseTree } from '../../../../../base/common/json.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; @@ -187,6 +187,8 @@ export interface IParseAllHookFilesOptions { additionalDisabledFileUris?: readonly URI[]; /** If true, also collect hooks from custom agent frontmatter */ includeAgentHooks?: boolean; + /** Restrict parsed hooks to one storage scope. */ + preferredStorage?: PromptsStorage; } /** @@ -203,7 +205,9 @@ export async function parseAllHookFiles( token: CancellationToken, options?: IParseAllHookFilesOptions ): Promise { - const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, token); + const hookFiles = options?.preferredStorage + ? await promptsService.listPromptFilesForStorage(PromptsType.hook, options.preferredStorage, token) + : await promptsService.listPromptFiles(PromptsType.hook, token); const parsedHooks: IParsedHook[] = []; for (const hookFile of hookFiles) { @@ -284,7 +288,7 @@ export async function parseAllHookFiles( if (options?.includeAgentHooks) { const agents = await promptsService.getCustomAgents(token); for (const agent of agents) { - if (!agent.hooks || !agent.enabled) { + if (!agent.hooks || !agent.enabled || options.preferredStorage && agent.source.storage !== options.preferredStorage) { continue; } for (const hookTypeValue of Object.values(HookType)) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index f2484aafea3..d19d4f34428 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2841,7 +2841,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!this._notificationWidget.value) { // Fall back to `getCurrentSessionType()` so the session-type // picker delegate is consulted before any real session exists - // (e.g. empty workspace + Copilot CLI [Agent Host] selected). Without + // (e.g. empty workspace + Copilot CLI selected). Without // this fallback, `_currentSessionType` stays undefined until // the user creates a session and `sessionTypes`-gated // notifications never render. diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 3ac0148c108..0928075ef46 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -35,6 +35,8 @@ export interface IAgentPlugin { readonly format: PluginFormat; /** Human-readable display name for the plugin. */ readonly label: string; + /** Version declared by the plugin manifest, falling back to marketplace metadata. */ + readonly version?: IObservable; readonly enablement: IObservable; /** * When `true`, the plugin is blocked by enterprise policy. It remains diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index b02f3ccf3b0..726299ae6cf 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -361,6 +361,13 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements // re-read whenever the manifest changes on disk. const initialManifest = await readPluginManifest(uri, format, this._fileService); const manifest = observableValue('agentPluginManifest', initialManifest); + const pluginVersion = derived(reader => { + const manifestVersion = manifest.read(reader)?.version; + if (typeof manifestVersion === 'string' && manifestVersion.trim()) { + return manifestVersion.trim(); + } + return fromMarketplace?.version || undefined; + }).recomputeInitiallyAndOnChange(store); const observeComponent = ( prop: PluginComponent, @@ -475,6 +482,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements uri, format: format.format, label: fromMarketplace?.name ?? manifestName ?? basename(uri), + version: pluginVersion, enablement, policyBlocked, remove: removeCallback, diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 141051b8be9..3d26ebea576 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -520,11 +520,15 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const cached = options?.refresh ? undefined : this._getCachedGitHubMarketplacePlugins(cache, reference.canonicalId); if (cached) { - return cached.map(c => ({ - ...c, - marketplace: reference.displayLabel, - marketplaceReference: reference, - })); + return cached.map(c => { + const plugin = ensureSourceDescriptor(c); + return { + ...plugin, + marketplace: reference.displayLabel, + marketplaceReference: reference, + readmeUri: getMarketplaceReadmeUri(plugin.sourceDescriptor, reference, plugin.source), + }; + }); } let repoMayBePrivate = true; @@ -810,7 +814,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke marketplace: reference.displayLabel, marketplaceReference: reference, marketplaceType, - readmeUri: repoDir ? getMarketplaceReadmeFileUri(repoDir, source) : getMarketplaceReadmeUri(reference.githubRepo ?? '', source), + readmeUri: getMarketplaceReadmeUri(sourceDescriptor, reference, source, repoDir), }]; }); } @@ -1267,10 +1271,34 @@ export function hasSourceChanged(installed: IPluginSourceDescriptor, marketplace } } -function getMarketplaceReadmeUri(repo: string, source: string): URI { +function getMarketplaceReadmeUri(sourceDescriptor: IPluginSourceDescriptor, reference: IMarketplaceReference, source: string, repoDir?: URI): URI | undefined { + if (sourceDescriptor.kind === PluginSourceKind.GitHub) { + const ref = sourceDescriptor.sha ?? sourceDescriptor.ref ?? 'main'; + const normalizedPath = sourceDescriptor.path?.trim().replace(/^\.?\/+|\/+$/g, ''); + const readmePath = normalizedPath ? `${normalizedPath}/README.md` : 'README.md'; + return URI.parse(`https://github.com/${sourceDescriptor.repo}/blob/${ref}/${readmePath}`); + } + + if (sourceDescriptor.kind === PluginSourceKind.GitUrl && sourceDescriptor.url.startsWith('https://github.com/')) { + const repo = sourceDescriptor.url.replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, ''); + const ref = sourceDescriptor.sha ?? sourceDescriptor.ref ?? 'main'; + const normalizedPath = sourceDescriptor.path?.trim().replace(/^\.?\/+|\/+$/g, ''); + const readmePath = normalizedPath ? `${normalizedPath}/README.md` : 'README.md'; + return URI.parse(`https://github.com/${repo}/blob/${ref}/${readmePath}`); + } + + if (repoDir) { + return getMarketplaceReadmeFileUri(repoDir, source); + } + + if (!reference.githubRepo) { + return undefined; + } + const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); const readmePath = normalizedSource ? `${normalizedSource}/README.md` : 'README.md'; - return URI.parse(`https://github.com/${repo}/blob/main/${readmePath}`); + const ref = reference.ref ?? 'main'; + return URI.parse(`https://github.com/${reference.githubRepo}/blob/${ref}/${readmePath}`); } function getMarketplaceReadmeFileUri(repoDir: URI, source: string): URI { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index 705e4228ffe..3f8b2d73c09 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -8,6 +8,7 @@ import { ResourceMap } from '../../../../../../base/common/map.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { CustomizationEnablementKind, CustomizationType, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { createAgentHostResourceUriMapper, identityAgentHostResourceUriMapper, IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, ILoggerService, NullLogService, NullLoggerService } from '../../../../../../platform/log/common/log.js'; @@ -20,6 +21,7 @@ class FakeTarget implements IAgentHostCustomizationTarget { readonly customizations: readonly Customization[], readonly workingDirectory?: string, private readonly _isBundledMcpServer: (pluginUri: string, serverName: string) => boolean = () => false, + readonly resourceUris: IAgentHostResourceUriMapper = identityAgentHostResourceUriMapper, ) { } isBundledMcpServer(pluginUri: string, serverName: string): boolean { @@ -133,6 +135,22 @@ suite('AbstractAgentHostCustomizationService', () => { assert.strictEqual(pluginServer.isClientBundled, true); }); + test('maps host MCP sources and omits synthetic top-level sources', () => { + const sut = createSut(); + const session = URI.parse('vscode-agent-session:///session-1'); + const fileServer = mcpServer('file-server', 'File Server'); + const topLevelServer = { ...mcpServer('top-level-server', 'Top Level Server'), uri: 'mcp-top-level:/top-level-server' }; + const resourceUris = createAgentHostResourceUriMapper('remote.example'); + sut.setTarget(session, new FakeTarget([fileServer, topLevelServer], undefined, undefined, resourceUris)); + + const servers = sut.getMcpServers(session); + + assert.deepStrictEqual(servers.map(server => server.sourceUri?.toString()), [ + resourceUris.fromAgentHost(URI.parse(fileServer.uri)).toString(), + undefined, + ]); + }); + test('preserves global and session decisions when re-enabling workspace enablement', () => { const sut = createSut(); const session = URI.parse('vscode-agent-session:///session-1'); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 0d5a2eedda6..677f932b800 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -13,7 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { AICustomizationListWidget } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; +import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getTargetedCreateActionLabel, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; @@ -21,14 +21,133 @@ import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../commo import { ContributionEnablementState } from '../../../common/enablement.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController } from '../../../browser/aiCustomization/customizationCardList.js'; suite('aiCustomizationListWidget', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('uses the inventory card layout for all file customization sections', () => { + assert.deepStrictEqual({ + agents: usesCustomizationCardLayout(AICustomizationManagementSection.Agents), + skills: usesCustomizationCardLayout(AICustomizationManagementSection.Skills), + instructions: usesCustomizationCardLayout(AICustomizationManagementSection.Instructions), + hooks: usesCustomizationCardLayout(AICustomizationManagementSection.Hooks), + prompts: usesCustomizationCardLayout(AICustomizationManagementSection.Prompts), + }, { + agents: true, + skills: true, + instructions: true, + hooks: true, + prompts: true, + }); + }); + + test('keeps editable source sections visible until search filtering starts', () => { + assert.deepStrictEqual({ + agents: getAlwaysVisibleCustomizationGroupKeys(AICustomizationManagementSection.Agents, false), + skills: getAlwaysVisibleCustomizationGroupKeys(AICustomizationManagementSection.Skills, false), + instructions: getAlwaysVisibleCustomizationGroupKeys(AICustomizationManagementSection.Instructions, false), + hooks: getAlwaysVisibleCustomizationGroupKeys(AICustomizationManagementSection.Hooks, false), + filtered: getAlwaysVisibleCustomizationGroupKeys(AICustomizationManagementSection.Agents, true), + prompts: getAlwaysVisibleCustomizationGroupKeys(AICustomizationManagementSection.Prompts, false), + }, { + agents: [PromptsStorage.local, PromptsStorage.user], + skills: [PromptsStorage.local, PromptsStorage.user], + instructions: [PromptsStorage.local, PromptsStorage.user], + hooks: [PromptsStorage.local, PromptsStorage.user], + filtered: [], + prompts: [PromptsStorage.local, PromptsStorage.user], + }); + }); + + test('uses localized compact labels instead of parsing display labels', () => { + assert.deepStrictEqual([ + getTargetedCreateActionLabel('$(add) Nuevo agente (Espacio de trabajo)', 'Nuevo agente'), + getTargetedCreateActionLabel('$(add) Create from provider'), + ], [ + 'Nuevo agente', + 'Create from provider', + ]); + }); + + test('card lists use roving focus and expose focused-row actions', async () => { + const disposables = new DisposableStore(); + const list = document.createElement('div'); + document.body.appendChild(list); + const controller = disposables.add(new CustomizationCardListController(list, 'Customizations')); + const createItem = (label: string) => { + const row = document.createElement('div'); + const primaryAction = createCustomizationCardPrimaryAction(row, label); + const action = document.createElement('button'); + row.appendChild(action); + list.appendChild(row); + controller.addItem({ row, primaryAction, label, actions: [action], contextMenuAction: action }); + return { row, primaryAction, action }; + }; + const alpha = createItem('Alpha'); + const beta = createItem('Beta'); + const disabledActionRow = document.createElement('div'); + const disabledActionPrimary = createCustomizationCardPrimaryAction(disabledActionRow, 'Disabled Action'); + const disabledAction = document.createElement('button'); + disabledAction.disabled = true; + const enabledAction = document.createElement('button'); + disabledActionRow.append(disabledAction, enabledAction); + list.appendChild(disabledActionRow); + controller.addItem({ row: disabledActionRow, primaryAction: disabledActionPrimary, label: 'Disabled Action', actions: [disabledAction, enabledAction], contextMenuAction: enabledAction }); + const remoteRow = document.createElement('div'); + const remoteAction = document.createElement('button'); + remoteRow.appendChild(remoteAction); + list.appendChild(remoteRow); + controller.addItem({ row: remoteRow, primaryAction: remoteRow, label: 'Remote', actions: [remoteAction], contextMenuAction: remoteAction }); + controller.finalize(); + + try { + alpha.primaryAction.focus(); + alpha.primaryAction.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + beta.primaryAction.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + beta.action.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true })); + const spaceKeyEvent = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + beta.primaryAction.dispatchEvent(spaceKeyEvent); + disabledActionPrimary.focus(); + disabledActionPrimary.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + const disabledActionTabTarget = document.activeElement; + beta.primaryAction.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); + enabledAction.tabIndex = 0; + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual({ + listRole: list.getAttribute('role'), + rowRoles: [alpha.row.getAttribute('role'), beta.row.getAttribute('role'), disabledActionRow.getAttribute('role'), remoteRow.getAttribute('role')], + positions: [alpha.row.getAttribute('aria-posinset'), beta.row.getAttribute('aria-posinset')], + remotePosition: remoteRow.getAttribute('aria-posinset'), + setSizes: [alpha.row.getAttribute('aria-setsize'), beta.row.getAttribute('aria-setsize'), disabledActionRow.getAttribute('aria-setsize'), remoteRow.getAttribute('aria-setsize')], + tabIndexes: [alpha.primaryAction.tabIndex, beta.primaryAction.tabIndex, remoteRow.tabIndex, alpha.action.tabIndex, beta.action.tabIndex, remoteAction.tabIndex], + spaceDefaultPrevented: spaceKeyEvent.defaultPrevented, + disabledActionTabIndexes: [disabledAction.tabIndex, enabledAction.tabIndex], + disabledActionTabTarget, + activeElement: document.activeElement, + }, { + listRole: 'list', + rowRoles: ['listitem', 'listitem', 'listitem', 'listitem'], + positions: ['1', '2'], + remotePosition: '4', + setSizes: ['4', '4', '4', '4'], + tabIndexes: [0, -1, -1, -1, -1, -1], + spaceDefaultPrevented: false, + disabledActionTabIndexes: [-1, -1], + disabledActionTabTarget: enabledAction, + activeElement: alpha.primaryAction, + }); + } finally { + disposables.dispose(); + list.remove(); + } + }); + suite('truncateToFirstLine', () => { test('keeps first line when text has multiple lines', () => { assert.strictEqual( 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 16ef00173f1..784e0e3e834 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 @@ -22,9 +22,24 @@ import { AICustomizationManagementSection, AICustomizationSources } from '../../ import type { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import { CustomizationMigrationCategoryId } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; import type { ICustomizationMigrationCategorySummary } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; +import { AICustomizationManagementEditorInput } from '../../../browser/aiCustomization/aiCustomizationManagementEditorInput.js'; suite('aiCustomizationManagementEditor', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('includes the customization target in the modal title', () => { + const input = store.add(new AICustomizationManagementEditorInput()); + const names = [input.getName()]; + input.setTargetLabel('Copilot'); + names.push(input.getName()); + input.setTargetLabel(undefined); + names.push(input.getName()); + assert.deepStrictEqual(names, [ + 'Agent Customizations', + 'Agent Customizations - Copilot', + 'Agent Customizations', + ]); + }); type TestableEditor = { currentEditingPromptType: PromptsType | undefined; @@ -49,9 +64,7 @@ suite('aiCustomizationManagementEditor', () => { migrationDescriptionElement: HTMLElement | undefined; migrationBannerContainer: HTMLElement | undefined; migrationLinkElement: HTMLAnchorElement | undefined; - migrationSearchQuery: string; selectedCustomizationMigrationItems: ResourceMap>; - collapsedCustomizationMigrationGroups: Set; migrationPageDisposables: DisposableStore; labelService: { getUriLabel(uri: URI, options?: { relative?: boolean }): string }; showEmbeddedEditor(...args: unknown[]): Promise; @@ -117,15 +130,13 @@ suite('aiCustomizationManagementEditor', () => { editor.migrationDescriptionElement = undefined; editor.migrationBannerContainer = undefined; editor.migrationLinkElement = undefined; - editor.migrationSearchQuery = ''; editor.selectedCustomizationMigrationItems = new ResourceMap(); - editor.collapsedCustomizationMigrationGroups = new Set(); editor.migrationPageDisposables = editor.editorPreviewDisposables.add(new DisposableStore()); editor.labelService = { getUriLabel: uri => uri.path, }; editor.showEmbeddedEditor = async () => { }; - editor.getActiveHarnessLabel = () => 'Copilot [Agent Host]'; + editor.getActiveHarnessLabel = () => 'Copilot'; editor.welcomePage = undefined; editor.contributedSectionContainers = new Map(); editor.editorPreviewRenderScheduler = { @@ -394,7 +405,7 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); }); - test('user data migration banner states the Settings Sync trade-off and replaces the description', () => { + test('migration banners include destination consequences when applicable', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsUserDataMigrationEnabled]: true, [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, @@ -442,12 +453,11 @@ suite('aiCustomizationManagementEditor', () => { document.body.appendChild(editor.migrationListContainer); const readBanner = () => ({ - title: editor.migrationBannerContainer!.querySelector('.customization-migration-banner-title')?.textContent ?? '', message: editor.migrationBannerContainer!.querySelector('.customization-migration-banner-message')?.textContent ?? '', consequence: editor.migrationBannerContainer!.querySelector('.customization-migration-banner-consequence')?.textContent ?? '', - consequenceMentionsSync: (editor.migrationBannerContainer!.querySelector('.customization-migration-banner-consequence')?.textContent ?? '').includes('Settings Sync'), bannerHidden: editor.migrationBannerContainer!.style.display === 'none', descriptionHidden: editor.migrationDescriptionElement!.style.display === 'none', + linkInBanner: editor.migrationLinkElement!.closest('.customization-migration-banner-content') !== null, }); try { @@ -455,27 +465,24 @@ suite('aiCustomizationManagementEditor', () => { editor.renderCustomizationMigrationPage(); const userData = readBanner(); - // The prompt-file migration keeps its plain description, with no banner. editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; editor.renderCustomizationMigrationPage(); const prompts = readBanner(); assert.deepStrictEqual({ userData, prompts }, { userData: { - title: '2 customizations are not available to Copilot [Agent Host]', message: 'They are stored in user data, which only VS Code reads. Move them to \'~/.copilot\' so both VS Code and this harness can use them, keeping their name, type, and content.', consequence: 'Migrated files aren\'t currently included in Settings Sync.', - consequenceMentionsSync: true, bannerHidden: false, descriptionHidden: true, + linkInBanner: true, }, prompts: { - title: '', - message: '', + message: 'Prompts are no longer supported by Copilot. Convert them to skills to keep them available in both VS Code and this harness.', consequence: '', - consequenceMentionsSync: false, - bannerHidden: true, - descriptionHidden: false, + bannerHidden: false, + descriptionHidden: true, + linkInBanner: true, }, }); } finally { @@ -603,7 +610,7 @@ suite('aiCustomizationManagementEditor', () => { groupChecked: 'false', itemCheckboxes: ['false', 'false'], selectedItems: [false, false], - migrateButton: { enabled: false, label: 'Migrate' }, + migrateButton: { enabled: false, label: 'Convert to Skills' }, }, afterReselecting: { groupRetainedFocus: true, @@ -611,7 +618,7 @@ suite('aiCustomizationManagementEditor', () => { groupChecked: 'true', itemCheckboxes: ['true', 'true'], selectedItems: [true, true], - migrateButton: { enabled: true, label: 'Migrate (2)' }, + migrateButton: { enabled: true, label: 'Convert 2 to Skills' }, }, }); } finally { @@ -621,7 +628,7 @@ suite('aiCustomizationManagementEditor', () => { } }); - test('customization migration groups can be collapsed independently', () => { + test('customization migration groups render as flat source sections', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, })); @@ -670,22 +677,16 @@ suite('aiCustomizationManagementEditor', () => { try { editor.renderCustomizationMigrationPage(); - const groupToggles = [...editor.migrationListContainer.querySelectorAll('.prompt-migration-group-toggle')] as HTMLButtonElement[]; - assert.deepStrictEqual(groupToggles.map(button => button.getAttribute('aria-expanded')), ['true', 'true']); - - groupToggles[0].click(); - const groupContainers = [...editor.migrationListContainer.querySelectorAll('.prompt-migration-group-items')] as HTMLElement[]; - assert.deepStrictEqual(groupContainers.map(container => container.style.display), ['none', '']); - assert.deepStrictEqual( - [...editor.migrationListContainer.querySelectorAll('.prompt-migration-group-toggle')].map(button => button.getAttribute('aria-expanded')), - ['false', 'true'], - ); - - editor.renderCustomizationMigrationPage(); - - const rerenderedContainers = [...editor.migrationListContainer.querySelectorAll('.prompt-migration-group-items')] as HTMLElement[]; - assert.deepStrictEqual(rerenderedContainers.map(container => container.style.display), ['none', '']); + assert.deepStrictEqual({ + groupTitles: [...editor.migrationListContainer.querySelectorAll('.prompt-migration-group-title')].map(element => element.textContent), + groupContainers: groupContainers.map(container => container.style.display), + collapseButtons: editor.migrationListContainer.querySelectorAll('.prompt-migration-group-toggle').length, + }, { + groupTitles: ['Workspace', 'User'], + groupContainers: ['', ''], + collapseButtons: 0, + }); } finally { editor.migrationListContainer.remove(); editor.migrationPageDisposables.dispose(); @@ -733,7 +734,7 @@ suite('aiCustomizationManagementEditor', () => { const readGroupChecked = () => groupCheckbox?.getAttribute('aria-checked'); const initiallyChecked = readGroupChecked(); - // Unchecking only one item already breaks "all selected", so the group checkbox should clear. + // Unchecking only one item leaves a partial group selection. itemCheckboxes[0].click(); const afterFirstUncheck = readGroupChecked(); // Unchecking the last remaining item must keep the group checkbox cleared (issue #331330). @@ -753,7 +754,7 @@ suite('aiCustomizationManagementEditor', () => { }, { itemCount: 2, initiallyChecked: 'true', - afterFirstUncheck: 'false', + afterFirstUncheck: 'mixed', afterLastUncheck: 'false', afterRecheckingAll: 'true', }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts index e7c2321609f..c1cea7cea77 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts @@ -11,6 +11,7 @@ import { PromptLaunchersAICustomizationWelcomePage } from '../../../browser/aiCu import { ICustomizationMigrationCategorySummary, IWelcomePageCallbacks } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; import { CustomizationMigrationCategoryId } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; import { IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection } from '../../../browser/aiCustomization/aiCustomizationManagement.js'; suite('aiCustomizationWelcomePagePromptLaunchers', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -31,9 +32,9 @@ suite('aiCustomizationWelcomePagePromptLaunchers', () => { { showGettingStartedBanner: false }, callbacks, {} as ICommandService, - { isSessionsWindow: true } as IAICustomizationWorkspaceService, + { isSessionsWindow: true, managementSections: [] } as unknown as IAICustomizationWorkspaceService, {} as IHoverService, - 'Copilot [Agent Host]', + 'Copilot', )); const category: ICustomizationMigrationCategorySummary = { id: CustomizationMigrationCategoryId.UserData, @@ -46,29 +47,83 @@ suite('aiCustomizationWelcomePagePromptLaunchers', () => { try { page.setMigrationCategories([category]); - const card = parent.querySelector('.welcome-prompts-migration-card'); - const action = card?.querySelector('.welcome-prompts-card-action'); - card?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - action?.click(); + const card = parent.querySelector('.welcome-prompts-migration-card'); + card?.click(); page.focus(); assert.deepStrictEqual({ - cardRole: card?.getAttribute('role'), - cardTabIndex: card?.getAttribute('tabindex'), + cardTagName: card?.tagName, buttonCount: card?.querySelectorAll('button').length, - actionTagName: action?.tagName, - focusedAction: document.activeElement === action, + focusedCard: document.activeElement === card, migratedCategories, }, { - cardRole: null, - cardTabIndex: null, - buttonCount: 1, - actionTagName: 'BUTTON', - focusedAction: true, + cardTagName: 'BUTTON', + buttonCount: 0, + focusedCard: true, migratedCategories: [CustomizationMigrationCategoryId.UserData], }); } finally { parent.remove(); } }); + + test('category cards are single native navigation targets', () => { + const parent = document.createElement('div'); + document.body.appendChild(parent); + const selectedSections: AICustomizationManagementSection[] = []; + const page = store.add(new PromptLaunchersAICustomizationWelcomePage( + parent, + { showGettingStartedBanner: false }, + { + selectSection: section => selectedSections.push(section), + selectSectionWithMarketplace() { }, + closeEditor() { }, + migrateCustomizations() { }, + prefillChat() { }, + }, + {} as ICommandService, + { + isSessionsWindow: true, + managementSections: [ + AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.Tools, + ], + } as unknown as IAICustomizationWorkspaceService, + {} as IHoverService, + 'Copilot', + )); + + try { + page.rebuildCards(new Set([ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.Tools, + ])); + const cards = [...parent.querySelectorAll('.welcome-prompts-navigation-card')]; + const card = cards.find(candidate => candidate.getAttribute('aria-label') === 'Open Agents'); + card?.click(); + assert.deepStrictEqual({ + cardLabels: cards.map(candidate => candidate.querySelector('.welcome-prompts-card-label')?.textContent), + tagName: card?.tagName, + nestedButtons: card?.querySelectorAll('button').length, + selectedSections, + }, { + cardLabels: ['Plugins', 'MCP Servers', 'Skills', 'Instructions', 'Agents', 'Hooks', 'Tools'], + tagName: 'BUTTON', + nestedButtons: 0, + selectedSections: [AICustomizationManagementSection.Agents], + }); + } finally { + parent.remove(); + } + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts index 1e17cbf6c6d..1db67a703e4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts @@ -87,7 +87,7 @@ suite('customizationMigration', () => { test('uses singular copy for one User Data customization', () => { const category = getCustomizationMigrationCategory(CustomizationMigrationCategoryId.UserData); - const harnessLabel = 'Copilot [Agent Host]'; + const harnessLabel = 'Copilot'; const agent: IPromptPath = { uri: URI.file('/user-data/prompts/reviewer.agent.md'), storage: PromptsStorage.user, @@ -122,8 +122,8 @@ suite('customizationMigration', () => { }, { shortcut: 'User data, 1 customization needs migration', agent: { - card: 'User data customizations are only used by VS Code. Found 1 agent that Copilot [Agent Host] ignores. Move it to keep it available.', - page: 'Found 1 agent in user data that local VS Code can still use, but Copilot [Agent Host] ignores. Move it to the harness agents folder to keep it available.', + card: 'User data customizations are only used by VS Code. Found 1 agent that Copilot ignores. Move it to keep it available.', + page: 'Found 1 agent in user data that local VS Code can still use, but Copilot ignores. Move it to the harness agents folder to keep it available.', confirmation: { message: 'Migrate user data customizations to \'~/.copilot/agents\'?', detail: 'This moves 1 agent out of user data.', @@ -132,12 +132,12 @@ suite('customizationMigration', () => { }, }, instruction: { - card: 'User data customizations are only used by VS Code. Found 1 instruction file that Copilot [Agent Host] ignores. Move it to keep it available.', - page: 'Found 1 instruction file in user data that local VS Code can still use, but Copilot [Agent Host] ignores. Move it to the harness instructions folder to keep it available.', + card: 'User data customizations are only used by VS Code. Found 1 instruction file that Copilot ignores. Move it to keep it available.', + page: 'Found 1 instruction file in user data that local VS Code can still use, but Copilot ignores. Move it to the harness instructions folder to keep it available.', confirmation: 'This moves 1 instruction file out of user data.', }, mixed: { - card: 'User data customizations are only used by VS Code. Found 2 customizations that Copilot [Agent Host] ignores. Move them to keep them available.', + card: 'User data customizations are only used by VS Code. Found 2 customizations that Copilot ignores. Move them to keep them available.', confirmation: 'This moves 2 customizations out of user data.', }, migrated: 'Migrated 1 user data customization.', diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts new file mode 100644 index 00000000000..4a3a5d55727 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedAgentPluginDetail.test.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer, bufferToStream } from '../../../../../../base/common/buffer.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IRequestContext } from '../../../../../../base/parts/request/common/request.js'; +import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; +import { IRequestService } from '../../../../../../platform/request/common/request.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { AgentPluginItemKind, IAgentPluginItem, IMarketplacePluginItem } from '../../../browser/agentPluginEditor/agentPluginItems.js'; +import { getPluginVersion, loadPluginReadme, PluginReadmeRenderGuard } from '../../../browser/aiCustomization/embeddedAgentPluginDetail.js'; +import { MarketplaceType, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; +import { parseMarketplaceReference } from '../../../common/plugins/marketplaceReference.js'; +import { IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; + +class StatusRequestService extends mock() { + override readonly onDidCompleteRequest = Event.None; + readonly requests: string[] = []; + + constructor( + private readonly statusCode: number, + private readonly body: string, + ) { + super(); + } + + override async request(options: { readonly url?: string }): Promise { + this.requests.push(options.url ?? ''); + return { + res: { statusCode: this.statusCode, headers: {} }, + stream: bufferToStream(VSBuffer.fromString(this.body)), + }; + } +} + +function createMarketplaceItem(readmeUri: URI): IMarketplacePluginItem { + return { + kind: AgentPluginItemKind.Marketplace, + name: 'example', + description: 'Example plugin', + version: '1.2.3', + source: 'plugins/example', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/example' }, + marketplace: 'owner/repo', + marketplaceReference: parseMarketplaceReference('owner/repo')!, + marketplaceType: MarketplaceType.Copilot, + readmeUri, + }; +} + +suite('embeddedAgentPluginDetail', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const fileService = new class extends mock() { }(); + + test('invalidates overlapping renders for the same plugin', () => { + const guard = new PluginReadmeRenderGuard(); + const first = guard.begin(); + const second = guard.begin(); + + assert.deepStrictEqual({ + firstCurrent: guard.isCurrent(first), + secondCurrent: guard.isCurrent(second), + }, { + firstCurrent: false, + secondCurrent: true, + }); + }); + + test('reads marketplace plugin versions', () => { + assert.strictEqual( + getPluginVersion(createMarketplaceItem(URI.parse('https://example.test/README.md'))), + '1.2.3', + ); + }); + + test('uses the fetched README URI as the Markdown base URI', async () => { + const requestService = new StatusRequestService(200, '[Guide](./docs/guide.md)'); + const readme = await loadPluginReadme( + createMarketplaceItem(URI.parse('https://github.com/owner/repo/blob/main/plugins/example/README.md')), + fileService, + requestService, + ); + + assert.deepStrictEqual({ + content: readme?.content, + baseUri: readme?.baseUri.toString(), + requests: requestService.requests, + }, { + content: '[Guide](./docs/guide.md)', + baseUri: 'https://raw.githubusercontent.com/owner/repo/main/plugins/example/README.md', + requests: ['https://raw.githubusercontent.com/owner/repo/main/plugins/example/README.md'], + }); + }); + + test('treats a missing installed README as expected absence', async () => { + const item = { + kind: AgentPluginItemKind.Installed, + name: 'example', + description: 'Example plugin', + plugin: new class extends mock() { + override readonly uri = URI.file('/plugins/example'); + }(), + } satisfies IAgentPluginItem; + const readme = await loadPluginReadme( + item, + { + readFile: async () => { + throw new FileOperationError('Not found', FileOperationResult.FILE_NOT_FOUND); + }, + }, + new StatusRequestService(200, ''), + ); + + assert.strictEqual(readme, undefined); + }); + + test('rejects HTTP error response bodies', async () => { + const results: string[] = []; + for (const statusCode of [404, 500]) { + try { + await loadPluginReadme( + createMarketplaceItem(URI.parse('https://example.test/README.md')), + fileService, + new StatusRequestService(statusCode, `${statusCode}: Not Found`), + ); + results.push('resolved'); + } catch (error) { + results.push(error instanceof Error ? error.message : String(error)); + } + } + + assert.deepStrictEqual(results, ['Server returned 404', 'Server returned 500']); + }); +}); 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 9c95bb1c8af..91a8362508b 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 @@ -21,7 +21,7 @@ import { IAICustomizationWorkspaceService } from '../../../common/aiCustomizatio import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { IAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; +import { IMcpService, McpConnectionState } from '../../../../mcp/common/mcpTypes.js'; import { DisableMcpServerForWorkspaceAction, DisableMcpServerGloballyAction, EnableMcpServerForWorkspaceAction, EnableMcpServerGloballyAction } from '../../../../mcp/browser/mcpServerActions.js'; import { AgentHostMcpServer, @@ -38,9 +38,13 @@ import { isMcpServerCollectionVisible, getMcpStatusRenderSignature, getServerItemContextMenuActions, + getToggledMcpEnablementState, McpServerItemRenderer, registerMcpInlineButtonAction, type IMcpStatusRenderInput, + updateMcpCardRuntimePresentation, + hasSameMcpMembership, + shouldLoadMcpGallerySnapshot, } from '../../../browser/aiCustomization/mcpListWidget.js'; function createAgentHostServer(overrides: Partial = {}): AgentHostMcpServer { @@ -125,6 +129,62 @@ suite('mcpListWidget', () => { }); }); + test('toggles MCP enablement without changing its scope', () => { + assert.deepStrictEqual([ + getToggledMcpEnablementState(ContributionEnablementState.EnabledProfile), + getToggledMcpEnablementState(ContributionEnablementState.DisabledProfile), + getToggledMcpEnablementState(ContributionEnablementState.EnabledWorkspace), + getToggledMcpEnablementState(ContributionEnablementState.DisabledWorkspace), + ], [ + ContributionEnablementState.DisabledProfile, + ContributionEnablementState.EnabledProfile, + ContributionEnablementState.DisabledWorkspace, + ContributionEnablementState.EnabledWorkspace, + ]); + }); + + test('updates card runtime status without replacing live nodes', () => { + const row = document.createElement('div'); + const primaryAction = document.createElement('button'); + const statusBadge = document.createElement('span'); + const description = document.createElement('span'); + row.append(primaryAction, statusBadge, description); + + updateMcpCardRuntimePresentation(statusBadge, primaryAction, description, McpConnectionState.Kind.Starting, undefined, 'Server, Starting', 'First description'); + const initialNodes = [...row.childNodes]; + updateMcpCardRuntimePresentation(statusBadge, primaryAction, description, McpConnectionState.Kind.Error, undefined, 'Server, Error', 'Updated description'); + + assert.deepStrictEqual({ + nodesPreserved: initialNodes.every((node, index) => row.childNodes[index] === node), + statusClass: statusBadge.className, + statusText: statusBadge.textContent, + ariaLabel: primaryAction.getAttribute('aria-label'), + description: description.textContent, + }, { + nodesPreserved: true, + statusClass: 'plugin-list-item-status mcp-runtime-status-badge error', + statusText: 'Error', + ariaLabel: 'Server, Error', + description: 'Updated description', + }); + }); + + test('loads gallery snapshots only for visible MCP sections', () => { + assert.deepStrictEqual([ + shouldLoadMcpGallerySnapshot(false, '', 0, false, false), + shouldLoadMcpGallerySnapshot(true, '', 0, false, false), + shouldLoadMcpGallerySnapshot(true, 'search', 0, false, false), + shouldLoadMcpGallerySnapshot(true, '', 1, false, false), + ], [false, true, false, false]); + }); + + test('distinguishes membership changes from state-only changes', () => { + assert.deepStrictEqual([ + hasSameMcpMembership('server:one:session', 'server:one:session'), + hasSameMcpMembership('server:one:session', 'server:one:session|server:two:session'), + ], [true, false]); + }); + test('renders host-published disabled reasons without changing legacy rows', () => { assert.deepStrictEqual([ getMcpStatusPresentation('disabled', { source: 'scope', scope: CustomizationEnablementKind.Global })?.label, 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 d179969897a..3d98acd0040 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 @@ -4,9 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { constObservable } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { PluginFormat } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; import { CustomizationEnablementKind } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { getRemotePluginDisabledLabel } from '../../../browser/aiCustomization/pluginListWidget.js'; +import { getInstalledPluginMetadata, getRemotePluginDisabledLabel, getToggledPluginEnablementState, PluginMarketplaceSnapshotModel, shouldLoadPluginMarketplaceSnapshot } from '../../../browser/aiCustomization/pluginListWidget.js'; +import { AgentPluginItemKind, IInstalledPluginItem } from '../../../browser/agentPluginEditor/agentPluginItems.js'; +import { ContributionEnablementState } from '../../../common/enablement.js'; +import { IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; suite('pluginListWidget', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -22,4 +29,73 @@ suite('pluginListWidget', () => { 'Disabled (Session)', ]); }); + + test('toggles plugin enablement without changing scope', () => { + assert.deepStrictEqual([ + getToggledPluginEnablementState(ContributionEnablementState.EnabledProfile), + getToggledPluginEnablementState(ContributionEnablementState.DisabledProfile), + getToggledPluginEnablementState(ContributionEnablementState.EnabledWorkspace), + getToggledPluginEnablementState(ContributionEnablementState.DisabledWorkspace), + ], [ + ContributionEnablementState.DisabledProfile, + ContributionEnablementState.EnabledProfile, + ContributionEnablementState.DisabledWorkspace, + ContributionEnablementState.EnabledWorkspace, + ]); + }); + + test('installed metadata contains contribution counts without enablement copy', () => { + const plugin = new class extends mock() { + override readonly uri = URI.file('/plugins/example'); + override readonly format = PluginFormat.Copilot; + override readonly label = 'Example'; + override readonly enablement = constObservable(ContributionEnablementState.EnabledProfile); + override readonly hooks = constObservable([]); + override readonly commands = constObservable([{ uri: URI.file('/plugins/example/commands/test.md'), name: 'test' }]); + override readonly skills = constObservable([ + { uri: URI.file('/plugins/example/skills/one/SKILL.md'), name: 'one' }, + { uri: URI.file('/plugins/example/skills/two/SKILL.md'), name: 'two' }, + ]); + override readonly agents = constObservable([]); + override readonly instructions = constObservable([]); + override readonly mcpServerDefinitions = constObservable([]); + }(); + const item: IInstalledPluginItem = { + kind: AgentPluginItemKind.Installed, + name: plugin.label, + description: 'Example plugin', + plugin, + }; + + assert.strictEqual(getInstalledPluginMetadata(item), '2 skills • 1 command'); + }); + + test('treats an empty marketplace snapshot as loaded', () => { + const snapshot = new PluginMarketplaceSnapshotModel(); + + const firstLoadStarted = snapshot.beginLoading(); + snapshot.complete([]); + const duplicateLoadStarted = snapshot.beginLoading(); + + assert.deepStrictEqual({ + firstLoadStarted, + state: snapshot.state, + items: snapshot.items, + duplicateLoadStarted, + }, { + firstLoadStarted: true, + state: 'loaded', + items: [], + duplicateLoadStarted: false, + }); + }); + + test('loads marketplace snapshots only for visible plugin sections', () => { + assert.deepStrictEqual([ + shouldLoadPluginMarketplaceSnapshot(false, 'uninitialized', true), + shouldLoadPluginMarketplaceSnapshot(true, 'uninitialized', true), + shouldLoadPluginMarketplaceSnapshot(true, 'loaded', true), + shouldLoadPluginMarketplaceSnapshot(true, 'uninitialized', false), + ], [false, true, false, false]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/toolsListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/toolsListWidget.test.ts new file mode 100644 index 00000000000..78739134c8b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/toolsListWidget.test.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { isToolsTreeKeyboardTarget } from '../../../browser/aiCustomization/toolsListWidget.js'; + +suite('toolsListWidget', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('handles tree keys only from the row itself', () => { + const row = document.createElement('div'); + const moreButton = document.createElement('button'); + row.appendChild(moreButton); + + assert.deepStrictEqual({ + row: isToolsTreeKeyboardTarget(row, row), + moreButton: isToolsTreeKeyboardTarget(moreButton, row), + }, { + row: true, + moreButton: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index 33cbbd85c1a..12070d48225 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -4,10 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { findHookCommandInYaml, findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { findHookCommandInYaml, findHookCommandSelection, parseAllHookFiles } from '../../../browser/promptSyntax/hookUtils.js'; import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; import { buildNewHookEntry, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; /** * Helper to extract the selected text from content using a selection range. @@ -30,6 +37,36 @@ function getSelectedText(content: string, selection: ITextEditorSelection): stri suite('hookUtils', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('restricts initial hook discovery to the preferred storage', async () => { + const calls: string[] = []; + const promptsService = new class extends mock() { + override async listPromptFiles() { + calls.push('all'); + return []; + } + override async listPromptFilesForStorage(_type: PromptsType, storage: PromptsStorage) { + calls.push(storage); + return []; + } + override async getCustomAgents() { + return []; + } + }(); + + await parseAllHookFiles( + promptsService, + new class extends mock() { }(), + new class extends mock() { }(), + undefined, + '/home/test', + OperatingSystem.Linux, + CancellationToken.None, + { includeAgentHooks: true, preferredStorage: PromptsStorage.user }, + ); + + assert.deepStrictEqual(calls, [PromptsStorage.user]); + }); + suite('findHookCommandSelection', () => { suite('simple format', () => { 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 635797fe8d3..0b123842990 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -26,11 +26,14 @@ import { PluginFormat } from '../../../../../platform/agentPlugins/common/plugin import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { IRequestService } from '../../../../../platform/request/common/request.js'; +import { IRequestContext } from '../../../../../base/parts/request/common/request.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IExtensionManifestPropertiesService } from '../../../../services/extensions/common/extensionManifestPropertiesService.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -49,6 +52,9 @@ import { IResolvedPromptSourceFolder } from '../../../../contrib/chat/common/pro import { ParsedPromptFile, PromptFileParser } from '../../../../contrib/chat/common/promptSyntax/promptFileParser.js'; import { PromptFileSource, PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IAgentPluginService, IAgentPlugin } from '../../../../contrib/chat/common/plugins/agentPluginService.js'; +import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IAgentHostToolSetEnablementService, IToolEnablementState } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; +import { ExtensionState, IExtension, IExtensionsWorkbenchService } from '../../../../contrib/extensions/common/extensions.js'; import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginSourceKind } from '../../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; import { MarketplaceReferenceKind } from '../../../../contrib/chat/common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../../../../contrib/chat/common/plugins/pluginInstallService.js'; @@ -56,7 +62,7 @@ import { AICustomizationManagementEditor } from '../../../../contrib/chat/browse import { CustomizationMigrationCategoryId } from '../../../../contrib/chat/browser/aiCustomization/customizationMigrationCategories.js'; import { IAICustomizationItemSource, IAICustomizationListItem } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; import { AICustomizationItemsModel, IAICustomizationItemsModel, ItemsModelSection } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; -import { EmbeddedMcpServerDetail } from '../../../../contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.js'; +import { createWorkbenchMcpServerDetailInput, EmbeddedMcpServerDetail } from '../../../../contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from '../../../../contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../../../../contrib/chat/browser/agentPluginEditor/agentPluginItems.js'; import { ContributionEnablementState } from '../../../../contrib/chat/common/enablement.js'; @@ -169,6 +175,7 @@ function createFixtureAgentHostItemProvider(files: readonly IFixtureFile[]): ICu name: file.name ?? '', description: file.description, source: file.storage as AICustomizationSource, + groupKey: 'remote-host', extensionId: file.extensionId, pluginUri: undefined, })); @@ -593,24 +600,79 @@ const mcpUserServers = [ makeLocalMcpServer('mcp-puppeteer', 'Puppeteer', LocalMcpServerScope.User, 'Browser automation'), ]; const mcpRuntimeServers = [ - { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(ContributionEnablementState.EnabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Starting }), showOutput() { } }, - { definition: { id: 'mcp-postgres', label: 'PostgreSQL' }, collection: { id: 'workspace-mcp', label: 'Workspace MCP' }, enablement: constObservable(ContributionEnablementState.EnabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Error }), showOutput() { } }, - { definition: { id: 'mcp-web-search', label: 'Web Search' }, collection: { id: 'user-mcp', label: 'User MCP' }, enablement: constObservable(ContributionEnablementState.DisabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Stopped }), showOutput() { } }, - { definition: { id: 'mcp-filesystem', label: 'Filesystem' }, collection: { id: 'user-mcp', label: 'User MCP' }, enablement: constObservable(ContributionEnablementState.EnabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Stopped }), showOutput() { } }, + { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(ContributionEnablementState.EnabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Starting }), readDefinitions: () => constObservable({ server: undefined, collection: undefined }), showOutput() { } }, + { definition: { id: 'mcp-postgres', label: 'PostgreSQL' }, collection: { id: 'workspace-mcp', label: 'Workspace MCP' }, enablement: constObservable(ContributionEnablementState.EnabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Error }), readDefinitions: () => constObservable({ server: undefined, collection: undefined }), showOutput() { } }, + { definition: { id: 'mcp-web-search', label: 'Web Search' }, collection: { id: 'user-mcp', label: 'User MCP' }, enablement: constObservable(ContributionEnablementState.DisabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Stopped }), readDefinitions: () => constObservable({ server: undefined, collection: undefined }), showOutput() { } }, + { definition: { id: 'mcp-filesystem', label: 'Filesystem' }, collection: { id: 'user-mcp', label: 'User MCP' }, enablement: constObservable(ContributionEnablementState.EnabledProfile), connectionState: constObservable({ state: McpConnectionState.Kind.Stopped }), readDefinitions: () => constObservable({ server: undefined, collection: undefined }), showOutput() { } }, ]; const activeSessionMcpServers: FixtureAgentHostMcpServer[] = [ { id: 'mcp-top-level:fixture:session:component-explorer', name: 'component-explorer', enabled: true, status: McpServerStatus.Ready, state: { kind: McpServerStatus.Ready }, logOutputChannelId: 'fixture-agent-host', start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, - { id: 'mcp-top-level:fixture:session:Remote Browser', name: 'Remote Browser', enabled: true, status: McpServerStatus.AuthRequired, state: { kind: McpServerStatus.AuthRequired, reason: McpAuthRequiredReason.Required, resource: { resource: 'https://mcp.example.com' } }, logOutputChannelId: 'fixture-agent-host', start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, + { id: 'mcp-top-level:fixture:session:Remote Browser', name: 'Remote Browser', enabled: true, status: McpServerStatus.AuthRequired, state: { kind: McpServerStatus.AuthRequired, reason: McpAuthRequiredReason.Required, resource: { resource: 'https://mcp.example.com' } }, sourceUri: URI.file('/workspace/.vscode/mcp.json'), logOutputChannelId: 'fixture-agent-host', start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, { id: 'mcp-top-level:fixture:session:Remote Search', name: 'Remote Search', enabled: true, status: McpServerStatus.Error, state: { kind: McpServerStatus.Error, error: { errorType: 'fixture', message: 'Fixture error' } }, logOutputChannelId: 'fixture-agent-host', start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, ]; +function makeFixtureTool(id: string, displayName: string, description: string, source: ToolDataSource): IToolData { + return { + id, + displayName, + modelDescription: description, + userDescription: description, + source, + }; +} + +const fixtureToolExtension = new class extends mock() { + override readonly identifier = { id: 'acme.agent-tools', uuid: undefined }; + override readonly displayName = 'Acme Agent Tools'; + override readonly publisherDisplayName = 'Acme'; + override readonly description = 'Issue tracking and deployment tools for agents.'; + override readonly state = ExtensionState.Installed; + override readonly local = undefined; +}(); + +const fixtureToolSets: readonly IToolSet[] = [ + { + id: 'vscode-core-tools', + referenceName: 'vscode', + description: 'VS Code', + detail: 'Built-in editor and workspace tools.', + icon: Codicon.tools, + source: ToolDataSource.Internal, + getTools: () => [ + makeFixtureTool('vscode.readFile', 'Read File', 'Read files from the active workspace.', ToolDataSource.Internal), + makeFixtureTool('vscode.search', 'Search Workspace', 'Search text and symbols in the workspace.', ToolDataSource.Internal), + makeFixtureTool('vscode.terminal', 'Run in Terminal', 'Run commands in the integrated terminal.', ToolDataSource.Internal), + ], + }, + { + id: 'acme-agent-tools', + referenceName: 'acme', + description: 'Acme Agent Tools', + detail: 'Tools contributed by the Acme extension.', + icon: Codicon.extensions, + source: { type: 'extension', label: 'Acme Agent Tools', extensionId: new ExtensionIdentifier('acme.agent-tools') }, + getTools: () => [ + makeFixtureTool('acme.issues', 'Find Issues', 'Find and summarize open issues.', { type: 'extension', label: 'Acme Agent Tools', extensionId: new ExtensionIdentifier('acme.agent-tools') }), + makeFixtureTool('acme.deploy', 'Create Deployment', 'Create a deployment for the current project.', { type: 'extension', label: 'Acme Agent Tools', extensionId: new ExtensionIdentifier('acme.agent-tools') }), + ], + }, +]; + interface IRenderEditorOptions { readonly sessionResource: URI; readonly isSessionsWindow?: boolean; readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; readonly selectedSection?: AICustomizationManagementSection; + readonly customizationSearchQuery?: string; + readonly mcpSearchQuery?: string; + readonly toolsSearchQuery?: string; + readonly migrationPartialSelection?: boolean; + readonly emptyMigrationUserSection?: boolean; + readonly emptyWorkspaceSection?: boolean; + readonly emptyUserSection?: boolean; + readonly emptyToolExtensions?: boolean; readonly scrollToBottom?: boolean; readonly width?: number; readonly height?: number; @@ -685,19 +747,20 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const isSessionsWindow = options.isSessionsWindow ?? false; const skillUIIntegrations = options.skillUIIntegrations ?? new Map(); const managementSections = options.managementSections ?? [ - AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.McpServers, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.Tools, AICustomizationManagementSection.Prompts, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, ]; const availableHarnesses = options.availableHarnesses ?? [ createVSCodeHarnessDescriptor(), { id: 'agent-host-copilotcli', - label: 'Copilot [Agent Host]', + label: 'Copilot', icon: ThemeIcon.fromId(Codicon.server.id), hiddenSections: [AICustomizationManagementSection.Prompts], hideGenerateButton: true, @@ -706,8 +769,19 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor ]; const allMcpServers = [...mcpWorkspaceServers, ...mcpUserServers]; - const fixtureFiles = allFiles.map(file => ({ ...file })); + const selectedPromptType = options.selectedSection === AICustomizationManagementSection.Agents ? PromptsType.agent + : options.selectedSection === AICustomizationManagementSection.Skills ? PromptsType.skill + : options.selectedSection === AICustomizationManagementSection.Instructions ? PromptsType.instructions + : options.selectedSection === AICustomizationManagementSection.Hooks ? PromptsType.hook + : options.selectedSection === AICustomizationManagementSection.Prompts ? PromptsType.prompt + : undefined; + const fixtureFiles = allFiles + .filter(file => !(file.type === selectedPromptType && options.emptyWorkspaceSection && file.storage === PromptsStorage.local)) + .filter(file => !(file.type === selectedPromptType && options.emptyUserSection && file.storage === PromptsStorage.user)) + .filter(file => !(options.emptyMigrationUserSection && file.type === PromptsType.prompt && file.storage === PromptsStorage.user)) + .map(file => ({ ...file })); const fileContents = createFixtureContentMap(fixtureFiles, agentInstructions); + fileContents.set(URI.file('/workspace/.vscode/mcp.json'), '{\n\t"servers": {\n\t\t"Remote Browser": {\n\t\t\t"type": "http",\n\t\t\t"url": "https://mcp.example.com"\n\t\t}\n\t}\n}\n'); const promptFilesDidChangeEmitter = ctx.disposableStore.add(new Emitter()); const createdFolders = new ResourceSet(); @@ -898,6 +972,26 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override isDirty(_resource: URI) { return false; } }()); reg.defineInstance(IExtensionService, new class extends mock() { }()); + reg.defineInstance(ILanguageModelToolsService, new class extends mock() { + override readonly toolSets = constObservable(options.emptyToolExtensions ? fixtureToolSets.filter(toolSet => toolSet.source.type !== 'extension') : fixtureToolSets); + }()); + const fixtureToolState: IToolEnablementState = { toolSets: new Map(), tools: new Map() }; + reg.defineInstance(IAgentHostToolSetEnablementService, new class extends mock() { + override observe() { return constObservable(fixtureToolState); } + override getState() { return fixtureToolState; } + override setToolSetEnabled() { } + override setToolEnabled() { } + }()); + reg.defineInstance(IExtensionsWorkbenchService, new class extends mock() { + override readonly local = options.emptyToolExtensions ? [] : [fixtureToolExtension]; + override readonly onChange = Event.None; + }()); + reg.defineInstance(IExtensionManifestPropertiesService, new class extends mock() { + override canExecuteOnSessionsWindow() { return true; } + }()); + reg.defineInstance(IWorkbenchEnvironmentService, new class extends mock() { + override readonly isSessionsWindow = false; + }()); reg.defineInstance(IQuickInputService, new class extends mock() { }()); reg.defineInstance(IViewsService, new class extends mock() { override async openView(_id: string, _focus?: boolean) { return null as T | null; } @@ -925,10 +1019,27 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onReset = Event.None; override readonly local = allMcpServers; override async queryLocal() { return allMcpServers; } + override async queryGallery(options?: { text?: string }): Promise> { + const query = options?.text?.toLowerCase().trim(); + const items = query + ? galleryServers.filter(server => server.label.toLowerCase().includes(query) || server.description.toLowerCase().includes(query)) + : galleryServers; + return { + firstPage: { items, hasMore: false }, + async getNextPage() { return { items: [], hasMore: false }; }, + }; + } override canInstall() { return true as const; } + override async install(server: IWorkbenchMcpServer) { return server; } }()); reg.defineInstance(IMcpService, new class extends mock() { override readonly servers = constObservable(mcpRuntimeServers as never[]); + override readonly enablementModel = { + readEnabled: () => ContributionEnablementState.EnabledProfile, + readProfileEnabled: () => true, + setEnabled: () => { }, + remove: () => { }, + }; }()); reg.defineInstance(IMcpRegistry, new class extends mock() { override readonly collections = constObservable([]); @@ -941,9 +1052,15 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock() { override readonly installedPlugins = constObservable([]); + override readonly recommendedPlugins = constObservable(new Set(['Figma@copilot', 'Stripe@copilot'])); override readonly onDidChangeMarketplaces = Event.None; + override async fetchMarketplacePlugins() { return marketplacePlugins; } + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { + override getPluginInstallUri(plugin: IMarketplacePlugin) { + return URI.file(`/home/dev/.vscode/agent-plugins/${plugin.source}`); + } }()); - reg.defineInstance(IPluginInstallService, new class extends mock() { }()); reg.defineInstance(IProductService, new class extends mock() { override readonly defaultChatAgent = new class extends mock>() { override readonly chatExtensionId = 'GitHub.copilot-chat'; @@ -974,6 +1091,55 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor editor.selectSectionById(options.selectedSection); } + if (options.customizationSearchQuery) { + const input = ctx.container.querySelector('.prompts-content-container input') as HTMLInputElement | null; + if (input) { + input.value = options.customizationSearchQuery; + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: options.customizationSearchQuery, inputType: 'insertText' })); + input.blur(); + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + if (options.mcpSearchQuery) { + const input = ctx.container.querySelector('.mcp-content-container input') as HTMLInputElement | null; + if (input) { + await new Promise(resolve => setTimeout(resolve, 100)); + input.value = options.mcpSearchQuery; + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: options.mcpSearchQuery, inputType: 'insertText' })); + await new Promise(resolve => setTimeout(resolve, 600)); + input.blur(); + for (const scrollbar of ctx.container.querySelectorAll('.mcp-content-container .scrollbar')) { + scrollbar.style.visibility = 'hidden'; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + } else if (options.selectedSection === AICustomizationManagementSection.McpServers) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + if (options.toolsSearchQuery) { + const input = ctx.container.querySelector('.tools-content-container input') as HTMLInputElement | null; + if (input) { + input.value = options.toolsSearchQuery; + input.dispatchEvent(new InputEvent('input', { bubbles: true, data: options.toolsSearchQuery, inputType: 'insertText' })); + input.blur(); + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + if (options.migrationPartialSelection) { + let firstMigrationCheckbox: HTMLElement | null = null; + for (let attempt = 0; attempt < 20 && !firstMigrationCheckbox; attempt++) { + firstMigrationCheckbox = ctx.container.querySelector('.prompt-migration-checkbox [role="checkbox"]'); + if (!firstMigrationCheckbox) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + firstMigrationCheckbox?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + if (options.scrollToBottom) { editor.revealLastItem(); } @@ -988,7 +1154,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const openItemLabel = options.openItemLabel; const rowToOpen = openItemLabel ? [...(visibleContent?.querySelectorAll('.monaco-list-row') ?? [])].find((row): row is HTMLElement => row instanceof HTMLElement && row.textContent?.includes(openItemLabel)) - : visibleContent?.querySelector('.monaco-list-row.ai-customization-list-item, .monaco-list-row.mcp-server-item') as HTMLElement | undefined; + : visibleContent?.querySelector('.monaco-list-row.ai-customization-list-item, .monaco-list-row.mcp-server-item, .plugin-home-row') as HTMLElement | undefined; if (rowToOpen) { rowToOpen.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })); rowToOpen.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); @@ -1100,9 +1266,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise ctx.container.appendChild(widget.element); widget.layout(height, width); - // Click the Browse Marketplace button to enter browse mode - const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; - browseButton?.click(); + widget.showBrowseMarketplace(); // Wait for the gallery query to resolve await new Promise(resolve => setTimeout(resolve, 50)); @@ -1112,17 +1276,32 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise // Plugin Browse Mode — standalone widget with marketplace results // ============================================================================ -function makeInstalledPlugin(name: string, uri: URI, enabled: boolean): IAgentPlugin { +function makeInstalledPlugin(name: string, uri: URI, enablement: boolean | ContributionEnablementState, policyBlocked = false): IAgentPlugin { + const contributionName = name.toLowerCase().replace(/\s+/g, '-'); + const enablementState = typeof enablement === 'boolean' + ? (enablement ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile) + : enablement; return new class extends mock() { override readonly uri = uri; override readonly format = PluginFormat.Copilot; override readonly label = name; - override readonly enablement = constObservable(enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); + override readonly version = constObservable('1.0.0'); + override readonly enablement = constObservable(enablementState); + override readonly policyBlocked = constObservable(policyBlocked); override readonly hooks = constObservable([]); - override readonly commands = constObservable([]); - override readonly skills = constObservable([]); - override readonly agents = constObservable([]); - override readonly instructions = constObservable([]); + override readonly commands = constObservable([ + { uri: URI.joinPath(uri, 'commands', `${contributionName}-lookup.md`), name: `${name} lookup`, description: `Search ${name} from chat.` }, + { uri: URI.joinPath(uri, 'commands', `${contributionName}-summarize.md`), name: `${name} summary`, description: `Summarize recent ${name} activity.` }, + ]); + override readonly skills = constObservable([ + { uri: URI.joinPath(uri, 'skills', `${contributionName}-triage.md`), name: `${name} triage`, description: `Help triage ${name} workflows.` }, + ]); + override readonly agents = constObservable([ + { uri: URI.joinPath(uri, 'agents', `${contributionName}.agent.md`), name: `${name} assistant`, description: `An agent specialized for ${name}.` }, + ]); + override readonly instructions = constObservable([ + { uri: URI.joinPath(uri, 'instructions', `${contributionName}.instructions.md`), name: `${name} instructions`, description: `Context rules for ${name}.` }, + ]); override readonly mcpServerDefinitions = constObservable([]); override remove() { } }(); @@ -1169,18 +1348,17 @@ const marketplacePlugins: IMarketplacePlugin[] = [ makeMarketplacePlugin('Vercel', 'Deployment and preview environments', 'vercel-plugin'), ]; -async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise { - const width = 650; - const height = 500; +async function renderPluginCatalog(ctx: ComponentFixtureContext, browse: boolean, searchQuery?: string, width = browse ? 650 : 840, noInstalledPlugins = false): Promise { + const height = browse ? 600 : 800; ctx.container.style.width = `${width}px`; ctx.container.style.height = `${height}px`; // Some marketplace plugins match installed plugins by URI so the renderer // shows them as "Installed" (exercises the installed-state check from #7379). - const browseInstalledPlugins = [ + const browseInstalledPlugins = noInstalledPlugins ? [] : [ makeInstalledPlugin('Linear', URI.file('/home/dev/.vscode/agent-plugins/example/linear-plugin'), true), makeInstalledPlugin('Sentry', URI.file('/home/dev/.vscode/agent-plugins/example/sentry-plugin'), true), - makeInstalledPlugin('Datadog', URI.file('/home/dev/.vscode/agent-plugins/example/datadog-plugin'), false), + makeInstalledPlugin('Datadog', URI.file('/home/dev/.vscode/agent-plugins/example/datadog-plugin'), false, true), ]; // Map plugin source descriptors to install URIs, matching installed URIs above @@ -1201,12 +1379,18 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = constObservable(URI.file('/workspace')); + override getActiveProjectRoot() { return URI.file('/workspace'); } + }()); reg.defineInstance(IAgentPluginService, new class extends mock() { override readonly plugins = constObservable(browseInstalledPlugins as readonly IAgentPlugin[]); override readonly enablementModel = undefined!; }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock() { override readonly installedPlugins = constObservable([]); + override readonly recommendedPlugins = constObservable(new Set(['Figma@copilot', 'Stripe@copilot'])); override readonly onDidChangeMarketplaces = Event.None; override async fetchMarketplacePlugins() { return marketplacePlugins; } }()); @@ -1221,18 +1405,23 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, searchQuery ? 600 : 100)); // Blur the search input to prevent cursor blink instability in screenshots (widget.element.querySelector('input') as HTMLElement)?.blur(); // Force-hide scrollbars to avoid fade-transition instability @@ -1242,6 +1431,26 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise setTimeout(resolve, 200)); } +function renderPluginHomeMode(ctx: ComponentFixtureContext): Promise { + return renderPluginCatalog(ctx, false); +} + +function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise { + return renderPluginCatalog(ctx, true); +} + +function renderPluginSearchMode(ctx: ComponentFixtureContext): Promise { + return renderPluginCatalog(ctx, false, 'a'); +} + +function renderPluginHomeNarrowMode(ctx: ComponentFixtureContext): Promise { + return renderPluginCatalog(ctx, false, undefined, 420); +} + +function renderPluginHomeEmptyInstalledMode(ctx: ComponentFixtureContext): Promise { + return renderPluginCatalog(ctx, false, undefined, 840, true); +} + // ============================================================================ // MCP / Plugin Disabled (access blocked) splash // ============================================================================ @@ -1338,12 +1547,18 @@ function renderPluginDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): override getActiveDescriptor() { return createVSCodeHarnessDescriptor(); } override registerExternalHarness() { return { dispose() { } }; } }()); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = constObservable(URI.file('/workspace')); + override getActiveProjectRoot() { return URI.file('/workspace'); } + }()); reg.defineInstance(IAgentPluginService, new class extends mock() { override readonly plugins = constObservable([]); override readonly enablementModel = undefined!; }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock() { override readonly installedPlugins = constObservable([]); + override readonly recommendedPlugins = constObservable(new Set()); override readonly onDidChangeMarketplaces = Event.None; override async fetchMarketplacePlugins() { return []; } }()); @@ -1352,7 +1567,7 @@ function renderPluginDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): }, }); - const widget = ctx.disposableStore.add(instantiationService.createInstance(PluginListWidget)); + const widget = ctx.disposableStore.add(instantiationService.createInstance(PluginListWidget, undefined)); ctx.container.appendChild(widget.element); widget.layout(height, width); } @@ -1377,6 +1592,7 @@ function renderEmbeddedMcpDetail(ctx: ComponentFixtureContext, server: IWorkbenc override readonly local: IWorkbenchMcpServer[] = server ? [server] : []; override async open() { /* no-op in fixture */ } }()); + reg.defineInstance(IFileService, new class extends mock() { }()); }, }); @@ -1388,7 +1604,7 @@ function renderEmbeddedMcpDetail(ctx: ComponentFixtureContext, server: IWorkbenc const detail = ctx.disposableStore.add(instantiationService.createInstance(EmbeddedMcpServerDetail, host)); if (server) { - detail.setInput(server); + detail.setInput(createWorkbenchMcpServerDetailInput(server)); } } @@ -1402,6 +1618,34 @@ function renderEmbeddedPluginDetail(ctx: ComponentFixtureContext, item: IAgentPl colorTheme: ctx.theme, additionalServices: (reg) => { registerWorkbenchServices(reg); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly activeHarness = constObservable('local'); + override getActiveDescriptor() { return createVSCodeHarnessDescriptor(); } + }()); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = constObservable(URI.file('/workspace')); + override getActiveProjectRoot() { return URI.file('/workspace'); } + }()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable(item?.kind === AgentPluginItemKind.Installed ? [item.plugin] : []); + override readonly enablementModel = undefined!; + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + reg.defineInstance(IFileService, new class extends mock() { + override async readFile(): Promise { throw new Error('Fixture README not found'); } + }()); + reg.defineInstance(IRequestService, new class extends mock() { + override async request(): Promise { throw new Error('Fixture request unavailable'); } + }()); + reg.defineInstance(IMarkdownRendererService, new class extends mock() { + override render(markdown: IMarkdownString | string) { + return { + element: renderFixtureMarkdown(typeof markdown === 'string' ? markdown : markdown.value), + dispose() { }, + }; + } + }()); }, }); @@ -1416,13 +1660,13 @@ function renderEmbeddedPluginDetail(ctx: ComponentFixtureContext, item: IAgentPl } } -function makeInstalledPluginItem(name: string, description: string): IAgentPluginItem { +function makeInstalledPluginItem(name: string, description: string, enablement = ContributionEnablementState.EnabledProfile, policyBlocked = false): IAgentPluginItem { return { kind: AgentPluginItemKind.Installed, name, description, marketplace: 'GitHub', - plugin: makeInstalledPlugin(name, URI.file(`/workspace/.copilot/plugins/${name.toLowerCase()}`), true), + plugin: makeInstalledPlugin(name, URI.file(`/workspace/.copilot/plugins/${name.toLowerCase()}`), enablement, policyBlocked), }; } @@ -1431,6 +1675,7 @@ function makeMarketplacePluginItem(name: string, description: string): IAgentPlu kind: AgentPluginItemKind.Marketplace, name, description, + version: '2.0.0', source: 'GitHub', sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: `acme/${name.toLowerCase()}` }, marketplace: 'GitHub', @@ -1460,10 +1705,15 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Welcome page — default state with no section selected WelcomePage: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource }), }), + WelcomePageNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, width: 550, height: 500 }), + }), + // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, // Generate buttons, AGENTS.md shortcut, all storage groups LocalHarness: defineComponentFixture({ @@ -1492,13 +1742,14 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { createVSCodeHarnessDescriptor(), ], managementSections: [ - AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.McpServers, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Instructions, - AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.Tools, + AICustomizationManagementSection.Prompts, ], }), }), @@ -1514,13 +1765,14 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { createVSCodeHarnessDescriptor(), ], managementSections: [ - AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.McpServers, AICustomizationManagementSection.Skills, AICustomizationManagementSection.Instructions, - AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, + AICustomizationManagementSection.Tools, + AICustomizationManagementSection.Prompts, ], skillUIIntegrations: new Map([ ['act-on-feedback', 'Used by the Submit Feedback button in the Changes toolbar'], @@ -1531,10 +1783,19 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // MCP Servers tab with many servers to verify scrollable list layout McpServersTab: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.McpServers, + }), + }), + + McpServersSearch: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, + mcpSearchQuery: 'search', }), }), @@ -1548,12 +1809,53 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + McpServersAuthRequired: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + isSessionsWindow: true, + selectedSection: AICustomizationManagementSection.McpServers, + activeSessionMcpServers, + mcpSearchQuery: 'Remote Browser', + }), + }), + + McpServerActiveSessionDetail: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + isSessionsWindow: true, + selectedSection: AICustomizationManagementSection.McpServers, + activeSessionMcpServers, + mcpSearchQuery: 'Remote Browser', + openFirstItem: true, + }), + }), + // Agents tab — workspace and user agents, scrollable AgentsTab: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Agents, + }), + }), + + AgentsSearch: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Agents, + customizationSearchQuery: 'review', + }), + }), + + AgentsEmptyUser: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Agents, + emptyUserSection: true, }), }), @@ -1566,6 +1868,14 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + RemoteSkillsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + selectedSection: AICustomizationManagementSection.Skills, + }), + }), + // Instructions tab — many instructions with applyTo patterns, scrollable InstructionsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -1584,6 +1894,15 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + HooksEmptyWorkspace: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Hooks, + emptyWorkspaceSection: true, + }), + }), + // Prompts tab — workspace and user prompts, scrollable PromptsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -1593,11 +1912,105 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + PromptsTabNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Prompts, + width: 550, + height: 500, + }), + }), + PromptMigration: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + migrationCategory: CustomizationMigrationCategoryId.PromptFiles, + }), + }), + + PromptMigrationNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { sessionResource: agentHostCopilotSessionResource, migrationCategory: CustomizationMigrationCategoryId.PromptFiles, + width: 550, + height: 500, + }), + }), + + PromptMigrationPartialSelection: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + migrationCategory: CustomizationMigrationCategoryId.PromptFiles, + migrationPartialSelection: true, + }), + }), + + PromptMigrationEmptyUser: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + migrationCategory: CustomizationMigrationCategoryId.PromptFiles, + emptyMigrationUserSection: true, + }), + }), + + ToolsTab: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Tools, + availableHarnesses: [{ ...createVSCodeHarnessDescriptor(), hiddenSections: [] }], + managementSections: [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Tools, + ], + }), + }), + + ToolsTabNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Tools, + availableHarnesses: [{ ...createVSCodeHarnessDescriptor(), hiddenSections: [] }], + managementSections: [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Tools, + ], + width: 550, + height: 500, + }), + }), + + ToolsSearchEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Tools, + availableHarnesses: [{ ...createVSCodeHarnessDescriptor(), hiddenSections: [] }], + managementSections: [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Tools, + ], + toolsSearchQuery: 'no such tool', + }), + }), + + ToolsEmptyExtensions: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Tools, + availableHarnesses: [{ ...createVSCodeHarnessDescriptor(), hiddenSections: [] }], + managementSections: [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Tools, + ], + emptyToolExtensions: true, }), }), @@ -1611,13 +2024,25 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Plugins tab PluginsTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, }), }), + SessionsPluginsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + isSessionsWindow: true, + selectedSection: AICustomizationManagementSection.Plugins, + availableHarnesses: [ + createVSCodeHarnessDescriptor(), + ], + }), + }), + // MCP browse/marketplace mode — standalone widget with gallery results, scrollable // Verifies fix for https://github.com/microsoft/vscode/issues/304139 McpBrowseMode: defineComponentFixture({ @@ -1631,6 +2056,26 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { render: renderPluginBrowseMode, }), + PluginCatalogHome: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderPluginHomeMode, + }), + + PluginCatalogSearch: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderPluginSearchMode, + }), + + PluginCatalogHomeNarrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: renderPluginHomeNarrowMode, + }), + + PluginCatalogHomeEmptyInstalled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderPluginHomeEmptyInstalledMode, + }), + // MCP disabled splash — chat.mcp.access set to 'none' by user McpDisabledByUser: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -1695,7 +2140,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), AgentsTabNarrow: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Agents, @@ -1704,6 +2149,16 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + PluginsTabNarrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + selectedSection: AICustomizationManagementSection.Plugins, + width: 550, + height: 400, + }), + }), + // Item-preview view (after clicking an agent) — verifies the structured front // matter preview and rendered markdown body. AgentsItemPreview: defineComponentFixture({ @@ -1750,7 +2205,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), - // MCP server detail view — same alignment check for the detail back button. + // MCP definition editor — matches the standard customization file editor layout. McpServerDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { @@ -1760,10 +2215,9 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), - // MCP server detail view in a narrow viewport — catches embedded header overflow - // and the single-tab configuration layout used by local workspace servers. + // Narrow MCP editor — catches header overflow and editor framing regressions. McpServerDetailNarrow: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.McpServers, @@ -1775,7 +2229,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Plugin detail view — same alignment check for the detail back button. PluginDetail: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: ctx => renderEditor(ctx, { sessionResource: localSessionResource, selectedSection: AICustomizationManagementSection.Plugins, @@ -1794,17 +2248,23 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), - // Standalone embedded MCP detail widget (compact split-pane component). - // Workspace-scope server with a description. + // Standalone embedded MCP detail widget with a workspace stdio definition. EmbeddedMcpDetailWorkspace: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEmbeddedMcpDetail(ctx, makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access for the active workspace')), + render: ctx => renderEmbeddedMcpDetail(ctx, makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access for the active workspace', { + type: McpServerType.LOCAL, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres'], + })), }), - // Standalone embedded MCP detail widget — user-scope server. + // Standalone embedded MCP detail widget with a user HTTP definition. EmbeddedMcpDetailUser: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEmbeddedMcpDetail(ctx, makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web from any session')), + render: ctx => renderEmbeddedMcpDetail(ctx, makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web from any session', { + type: McpServerType.REMOTE, + url: 'https://mcp.example.com/search', + })), }), // Standalone embedded MCP detail widget — empty / no input state. @@ -1819,6 +2279,16 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { render: ctx => renderEmbeddedPluginDetail(ctx, makeInstalledPluginItem('Linear', 'Issue tracking and project management integration')), }), + EmbeddedPluginDetailExcludedWorkspace: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedPluginDetail(ctx, makeInstalledPluginItem('PagerDuty', 'Incident response and on-call management', ContributionEnablementState.DisabledWorkspace)), + }), + + EmbeddedPluginDetailPolicyBlocked: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedPluginDetail(ctx, makeInstalledPluginItem('Deployment Guard', 'Deployment controls managed by your organization', ContributionEnablementState.DisabledProfile, true)), + }), + // Standalone embedded plugin detail widget — marketplace plugin. EmbeddedPluginDetailMarketplace: defineComponentFixture({ labels: { kind: 'screenshot' }, diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 0758da90f44..e4cf6c16b81 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -1,16 +1,100 @@ #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentHostPromptMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/53dd74097d2c0edcd2338371e42205bfb9dab59057d98503e4b4324597aca244) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/73125119f7ad87d8bbae85ed0e685ceafb7e79d1d72d036486990e54160223f1) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentHostPromptMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d5a7ac4040ceeaceb45f5b2d8fdecda15534938fdd1ff52b6a0a69a646bffb44) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/631f44328275de7742be0cf9f85bcf8e23692104192d653d3f734f61a2b3bdd6) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/299142bb43919679f11b1320d1297e44cd15f016af0b3d99157976d93721d090) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f33c8eadc7d82a9fb30f2e59546b15bf4d6540260c7f0b9247b8d956549acad) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/04f8aa6cc6243811fd60682fbd1dd871b7045e6d32b54ffa2d4f4d5331d58df8) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e4677fa2853de20ba9e307155e270fa388465c07c10358a9a1f3ff61a0a1214e) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/49cd709a8c4d88a3d569d93174245949a9bb187209172e859b1c503e065b2afe) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/18a54b51f477fa721d8c9b1ab56ff55e953801bb3b2d14f01eb043c249b73aeb) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/60660b23153246506d9710d0239c15292079b357def9bbc8d7f92b8184c058c4) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a424b83224188683990741c5a50163601605069c5809ae8e60c01dbee749000) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/fd25d29993b79ab91112214f250cfad1defa6824963c3af81e680755101984e6) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/49c3b0c63a80c07b08a3743b8796d9cf6b02258bb9173b92193cff8b96b39fab) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6c43ef7d52aa8eb0d576b692fb2d2b0a20dd7677e2280b59c2a80feb2dc34b11) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/24f0815b7784be0b7a0f10144de52e1746d95874cf19d1b1eb0522595f363ca3) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1671f24b9ab6711ae5f09998ba83110b8e8776b36b2a79faa72f19b603b5d39f) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/fdf59b4c9ed25069eb8cc0e60b83a7c6cab5fefbb15186413549bc7d084e4155) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/22686ac92c2a14563ba7e6d01bb550f78f11eb1c43afeb1ced96529589e82265) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e97446149b544a13b4549bad9c6b5662ea0b8c0e90d0a7650423ac370d8d69cc) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0f3957abfe413adc6948235a3719e3816c894732811ebe7183746dcc9b8c1b4c) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/cebf11554d8406530a8096a278fd9e31a1e365e0a81de7ee8b5f363b0c4dc928) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0f3957abfe413adc6948235a3719e3816c894732811ebe7183746dcc9b8c1b4c) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/cebf11554d8406530a8096a278fd9e31a1e365e0a81de7ee8b5f363b0c4dc928) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/35d360958fc72a25ba7c2dd68ff3cf994e1f26ed91b6ef1a491fa35690a8e0da) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/53ddc34360c336aba42bc527fced457fad6503ea7ed7c5739df13114e3d608b7) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9b08554c2be3dc91914ddd8575f848f8ecdd0a12c585456d12ab393d5a13ab16) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/243178e6c1365f1a924d8a302f748d2616f009fee9fbb8ba18b21306f1d442bb) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2a9c427e7535bcd815a0c7f5d9b31edc068c9cd27662d3a037ba35478b31537d) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/46decd9bfa89d5a109c774be82bf9ddf033708f775b0ec59f3dff4caa548375c) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/53621b01eef395a64be3cc60859235a40e0345e948e95f8df78558cf97b10591) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7604d5a063bcc2fc5b931d10e0b0846ed1002fd8d7103d1032bbedece2990e6a) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/027e97161e9d3347e891acf82cf0775854e5379ee09bdf9572fcc7d8b2dc6ba2) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e4902a982f70ddf296118fb5b9d3f604494ac895e7ef46aa2f2c9cba8a0204a6) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/WelcomePage/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bb845f60651850b28b0c3e5ca0b8ed12910a706c00e6bb71c1715bf61a646f2c) + +#### chat/aiCustomizations/aiCustomizationManagementEditor/WelcomePage/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dd46998ca3d441faee2d504279e5619711d13fb90fc8c6d48d1ea19d5535ca37) #### chat/chatPetAccessoryRig/chatPetAccessoryRig/AllAccessoriesFacing/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/4cc1460fb1925cee3a2de5e5ae60770984f0490780e294bdcf56fac47c2d1490)