chat: route AI customization list through item sources#308626
chat: route AI customization list through item sources#308626joshspicer wants to merge 1 commit intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the Chat Customizations management UI to use a single provider-based item fetching path for all harnesses by extracting the previous core IPromptsService-backed logic into a new PromptsServiceCustomizationProvider.
Changes:
- Introduces
PromptsServiceCustomizationProviderimplementingIExternalCustomizationItemProviderto surface agents/skills/instructions/prompts/hooks through the provider pipeline. - Simplifies
AICustomizationListWidgetby removing the legacy “core” fetching/filtering path and routing all harnesses throughfetchItemsFromProvider. - Updates VS Code + Sessions harness services and related tests/fixtures to attach an
itemProvider(lazily in production, explicitly in fixtures).
Show a summary per file
| File | Description |
|---|---|
| src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationProvider.ts | New provider wrapping IPromptsService to produce IExternalCustomizationItem[] with grouping/badges/filtering. |
| src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts | Removes legacy core path; single item-loading + grouping path via providers. |
| src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts | VS Code harness now supplies a lazy PromptsServiceCustomizationProvider as itemProvider. |
| src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts | Sessions harness now supplies a lazy PromptsServiceCustomizationProvider as itemProvider. |
| src/vs/workbench/contrib/chat/common/customizationHarnessService.ts | Makes CustomizationHarnessServiceBase disposable for lifecycle management. |
| src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts | Adjusts tests to dispose the harness service via the suite’s DisposableStore. |
| src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts | Ensures fixture harness descriptors always have an itemProvider so the unified path renders items. |
| src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts | Updates list widget fixture mocks to provide a provider-backed harness descriptor. |
Copilot's findings
Comments suppressed due to low confidence (3)
src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationProvider.ts:313
- The workspace-root containment check uses
item.uri.path.startsWith(projectRoot.path), which can produce false positives (e.g./workspace-foomatches/workspace) and won’t handle case-insensitive filesystems correctly. UseisEqualOrParent(item.uri, projectRoot)(or equivalent URI-aware helper) instead of string prefix matching.
const projectRoot = this._workspaceService.getActiveProjectRoot();
result = result.filter(item => {
if (item.uri.scheme !== Schemas.file || !projectRoot || !item.uri.path.startsWith(projectRoot.path)) {
return true;
}
if (matchesWorkspaceSubpath(item.uri.path, subpaths)) {
src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts:835
- Copy-path actions are now shown unconditionally. Some customization items can have non-
file:URIs (e.g.untitled:prompt models fromgetPromptSlashCommands), whereuri.fsPathis not a real filesystem path and copying it is misleading. Consider only adding these actions whenitem.uri.scheme === Schemas.file(or otherwise adjust the label/value for non-file URIs).
// Add copy path actions
const copyActions = [
new Separator(),
new Action('copyFullPath', localize('copyFullPath', "Copy Full Path"), undefined, true, async () => {
await this.clipboardService.writeText(item.uri.fsPath);
}),
src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts:131
- In this fixture,
PromptsServiceCustomizationProvideris created and captured on the harness descriptor but never disposed. Since it registers event listeners onIPromptsService, this can leak across fixture runs. Consider adding the provider toctx.disposableStore(or returning anICustomizationHarnessServicethat disposes its provider) so it’s cleaned up with the rest of the fixture services.
function createMockHarnessService(promptsService: IPromptsService, workspaceService: IAICustomizationWorkspaceService): ICustomizationHarnessService {
const baseDescriptor = createVSCodeHarnessDescriptor([PromptsStorage.extension]);
const provider = new PromptsServiceCustomizationProvider(
baseDescriptor, promptsService, workspaceService, new class extends mock<IProductService>() { }(),
);
const descriptor: IHarnessDescriptor = { ...baseDescriptor, itemProvider: provider };
return new class extends mock<ICustomizationHarnessService>() {
override readonly activeHarness = observableValue<string>('activeHarness', CustomizationHarness.VSCode);
override readonly availableHarnesses = observableValue<readonly IHarnessDescriptor[]>('harnesses', [descriptor]);
override getStorageSourceFilter() { return defaultFilter; }
override getActiveDescriptor() { return descriptor; }
override registerExternalHarness() { return { dispose() { } }; }
}();
- Files reviewed: 8/8 changed files
- Comments generated: 5
| // Agent-frontmatter hooks (not in sessions window) | ||
| if (!this._workspaceService.isSessionsWindow) { | ||
| for (const agent of agents ?? []) { | ||
| if (!agent.hooks) { | ||
| continue; | ||
| } | ||
| for (const hookType of Object.values(HookType)) { | ||
| const hookCommands = agent.hooks[hookType]; | ||
| if (!hookCommands || hookCommands.length === 0) { | ||
| continue; | ||
| } | ||
| const hookMeta = HOOK_METADATA[hookType]; | ||
| for (let i = 0; i < hookCommands.length; i++) { | ||
| const hook = hookCommands[i]; | ||
| const cmdLabel = formatHookCommandLabel(hook, OS); | ||
| const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; | ||
| items.push({ | ||
| uri: agent.uri, | ||
| type: PromptsType.hook, | ||
| name: hookMeta?.label ?? hookType, | ||
| description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, | ||
| groupKey: 'agents', | ||
| enabled: !disabledHooks.has(agent.uri), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
provideChatSessionCustomizations emits multiple agent-frontmatter hook items that all share the same uri (agent.uri) and type: PromptsType.hook. In the list widget, the hooks provider path expands hook items and uses item.uri.toString() as the fallback id when parsing fails; this will create duplicate IDs for these agent hooks and can break list rendering/selection (and also triggers repeated file reads of the agent file). Consider either (a) updating the widget’s hook-expansion logic to bypass parsing and generate unique IDs for items with groupKey: 'agents', or (b) changing the provider/widget contract so already-expanded hook items can flow through without being treated as hook files.
This issue also appears on line 308 of the same file.
| // --- Post-processing: storage source filter --- | ||
| const filteredItems = this._applyFilters(items); | ||
|
|
||
| return filteredItems; | ||
| } |
There was a problem hiding this comment.
The comment says “Post-processing: storage source filter”, but _applyFilters only applies workspaceSubpaths / instructionFileFilter checks and does not apply getStorageSourceFilter(...) / applyStorageSourceFilter. Either update the comment or implement the storage source filtering so harness-specific user-root restrictions are still respected through the provider path.
| } | ||
|
|
||
| /** | ||
| /** |
There was a problem hiding this comment.
There’s an extra stray /** starting a nested JSDoc comment here, which will render oddly in generated docs and is likely accidental. Remove the duplicate opener so this is a single well-formed doc comment.
| /** |
| // Lazy provider: created on first call to avoid cyclic DI resolution. | ||
| let provider: PromptsServiceCustomizationProvider | undefined; | ||
| const lazyItemProvider: IExternalCustomizationItemProvider = { | ||
| get onDidChange() { | ||
| provider ??= instantiationService.createInstance(PromptsServiceCustomizationProvider, descriptor); | ||
| return provider.onDidChange; | ||
| }, | ||
| provideChatSessionCustomizations(token) { | ||
| provider ??= instantiationService.createInstance(PromptsServiceCustomizationProvider, descriptor); | ||
| return provider.provideChatSessionCustomizations(token); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
The lazily created PromptsServiceCustomizationProvider is never disposed. Since it’s a Disposable that registers listeners, it should be disposed when the harness service is disposed (e.g. keep a MutableDisposable/DisposableStore field and _register it, or override dispose() and dispose the created provider).
| // Lazy provider: created on first call to avoid cyclic DI resolution. | ||
| let provider: PromptsServiceCustomizationProvider | undefined; | ||
| const lazyItemProvider: IExternalCustomizationItemProvider = { | ||
| get onDidChange() { | ||
| provider ??= instantiationService.createInstance(PromptsServiceCustomizationProvider, descriptor); | ||
| return provider.onDidChange; | ||
| }, | ||
| provideChatSessionCustomizations(token) { | ||
| provider ??= instantiationService.createInstance(PromptsServiceCustomizationProvider, descriptor); | ||
| return provider.provideChatSessionCustomizations(token); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Same as core: the lazily created PromptsServiceCustomizationProvider is never disposed, even though it registers listeners. Consider disposing it from the harness service lifecycle (e.g. via _register/DisposableStore on the service) so it can be cleaned up when sessions window shuts down or tests dispose the service.
77c94a6 to
909441a
Compare
There was a problem hiding this comment.
Pull request overview
This PR refactors the AI Customizations management list to load items through a small browser-internal “item source” abstraction, unifying the promptsService-backed (local/static) path and extension-provider path behind a normalized list-item model.
Changes:
- Introduces a provider-shaped adapter for promptsService (
PromptsServiceCustomizationItemProvider) and a browser item source (ProviderCustomizationItemSource) that merges remote + local syncable items. - Centralizes provider-item → UI-item conversion in
AICustomizationItemNormalizerand moves the list-item model to its own module. - Updates the list widget to consume the item source instead of branching between provider vs core promptsService code paths.
Show a summary per file
| File | Description |
|---|---|
| src/vs/workbench/contrib/chat/browser/aiCustomization/providerCustomizationItemSource.ts | New item source that fetches provider items, expands hook items, and optionally blends local syncable items. |
| src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts | New adapter that exposes promptsService discovery/filtering as provider-shaped items. |
| src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts | Refactors list loading to select an item source + normalizer; removes large inline fetching/filtering logic. |
| src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListItem.ts | New module for the browser list item model + item source interface. |
| src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSourceUtils.ts | New helper for deriving friendly names from filenames. |
| src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemNormalizer.ts | New normalizer to infer storage/grouping metadata and build UI list items from provider-shaped rows. |
| src/vs/sessions/AI_CUSTOMIZATIONS.md | Updates architecture docs to describe the new list discovery/normalization pipeline. |
Copilot's findings
Comments suppressed due to low confidence (1)
src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts:261
- The instructions path also ignores the CancellationToken by calling
getInstructionFiles(CancellationToken.None)andlistAgentInstructions(CancellationToken.None, ...). Please propagate the token fromprovideCustomizations()intofetchPromptServiceInstructionsso refreshes can be cancelled consistently.
private async fetchPromptServiceInstructions(items: IPromptsServiceCustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, disabledUris: ResourceSet, promptType: PromptsType): Promise<void> {
const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None);
for (const file of instructionFiles) {
if (file.extension) {
extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName });
}
}
const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined);
const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri));
- Files reviewed: 7/7 changed files
- Comments generated: 4
| private async fetchPromptServiceHooks(items: IPromptsServiceCustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise<void> { | ||
| const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); | ||
| const activeRoot = this.workspaceService.getActiveProjectRoot(); | ||
| const userHomeUri = await this.pathService.userHome(); | ||
| const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; |
There was a problem hiding this comment.
provideCustomizations() accepts a CancellationToken, but the hook-fetching path ignores it (e.g. listPromptFiles(..., CancellationToken.None), getCustomAgents(CancellationToken.None) and fileService.readFile(...) without passing the token). This can make the list refresh work uncancellable and potentially slower when switching sections/harnesses. Thread the token into fetchPromptServiceHooks and pass it through to promptsService/fileService calls (IFileService.readFile supports a token as the 3rd arg).
This issue also appears on line 253 of the same file.
| const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); | ||
|
|
||
| if (promptType === PromptsType.agent) { | ||
| const agents = await this.promptsService.getCustomAgents(token); | ||
| const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, token); | ||
| for (const file of allAgentFiles) { | ||
| if (file.extension) { | ||
| extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); |
There was a problem hiding this comment.
extensionInfoByUri stores { id, displayName }, but displayName is never used (only id is consulted for built-in grouping). This likely regresses the tooltip/source labeling from a friendly extension display name to a raw inferred id. Either drop displayName from the map to avoid dead data, or preferably plumb the display name through to the list items (e.g. by adding an optional extension label field to the provider item shape and having the normalizer prefer it when present).
| const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); | |
| if (promptType === PromptsType.agent) { | |
| const agents = await this.promptsService.getCustomAgents(token); | |
| const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, token); | |
| for (const file of allAgentFiles) { | |
| if (file.extension) { | |
| extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); | |
| const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier }>(); | |
| if (promptType === PromptsType.agent) { | |
| const agents = await this.promptsService.getCustomAgents(token); | |
| const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, token); | |
| for (const file of allAgentFiles) { | |
| if (file.extension) { | |
| extensionInfoByUri.set(file.uri, { id: file.extension.identifier }); |
| private groupMatchedItems(matchedItems: IAICustomizationListItem[]): void { | ||
| const activeDescriptor = this.harnessService.getActiveDescriptor(); | ||
|
|
||
| if (activeDescriptor.syncProvider) { |
There was a problem hiding this comment.
In groupMatchedItems, the non-sync grouping relies on a predefined groups list. Right now, hook items with groupKey: 'agents' (agent-frontmatter hooks) and instruction items that normalize to storage/groupKey === PromptsStorage.extension will fall into the dynamically-created fallback group, losing the intended labels/icons/descriptions (and e.g. showing "Extension" instead of "Extensions"). Consider adding explicit groups for agents (Hooks section) and PromptsStorage.extension (Instructions section) so these items render consistently with the previous core grouping.
| readonly name: string; | ||
| readonly filename: string; | ||
| readonly description?: string; | ||
| /** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */ |
There was a problem hiding this comment.
The doc comment on storage?: PromptsStorage says it is "set by core when items come from promptsService; omitted for external provider items", but with the new normalization pipeline AICustomizationItemNormalizer infers storage for provider items as well (workspace/user/plugin/extension/built-in). Please update this comment to reflect the current behavior (or make storage truly optional for provider-sourced items).
| /** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */ | |
| /** Storage origin, populated by core when it can be inferred, including normalized provider items. */ |
909441a to
bad11e6
Compare
|
resuming this in another change |
Summary
Route the AI Customizations list widget through a small browser-internal item-source abstraction instead of branching directly between the promptsService path and extension-provider path.
This keeps Local/static harnesses on the existing rich promptsService loading pipeline (storage filtering, plugin metadata, hook parsing, built-in grouping, etc.) while adapting extension-contributed providers into the same list-item source shape.
Resolves #309627
Notes
Changes
Commit 1: Extract AI customization item sources
Introduces a pipeline architecture where both data sources converge on
IExternalCustomizationItemearly:New files:
aiCustomizationListItem.ts,aiCustomizationItemSourceUtils.ts,aiCustomizationItemNormalizer.ts,promptsServiceCustomizationItemProvider.ts,providerCustomizationItemSource.tsCommit 2: Clean up extraction
Council-review-driven cleanup:
isChatExtensionItem()and hook expansion logic into shared utilsstorageToIcon()to icons file (pure function, retainsPromptsStoragetype safety).filter()chainsProviderCustomizationItemSourceper active harness descriptor.find()Validation