chat customizations: component fixtures, developer skill, spec updates (#303309)

* component explorer fixture for chat customization tabs

* chat customizations: full editor fixture + developer skill

* Refine AI customization management editor fixtures

* fix: update DOM element creation to use shorthand syntax

---------

Co-authored-by: Martin Aeschlimann <martinae@microsoft.com>
This commit is contained in:
Josh Spicer
2026-03-19 17:37:36 -07:00
committed by GitHub
parent 6cf4072494
commit 9541d49335
5 changed files with 506 additions and 55 deletions

View File

@@ -0,0 +1,42 @@
---
name: chat-customizations-editor
description: Use when working on the Chat Customizations editor — the management UI for agents, skills, instructions, hooks, prompts, MCP servers, and plugins.
---
# Chat Customizations Editor
Split-view management pane for AI customization items across workspace, user, extension, and plugin storage. Supports harness-based filtering (Local, Copilot CLI, Claude).
## Spec
**`src/vs/sessions/AI_CUSTOMIZATIONS.md`** — always read before making changes, always update after.
## Key Folders
| Folder | What |
|--------|------|
| `src/vs/workbench/contrib/chat/common/` | `ICustomizationHarnessService`, `ISectionOverride`, `IStorageSourceFilter` — shared interfaces and filter helpers |
| `src/vs/workbench/contrib/chat/browser/aiCustomization/` | Management editor, list widgets (prompts, MCP, plugins), harness service registration |
| `src/vs/sessions/contrib/chat/browser/` | Sessions-window overrides (harness service, workspace service) |
| `src/vs/sessions/contrib/sessions/browser/` | Sessions tree view counts and toolbar |
When changing harness descriptor interfaces or factory functions, verify both core and sessions registrations compile.
## Key Interfaces
- **`IHarnessDescriptor`** — drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference.
- **`ISectionOverride`** — per-section button customization (command invocation, root file creation, type labels, file extensions).
- **`IStorageSourceFilter`** — controls which storage sources and user roots are visible per harness/type.
Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code.
## Testing
Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`.
```bash
./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts"
npm run compile-check-ts-native && npm run valid-layers-check
```
See the `sessions` skill for sessions-window specific guidance.

View File

@@ -15,18 +15,21 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/
├── aiCustomizationManagementEditor.ts # SplitView list/editor
├── aiCustomizationManagementEditorInput.ts # Singleton input
├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle
├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.)
├── aiCustomizationDebugPanel.ts # Debug diagnostics panel
├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl
├── customizationHarnessService.ts # Core harness service impl (VS Code harness)
├── customizationHarnessService.ts # Core harness service impl (agent-gated)
├── customizationCreatorService.ts # AI-guided creation flow
├── mcpListWidget.ts # MCP servers section
├── customizationGroupHeaderRenderer.ts # Collapsible group header renderer
├── mcpListWidget.ts # MCP servers section (Extensions + Built-in groups)
├── pluginListWidget.ts # Agent plugins section
├── aiCustomizationIcons.ts # Icons
└── media/
└── aiCustomizationManagement.css
src/vs/workbench/contrib/chat/common/
├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter
└── customizationHarnessService.ts # ICustomizationHarnessService + CustomizationHarness enum
└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers
```
The tree view and overview live in `vs/sessions` (sessions window only):
@@ -46,9 +49,10 @@ Sessions-specific overrides:
```
src/vs/sessions/contrib/chat/browser/
├── aiCustomizationWorkspaceService.ts # Sessions workspace service override
├── customizationHarnessService.ts # Sessions harness service (CLI + Claude harnesses)
├── customizationHarnessService.ts # Sessions harness service (CLI harness only)
└── promptsService.ts # AgenticPromptsService (CLI user roots)
src/vs/sessions/contrib/sessions/browser/
├── aiCustomizationShortcutsWidget.ts # Shortcuts widget
├── customizationCounts.ts # Source count utilities (type-aware)
└── customizationsToolbar.contribution.ts # Sidebar customization links
```
@@ -59,7 +63,7 @@ The `IAICustomizationWorkspaceService` interface controls per-window behavior:
| Property / Method | Core VS Code | Sessions Window |
|----------|-------------|----------|
| `managementSections` | All sections except Models | Same minus MCP |
| `managementSections` | All sections except Models | All sections except Models |
| `getStorageSourceFilter(type)` | Delegates to `ICustomizationHarnessService` | Delegates to `ICustomizationHarnessService` |
| `isSessionsWindow` | `false` | `true` |
| `activeProjectRoot` | First workspace folder | Active session worktree |
@@ -71,19 +75,34 @@ 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.
- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor` — parameterized by an `extras` array (the additional storage sources beyond `local`, `user`, `plugin`). Core passes `[PromptsStorage.extension]`; sessions passes `[BUILTIN_STORAGE]`.
- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge so it isn't duplicated.
- **`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]`.
- **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.
Available harnesses:
| Harness | Label | Description |
|---------|-------|-------------|
| `vscode` | VS Code | Shows all storage sources (default in core) |
| `vscode` | Local | Shows all storage sources (default in core) |
| `cli` | Copilot CLI | Restricts user roots to `~/.copilot`, `~/.claude`, `~/.agents` |
| `claude` | Claude | Restricts user roots to `~/.claude` |
| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections |
In core VS Code, all three harnesses are registered; VS Code is the default.
In sessions, `cli` and `claude` harnesses are registered with a toggle bar above the list.
In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default.
In sessions, only CLI is registered (single harness, toggle bar hidden).
### IHarnessDescriptor
Key properties on the harness descriptor:
| Property | Purpose |
|----------|--------|
| `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) |
| `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) |
| `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" |
| `sectionOverrides` | Per-section `ISectionOverride` map for button behavior |
| `requiredAgentId` | Agent ID that must be registered for harness to appear |
| `instructionFileFilter` | Filename/path patterns to filter instruction items |
### IStorageSourceFilter
@@ -99,9 +118,7 @@ interface IStorageSourceFilter {
The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array.
**Sessions filter behavior by harness and type:**
CLI harness:
**Sessions filter behavior (CLI harness):**
| Type | sources | includedUserFileRoots |
|------|---------|----------------------|
@@ -109,15 +126,31 @@ CLI harness:
| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) |
| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.copilot, ~/.claude, ~/.agents]` |
Claude harness:
**Core VS Code filter behavior:**
Local harness: all types use `[local, user, extension, plugin]` with no user root filter.
CLI harness (core):
| Type | sources | includedUserFileRoots |
|------|---------|----------------------|
| Hooks | `[local, plugin]` | N/A |
| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) |
| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.claude]` |
| Prompts | `[local, user, plugin]` | `undefined` (all roots) |
| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.copilot, ~/.claude, ~/.agents]` |
**Core VS Code:** All types use `[local, user, extension, plugin]` with no user root filter.
Claude harness (core):
| Type | sources | includedUserFileRoots |
|------|---------|----------------------|
| Hooks | `[local, plugin]` | N/A |
| Prompts | `[local, user, plugin]` | `undefined` (all roots) |
| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.claude]` |
Claude additionally applies:
- `hiddenSections: [Prompts, Plugins]`
- `instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md']`
- `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
### AgenticPromptsService (Sessions)
@@ -149,7 +182,7 @@ Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. T
| Skills | `findAgentSkills()` | Parsed skills with frontmatter |
| Prompts | `getPromptSlashCommands()` | Filters out skill-type commands |
| Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. |
| Hooks | `listPromptFiles()` | Raw hook files |
| Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` |
### Debug Panel
@@ -175,8 +208,9 @@ All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizatio
## Settings
Settings use the `chat.customizationsMenu.` namespace:
Settings use the `chat.customizationsMenu.` and `chat.customizations.` namespaces:
| Setting | Default | Description |
|---------|---------|-------------|
| `chat.customizationsMenu.enabled` | `true` | Show the Chat Customizations editor in the Command Palette |
| `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar |

View File

@@ -74,6 +74,8 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js';
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
import { Action } from '../../../../../base/common/actions.js';
import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js';
import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js';
import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js';
@@ -331,6 +333,7 @@ export class AICustomizationManagementEditor extends EditorPane {
@IHoverService private readonly hoverService: IHoverService,
@IModelService private readonly modelService: IModelService,
@IQuickInputService private readonly quickInputService: IQuickInputService,
@IContextMenuService private readonly contextMenuService: IContextMenuService,
@IFileService private readonly fileService: IFileService,
@INotificationService private readonly notificationService: INotificationService,
@ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService,
@@ -606,7 +609,7 @@ export class AICustomizationManagementEditor extends EditorPane {
this.updateHarnessDropdown();
this.editorDisposables.add(DOM.addDisposableListener(this.harnessDropdownButton, 'click', () => {
this.showHarnessPicker();
this.showHarnessMenu();
}));
}
@@ -627,31 +630,26 @@ export class AICustomizationManagementEditor extends EditorPane {
}
}
private showHarnessPicker(): void {
private showHarnessMenu(): void {
if (!this.harnessDropdownButton) {
return;
}
const harnesses = this.harnessService.availableHarnesses.get();
const activeId = this.harnessService.activeHarness.get();
const items = harnesses.map(h => ({
label: h.label,
iconClass: ThemeIcon.asClassName(h.icon),
id: h.id,
picked: h.id === activeId,
}));
const picker = this.quickInputService.createQuickPick();
picker.items = items;
picker.placeholder = localize('selectTarget', "Select customization target");
picker.canSelectMany = false;
picker.activeItems = items.filter(i => i.picked);
picker.onDidAccept(() => {
const selected = picker.activeItems[0] as typeof items[0] | undefined;
if (selected) {
this.harnessService.setActiveHarness(selected.id);
}
picker.dispose();
const actions = harnesses.map(h => {
const action = new Action(h.id, h.label, ThemeIcon.asClassName(h.icon), true, () => {
this.harnessService.setActiveHarness(h.id);
});
action.checked = h.id === activeId;
return action;
});
this.contextMenuService.showContextMenu({
getAnchor: () => this.harnessDropdownButton!,
getActions: () => actions,
getCheckedActionsRepresentation: () => 'radio',
});
picker.onDidHide(() => picker.dispose());
picker.show();
}
private createFolderPicker(sidebarContent: HTMLElement): void {

View File

@@ -5,7 +5,7 @@
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Event } from '../../../../base/common/event.js';
import { ResourceSet } from '../../../../base/common/map.js';
import { ResourceMap, ResourceSet } from '../../../../base/common/map.js';
import { observableValue } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { mock } from '../../../../base/test/common/mock.js';
@@ -17,7 +17,7 @@ import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../..
import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../contrib/chat/common/customizationHarnessService.js';
import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js';
import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js';
import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath, IExtensionPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js';
import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js';
import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js';
import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js';
import { IPathService } from '../../../services/path/common/pathService.js';
@@ -33,16 +33,28 @@ import '../../../../platform/theme/common/colors/listColors.js';
// ============================================================================
const defaultFilter: IStorageSourceFilter = {
sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension],
sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin],
};
interface IFixtureInstructionFile {
readonly promptPath: IPromptPath;
readonly uri: URI;
readonly storage: PromptsStorage;
readonly type: PromptsType;
readonly name?: string;
readonly description?: string;
/** If set, this instruction file has an applyTo pattern (on-demand). */
readonly applyTo?: string;
}
function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): IPromptsService {
// Build a map from URI to applyTo for parseNew
const applyToMap = new ResourceMap<string | undefined>();
const descriptionMap = new ResourceMap<string | undefined>();
for (const file of instructionFiles) {
applyToMap.set(file.uri, file.applyTo);
descriptionMap.set(file.uri, file.description);
}
return new class extends mock<IPromptsService>() {
override readonly onDidChangeCustomAgents = Event.None;
override readonly onDidChangeSlashCommands = Event.None;
@@ -50,14 +62,26 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a
override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); }
override async listPromptFiles(type: PromptsType) {
if (type === PromptsType.instructions) {
return instructionFiles.map(f => f.promptPath);
return instructionFiles.map(f => ({
uri: f.uri,
storage: f.storage as PromptsStorage.local,
type: f.type,
name: f.name,
description: f.description,
}));
}
return [];
}
override async listAgentInstructions() { return agentInstructionFiles; }
override async getCustomAgents() { return []; }
override async parseNew(uri: URI, _token: CancellationToken): Promise<ParsedPromptFile> {
return new ParsedPromptFile(uri);
const applyTo = applyToMap.get(uri);
const description = descriptionMap.get(uri);
const header = {
get applyTo() { return applyTo; },
get description() { return description; },
};
return new ParsedPromptFile(uri, header as never);
}
}();
}
@@ -154,20 +178,20 @@ async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFi
// Fixtures
// ============================================================================
export default defineThemedFixtureGroup({ path: 'chat/' }, {
export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, {
InstructionsTabWithItems: defineComponentFixture({
labels: { kind: 'screenshot' },
render: ctx => renderInstructionsTab(ctx, [
// Always-active instructions (no applyTo)
{ promptPath: { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' } },
{ promptPath: { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style preferences' } },
{ uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' },
{ uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style preferences' },
// Always-included instruction (applyTo: **)
{ promptPath: { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'General Guidelines', description: 'General development guidelines' }, applyTo: '**' },
{ uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'General Guidelines', description: 'General development guidelines', applyTo: '**' },
// On-demand instructions (with applyTo pattern)
{ promptPath: { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing Guidelines', description: 'Testing best practices' }, applyTo: '**/*.test.ts' },
{ promptPath: { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security Review', description: 'Security review checklist' }, applyTo: 'src/auth/**' },
{ promptPath: { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, name: 'TypeScript Rules', description: 'TypeScript conventions', extension: undefined!, source: undefined! } satisfies IExtensionPromptPath, applyTo: '**/*.ts' },
{ uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing Guidelines', description: 'Testing best practices', applyTo: '**/*.test.ts' },
{ uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security Review', description: 'Security review checklist', applyTo: 'src/auth/**' },
{ uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Typescript Rules', description: 'TypeScript conventions', applyTo: '**/*.ts' },
], [
// Agent instruction files (AGENTS.md, copilot-instructions.md)
{ uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd },

View File

@@ -0,0 +1,353 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as DOM from '../../../../base/browser/dom.js';
import { Dimension } from '../../../../base/browser/dom.js';
import { IRenderedMarkdown } from '../../../../base/browser/markdownRenderer.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Event } from '../../../../base/common/event.js';
import { ResourceMap, ResourceSet } from '../../../../base/common/map.js';
import { constObservable, observableValue } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { mock } from '../../../../base/test/common/mock.js';
import { ITextModelService } from '../../../../editor/common/services/resolverService.js';
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IListService, ListService } from '../../../../platform/list/browser/listService.js';
import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';
import { IRequestService } from '../../../../platform/request/common/request.js';
import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';
import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
import { IPathService } from '../../../services/path/common/pathService.js';
import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js';
import { IWebviewService } from '../../../contrib/webview/browser/webview.js';
import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js';
import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createClaudeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots, getClaudeUserRoots } from '../../../contrib/chat/common/customizationHarnessService.js';
import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js';
import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js';
import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js';
import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js';
import { IPluginMarketplaceService } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js';
import { IPluginInstallService } from '../../../contrib/chat/common/plugins/pluginInstallService.js';
import { AICustomizationManagementEditor } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js';
import { AICustomizationManagementEditorInput } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js';
import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../contrib/mcp/common/mcpTypes.js';
import { IMcpRegistry } from '../../../contrib/mcp/common/mcpRegistryTypes.js';
import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js';
import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js';
// Ensure theme colors & widget CSS are loaded
import '../../../../platform/theme/common/colors/inputColors.js';
import '../../../../platform/theme/common/colors/listColors.js';
import '../../../contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css';
// ============================================================================
// Mock helpers
// ============================================================================
const userHome = URI.file('/home/dev');
const BUILTIN_STORAGE = 'builtin';
interface IFixtureFile {
readonly uri: URI;
readonly storage: PromptsStorage;
readonly type: PromptsType;
readonly name?: string;
readonly description?: string;
readonly applyTo?: string;
}
function createMockEditorGroup(): IEditorGroup {
return new class extends mock<IEditorGroup>() {
override windowId = mainWindow.vscodeWindowId;
}();
}
function createMockPromptsService(files: IFixtureFile[], agentInstructions: IResolvedAgentFile[]): IPromptsService {
const applyToMap = new ResourceMap<string | undefined>();
const descriptionMap = new ResourceMap<string | undefined>();
for (const f of files) { applyToMap.set(f.uri, f.applyTo); descriptionMap.set(f.uri, f.description); }
return new class extends mock<IPromptsService>() {
override readonly onDidChangeCustomAgents = Event.None;
override readonly onDidChangeSlashCommands = Event.None;
override readonly onDidChangeSkills = Event.None;
override readonly onDidChangeInstructions = Event.None;
override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); }
override async listPromptFiles(type: PromptsType) {
return files.filter(f => f.type === type).map(f => ({
uri: f.uri, storage: f.storage as PromptsStorage.local, type: f.type, name: f.name, description: f.description,
}));
}
override async listAgentInstructions() { return agentInstructions; }
override async getCustomAgents() {
return files.filter(f => f.type === PromptsType.agent).map(a => ({
uri: a.uri, name: a.name ?? 'agent', description: a.description, storage: a.storage,
source: { storage: a.storage },
})) as never[];
}
override async parseNew(uri: URI, _token: CancellationToken): Promise<ParsedPromptFile> {
const header = {
get applyTo() { return applyToMap.get(uri); },
get description() { return descriptionMap.get(uri); },
};
return new ParsedPromptFile(uri, header as never);
}
override async getSourceFolders() { return [] as never[]; }
override async findAgentSkills() { return [] as never[]; }
override async getPromptSlashCommands() { return [] as never[]; }
}();
}
function createMockHarnessService(activeHarness: CustomizationHarness, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService {
const active = observableValue('activeHarness', activeHarness);
return new class extends mock<ICustomizationHarnessService>() {
override readonly activeHarness = active;
override readonly availableHarnesses = constObservable(descriptors);
override getStorageSourceFilter(type: PromptsType) {
const d = descriptors.find(h => h.id === active.get()) ?? descriptors[0];
return d.getStorageSourceFilter(type);
}
override getActiveDescriptor() {
return descriptors.find(h => h.id === active.get()) ?? descriptors[0];
}
override setActiveHarness(id: CustomizationHarness) { active.set(id, undefined); }
}();
}
function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScope, description?: string): IWorkbenchMcpServer {
return new class extends mock<IWorkbenchMcpServer>() {
override readonly id = id;
override readonly name = id;
override readonly label = label;
override readonly description = description ?? '';
override readonly installState = McpServerInstallState.Installed;
override readonly local = new class extends mock<IWorkbenchLocalMcpServer>() {
override readonly id = id;
override readonly scope = scope;
}();
}();
}
// ============================================================================
// Realistic test data — a project that has Copilot + Claude customizations
// ============================================================================
const allFiles: IFixtureFile[] = [
// Copilot instructions
{ uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' },
{ uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' },
{ uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' },
// Claude rules
{ uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' },
{ uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' },
{ uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' },
// Agents
{ uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' },
{ uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' },
{ uri: URI.file('/workspace/.claude/agents/planner.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' },
// Skills
{ uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' },
{ uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' },
// Prompts
{ uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' },
{ uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' },
];
const agentInstructions: IResolvedAgentFile[] = [
{ uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd },
{ uri: URI.file('/workspace/CLAUDE.md'), realPath: undefined, type: AgentFileType.claudeMd },
{ uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd },
];
const mcpWorkspaceServers = [
makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'),
makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'),
];
const mcpUserServers = [
makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'),
];
const mcpRuntimeServers = [
{ definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) },
];
interface IRenderEditorOptions {
readonly harness: CustomizationHarness;
readonly isSessionsWindow?: boolean;
readonly managementSections?: readonly AICustomizationManagementSection[];
readonly availableHarnesses?: readonly IHarnessDescriptor[];
}
// ============================================================================
// Render helper — creates the full management editor
// ============================================================================
async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditorOptions): Promise<void> {
const width = 900;
const height = 600;
ctx.container.style.width = `${width}px`;
ctx.container.style.height = `${height}px`;
const isSessionsWindow = options.isSessionsWindow ?? false;
const managementSections = options.managementSections ?? [
AICustomizationManagementSection.Agents,
AICustomizationManagementSection.Skills,
AICustomizationManagementSection.Instructions,
AICustomizationManagementSection.Hooks,
AICustomizationManagementSection.Prompts,
AICustomizationManagementSection.McpServers,
AICustomizationManagementSection.Plugins,
];
const availableHarnesses = options.availableHarnesses ?? [
createVSCodeHarnessDescriptor([PromptsStorage.extension]),
createCliHarnessDescriptor(getCliUserRoots(userHome), []),
createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), []),
];
const allMcpServers = [...mcpWorkspaceServers, ...mcpUserServers];
const instantiationService = createEditorServices(ctx.disposableStore, {
colorTheme: ctx.theme,
additionalServices: (reg) => {
const harnessService = createMockHarnessService(options.harness, availableHarnesses);
registerWorkbenchServices(reg);
reg.define(IListService, ListService);
reg.defineInstance(IPromptsService, createMockPromptsService(allFiles, agentInstructions));
reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock<IAICustomizationWorkspaceService>() {
override readonly isSessionsWindow = isSessionsWindow;
override readonly activeProjectRoot = observableValue('root', URI.file('/workspace'));
override readonly hasOverrideProjectRoot = observableValue('hasOverride', false);
override getActiveProjectRoot() { return URI.file('/workspace'); }
override getStorageSourceFilter(type: PromptsType) { return harnessService.getStorageSourceFilter(type); }
override clearOverrideProjectRoot() { }
override setOverrideProjectRoot() { }
override readonly managementSections = managementSections;
override async generateCustomization() { }
}());
reg.defineInstance(ICustomizationHarnessService, harnessService);
reg.defineInstance(IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() {
override readonly onDidChangeWorkspaceFolders = Event.None;
override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; }
override getWorkbenchState(): WorkbenchState { return WorkbenchState.WORKSPACE; }
}());
reg.defineInstance(IFileService, new class extends mock<IFileService>() {
override readonly onDidFilesChange = Event.None;
}());
reg.defineInstance(IPathService, new class extends mock<IPathService>() {
override readonly defaultUriScheme = 'file';
override userHome(): URI;
override userHome(): Promise<URI>;
override userHome(): URI | Promise<URI> { return userHome; }
}());
reg.defineInstance(ITextModelService, new class extends mock<ITextModelService>() { }());
reg.defineInstance(IWorkingCopyService, new class extends mock<IWorkingCopyService>() {
override readonly onDidChangeDirty = Event.None;
}());
reg.defineInstance(IFileDialogService, new class extends mock<IFileDialogService>() { }());
reg.defineInstance(IExtensionService, new class extends mock<IExtensionService>() { }());
reg.defineInstance(IQuickInputService, new class extends mock<IQuickInputService>() { }());
reg.defineInstance(IRequestService, new class extends mock<IRequestService>() { }());
reg.defineInstance(IMarkdownRendererService, new class extends mock<IMarkdownRendererService>() {
override render() {
const rendered: IRenderedMarkdown = {
element: DOM.$('span'),
dispose() { },
};
return rendered;
}
}());
reg.defineInstance(IWebviewService, new class extends mock<IWebviewService>() { }());
reg.defineInstance(IMcpWorkbenchService, new class extends mock<IMcpWorkbenchService>() {
override readonly onChange = Event.None;
override readonly onReset = Event.None;
override readonly local = allMcpServers;
override async queryLocal() { return allMcpServers; }
override canInstall() { return true as const; }
}());
reg.defineInstance(IMcpService, new class extends mock<IMcpService>() {
override readonly servers = constObservable(mcpRuntimeServers as never[]);
}());
reg.defineInstance(IMcpRegistry, new class extends mock<IMcpRegistry>() {
override readonly collections = constObservable([]);
override readonly delegates = constObservable([]);
override readonly onDidChangeInputs = Event.None;
}());
reg.defineInstance(IAgentPluginService, new class extends mock<IAgentPluginService>() {
override readonly plugins = constObservable([]);
override readonly enablementModel = undefined as never;
}());
reg.defineInstance(IPluginMarketplaceService, new class extends mock<IPluginMarketplaceService>() {
override readonly installedPlugins = constObservable([]);
override readonly onDidChangeMarketplaces = Event.None;
}());
reg.defineInstance(IPluginInstallService, new class extends mock<IPluginInstallService>() { }());
},
});
const editor = ctx.disposableStore.add(
instantiationService.createInstance(AICustomizationManagementEditor, createMockEditorGroup())
);
editor.create(ctx.container);
editor.layout(new Dimension(width, height));
// setInput may fail on unmocked service calls — catch to still show the editor shell
try {
await editor.setInput(AICustomizationManagementEditorInput.getOrCreate(), undefined, {}, CancellationToken.None);
} catch {
// Expected in fixture — some services are partially mocked
}
}
// ============================================================================
// Fixtures
// ============================================================================
export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, {
// Full editor with Local (VS Code) harness — all sections visible, harness dropdown,
// Generate buttons, AGENTS.md shortcut, all storage groups
LocalHarness: defineComponentFixture({
labels: { kind: 'screenshot' },
render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }),
}),
// Full editor with Copilot CLI harness — no prompts section, CLI-specific
// root files and instruction filtering under .github/.copilot paths.
CliHarness: defineComponentFixture({
labels: { kind: 'screenshot' },
render: ctx => renderEditor(ctx, { harness: CustomizationHarness.CLI }),
}),
// Full editor with Claude harness — Prompts+Plugins hidden, Agents visible,
// "Add CLAUDE.md" button, "New Rule" dropdown, instruction filtering, bridged MCP badge
ClaudeHarness: defineComponentFixture({
labels: { kind: 'screenshot' },
render: ctx => renderEditor(ctx, { harness: CustomizationHarness.Claude }),
}),
// Sessions-window variant of the full editor with workspace override UX
// and sessions section ordering.
Sessions: defineComponentFixture({
labels: { kind: 'screenshot' },
render: ctx => renderEditor(ctx, {
harness: CustomizationHarness.CLI,
isSessionsWindow: true,
availableHarnesses: [
createCliHarnessDescriptor(getCliUserRoots(userHome), [BUILTIN_STORAGE]),
],
managementSections: [
AICustomizationManagementSection.Agents,
AICustomizationManagementSection.Skills,
AICustomizationManagementSection.Instructions,
AICustomizationManagementSection.Prompts,
AICustomizationManagementSection.Hooks,
AICustomizationManagementSection.McpServers,
AICustomizationManagementSection.Plugins,
],
}),
}),
});