Skip to content

chat: route AI customization list through item sources#308626

Closed
joshspicer wants to merge 1 commit intomainfrom
josh/refactor-2
Closed

chat: route AI customization list through item sources#308626
joshspicer wants to merge 1 commit intomainfrom
josh/refactor-2

Conversation

@joshspicer
Copy link
Copy Markdown
Member

@joshspicer joshspicer commented Apr 8, 2026

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

  • No promptsService provider is attached to static harness descriptors.
  • No lazy provider/proxy is used.
  • The harness service remains descriptor/state-only and does not depend on IPromptsService.
  • promptsService change subscriptions are owned by the widget's Local item source.

Changes

Commit 1: Extract AI customization item sources

Introduces a pipeline architecture where both data sources converge on IExternalCustomizationItem early:

promptsService ──→ PromptsServiceCustomizationItemProvider ──→ IExternalCustomizationItem[]
                                                                        │
External Provider ────────────────────────────────────────→ IExternalCustomizationItem[]
                                                                        │
                                                                        ▼
                                                  ProviderCustomizationItemSource → AICustomizationItemNormalizer → Widget

New files: aiCustomizationListItem.ts, aiCustomizationItemSourceUtils.ts, aiCustomizationItemNormalizer.ts, promptsServiceCustomizationItemProvider.ts, providerCustomizationItemSource.ts

Commit 2: Clean up extraction

Council-review-driven cleanup:

  • Deduplicate isChatExtensionItem() and hook expansion logic into shared utils
  • Move storageToIcon() to icons file (pure function, retains PromptsStorage type safety)
  • Replace fragile backward-index splice loops with .filter() chains
  • Cache ProviderCustomizationItemSource per active harness descriptor
  • Use O(n) Map lookup for hook storage recovery instead of O(n²) .find()

Validation

  • VS Code - Build watch: clean
  • npm run precommit: passed
  • npm run compile-check-ts-native: clean (only pre-existing errors in copilotAgent.ts)

Copilot AI review requested due to automatic review settings April 8, 2026 22:15
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 8, 2026

Screenshot Changes

Base: 46933de5 Current: 60298d83

Changed (1)

agentSessionsViewer/ApprovalRowLongLabel/Dark
Before After
before after

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PromptsServiceCustomizationProvider implementing IExternalCustomizationItemProvider to surface agents/skills/instructions/prompts/hooks through the provider pipeline.
  • Simplifies AICustomizationListWidget by removing the legacy “core” fetching/filtering path and routing all harnesses through fetchItemsFromProvider.
  • 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-foo matches /workspace) and won’t handle case-insensitive filesystems correctly. Use isEqualOrParent(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 from getPromptSlashCommands), where uri.fsPath is not a real filesystem path and copying it is misleading. Consider only adding these actions when item.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, PromptsServiceCustomizationProvider is created and captured on the harness descriptor but never disposed. Since it registers event listeners on IPromptsService, this can leak across fixture runs. Consider adding the provider to ctx.disposableStore (or returning an ICustomizationHarnessService that 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

Comment on lines +189 to +216
// 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),
});
}
}
}
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +276 to +280
// --- Post-processing: storage source filter ---
const filteredItems = this._applyFilters(items);

return filteredItems;
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}

/**
/**
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/**

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +51
// 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);
},
};
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +51
// 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);
},
};
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@joshspicer joshspicer changed the title chat: unify customization item paths via PromptsServiceCustomizationProvider chat: route AI customization list through item sources Apr 8, 2026
@joshspicer joshspicer force-pushed the josh/refactor-2 branch 5 times, most recently from 77c94a6 to 909441a Compare April 9, 2026 00:47
@joshspicer joshspicer requested a review from Copilot April 9, 2026 00:57
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AICustomizationItemNormalizer and 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) and listAgentInstructions(CancellationToken.None, ...). Please propagate the token from provideCustomizations() into fetchPromptServiceInstructions so 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

Comment on lines +166 to +170
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;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +78
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 });
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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 });

Copilot uses AI. Check for mistakes.
Comment on lines +1256 to 1259
private groupMatchedItems(matchedItems: IAICustomizationListItem[]): void {
const activeDescriptor = this.harnessService.getActiveDescriptor();

if (activeDescriptor.syncProvider) {
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
readonly name: string;
readonly filename: string;
readonly description?: string;
/** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
/** 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. */

Copilot uses AI. Check for mistakes.
@joshspicer
Copy link
Copy Markdown
Member Author

resuming this in another change

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI Customization: Unify item discovery through provider abstraction & cut over sessions

2 participants