Add "Organization" label for prompt files (#289722)

This commit is contained in:
Paul
2026-01-22 13:01:57 -08:00
committed by GitHub
parent a78d4d45b6
commit c03bc3da59
4 changed files with 205 additions and 6 deletions
@@ -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<IPromptPickerQuickPickItem, { useSeparators: true }>;
/**
@@ -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<string, IPromptPath[]>();
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:
@@ -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);
}
@@ -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;
}
@@ -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<IProductService>({
defaultChatAgent: chatExtensionId ? { chatExtensionId } : undefined,
} as Partial<IProductService>);
}
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<IProductService>({
defaultChatAgent: { chatExtensionId: '' },
} as Partial<IProductService>);
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}`,
);
}
});
});
});