mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 00:09:30 +01:00
feat: group chat extension customizations under 'Built-in' in managem… (#303584)
feat: group chat extension customizations under 'Built-in' in management editor Items contributed by the default chat extension (GitHub Copilot Chat) are now shown under a 'Built-in' group header instead of 'Extensions' in the Chat Customizations editor. This applies to agents, skills, prompts, and instructions — matching the existing pattern used by the MCP list widget. The grouping is determined at the UI layer using IProductService to identify the chat extension ID, setting groupKey on matching items. No data model changes needed. - Move BUILTIN_STORAGE/AICustomizationPromptsStorage to common layer - Add BUILTIN_STORAGE to VS Code harness filter sources - Add isChatExtensionItem() helper using productService.defaultChatAgent - Set groupKey: BUILTIN_STORAGE for items from the chat extension - Add tests for builtin source filtering - Update AI_CUSTOMIZATIONS.md spec
This commit is contained in:
@@ -28,7 +28,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/
|
||||
└── aiCustomizationManagement.css
|
||||
|
||||
src/vs/workbench/contrib/chat/common/
|
||||
├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter
|
||||
├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE
|
||||
└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers
|
||||
```
|
||||
|
||||
@@ -76,7 +76,7 @@ Storage answers "where did this come from?"; harness answers "who consumes it?".
|
||||
The service is defined in `common/customizationHarnessService.ts` which also provides:
|
||||
- **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch.
|
||||
- **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts).
|
||||
- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`.
|
||||
- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`.
|
||||
- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge.
|
||||
- **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching.
|
||||
|
||||
@@ -128,7 +128,7 @@ The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri,
|
||||
|
||||
**Core VS Code filter behavior:**
|
||||
|
||||
Local harness: all types use `[local, user, extension, plugin]` with no user root filter.
|
||||
Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget.
|
||||
|
||||
CLI harness (core):
|
||||
|
||||
@@ -152,6 +152,21 @@ Claude additionally applies:
|
||||
- `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt)
|
||||
- `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension
|
||||
|
||||
### Built-in Extension Grouping (Core VS Code)
|
||||
|
||||
In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions".
|
||||
|
||||
This follows the same pattern as the MCP list widget, which determines grouping at the UI layer by inspecting collection sources. The list widget uses `IProductService` to identify the chat extension and sets `groupKey: BUILTIN_STORAGE` on matching items:
|
||||
|
||||
- **Agents**: checks `agent.source.extensionId` against the chat extension ID
|
||||
- **Skills**: builds a URI→ExtensionIdentifier lookup from `listPromptFiles(PromptsType.skill)`, then checks each skill's URI
|
||||
- **Prompts**: checks `command.promptPath.extension?.identifier`
|
||||
- **Instructions/Hooks**: checks `item.extension?.identifier` via `IPromptPath`
|
||||
|
||||
The underlying `storage` remains `PromptsStorage.extension` — the grouping is a UI-level override via `groupKey` that keeps `applyStorageSourceFilter` working with existing storage types while visually distinguishing chat-extension items from third-party extension items.
|
||||
|
||||
`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility.
|
||||
|
||||
### AgenticPromptsService (Sessions)
|
||||
|
||||
Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`):
|
||||
|
||||
@@ -4,19 +4,12 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
|
||||
import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';
|
||||
import { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
|
||||
|
||||
/**
|
||||
* Extended storage type for AI Customization that includes built-in prompts
|
||||
* shipped with the application, alongside the core `PromptsStorage` values.
|
||||
*/
|
||||
export type AICustomizationPromptsStorage = PromptsStorage | 'builtin';
|
||||
|
||||
/**
|
||||
* Storage type discriminator for built-in prompts shipped with the application.
|
||||
*/
|
||||
export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin';
|
||||
// Re-export from common for backward compatibility
|
||||
export type { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
|
||||
export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
|
||||
|
||||
/**
|
||||
* Prompt path for built-in prompts bundled with the Sessions app.
|
||||
|
||||
@@ -56,6 +56,8 @@ import { ICustomizationHarnessService, matchesWorkspaceSubpath, matchesInstructi
|
||||
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
|
||||
import { getCleanPromptName, isInClaudeRulesFolder } from '../../common/promptSyntax/config/promptFileLocations.js';
|
||||
import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js';
|
||||
import { IProductService } from '../../../../../platform/product/common/productService.js';
|
||||
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
|
||||
|
||||
export { truncateToFirstSentence } from './aiCustomizationListWidgetUtils.js';
|
||||
|
||||
@@ -514,6 +516,7 @@ export class AICustomizationListWidget extends Disposable {
|
||||
@ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService,
|
||||
@IAgentPluginService private readonly agentPluginService: IAgentPluginService,
|
||||
@ICommandService private readonly commandService: ICommandService,
|
||||
@IProductService private readonly productService: IProductService,
|
||||
) {
|
||||
super();
|
||||
this.element = $('.ai-customization-list-widget');
|
||||
@@ -1020,6 +1023,61 @@ export class AICustomizationListWidget extends Disposable {
|
||||
return items.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given extension identifier matches the default
|
||||
* chat extension (e.g. GitHub Copilot Chat). Used to group items from
|
||||
* the chat extension under "Built-in" instead of "Extensions", similar
|
||||
* to how MCP categorizes built-in servers.
|
||||
*/
|
||||
private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean {
|
||||
const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId;
|
||||
return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the display group key for an extension-storage item.
|
||||
* Items from the default chat extension are re-grouped under "Built-in";
|
||||
* all other extension items keep their original storage as group key.
|
||||
*
|
||||
* Returns `undefined` when no override is needed (the item will fall back
|
||||
* to its `storage` value for grouping).
|
||||
*
|
||||
* This is the single point where extension → group mapping is decided,
|
||||
* making it easy to add dynamic filter layers in the future.
|
||||
*/
|
||||
private resolveExtensionGroupKey(extensionId: ExtensionIdentifier | undefined): string | undefined {
|
||||
if (extensionId && this.isChatExtensionItem(extensionId)) {
|
||||
return BUILTIN_STORAGE;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processes items to assign groupKey overrides for extension-sourced
|
||||
* items. Applies the built-in grouping consistently across all item types.
|
||||
*
|
||||
* Items that already have an explicit groupKey (e.g. instruction categories,
|
||||
* agent hooks) are left untouched — groupKey overrides are only applied to
|
||||
* items whose current groupKey is `undefined`.
|
||||
*/
|
||||
private applyBuiltinGroupKeys(items: IAICustomizationListItem[], extensionIdByUri: ReadonlyMap<string, ExtensionIdentifier>): void {
|
||||
for (const item of items) {
|
||||
if (item.groupKey !== undefined) {
|
||||
continue; // respect explicit groupKey from upstream (e.g. instruction categories)
|
||||
}
|
||||
if (item.storage !== PromptsStorage.extension) {
|
||||
continue;
|
||||
}
|
||||
const extId = extensionIdByUri.get(item.uri.toString());
|
||||
const override = this.resolveExtensionGroupKey(extId);
|
||||
if (override) {
|
||||
// IAICustomizationListItem.groupKey is readonly for consumers but
|
||||
// we own the items array here, so the mutation is safe.
|
||||
(item as { groupKey?: string }).groupKey = override;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and filters items for a given section.
|
||||
* Shared between `loadItems` (active section) and `computeItemCountForSection` (any section).
|
||||
@@ -1028,6 +1086,7 @@ export class AICustomizationListWidget extends Disposable {
|
||||
const promptType = sectionToPromptType(section);
|
||||
const items: IAICustomizationListItem[] = [];
|
||||
const disabledUris = this.promptsService.getDisabledPromptFiles(promptType);
|
||||
const extensionIdByUri = new Map<string, ExtensionIdentifier>();
|
||||
|
||||
|
||||
if (promptType === PromptsType.agent) {
|
||||
@@ -1046,10 +1105,21 @@ export class AICustomizationListWidget extends Disposable {
|
||||
pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined,
|
||||
disabled: disabledUris.has(agent.uri),
|
||||
});
|
||||
// Track extension ID for built-in grouping
|
||||
if (agent.source.storage === PromptsStorage.extension) {
|
||||
extensionIdByUri.set(agent.uri.toString(), agent.source.extensionId);
|
||||
}
|
||||
}
|
||||
} else if (promptType === PromptsType.skill) {
|
||||
// Use findAgentSkills for enabled skills (has parsed name/description from frontmatter)
|
||||
const skills = await this.promptsService.findAgentSkills(CancellationToken.None);
|
||||
// Build extension ID lookup from raw file list (like MCP builds collectionSources)
|
||||
const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None);
|
||||
for (const file of allSkillFiles) {
|
||||
if (file.extension) {
|
||||
extensionIdByUri.set(file.uri.toString(), file.extension.identifier);
|
||||
}
|
||||
}
|
||||
const seenUris = new ResourceSet();
|
||||
for (const skill of skills || []) {
|
||||
const filename = basename(skill.uri);
|
||||
@@ -1069,7 +1139,6 @@ export class AICustomizationListWidget extends Disposable {
|
||||
}
|
||||
// Also include disabled skills from the raw file list
|
||||
if (disabledUris.size > 0) {
|
||||
const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None);
|
||||
for (const file of allSkillFiles) {
|
||||
if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) {
|
||||
const filename = basename(file.uri);
|
||||
@@ -1106,6 +1175,9 @@ export class AICustomizationListWidget extends Disposable {
|
||||
pluginUri: command.promptPath.storage === PromptsStorage.plugin ? command.promptPath.pluginUri : undefined,
|
||||
disabled: disabledUris.has(command.promptPath.uri),
|
||||
});
|
||||
if (command.promptPath.extension) {
|
||||
extensionIdByUri.set(command.promptPath.uri.toString(), command.promptPath.extension.identifier);
|
||||
}
|
||||
}
|
||||
} else if (promptType === PromptsType.hook) {
|
||||
// Try to parse individual hooks from each file; fall back to showing the file itself
|
||||
@@ -1212,6 +1284,11 @@ export class AICustomizationListWidget extends Disposable {
|
||||
} else {
|
||||
// For instructions, group by category: agent instructions, context instructions, on-demand instructions
|
||||
const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None);
|
||||
for (const file of promptFiles) {
|
||||
if (file.extension) {
|
||||
extensionIdByUri.set(file.uri.toString(), file.extension.identifier);
|
||||
}
|
||||
}
|
||||
const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined);
|
||||
const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri));
|
||||
|
||||
@@ -1296,6 +1373,12 @@ export class AICustomizationListWidget extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
// Assign built-in groupKeys — items from the default chat extension
|
||||
// are re-grouped under "Built-in" instead of "Extensions".
|
||||
// This is a single-pass transformation applied after all items are
|
||||
// collected, keeping the item-building code free of grouping logic.
|
||||
this.applyBuiltinGroupKeys(items, extensionIdByUri);
|
||||
|
||||
// Apply storage source filter (removes items not in visible sources or excluded user roots)
|
||||
const filter = this.workspaceService.getStorageSourceFilter(promptType);
|
||||
const filteredItems = applyStorageSourceFilter(items, filter);
|
||||
@@ -1406,8 +1489,8 @@ export class AICustomizationListWidget extends Disposable {
|
||||
: [
|
||||
{ groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] },
|
||||
{ groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] },
|
||||
{ groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] },
|
||||
{ groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] },
|
||||
{ groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] },
|
||||
{ groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] },
|
||||
{ groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] },
|
||||
].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents');
|
||||
|
||||
@@ -5,23 +5,13 @@
|
||||
|
||||
import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';
|
||||
import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js';
|
||||
import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
|
||||
import { localize } from '../../../../../nls.js';
|
||||
import { MenuId } from '../../../../../platform/actions/common/actions.js';
|
||||
|
||||
// Re-export for convenience — consumers import from this file
|
||||
export { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js';
|
||||
|
||||
/**
|
||||
* Extended storage type for AI Customization that includes built-in prompts
|
||||
* shipped with the application, alongside the core `PromptsStorage` values.
|
||||
*/
|
||||
export type AICustomizationPromptsStorage = PromptsStorage | 'builtin';
|
||||
|
||||
/**
|
||||
* Storage type discriminator for built-in prompts shipped with the application.
|
||||
*/
|
||||
export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin';
|
||||
export type { AICustomizationPromptsStorage } from '../../common/aiCustomizationWorkspaceService.js';
|
||||
export { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js';
|
||||
|
||||
/**
|
||||
* Editor pane ID for the AI Customizations Management Editor.
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getClaudeUserRoots,
|
||||
} from '../../common/customizationHarnessService.js';
|
||||
import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
|
||||
import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js';
|
||||
import { IPathService } from '../../../../services/path/common/pathService.js';
|
||||
import { IChatAgentService } from '../../common/participants/chatAgents.js';
|
||||
|
||||
@@ -31,9 +32,10 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase {
|
||||
@IChatAgentService chatAgentService: IChatAgentService,
|
||||
) {
|
||||
const userHome = pathService.userHome({ preferLocal: true });
|
||||
// Only the Local harness includes extension-contributed customizations.
|
||||
// The Local harness includes extension-contributed and built-in customizations.
|
||||
// Built-in items come from the default chat extension (productService.defaultChatAgent).
|
||||
// CLI and Claude harnesses don't consume extension contributions.
|
||||
const localExtras = [PromptsStorage.extension];
|
||||
const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE];
|
||||
const restrictedExtras: readonly string[] = [];
|
||||
const allHarnesses: readonly IHarnessDescriptor[] = [
|
||||
createVSCodeHarnessDescriptor(localExtras),
|
||||
|
||||
@@ -13,6 +13,17 @@ import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/
|
||||
|
||||
export const IAICustomizationWorkspaceService = createDecorator<IAICustomizationWorkspaceService>('aiCustomizationWorkspaceService');
|
||||
|
||||
/**
|
||||
* Extended storage type for AI Customization that includes built-in prompts
|
||||
* shipped with the application, alongside the core `PromptsStorage` values.
|
||||
*/
|
||||
export type AICustomizationPromptsStorage = PromptsStorage | 'builtin';
|
||||
|
||||
/**
|
||||
* Storage type discriminator for built-in customizations shipped with the application.
|
||||
*/
|
||||
export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin';
|
||||
|
||||
/**
|
||||
* Possible section IDs for the AI Customization Management Editor sidebar.
|
||||
*/
|
||||
|
||||
@@ -7,9 +7,9 @@ import assert from 'assert';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js';
|
||||
import { applyStorageSourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js';
|
||||
import { applyStorageSourceFilter, BUILTIN_STORAGE, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js';
|
||||
|
||||
function item(path: string, storage: PromptsStorage): { uri: URI; storage: PromptsStorage } {
|
||||
function item(path: string, storage: PromptsStorage | string): { uri: URI; storage: string } {
|
||||
return { uri: URI.file(path), storage };
|
||||
}
|
||||
|
||||
@@ -218,6 +218,36 @@ suite('applyStorageSourceFilter', () => {
|
||||
};
|
||||
assert.strictEqual(applyStorageSourceFilter(items, filter).length, 4);
|
||||
});
|
||||
|
||||
test('core-like filter with builtin: extension items pass when both extension and builtin are in sources', () => {
|
||||
// Items from the chat extension have storage=extension but groupKey=builtin.
|
||||
// The filter operates on storage, so extension items pass through regardless of groupKey.
|
||||
const items = [
|
||||
item('/w/a.md', PromptsStorage.local),
|
||||
item('/e/builtin-agent.md', PromptsStorage.extension),
|
||||
item('/e/third-party.md', PromptsStorage.extension),
|
||||
item('/b/sessions-builtin.md', BUILTIN_STORAGE),
|
||||
];
|
||||
const filter: IStorageSourceFilter = {
|
||||
sources: [PromptsStorage.local, PromptsStorage.extension, BUILTIN_STORAGE],
|
||||
};
|
||||
const result = applyStorageSourceFilter(items, filter);
|
||||
assert.strictEqual(result.length, 4);
|
||||
});
|
||||
|
||||
test('builtin source is respected independently', () => {
|
||||
const items = [
|
||||
item('/e/from-extension.md', PromptsStorage.extension),
|
||||
item('/b/from-sessions.md', BUILTIN_STORAGE),
|
||||
];
|
||||
// Only builtin in sources — extension items excluded
|
||||
const filter: IStorageSourceFilter = {
|
||||
sources: [BUILTIN_STORAGE],
|
||||
};
|
||||
const result = applyStorageSourceFilter(items, filter);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].storage, BUILTIN_STORAGE);
|
||||
});
|
||||
});
|
||||
|
||||
suite('type safety', () => {
|
||||
|
||||
Reference in New Issue
Block a user