From 3a3f5b235226d0030480c7d177b6354f0a8f754f Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Thu, 10 Sep 2026 16:13:48 -0700 Subject: [PATCH 1/2] chat: Make customizations multi-root workspace aware Group workspace customizations by folder, scope creation flows to the originating workspace, and consistently show source provenance in customization rows while moving descriptions to hovers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationItemSource.ts | 2 +- .../aiCustomizationListWidget.ts | 159 +++++++++++------- .../aiCustomizationListWidgetUtils.ts | 10 +- .../aiCustomizationManagementEditor.ts | 11 +- .../customizationCreatorService.ts | 59 ++++++- .../browser/aiCustomization/mcpListWidget.ts | 79 +++++---- .../chat/browser/promptSyntax/hookActions.ts | 22 ++- .../aiCustomizationListWidget.test.ts | 118 ++++++++++++- .../customizationCreatorService.test.ts | 90 +++++++++- .../aiCustomization/mcpListWidget.test.ts | 25 ++- .../browser/promptSyntax/hookActions.test.ts | 37 ++++ ...aiCustomizationManagementEditor.fixture.ts | 3 + 12 files changed, 482 insertions(+), 133 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookActions.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 188785b1fcb761..03008af451f93f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -209,7 +209,7 @@ export class AICustomizationItemNormalizer { uri: item.uri, name: item.name, filename: item.uri.scheme === Schemas.file - ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem }) + ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem, noPrefix: isWorkspaceItem }) : basename(item.uri), description: item.description, source, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index b4a9197c4c6882..311cc75087aea3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -28,7 +28,7 @@ import { defaultButtonStyles, defaultInputBoxStyles, getButtonStyles } from '../ 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'; -import { matchesContiguousSubString, IMatch } from '../../../../../base/common/filters.js'; +import { matchesContiguousSubString } from '../../../../../base/common/filters.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; import { IMenu, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; @@ -52,6 +52,7 @@ import { IAICustomizationItemsModel, ItemsModelSection } from './aiCustomization import { createCustomizationCardPrimaryAction, CustomizationCardListController, getVirtualizedSectionMinimumHeight, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setupCollapsibleSection } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -236,6 +237,13 @@ export function formatDisplayName(name: string): string { return name.replace(/\.md$/i, ''); } +export function getCustomizationItemHoverContent(item: IAICustomizationListItem, sourceLabel: string): string { + if (item.description?.trim()) { + return item.description.trim(); + } + return `${item.name}\n${sourceLabel}`; +} + /** * Renderer for AI customization list items. */ @@ -331,22 +339,24 @@ class AICustomizationItemRenderer implements IListRenderer { - let content: string; + let sourceLabel: string; if (element.isBuiltin) { - content = `${element.name}\n${localize('builtinSource', "Built-in")}`; + sourceLabel = localize('builtinSource', "Built-in"); } else if (element.extensionId) { - content = `${element.name}\n${localize('fromExtension', "Extension: {0}", element.extensionId)}`; + sourceLabel = localize('fromExtension', "Extension: {0}", element.extensionId); } else { const isWorkspaceItem = element.source === AICustomizationSources.local; - const uriLabel = this.labelService.getUriLabel(element.uri, { relative: isWorkspaceItem }); - content = `${element.name}\n${uriLabel}`; - } - if (element.badgeTooltip) { - content += `\n\n${element.badgeTooltip}`; + sourceLabel = this.labelService.getUriLabel(element.uri, { relative: isWorkspaceItem }); } - const plugin = element.pluginUri && this.agentPluginService.plugins.get().find(p => isEqual(p.uri, element.pluginUri)); - if (plugin) { - content += `\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`; + let content = getCustomizationItemHoverContent(element, sourceLabel); + if (!element.description?.trim()) { + if (element.badgeTooltip) { + content += `\n\n${element.badgeTooltip}`; + } + const plugin = element.pluginUri && this.agentPluginService.plugins.get().find(p => isEqual(p.uri, element.pluginUri)); + if (plugin) { + content += `\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`; + } } return { content, @@ -412,33 +422,11 @@ class AICustomizationItemRenderer implements IListRenderer { - // Discard matches that are entirely outside the visible portion. - if (match.start >= maxLength || match.end <= 0) { - return undefined; - } - const clampedStart = Math.max(0, match.start); - const clampedEnd = Math.min(match.end, maxLength); - return clampedEnd > clampedStart ? { start: clampedStart, end: clampedEnd } : undefined; - }).filter((match): match is IMatch => !!match); - secondaryTextMatches = clampedMatches.length ? clampedMatches : undefined; - } - } + const secondaryText = getCustomizationSecondaryText(element.filename); if (secondaryText) { - templateData.description.set(secondaryText, secondaryTextMatches); + templateData.description.set(secondaryText, undefined); templateData.description.element.style.display = ''; - // Style differently for filename vs description - templateData.description.element.classList.toggle('is-filename', !element.description); + templateData.description.element.classList.add('is-filename'); } else { templateData.description.set('', undefined); templateData.description.element.style.display = 'none'; @@ -607,7 +595,7 @@ interface ICreateAction { readonly tooltip?: string; readonly kind?: 'generate'; readonly target?: 'workspace' | 'user'; - run(): void; + run(workspaceFolder?: URI): void; } interface ICustomizationItemGroup { @@ -616,6 +604,17 @@ interface ICustomizationItemGroup { readonly icon: ThemeIcon; readonly description: string; readonly items: IAICustomizationListItem[]; + readonly workspaceFolder?: URI; +} + +const WORKSPACE_GROUP_KEY_PREFIX = 'workspace:'; + +export function getWorkspaceCustomizationGroupKey(uri: URI): string { + return `${WORKSPACE_GROUP_KEY_PREFIX}${uri.toString()}`; +} + +function isWorkspaceCustomizationGroupKey(groupKey: string): boolean { + return groupKey.startsWith(WORKSPACE_GROUP_KEY_PREFIX); } /** @@ -637,7 +636,7 @@ export function getCustomizationItemStatusLabel(item: IAICustomizationListItem): export function getCustomizationItemAriaLabel(item: IAICustomizationListItem): string { const displayName = item.displayName ?? formatDisplayName(item.name); - const secondaryText = getCustomizationSecondaryText(item.description, item.filename, item.promptType); + const secondaryText = getCustomizationSecondaryText(item.filename); const statusLabel = getCustomizationItemStatusLabel(item); const accessibleSecondaryText = [secondaryText, statusLabel, item.statusMessage].filter(Boolean).join('. '); const nameAndDescription = accessibleSecondaryText ? localize('itemAriaLabel', "{0}. {1}", displayName, accessibleSecondaryText) : displayName; @@ -709,8 +708,8 @@ export class AICustomizationListWidget extends Disposable { private readonly _onDidRequestCreate = this._register(new Emitter()); readonly onDidRequestCreate: Event = this._onDidRequestCreate.event; - private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string }>()); - readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string }> = this._onDidRequestCreateManual.event; + private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string; workspaceFolder?: URI }>()); + readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string; workspaceFolder?: URI }> = this._onDidRequestCreateManual.event; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -730,6 +729,7 @@ export class AICustomizationListWidget extends Disposable { @ICommandService private readonly commandService: ICommandService, @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, ) { super(); this.element = $('.ai-customization-list-widget.plugin-list-widget'); @@ -747,6 +747,7 @@ export class AICustomizationListWidget extends Disposable { this.harnessService.availableHarnesses.read(reader); this.updateAddButton(); })); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.filterItems())); } private create(): void { @@ -1301,7 +1302,7 @@ export class AICustomizationListWidget extends Disposable { label: override.label ?? localize('newCustomization', "New {0}", typeLabel), enabled: true, target: 'workspace', - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root' }); }, + run: workspaceFolder => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root', workspaceFolder }); }, }); addedTargets.add('workspace-root'); } @@ -1323,7 +1324,7 @@ export class AICustomizationListWidget extends Disposable { compactLabel: localize('newHook', "New Hook"), enabled: true, target: 'workspace', - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, + run: workspaceFolder => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local', workspaceFolder }); }, }); } actions.push({ @@ -1356,7 +1357,7 @@ export class AICustomizationListWidget extends Disposable { compactLabel: localize('newCustomization', "New {0}", createTypeLabel), enabled: true, target: 'workspace', - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, + run: workspaceFolder => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local', workspaceFolder }); }, }); addedTargets.add('workspace'); } else { @@ -1379,7 +1380,7 @@ export class AICustomizationListWidget extends Disposable { compactLabel: localize('newCustomization', "New {0}", createTypeLabel), enabled: true, target: 'workspace', - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, + run: workspaceFolder => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local', workspaceFolder }); }, }); } @@ -1400,7 +1401,7 @@ export class AICustomizationListWidget extends Disposable { label: localize('newCustomizationFile', "New {0}", fileName), enabled: true, target: 'workspace', - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root', rootFileName: fileName }); }, + run: workspaceFolder => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root', rootFileName: fileName, workspaceFolder }); }, }); } } @@ -1588,8 +1589,26 @@ export class AICustomizationListWidget extends Disposable { * Groups items by normalized storage/groupKey. */ private groupMatchedItems(matchedItems: IAICustomizationListItem[]): void { + const workspaceFolders = this.workspaceContextService.getWorkspace().folders; + const isMultiRootWorkspace = workspaceFolders.length > 1; + const workspaceGroups: ICustomizationItemGroup[] = isMultiRootWorkspace + ? workspaceFolders.map(folder => ({ + groupKey: getWorkspaceCustomizationGroupKey(folder.uri), + label: folder.name, + icon: workspaceIcon, + description: localize('workspaceFolderGroupDescription', "Customizations stored in the {0} workspace folder.", folder.name), + items: [], + workspaceFolder: folder.uri, + })) + : [{ + 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: [], + }]; 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: [] }, + ...workspaceGroups, { 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: [] }, @@ -1597,9 +1616,15 @@ export class AICustomizationListWidget extends Disposable { ]; for (const item of matchedItems) { - const key = this.currentSection === AICustomizationManagementSection.Instructions + let key = this.currentSection === AICustomizationManagementSection.Instructions ? item.source : item.groupKey ?? item.source ?? AICustomizationSources.local; + if (isMultiRootWorkspace && key === PromptsStorage.local) { + const workspaceFolder = this.workspaceContextService.getWorkspaceFolder(item.uri); + if (workspaceFolder) { + key = getWorkspaceCustomizationGroupKey(workspaceFolder.uri); + } + } let group = groups.find(g => g.groupKey === key); if (!group) { // Dynamically create a group for unknown groupKeys from providers @@ -1661,7 +1686,13 @@ export class AICustomizationListWidget extends Disposable { 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 || this.sectionLoading && !isFiltering); + const visibleGroups = groups.filter(group => + group.items.length > 0 + || alwaysVisibleGroupKeys.has(group.groupKey) + || isWorkspaceCustomizationGroupKey(group.groupKey) && alwaysVisibleGroupKeys.has(PromptsStorage.local) + || group.groupKey === createGroupKey + || this.sectionLoading && !isFiltering + ); if (visibleGroups.length === 0) { this.captureCardSectionScrollPositions(); this.cardDisposables.clear(); @@ -1717,8 +1748,8 @@ export class AICustomizationListWidget extends Disposable { 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); + if (!isFiltering && usesTargetedCreateActions && (group.groupKey === PromptsStorage.local || isWorkspaceCustomizationGroupKey(group.groupKey) || group.groupKey === PromptsStorage.user)) { + this.renderTargetedCardCreateActions(header, group.groupKey, group.workspaceFolder); } else if (group.groupKey === createGroupKey) { this.renderCardCreateActions(header); } @@ -1873,8 +1904,9 @@ export class AICustomizationListWidget extends Disposable { return this.hasActiveWorkspace() ? PromptsStorage.local : PromptsStorage.user; } - private renderTargetedCardCreateActions(header: HTMLElement, groupKey: string): void { - const target = groupKey === PromptsStorage.local ? 'workspace' : 'user'; + private renderTargetedCardCreateActions(header: HTMLElement, groupKey: string, workspaceFolder?: URI): void { + const workspaceGroup = groupKey === PromptsStorage.local || isWorkspaceCustomizationGroupKey(groupKey); + const target = workspaceGroup ? 'workspace' : 'user'; const hasWorkspace = this.hasActiveWorkspace(); const actions = this.buildCreateActions().filter(action => action.target === target @@ -1897,7 +1929,7 @@ export class AICustomizationListWidget extends Disposable { button.label = label; button.enabled = primary.enabled; this.firstCardFocusElement ??= button.element; - this.cardDisposables.add(button.onDidClick(() => primary.run())); + this.cardDisposables.add(button.onDidClick(() => primary.run(workspaceFolder))); const generateAction = actions.find(action => action.kind === 'generate'); if (generateAction && generateAction !== primary) { @@ -1910,12 +1942,12 @@ export class AICustomizationListWidget extends Disposable { generateButton.element.classList.add('customization-generate-action'); generateButton.label = generateAction.label; generateButton.enabled = generateAction.enabled; - this.cardDisposables.add(generateButton.onDidClick(() => generateAction.run())); + this.cardDisposables.add(generateButton.onDidClick(() => generateAction.run(workspaceFolder))); } 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 moreLabel = localize('moreCreateActions', "More creation actions for {0}", workspaceGroup ? localize('workspace', "Workspace") : localize('user', "User")); const more = this.cardDisposables.add(new Button(container, { ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), secondary: true, @@ -1925,7 +1957,7 @@ export class AICustomizationListWidget extends Disposable { })); 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))); + this.cardDisposables.add(more.onDidClick(() => this.showCreateActionsMenu(secondaryActions, more.element, workspaceFolder))); } } @@ -1933,14 +1965,14 @@ export class AICustomizationListWidget extends Disposable { return getTargetedCreateActionLabel(action.label, action.compactLabel); } - private showCreateActionsMenu(createActions: readonly ICreateAction[], anchor: HTMLElement): void { + private showCreateActionsMenu(createActions: readonly ICreateAction[], anchor: HTMLElement, workspaceFolder?: URI): 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(), + () => action.run(workspaceFolder), ))); this.contextMenuService.showContextMenu({ getAnchor: () => anchor, @@ -1950,7 +1982,7 @@ export class AICustomizationListWidget extends Disposable { } private getEmptyGroupMessage(groupKey: string): string { - const workspace = groupKey === PromptsStorage.local; + const workspace = groupKey === PromptsStorage.local || isWorkspaceCustomizationGroupKey(groupKey); switch (this.currentSection) { case AICustomizationManagementSection.Agents: return workspace ? localize('noWorkspaceAgents', "No workspace agents yet.") : localize('noUserAgents', "No user agents yet."); @@ -2010,7 +2042,7 @@ export class AICustomizationListWidget extends Disposable { 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 secondaryText = getCustomizationSecondaryText(item.filename); const statusLabel = getCustomizationItemStatusLabel(item); const accessibleSecondaryText = [secondaryText, statusLabel].filter(Boolean).join('. '); const accessibleLabel = item.disabled @@ -2040,7 +2072,10 @@ export class AICustomizationListWidget extends Disposable { })); } this.cardDisposables.add(this.hoverService.setupDelayedHover(row, () => ({ - content: `${displayName}\n${this.labelService.getUriLabel(item.uri, { relative: item.source === AICustomizationSources.local })}`, + content: getCustomizationItemHoverContent( + item, + this.labelService.getUriLabel(item.uri, { relative: item.source === AICustomizationSources.local, noPrefix: item.source === AICustomizationSources.local }), + ), appearance: { compact: true, skipFadeInAnimation: true }, }))); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts index 60ad92d8ab7883..7ad8e6696b21cc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidgetUtils.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; - /** * Truncates a description string to the first line. * The UI applies CSS text-overflow ellipsis for width overflow. @@ -20,12 +18,8 @@ export function truncateToFirstLine(text: string): string { /** * Returns the secondary text shown for a customization item. */ -export function getCustomizationSecondaryText(description: string | undefined, filename: string, promptType: PromptsType): string { - if (!description) { - return filename; - } - - return promptType === PromptsType.hook ? description : truncateToFirstLine(description); +export function getCustomizationSecondaryText(filename: string): string { + return filename; } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 5d4552b36a6a68..a8e5d7dfec21c4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -1206,8 +1206,8 @@ export class AICustomizationManagementEditor extends EditorPane { })); // Handle manual create actions - open editor directly - this.editorDisposables.add(this.listWidget.onDidRequestCreateManual(({ type, target, rootFileName }) => { - this.createNewItemManual(type, target, rootFileName); + this.editorDisposables.add(this.listWidget.onDidRequestCreateManual(({ type, target, rootFileName, workspaceFolder }) => { + this.createNewItemManual(type, target, rootFileName, workspaceFolder); })); // Container for Models content (only in sessions) @@ -2819,7 +2819,7 @@ export class AICustomizationManagementEditor extends EditorPane { /** * Creates a new prompt file and opens it in the embedded editor. */ - private async createNewItemManual(type: PromptsType, target: 'local' | 'user' | 'workspace-root', rootFileName?: string): Promise { + private async createNewItemManual(type: PromptsType, target: 'local' | 'user' | 'workspace-root', rootFileName?: string, workspaceFolder?: URI): Promise { this.telemetryService.publicLog2('chatCustomizationEditor.createItem', { section: this.selectedSection ?? 'welcome', promptType: type, @@ -2831,7 +2831,7 @@ export class AICustomizationManagementEditor extends EditorPane { // rootFileName is passed from rootFileShortcuts; falls back to // the section override's rootFile, then AGENTS.md as the default. if (target === 'workspace-root') { - const projectRoot = this.workspaceService.getActiveProjectRoot(); + const projectRoot = workspaceFolder ?? this.workspaceService.getActiveProjectRoot(); if (!projectRoot) { return; } @@ -2860,6 +2860,7 @@ export class AICustomizationManagementEditor extends EditorPane { }, target: Target.GitHubCopilot, preferredStorage, + workspaceFolder, }); } else { // Core: use the default core behaviour @@ -2869,6 +2870,7 @@ export class AICustomizationManagementEditor extends EditorPane { return; }, preferredStorage, + workspaceFolder, }); } return; @@ -2879,6 +2881,7 @@ export class AICustomizationManagementEditor extends EditorPane { sessionResource, type, target, + workspaceFolder, ); if (targetDir === null) { return; // User cancelled the picker diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index 1ff63fb5916a8a..ca1bac6b862626 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -14,12 +14,14 @@ import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../../nls.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { isEqual } from '../../../../../base/common/resources.js'; /** * Service that opens an AI-guided chat session to help the user create @@ -130,7 +132,8 @@ export class CustomizationLocationPicker { @IQuickInputService private readonly quickInputService: IQuickInputService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @ILabelService private readonly labelService: ILabelService + @ILabelService private readonly labelService: ILabelService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, ) { } /** @@ -145,7 +148,7 @@ export class CustomizationLocationPicker { * @returns the resolved URI, `undefined` when no folder is available, * or `null` when the user cancelled the picker. */ - public async resolveTargetDirectoryWithPicker(sessionResource: URI, type: PromptsType, target: 'local' | 'user'): Promise { + public async resolveTargetDirectoryWithPicker(sessionResource: URI, type: PromptsType, target: 'local' | 'user', workspaceFolder?: URI): Promise { const sessionType = getChatSessionType(sessionResource); const descriptor = this.harnessService.findHarnessById(sessionType); const provider = descriptor?.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider); @@ -158,7 +161,7 @@ export class CustomizationLocationPicker { return undefined; } - const matchingFolders = allFolders.filter(f => f.source === target); + const matchingFolders = filterCustomizationSourceFolders(allFolders, target, workspaceFolder, this.workspaceContextService); if (matchingFolders.length === 0) { // No matching folders — return undefined so the command can fall // back to askForPromptSourceFolder (not null which means cancellation) @@ -170,20 +173,58 @@ export class CustomizationLocationPicker { } // Multiple directories — ask the user which one to use - const items: (IQuickPickItem & { uri: URI })[] = matchingFolders.map(folder => ({ - label: folder.label, - description: this.labelService.getUriLabel(folder.uri, { relative: true }), - uri: folder.uri, - })); + const items = getCustomizationLocationPickItems(matchingFolders, this.labelService, this.workspaceContextService, workspaceFolder); const picked = await this.quickInputService.pick(items, { placeHolder: localize('selectTargetDirectory', "Select a directory for the new customization file"), + matchOnDescription: true, }); return picked?.uri ?? null; } } +export function filterCustomizationSourceFolders( + folders: readonly ICustomizationSourceFolder[], + target: 'local' | 'user', + workspaceFolder: URI | undefined, + workspaceContextService: IWorkspaceContextService, +): readonly ICustomizationSourceFolder[] { + return folders.filter(folder => { + if (folder.source !== target) { + return false; + } + if (!workspaceFolder || target !== PromptsStorage.local) { + return true; + } + return isEqual(workspaceContextService.getWorkspaceFolder(folder.uri)?.uri, workspaceFolder); + }); +} + +export function getCustomizationLocationPickItems( + folders: readonly ICustomizationSourceFolder[], + labelService: ILabelService, + workspaceContextService: IWorkspaceContextService, + selectedWorkspaceFolder?: URI, +): (IQuickPickItem & { uri: URI })[] { + const isMultiRootWorkspace = workspaceContextService.getWorkspace().folders.length > 1; + return folders.map(folder => { + const workspaceFolder = folder.source === PromptsStorage.local ? workspaceContextService.getWorkspaceFolder(folder.uri) : undefined; + const relativePath = labelService.getUriLabel(folder.uri, { relative: true, noPrefix: !!workspaceFolder }); + if (workspaceFolder && selectedWorkspaceFolder && isEqual(workspaceFolder.uri, selectedWorkspaceFolder)) { + return { + label: relativePath, + uri: folder.uri, + }; + } + return { + label: isMultiRootWorkspace && workspaceFolder ? workspaceFolder.name : folder.label, + description: relativePath, + uri: folder.uri, + }; + }); +} + /** * Resolves the workspace directory for a new customization file based on the active project root. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 987e253a15e4e9..ce1b7b389e2bfa 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -29,6 +29,7 @@ import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isEqualOrParent } from '../../../../../base/common/resources.js'; import { InputBox, MessageType } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; @@ -60,6 +61,7 @@ import { createCustomizationCardPrimaryAction, CustomizationCardListController, import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; const $ = DOM.$; @@ -76,6 +78,17 @@ function getPluginUriFromCollectionId(collectionId: string | undefined): string return collectionId?.startsWith(PLUGIN_COLLECTION_PREFIX) ? collectionId.slice(PLUGIN_COLLECTION_PREFIX.length) : undefined; } +export function getMcpServerSecondaryText(sourceUri: URI | undefined, pluginLabel: string | undefined, labelService: ILabelService): string | undefined { + if (pluginLabel) { + return localize('fromPlugin', "Plugin: {0}", pluginLabel); + } + return sourceUri ? labelService.getUriLabel(sourceUri, { relative: true, noPrefix: true }) : undefined; +} + +export function getMcpServerHoverContent(description: string | undefined, secondaryText: string | undefined): string | undefined { + return description?.trim() || secondaryText; +} + /** * Represents an individual MCP server item in the list. */ @@ -201,6 +214,7 @@ export class McpServerItemRenderer implements IListRenderer ({ + content: hoverContent, + appearance: { compact: true, skipFadeInAnimation: true }, + }))); + } if (element.type === 'builtin-item') { templateData.container.classList.add('builtin'); templateData.container.classList.toggle('has-detail', false); templateData.name.textContent = formatDisplayName(element.label); - if (element.description) { - templateData.description.textContent = truncateToFirstLine(element.description); - templateData.description.style.display = ''; - } else { - templateData.description.textContent = ''; - templateData.description.style.display = 'none'; - } this.updateKnownServerStatus(templateData, element); - - // Add hover with plugin provenance for plugin-sourced builtin items - const pluginUriStr = getPluginUriFromCollectionId(element.collectionId); - if (pluginUriStr) { - templateData.elementDisposables.add(this.hoverService.setupDelayedHover(templateData.container, () => { - const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); - if (plugin) { - return { - content: `${element.label}\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`, - appearance: { compact: true, skipFadeInAnimation: true }, - }; - } - return { content: element.label, appearance: { compact: true, skipFadeInAnimation: true } }; - })); - } return; } @@ -286,8 +288,6 @@ export class McpServerItemRenderer implements IListRenderer candidate.uri.toString() === pluginUriString) + : activeSessionServer?.isPluginProvided && sourceUri + ? this.agentPluginService.plugins.get().find(candidate => isEqualOrParent(sourceUri, candidate.uri)) + : undefined; + const disabledReason = activeSessionServer?.disabledReason; + const pluginLabel = plugin?.label ?? (disabledReason?.source === 'plugin' ? disabledReason.plugin.name : undefined); + return getMcpServerSecondaryText(sourceUri, pluginLabel, this.labelService); + } + private updateKnownServerStatus(templateData: IMcpServerItemTemplateData, element: IMcpServerItemEntry | IMcpBuiltinItemEntry): void { let localDisabled = false; const update = () => { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 7dedb04c77d57f..e7fcf3f5eb85f8 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -309,6 +309,12 @@ export interface IHookQuickPickOptions { readonly target?: Target; /** Restrict hook files and creation destinations to one storage scope. */ readonly preferredStorage?: PromptsStorage; + /** Restrict workspace hook files and creation destinations to one workspace folder. */ + readonly workspaceFolder?: URI; +} + +export function isHookResourceInWorkspace(resource: URI, workspaceFolder: URI | undefined, workspaceService: IWorkspaceContextService): boolean { + return !workspaceFolder || isEqual(workspaceService.getWorkspaceFolder(resource)?.uri, workspaceFolder); } /** @@ -335,13 +341,15 @@ export async function showConfigureHooksQuickPick( const targetOS = remoteEnv?.os ?? OS; // Get workspace root and user home for path resolution - const workspaceFolder = workspaceService.getWorkspace().folders[0]; + const workspaceFolder = options?.workspaceFolder + ? workspaceService.getWorkspaceFolder(options.workspaceFolder) + : workspaceService.getWorkspace().folders[0]; const workspaceRootUri = workspaceFolder?.uri; const userHomeUri = await pathService.userHome(); const userHome = userHomeUri.fsPath ?? userHomeUri.path; // Parse all hook files upfront to count hooks per type - const hookEntries = await parseAllHookFiles( + const hookEntries = (await parseAllHookFiles( promptsService, fileService, labelService, @@ -350,7 +358,7 @@ export async function showConfigureHooksQuickPick( targetOS, CancellationToken.None, { includeAgentHooks: true, preferredStorage: options?.preferredStorage } - ); + )).filter(entry => isHookResourceInWorkspace(entry.fileUri, options?.workspaceFolder, workspaceService)); // Count hooks per type const hookCountByType = new Map(); @@ -577,7 +585,8 @@ export async function showConfigureHooksQuickPick( case Step.SelectFile: { // 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 hookFiles = (await promptsService.listPromptFilesForStorage(PromptsType.hook, hookStorage, CancellationToken.None)) + .filter(file => isHookResourceInWorkspace(file.uri, options?.workspaceFolder, workspaceService)); const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; @@ -650,7 +659,8 @@ export async function showConfigureHooksQuickPick( // Get source folders for hooks (uses getSourceFolders which // excludes Claude paths and normalizes to directories) const allFolders = (await promptsService.getSourceFolders(PromptsType.hook)) - .filter(folder => options?.preferredStorage === undefined || folder.storage === options.preferredStorage); + .filter(folder => (options?.preferredStorage === undefined || folder.storage === options.preferredStorage) + && isHookResourceInWorkspace(folder.uri, options?.workspaceFolder, workspaceService)); if (allFolders.length === 0) { notificationService.error(options?.preferredStorage === PromptsStorage.user @@ -663,7 +673,7 @@ export async function showConfigureHooksQuickPick( selectedFolder = allFolders[0]; if (allFolders.length > 1) { const folderItems = allFolders.map((folder, index) => { - const basePath = labelService.getUriLabel(folder.uri, { relative: folder.storage === PromptsStorage.local }); + const basePath = labelService.getUriLabel(folder.uri, { relative: folder.storage === PromptsStorage.local, noPrefix: !!options?.workspaceFolder && folder.storage === PromptsStorage.local }); const label = index === 0 ? localize('commands.hook.defaultFolder', "{0} (default)", basePath) : basePath; return { label, 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 9143ab3d867ba8..6ef2e93dac2e3d 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 @@ -14,7 +14,7 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { IListService, ListService } from '../../../../../../platform/list/browser/listService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getCollapsedCustomizationGroupKey, getCustomizationItemAriaLabel, getTargetedCreateActionLabel, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; +import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getCollapsedCustomizationGroupKey, getCustomizationItemAriaLabel, getCustomizationItemHoverContent, getTargetedCreateActionLabel, getWorkspaceCustomizationGroupKey, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; import { IAICustomizationListItem } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; @@ -28,6 +28,7 @@ import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController, getVirtualizedSectionMinimumHeight, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from '../../../browser/aiCustomization/customizationCardList.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; suite('aiCustomizationListWidget', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -194,7 +195,7 @@ suite('aiCustomizationListWidget', () => { status: 'degraded', }; - assert.strictEqual(getCustomizationItemAriaLabel(item), 'Review. Review the current changes. Needs attention'); + assert.strictEqual(getCustomizationItemAriaLabel(item), 'Review. review.prompt.md. Needs attention'); }); test('virtualized row actions use a focused-row tab stop and skip disabled controls', () => { @@ -461,23 +462,48 @@ suite('aiCustomizationListWidget', () => { }); suite('getCustomizationSecondaryText', () => { - test('keeps hook descriptions intact', () => { + test('shows hook source paths instead of descriptions', () => { assert.strictEqual( - getCustomizationSecondaryText('echo "setup". echo "run".', 'hook.json', PromptsType.hook), - 'echo "setup". echo "run".' + getCustomizationSecondaryText('.github/hooks/hook.json'), + '.github/hooks/hook.json' ); }); - test('truncates non-hook descriptions to the first line', () => { + test('shows prompt source paths instead of descriptions', () => { assert.strictEqual( - getCustomizationSecondaryText('Show the first line.\nHide the rest.', 'prompt.md', PromptsType.prompt), - 'Show the first line.' + getCustomizationSecondaryText('.github/prompts/example.prompt.md'), + '.github/prompts/example.prompt.md' + ); + }); + + test('shows the source path instead of the description for skills', () => { + assert.strictEqual( + getCustomizationSecondaryText('.github/skills/example/SKILL.md'), + '.github/skills/example/SKILL.md' + ); + }); + + test('shows descriptions in hover content', () => { + const item: IAICustomizationListItem = { + id: 'example', + uri: URI.file('/workspace/.github/skills/example/SKILL.md'), + name: 'Example', + filename: '.github/skills/example/SKILL.md', + description: 'Use this skill for example tasks.', + source: PromptsStorage.local, + promptType: PromptsType.skill, + disabled: false, + }; + + assert.strictEqual( + getCustomizationItemHoverContent(item, '.github/skills/example/SKILL.md'), + 'Use this skill for example tasks.' ); }); test('falls back to filename when description is missing', () => { assert.strictEqual( - getCustomizationSecondaryText(undefined, 'prompt.md', PromptsType.prompt), + getCustomizationSecondaryText('prompt.md'), 'prompt.md' ); }); @@ -618,6 +644,11 @@ suite('aiCustomizationListWidget', () => { setOverrideProjectRoot: () => { }, clearOverrideProjectRoot: () => { }, }); + instaService.stub(IWorkspaceContextService, { + getWorkspace: () => ({ id: 'test', folders: [] }), + getWorkspaceFolder: () => null, + onDidChangeWorkspaceFolders: Event.None, + }); const activeSessionResource = observableValue('test', URI.parse('test:///session')); const activeHarness = derived(reader => getChatSessionType(activeSessionResource.read(reader))); @@ -692,6 +723,75 @@ suite('aiCustomizationListWidget', () => { assert.strictEqual(widget.element.querySelector('.list-container')!.style.height, '830px'); }); + test('groups workspace customizations by folder in a multi-root workspace', async () => { + const firstFolderUri = URI.file('Q:\\workspace\\first'); + const secondFolderUri = URI.file('Q:\\workspace\\second'); + const firstFolder = { uri: firstFolderUri, name: 'First', index: 0, toResource: (path: string) => URI.joinPath(firstFolderUri, path) }; + const secondFolder = { uri: secondFolderUri, name: 'Second', index: 1, toResource: (path: string) => URI.joinPath(secondFolderUri, path) }; + instaService.stub(IWorkspaceContextService, { + getWorkspace: () => ({ id: 'multi-root', folders: [firstFolder, secondFolder] }), + getWorkspaceFolder: uri => uri.path.includes('/first/') ? firstFolder : uri.path.includes('/second/') ? secondFolder : null, + onDidChangeWorkspaceFolders: Event.None, + }); + instaService.stub(IAICustomizationWorkspaceService, 'getActiveProjectRoot', () => firstFolderUri); + const items = observableValue('test', [{ + id: 'first-skill', + uri: URI.joinPath(firstFolderUri, '.github/skills/first/SKILL.md'), + name: 'First skill', + filename: '.github/skills/first/SKILL.md', + description: 'First skill description', + source: PromptsStorage.local, + promptType: PromptsType.skill, + disabled: false, + }, { + id: 'second-skill', + uri: URI.joinPath(secondFolderUri, '.github/skills/second/SKILL.md'), + name: 'Second skill', + filename: '.github/skills/second/SKILL.md', + description: 'Second skill description', + source: PromptsStorage.local, + promptType: PromptsType.skill, + disabled: false, + }]); + instaService.stub(IAICustomizationItemsModel, { + getItems: () => items, + getCount: () => observableValue('test', 2), + getPluginCount: () => observableValue('test', 0), + whenSectionLoaded: async () => { }, + getActiveItemSource: () => ({ onDidAICustomizationItemsChange: Event.None, fetchProviderItems: async () => [], fetchAICustomizationItems: async () => [], fetchSourceFolders: async () => [], sessionResource: URI.parse('test:///session'), dispose() { } }), + }); + const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); + document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + setLayoutHeights(widget, 500); + + await widget.setSection(AICustomizationManagementSection.Skills); + widget.layout(800, 500); + let createRequest: URI | undefined; + disposables.add(widget.onDidRequestCreateManual(event => createRequest = event.workspaceFolder)); + widget.element.querySelector('.plugin-card-section .customization-create-action')?.click(); + + assert.deepStrictEqual( + { + sections: Array.from(widget.element.querySelectorAll('.plugin-card-section')).map(section => ({ + title: section.querySelector('.plugin-card-section-title')?.textContent, + items: Array.from(section.querySelectorAll('.item-name')).map(item => item.textContent), + sources: Array.from(section.querySelectorAll('.item-description')).map(item => item.textContent), + })), + createRequest: createRequest?.toString(), + }, + { + sections: [ + { title: 'First', items: ['First skill'], sources: ['.github/skills/first/SKILL.md'] }, + { title: 'Second', items: ['Second skill'], sources: ['.github/skills/second/SKILL.md'] }, + { title: 'User', items: [], sources: [] }, + ], + createRequest: firstFolderUri.toString(), + } + ); + assert.notStrictEqual(getWorkspaceCustomizationGroupKey(firstFolderUri), getWorkspaceCustomizationGroupKey(secondFolderUri)); + }); + test('instruction rows use an overflow menu without loaded status or targeting badges', async () => { const items = observableValue('test', [{ id: 'instruction', diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts index 7d08e6f7fa6a29..6fc6223d1d0605 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts @@ -15,7 +15,8 @@ import { IQuickInputService } from '../../../../../../platform/quickinput/common import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; -import { CustomizationLocationPicker, resolveUserTargetDirectory } from '../../../browser/aiCustomization/customizationCreatorService.js'; +import { CustomizationLocationPicker, filterCustomizationSourceFolders, getCustomizationLocationPickItems, resolveUserTargetDirectory } from '../../../browser/aiCustomization/customizationCreatorService.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; suite('customizationCreatorService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -81,10 +82,97 @@ suite('customizationCreatorService', () => { harnessService, new class extends mock() { }(), new class extends mock() { }(), + new class extends mock() { }(), ); const result = await picker.resolveTargetDirectoryWithPicker(sessionResource, PromptsType.agent, 'local'); assert.strictEqual(result, targetDirectory); }); + + test('identifies workspace folders in a multi-root location picker', () => { + const firstWorkspace = URI.file('/workspace/first'); + const secondWorkspace = URI.file('/workspace/second'); + const firstFolder = { uri: firstWorkspace, name: 'First', index: 0, toResource: (path: string) => URI.joinPath(firstWorkspace, path) }; + const secondFolder = { uri: secondWorkspace, name: 'Second', index: 1, toResource: (path: string) => URI.joinPath(secondWorkspace, path) }; + const labelService = new class extends mock() { + override getUriLabel(resource: URI, options?: { noPrefix?: boolean }): string { + assert.strictEqual(options?.noPrefix, true); + return resource.path.split('/').slice(-2).join('/'); + } + }(); + const workspaceContextService = new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace() { return { id: 'multi-root', folders: [firstFolder, secondFolder] }; } + override getWorkspaceFolder(resource: URI) { + return resource.path.startsWith(firstWorkspace.path) ? firstFolder : resource.path.startsWith(secondWorkspace.path) ? secondFolder : null; + } + }(); + const items = getCustomizationLocationPickItems( + [ + { uri: URI.joinPath(firstWorkspace, '.github/agents'), label: 'Workspace', source: PromptsStorage.local }, + { uri: URI.joinPath(secondWorkspace, '.github/agents'), label: 'Workspace', source: PromptsStorage.local }, + ], + labelService, + workspaceContextService, + ); + + assert.deepStrictEqual(items.map(({ label, description }) => ({ label, description })), [ + { label: 'First', description: '.github/agents' }, + { label: 'Second', description: '.github/agents' }, + ]); + }); + + test('omits the repeated workspace name in a folder-scoped location picker', () => { + const workspace = URI.file('/workspace/vscode'); + const workspaceFolder = { uri: workspace, name: 'vscode', index: 0, toResource: (path: string) => URI.joinPath(workspace, path) }; + const labelService = new class extends mock() { + override getUriLabel(resource: URI): string { + return resource.path.split('/').slice(-2).join('/'); + } + }(); + const workspaceContextService = new class extends mock() { + override getWorkspace() { return { id: 'multi-root', folders: [workspaceFolder, { ...workspaceFolder, name: 'other', index: 1 }] }; } + override getWorkspaceFolder() { return workspaceFolder; } + }(); + const items = getCustomizationLocationPickItems( + [ + { uri: URI.joinPath(workspace, '.agents/skills'), label: '.agents/skills', source: PromptsStorage.local }, + { uri: URI.joinPath(workspace, '.github/skills'), label: '.github/skills', source: PromptsStorage.local }, + ], + labelService, + workspaceContextService, + workspace, + ); + + assert.deepStrictEqual(items.map(({ label, description }) => ({ label, description })), [ + { label: '.agents/skills', description: undefined }, + { label: '.github/skills', description: undefined }, + ]); + }); + + test('filters creation locations to the selected workspace folder', () => { + const firstWorkspace = URI.file('/workspace/first'); + const secondWorkspace = URI.file('/workspace/second'); + const firstFolder = { uri: firstWorkspace, name: 'First', index: 0, toResource: (path: string) => URI.joinPath(firstWorkspace, path) }; + const secondFolder = { uri: secondWorkspace, name: 'Second', index: 1, toResource: (path: string) => URI.joinPath(secondWorkspace, path) }; + const workspaceContextService = new class extends mock() { + override getWorkspaceFolder(resource: URI) { + return resource.path.startsWith(firstWorkspace.path) ? firstFolder : resource.path.startsWith(secondWorkspace.path) ? secondFolder : null; + } + }(); + const folders = [ + { uri: URI.joinPath(firstWorkspace, '.github/skills'), label: '.github/skills', source: PromptsStorage.local }, + { uri: URI.joinPath(firstWorkspace, '.claude/skills'), label: '.claude/skills', source: PromptsStorage.local }, + { uri: URI.joinPath(secondWorkspace, '.github/skills'), label: '.github/skills', source: PromptsStorage.local }, + { uri: URI.file('/user/skills'), label: 'User', source: PromptsStorage.user }, + ]; + + const filtered = filterCustomizationSourceFolders(folders, 'local', firstWorkspace, workspaceContextService); + + assert.deepStrictEqual(filtered.map(folder => folder.uri.toString()), [ + URI.joinPath(firstWorkspace, '.github/skills').toString(), + URI.joinPath(firstWorkspace, '.claude/skills').toString(), + ]); + }); }); 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 26c8943a589b90..80ddd1b23d87ab 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 @@ -37,6 +37,8 @@ import { getAgentHostMcpServerEnablementActions, getLocalMcpServerEnablementActions, getMcpServerOutputHandler, + getMcpServerHoverContent, + getMcpServerSecondaryText, getMcpStatusPresentation, isMcpServerCollectionVisible, isPrimaryMcpServerEnabled, @@ -52,6 +54,7 @@ import { setPrimaryMcpServerEnablement, shouldLoadMcpGallerySnapshot, } from '../../../browser/aiCustomization/mcpListWidget.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; function createAgentHostServer(overrides: Partial = {}): AgentHostMcpServer { return { @@ -196,6 +199,25 @@ function createMcpAccessTestWidget(access: McpAccessValue, policyAccess: McpAcce suite('mcpListWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('shows source provenance in rows and descriptions in hovers', () => { + const labelService = { + getUriLabel: () => '.vscode/mcp.json', + } as unknown as ILabelService; + const source = URI.file('/workspace/.vscode/mcp.json'); + + assert.deepStrictEqual({ + source: getMcpServerSecondaryText(source, undefined, labelService), + plugin: getMcpServerSecondaryText(source, 'Example Plugin', labelService), + descriptionHover: getMcpServerHoverContent(' Server description. ', '.vscode/mcp.json'), + fallbackHover: getMcpServerHoverContent(undefined, '.vscode/mcp.json'), + }, { + source: '.vscode/mcp.json', + plugin: 'Plugin: Example Plugin', + descriptionHover: 'Server description.', + fallbackHover: '.vscode/mcp.json', + }); + }); + test('classifies active-session-only MCP servers as built-in entries', () => { const server = createAgentHostServer({ name: 'node_repl' }); @@ -896,7 +918,8 @@ suite('mcpListWidget', () => { () => { }, { isSessionsWindow: true } as IAICustomizationWorkspaceService, { plugins: observableValue('plugins', []) } as unknown as IAgentPluginService, - { setupManagedHover: () => Disposable.None } as unknown as IHoverService, + { setupManagedHover: () => Disposable.None, setupDelayedHover: () => Disposable.None } as unknown as IHoverService, + { getUriLabel: (resource: URI) => resource.path } as unknown as ILabelService, agentHostCustomizationService, customizationHarnessService, { showChannel: async () => { } } as unknown as IOutputService, diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookActions.test.ts new file mode 100644 index 00000000000000..2cac4924f2ff06 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookActions.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { isHookResourceInWorkspace } from '../../../browser/promptSyntax/hookActions.js'; + +suite('hookActions', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('restricts hook resources to the selected workspace folder', () => { + const firstWorkspace = URI.file('/workspace/first'); + const secondWorkspace = URI.file('/workspace/second'); + const firstFolder = { uri: firstWorkspace, name: 'First', index: 0, toResource: (path: string) => URI.joinPath(firstWorkspace, path) }; + const secondFolder = { uri: secondWorkspace, name: 'Second', index: 1, toResource: (path: string) => URI.joinPath(secondWorkspace, path) }; + const workspaceService = new class extends mock() { + override getWorkspaceFolder(resource: URI) { + return resource.path.startsWith(firstWorkspace.path) ? firstFolder : resource.path.startsWith(secondWorkspace.path) ? secondFolder : null; + } + }(); + + assert.deepStrictEqual({ + first: isHookResourceInWorkspace(URI.joinPath(firstWorkspace, '.github/hooks/hooks.json'), firstWorkspace, workspaceService), + second: isHookResourceInWorkspace(URI.joinPath(secondWorkspace, '.github/hooks/hooks.json'), firstWorkspace, workspaceService), + unscoped: isHookResourceInWorkspace(URI.joinPath(secondWorkspace, '.github/hooks/hooks.json'), undefined, workspaceService), + }, { + first: true, + second: false, + unscoped: true, + }); + }); +}); 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 31a473dff74cbb..75e9d3fb58e64e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -468,6 +468,9 @@ function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScop override readonly local = new class extends mock() { override readonly id = id; override readonly scope = scope; + override readonly mcpResource = scope === LocalMcpServerScope.Workspace + ? URI.file('/workspace/.vscode/mcp.json') + : URI.file('/home/dev/.config/Code/User/mcp.json'); }(); }(); } From 0a7515d3b63c3a6a4e459d0465ada877108ee8cc Mon Sep 17 00:00:00 2001 From: Jessie Houghton Date: Fri, 11 Sep 2026 09:49:54 -0700 Subject: [PATCH 2/2] chat: Address multi-root customization feedback Preserve workspace context for generated customizations, support remote workspace source labels, and expose MCP provenance to screen readers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationWorkspaceService.ts | 4 +- .../chat/browser/actions/chatActions.ts | 15 ++++-- .../aiCustomizationItemSource.ts | 2 +- .../aiCustomizationListWidget.ts | 8 ++-- .../aiCustomizationManagementEditor.ts | 8 ++-- .../aiCustomizationWorkspaceService.ts | 4 +- .../customizationCreatorService.ts | 3 +- .../browser/aiCustomization/mcpListWidget.ts | 46 ++++++++++++------- .../common/aiCustomizationWorkspaceService.ts | 2 +- .../aiCustomizationListWidget.test.ts | 38 +++++++++++++-- .../aiCustomization/mcpListWidget.test.ts | 5 ++ 11 files changed, 95 insertions(+), 40 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 6f37887ef69d1b..c9f9b48347f960 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -270,9 +270,9 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization } } - async generateCustomization(type: PromptsType): Promise { + async generateCustomization(type: PromptsType, workspaceFolder?: URI): Promise { const creator = this.instantiationService.createInstance(CustomizationCreatorService); - await creator.createWithAI(type); + await creator.createWithAI(type, workspaceFolder); } async getFilteredPromptSlashCommands(token: CancellationToken): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index d957936bdfd31b..7b6d49d75a0485 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1361,12 +1361,13 @@ export function registerChatActions() { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, workspaceFolder?: URI): Promise { const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', query: '/create-instructions ', isPartialQuery: true, + attachFiles: workspaceFolder ? [workspaceFolder] : undefined, }); } }); @@ -1384,12 +1385,13 @@ export function registerChatActions() { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, workspaceFolder?: URI): Promise { const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', query: '/create-prompt ', isPartialQuery: true, + attachFiles: workspaceFolder ? [workspaceFolder] : undefined, }); } }); @@ -1407,12 +1409,13 @@ export function registerChatActions() { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, workspaceFolder?: URI): Promise { const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', query: '/create-skill ', isPartialQuery: true, + attachFiles: workspaceFolder ? [workspaceFolder] : undefined, }); } }); @@ -1430,12 +1433,13 @@ export function registerChatActions() { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, workspaceFolder?: URI): Promise { const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', query: '/create-agent ', isPartialQuery: true, + attachFiles: workspaceFolder ? [workspaceFolder] : undefined, }); } }); @@ -1453,12 +1457,13 @@ export function registerChatActions() { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, workspaceFolder?: URI): Promise { const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.open', { mode: 'agent', query: '/create-hook ', isPartialQuery: true, + attachFiles: workspaceFolder ? [workspaceFolder] : undefined, }); } }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 03008af451f93f..dbcf3fac46ebb3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -208,7 +208,7 @@ export class AICustomizationItemNormalizer { id: `${item.uri.toString()}${duplicateSuffix}`, uri: item.uri, name: item.name, - filename: item.uri.scheme === Schemas.file + filename: isWorkspaceItem ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem, noPrefix: isWorkspaceItem }) : basename(item.uri), description: item.description, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 311cc75087aea3..62ff3f0367230a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -705,8 +705,8 @@ export class AICustomizationListWidget extends Disposable { private readonly _onDidChangeItemCount = this._register(new Emitter()); readonly onDidChangeItemCount: Event = this._onDidChangeItemCount.event; - private readonly _onDidRequestCreate = this._register(new Emitter()); - readonly onDidRequestCreate: Event = this._onDidRequestCreate.event; + private readonly _onDidRequestCreate = this._register(new Emitter<{ type: PromptsType; workspaceFolder?: URI }>()); + readonly onDidRequestCreate: Event<{ type: PromptsType; workspaceFolder?: URI }> = this._onDidRequestCreate.event; private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string; workspaceFolder?: URI }>()); readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string; workspaceFolder?: URI }> = this._onDidRequestCreateManual.event; @@ -1315,7 +1315,7 @@ export class AICustomizationListWidget extends Disposable { tooltip: localize('generateCustomizationWithAI', "Generate {0} with AI", typeLabel), enabled: true, kind: 'generate', - run: () => { this._onDidRequestCreate.fire(promptType); }, + run: workspaceFolder => { this._onDidRequestCreate.fire({ type: promptType, workspaceFolder }); }, }); } if (hasWorkspace) { @@ -1348,7 +1348,7 @@ export class AICustomizationListWidget extends Disposable { tooltip: localize('generateCustomizationWithAI', "Generate {0} with AI", typeLabel), enabled: true, kind: 'generate', - run: () => { this._onDidRequestCreate.fire(promptType); }, + run: workspaceFolder => { this._onDidRequestCreate.fire({ type: promptType, workspaceFolder }); }, }); } else if (hasWorkspace) { // Sessions or non-local harness with workspace: workspace is primary diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index a8e5d7dfec21c4..9ceec05e133669 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -1201,8 +1201,8 @@ export class AICustomizationManagementEditor extends EditorPane { })); // Handle create actions - AI-guided creation - this.editorDisposables.add(this.listWidget.onDidRequestCreate(promptType => { - this.createNewItemWithAI(promptType); + this.editorDisposables.add(this.listWidget.onDidRequestCreate(({ type, workspaceFolder }) => { + this.createNewItemWithAI(type, workspaceFolder); })); // Handle manual create actions - open editor directly @@ -2803,7 +2803,7 @@ export class AICustomizationManagementEditor extends EditorPane { /** * Creates a new customization using the AI-guided flow. */ - private async createNewItemWithAI(type: PromptsType): Promise { + private async createNewItemWithAI(type: PromptsType, workspaceFolder?: URI): Promise { this.telemetryService.publicLog2('chatCustomizationEditor.createItem', { section: this.selectedSection ?? 'welcome', promptType: type, @@ -2813,7 +2813,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.input) { this.group.closeEditor(this.input); } - await this.workspaceService.generateCustomization(type); + await this.workspaceService.generateCustomization(type, workspaceFolder); } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 65692071f8d1d1..f496567e687276 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -81,7 +81,7 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic // No-op in core VS Code. } - async generateCustomization(type: PromptsType): Promise { + async generateCustomization(type: PromptsType, workspaceFolder?: URI): Promise { const commandIds: Partial> = { [PromptsType.agent]: GENERATE_AGENT_COMMAND_ID, [PromptsType.skill]: GENERATE_SKILL_COMMAND_ID, @@ -91,7 +91,7 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic }; const commandId = commandIds[type]; if (commandId) { - await this.commandService.executeCommand(commandId); + await this.commandService.executeCommand(commandId, workspaceFolder); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index ca1bac6b862626..d7db76c3d36b52 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -45,7 +45,7 @@ export class CustomizationCreatorService { ) { } - async createWithAI(type: PromptsType): Promise { + async createWithAI(type: PromptsType, workspaceFolder?: URI): Promise { const currentSessionResource = this.harnessService.activeSessionResource.get(); @@ -77,6 +77,7 @@ export class CustomizationCreatorService { currentSessionResource, type, 'local', + workspaceFolder, ); if (targetDir === null) { return; // User cancelled the picker diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index ce1b7b389e2bfa..07dfc40bb5e1c8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -29,7 +29,7 @@ import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; -import { isEqualOrParent } from '../../../../../base/common/resources.js'; +import { isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; import { InputBox, MessageType } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; @@ -135,6 +135,29 @@ export function isMcpServerCollectionVisible(collectionId: string, hiddenCollect type IMcpInstalledEntry = IMcpServerItemEntry | IMcpSessionServerItemEntry | IMcpBuiltinItemEntry; +function getMcpEntrySecondaryText(element: IMcpInstalledEntry, plugins: readonly { uri: URI; label: string }[], labelService: ILabelService): string | undefined { + const activeSessionServer = getActiveSessionServer(element); + const localServer = element.type === 'session-server-item' ? undefined : element.localServer; + const pluginUriString = getPluginUriFromCollectionId(localServer?.collection.id); + const pluginUri = pluginUriString ? URI.parse(pluginUriString) : undefined; + const sourceUri = createInstalledMcpServerDetailInput(element).source?.uri; + const plugin = pluginUri + ? plugins.find(candidate => isEqual(candidate.uri, pluginUri)) + : activeSessionServer?.isPluginProvided && sourceUri + ? plugins.find(candidate => isEqualOrParent(sourceUri, candidate.uri)) + : undefined; + const disabledReason = activeSessionServer?.disabledReason; + const pluginLabel = plugin?.label ?? (disabledReason?.source === 'plugin' ? disabledReason.plugin.name : undefined); + return getMcpServerSecondaryText(sourceUri, pluginLabel, labelService); +} + +export function getMcpServerAriaLabel(label: string, secondaryText: string | undefined, status: string | undefined): string { + const labelWithSource = secondaryText ? localize('mcpServerAriaLabelWithSource', "{0}. {1}", label, secondaryText) : label; + return status + ? localize('mcpServerAriaLabelWithStatus', "{0}, {1}", labelWithSource, status) + : labelWithSource; +} + interface IMcpMarketplaceEntry { readonly type: 'marketplace-item'; readonly server: IWorkbenchMcpServer; @@ -324,18 +347,7 @@ export class McpServerItemRenderer implements IListRenderer candidate.uri.toString() === pluginUriString) - : activeSessionServer?.isPluginProvided && sourceUri - ? this.agentPluginService.plugins.get().find(candidate => isEqualOrParent(sourceUri, candidate.uri)) - : undefined; - const disabledReason = activeSessionServer?.disabledReason; - const pluginLabel = plugin?.label ?? (disabledReason?.source === 'plugin' ? disabledReason.plugin.name : undefined); - return getMcpServerSecondaryText(sourceUri, pluginLabel, this.labelService); + return getMcpEntrySecondaryText(element, this.agentPluginService.plugins.get(), this.labelService); } private updateKnownServerStatus(templateData: IMcpServerItemTemplateData, element: IMcpServerItemEntry | IMcpBuiltinItemEntry): void { @@ -717,9 +729,7 @@ function getMcpEntryAriaLabel(element: IMcpInstalledEntry, isSessionsWindow: boo const statusKind = getMcpStatusKind(element, isSessionsWindow); const disabledReason = statusKind === 'disabled' ? getMcpDisabledReason(element) : undefined; const status = getMcpStatusPresentation(statusKind, disabledReason); - return status - ? localize('mcpServerAriaLabelWithStatus', "{0}, {1}", label, status.label) - : label; + return getMcpServerAriaLabel(label, undefined, status?.label); } function getMcpDisabledReason(entry: IMcpServerItemEntry | IMcpSessionServerItemEntry | IMcpBuiltinItemEntry): CustomizationDisabledReason | undefined { @@ -1274,6 +1284,7 @@ export class McpListWidget extends Disposable { @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @INotificationService private readonly notificationService: INotificationService, + @ILabelService private readonly labelService: ILabelService, @IMcpGalleryManifestService mcpGalleryManifestService: IMcpGalleryManifestService, ) { super(); @@ -1765,7 +1776,8 @@ export class McpListWidget extends Disposable { statusKind = entry.localServer?.connectionState.read(reader).state; } const status = getMcpStatusPresentation(statusKind, disabledReason); - return status ? localize('mcpServerAriaLabelWithStatus', "{0}, {1}", label, status.label) : label; + const secondaryText = getMcpEntrySecondaryText(entry, this.agentPluginService.plugins.read(reader), this.labelService); + return getMcpServerAriaLabel(label, secondaryText, status?.label); }); } diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index f9c6019aa00910..32f90bb0a6032b 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -130,7 +130,7 @@ export interface IAICustomizationWorkspaceService { /** * Launches the AI-guided creation flow for the given customization type. */ - generateCustomization(type: PromptsType): Promise; + generateCustomization(type: PromptsType, workspaceFolder?: URI): Promise; /** * Whether a transient project root override is currently active. 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 6ef2e93dac2e3d..ddca9810328f77 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 @@ -10,16 +10,17 @@ import { Event } from '../../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { derived, observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IListService, ListService } from '../../../../../../platform/list/browser/listService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getCollapsedCustomizationGroupKey, getCustomizationItemAriaLabel, getCustomizationItemHoverContent, getTargetedCreateActionLabel, getWorkspaceCustomizationGroupKey, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; -import { IAICustomizationListItem } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; +import { AICustomizationItemNormalizer, IAICustomizationListItem } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; -import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; +import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationHarnessService, ICustomizationItem, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; @@ -29,6 +30,8 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController, getVirtualizedSectionMinimumHeight, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from '../../../browser/aiCustomization/customizationCardList.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; suite('aiCustomizationListWidget', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -49,6 +52,30 @@ suite('aiCustomizationListWidget', () => { }); }); + test('uses relative source labels for remote workspace customizations', () => { + const remoteUri = URI.parse('vscode-remote://ssh-remote+test/workspace/.github/skills/review/SKILL.md'); + const labelService = new class extends mock() { + override getUriLabel(resource: URI, options?: { relative?: boolean; noPrefix?: boolean }): string { + assert.deepStrictEqual({ resource: resource.toString(), options }, { + resource: remoteUri.toString(), + options: { relative: true, noPrefix: true }, + }); + return '.github/skills/review/SKILL.md'; + } + }; + const normalizer = new AICustomizationItemNormalizer(labelService, new class extends mock() { }); + const item: ICustomizationItem = { + uri: remoteUri, + type: PromptsType.skill, + name: 'Review', + source: AICustomizationSources.local, + extensionId: undefined, + pluginUri: undefined, + }; + + assert.strictEqual(normalizer.normalizeItem(item, PromptsType.skill).filename, '.github/skills/review/SKILL.md'); + }); + test('keeps editable source sections visible until search filtering starts', () => { assert.deepStrictEqual({ agents: getAlwaysVisibleCustomizationGroupKeys(AICustomizationManagementSection.Agents, false), @@ -769,7 +796,10 @@ suite('aiCustomizationListWidget', () => { widget.layout(800, 500); let createRequest: URI | undefined; disposables.add(widget.onDidRequestCreateManual(event => createRequest = event.workspaceFolder)); + let generateRequest: URI | undefined; + disposables.add(widget.onDidRequestCreate(event => generateRequest = event.workspaceFolder)); widget.element.querySelector('.plugin-card-section .customization-create-action')?.click(); + widget.element.querySelectorAll('.plugin-card-section')[1].querySelector('.customization-generate-action')?.click(); assert.deepStrictEqual( { @@ -779,6 +809,7 @@ suite('aiCustomizationListWidget', () => { sources: Array.from(section.querySelectorAll('.item-description')).map(item => item.textContent), })), createRequest: createRequest?.toString(), + generateRequest: generateRequest?.toString(), }, { sections: [ @@ -787,6 +818,7 @@ suite('aiCustomizationListWidget', () => { { title: 'User', items: [], sources: [] }, ], createRequest: firstFolderUri.toString(), + generateRequest: secondFolderUri.toString(), } ); assert.notStrictEqual(getWorkspaceCustomizationGroupKey(firstFolderUri), getWorkspaceCustomizationGroupKey(secondFolderUri)); 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 80ddd1b23d87ab..887de81ffcba8d 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 @@ -38,6 +38,7 @@ import { getLocalMcpServerEnablementActions, getMcpServerOutputHandler, getMcpServerHoverContent, + getMcpServerAriaLabel, getMcpServerSecondaryText, getMcpStatusPresentation, isMcpServerCollectionVisible, @@ -210,11 +211,15 @@ suite('mcpListWidget', () => { plugin: getMcpServerSecondaryText(source, 'Example Plugin', labelService), descriptionHover: getMcpServerHoverContent(' Server description. ', '.vscode/mcp.json'), fallbackHover: getMcpServerHoverContent(undefined, '.vscode/mcp.json'), + accessibleSource: getMcpServerAriaLabel('Filesystem', '.vscode/mcp.json', undefined), + accessiblePluginAndStatus: getMcpServerAriaLabel('GitHub', 'Plugin: GitHub', 'Running'), }, { source: '.vscode/mcp.json', plugin: 'Plugin: Example Plugin', descriptionHover: 'Server description.', fallbackHover: '.vscode/mcp.json', + accessibleSource: 'Filesystem. .vscode/mcp.json', + accessiblePluginAndStatus: 'GitHub. Plugin: GitHub, Running', }); });