From c03bc3da592aa78ec1ca9a8bc18737d7b8d2b609 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 22 Jan 2026 13:01:57 -0800 Subject: [PATCH] Add "Organization" label for prompt files (#289722) --- .../promptSyntax/pickers/promptFilePickers.ts | 41 +++++- .../widget/input/modePickerActionItem.ts | 19 ++- .../promptSyntax/utils/promptsServiceUtils.ts | 28 ++++ .../utils/promptsServiceUtils.test.ts | 123 ++++++++++++++++++ 4 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptsServiceUtils.ts create mode 100644 src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptsServiceUtils.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index 60e561cca606..e350c3b6289b 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -7,7 +7,7 @@ import { localize } from '../../../../../../nls.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IExtensionPromptPath, IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { dirname, extUri, joinPath } from '../../../../../../base/common/resources.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; @@ -25,7 +25,9 @@ import { askForPromptSourceFolder } from './askForPromptSourceFolder.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { PromptFileRewriter } from '../promptFileRewriter.js'; +import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; /** * Options for the {@link askToSelectInstructions} function. @@ -122,6 +124,13 @@ function isPromptFileItem(item: IPromptPickerQuickPickItem | IQuickPickSeparator return item.type === 'item' && !!item.promptFileUri; } +/** + * Type guard for extension prompt paths. + */ +function isExtensionPromptPath(prompt: IPromptPath): prompt is IExtensionPromptPath { + return prompt.storage === PromptsStorage.extension && !!prompt.extension; +} + type IPromptQuickPick = IQuickPick; /** @@ -259,6 +268,7 @@ export class PromptFilePickers { @IPromptsService private readonly _promptsService: IPromptsService, @ILabelService private readonly _labelService: ILabelService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IProductService private readonly _productService: IProductService, ) { } @@ -408,9 +418,8 @@ export class PromptFilePickers { result.push(...sortByLabel(await Promise.all(agentInstructionFiles.map(l => this._createPromptPickItem(l, agentButtons, getVisibility(l), token))))); } - const exts = await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.extension, token); + const exts = (await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.extension, token)).filter(isExtensionPromptPath); if (exts.length) { - result.push({ type: 'separator', label: localize('separator.extensions', "Extensions") }); const extButtons: IQuickInputButton[] = []; if (options.optionEdit !== false) { extButtons.push(EDIT_BUTTON); @@ -418,7 +427,21 @@ export class PromptFilePickers { if (options.optionCopy !== false) { extButtons.push(COPY_BUTTON); } - result.push(...sortByLabel(await Promise.all(exts.map(e => this._createPromptPickItem(e, extButtons, getVisibility(e), token))))); + + const groupedExts = new Map(); + for (const ext of exts) { + const groupLabel = this._getExtensionGroupLabel(ext); + if (!groupedExts.has(groupLabel)) { + groupedExts.set(groupLabel, []); + } + groupedExts.get(groupLabel)!.push(ext); + } + + const sortedGroupedExts = Array.from(groupedExts.entries()).sort((a, b) => a[0].localeCompare(b[0])); + for (const [groupLabel, groupExts] of sortedGroupedExts) { + result.push({ type: 'separator', label: groupLabel }); + result.push(...sortByLabel(await Promise.all(groupExts.map(e => this._createPromptPickItem(e, extButtons, getVisibility(e), token))))); + } } const users = await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.user, token); if (users.length) { @@ -428,6 +451,16 @@ export class PromptFilePickers { return result; } + private _getExtensionGroupLabel(extPath: IExtensionPromptPath): string { + if (isOrganizationPromptFile(extPath.uri, extPath.extension.identifier, this._productService)) { + return localize('separator.organization', "Organization"); + } + + // By default, extension prompt files are grouped under "Extensions" + return localize('separator.extensions', "Extensions"); + + } + private _getNewItems(type: PromptsType): IPromptPickerQuickPickItem[] { switch (type) { case PromptsType.prompt: diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index c0334c5aef88..c8b2fc02ca7b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -25,8 +25,9 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; +import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { ExtensionAgentSourceType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -256,5 +257,19 @@ function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductServic if (mode.isBuiltin) { return true; } - return mode.source?.storage === PromptsStorage.extension && mode.source.extensionId.value === productService.defaultChatAgent?.chatExtensionId && mode.source.type === ExtensionAgentSourceType.contribution; + // Not built-in if not from the built-in chat extension + if (mode.source?.storage !== PromptsStorage.extension) { + return false; + } + const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; + if (!chatExtensionId || mode.source.extensionId.value !== chatExtensionId) { + return false; + } + // Organization-provided agents (under /github/ path) are also not considered built-in + const modeUri = mode.uri?.get(); + if (!modeUri) { + // If somehow there is no URI, but it's from the built-in chat extension, consider it built-in + return true; + } + return !isOrganizationPromptFile(modeUri, mode.source.extensionId, productService); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptsServiceUtils.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptsServiceUtils.ts new file mode 100644 index 000000000000..f458870f629c --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptsServiceUtils.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; + +/** + * Checks if a prompt file is organization-provided. + * Organization-provided prompt files come from the built-in chat extension + * and are located under a `/github/` path. + * + * @param uri The URI of the prompt file + * @param extensionId The extension identifier that provides the prompt file + * @param productService The product service to get the built-in chat extension ID + * @returns `true` if the prompt file is organization-provided, `false` otherwise + */ +export function isOrganizationPromptFile(uri: URI, extensionId: ExtensionIdentifier, productService: IProductService): boolean { + const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; + if (!chatExtensionId) { + return false; + } + const isFromBuiltinChatExtension = ExtensionIdentifier.equals(extensionId, chatExtensionId); + const pathContainsGithub = uri.path.includes('/github/'); + return isFromBuiltinChatExtension && pathContainsGithub; +} diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptsServiceUtils.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptsServiceUtils.test.ts new file mode 100644 index 000000000000..58e9f47da29e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptsServiceUtils.test.ts @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; +import { IProductService } from '../../../../../../../platform/product/common/productService.js'; +import { isOrganizationPromptFile } from '../../../../common/promptSyntax/utils/promptsServiceUtils.js'; +import { mockService } from './mock.js'; + +suite('promptsServiceUtils', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('isOrganizationPromptFile', () => { + const CHAT_EXTENSION_ID = 'github.copilot-chat'; + + function createProductService(chatExtensionId: string | undefined): IProductService { + return mockService({ + defaultChatAgent: chatExtensionId ? { chatExtensionId } : undefined, + } as Partial); + } + + test('returns false when no chatExtensionId is configured', () => { + const uri = URI.file('/some/path/github/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(undefined); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when chatExtensionId is not configured', + ); + }); + + test('returns false when extension ID does not match', () => { + const uri = URI.file('/some/path/github/prompt.md'); + const extensionId = new ExtensionIdentifier('some.other-extension'); + const productService = createProductService(CHAT_EXTENSION_ID); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when extension ID does not match the built-in chat extension', + ); + }); + + test('returns false when path does not contain /github/', () => { + const uri = URI.file('/some/path/to/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(CHAT_EXTENSION_ID); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when path does not contain /github/', + ); + }); + + test('returns true when extension matches and path contains /github/', () => { + const uri = URI.file('/some/path/github/prompts/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(CHAT_EXTENSION_ID); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + true, + 'Should return true when extension matches and path contains /github/', + ); + }); + + test('extension ID comparison is case-insensitive', () => { + const uri = URI.file('/some/github/prompt.md'); + const extensionId = new ExtensionIdentifier('GITHUB.COPILOT-CHAT'); + const productService = createProductService('github.copilot-chat'); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + true, + 'Extension ID comparison should be case-insensitive', + ); + }); + + test('returns false when defaultChatAgent exists but chatExtensionId is empty', () => { + const uri = URI.file('/some/github/prompt.md'); + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = mockService({ + defaultChatAgent: { chatExtensionId: '' }, + } as Partial); + + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + 'Should return false when chatExtensionId is empty string', + ); + }); + + test('returns false for similar but incorrect paths', () => { + const extensionId = new ExtensionIdentifier(CHAT_EXTENSION_ID); + const productService = createProductService(CHAT_EXTENSION_ID); + + const invalidPaths = [ + '/some/githubs/prompt.md', // extra 's' + '/some/github-org/prompt.md', // hyphenated + '/some/mygithub/prompt.md', // prefix + '/some/githubstuff/prompt.md', // suffix + '/some/GITHUB/prompt.md', // uppercase (path matching is case-sensitive) + '/some/Github/prompt.md', // mixed case + ]; + + for (const path of invalidPaths) { + const uri = URI.file(path); + assert.strictEqual( + isOrganizationPromptFile(uri, extensionId, productService), + false, + `Should return false for path: ${path}`, + ); + } + }); + }); +});