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)