mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-24 18:49:00 +01:00
* Add telemetry to Chat Customizations editor Instrument 7 key user interactions in the AI Customization Management Editor with GDPR-compliant publicLog2 telemetry events: - chatCustomizationEditor.opened: tracks editor opens with initial section - chatCustomizationEditor.sectionChanged: tracks sidebar navigation - chatCustomizationEditor.itemSelected: tracks item selection with type/storage - chatCustomizationEditor.createItem: tracks AI-guided and manual creation - chatCustomizationEditor.saveItem: tracks save actions (builtin override + existing) - chatCustomizationEditor.deleteItem: tracks confirmed deletions - chatCustomizationEditor.search: tracks search usage with result counts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: improve search telemetry logging in Chat Customizations editor --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { Disposable } from '../../../../../base/common/lifecycle.js';
|
|
import { localize, localize2 } from '../../../../../nls.js';
|
|
import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js';
|
|
import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js';
|
|
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
|
|
import { Registry } from '../../../../../platform/registry/common/platform.js';
|
|
import { IEditorPaneRegistry, EditorPaneDescriptor } from '../../../../browser/editor.js';
|
|
import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js';
|
|
import { EditorInput } from '../../../../common/editor/editorInput.js';
|
|
import { IEditorService, MODAL_GROUP } from '../../../../services/editor/common/editorService.js';
|
|
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
|
|
import { CHAT_CATEGORY } from '../actions/chatActions.js';
|
|
import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js';
|
|
import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js';
|
|
import {
|
|
AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID,
|
|
AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID,
|
|
AICustomizationManagementCommands,
|
|
AICustomizationManagementItemMenuId,
|
|
AICustomizationManagementSection,
|
|
} from './aiCustomizationManagement.js';
|
|
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js';
|
|
import { Codicon } from '../../../../../base/common/codicons.js';
|
|
import { URI } from '../../../../../base/common/uri.js';
|
|
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
|
|
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
|
|
import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
|
|
import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js';
|
|
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
|
|
import { ChatConfiguration } from '../../common/constants.js';
|
|
import { IFileService } from '../../../../../platform/files/common/files.js';
|
|
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
|
|
import { basename, dirname } from '../../../../../base/common/resources.js';
|
|
import { Schemas } from '../../../../../base/common/network.js';
|
|
import { isWindows, isMacintosh } from '../../../../../base/common/platform.js';
|
|
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
|
|
|
|
//#region Telemetry
|
|
|
|
type CustomizationEditorDeleteItemEvent = {
|
|
promptType: string;
|
|
storage: string;
|
|
};
|
|
|
|
type CustomizationEditorDeleteItemClassification = {
|
|
promptType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of customization being deleted.' };
|
|
storage: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The storage location of the deleted item.' };
|
|
owner: 'joshspicer';
|
|
comment: 'Tracks item deletion in the Chat Customizations editor.';
|
|
};
|
|
|
|
//#endregion
|
|
|
|
//#region Editor Registration
|
|
|
|
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
|
|
EditorPaneDescriptor.create(
|
|
AICustomizationManagementEditor,
|
|
AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID,
|
|
localize('aiCustomizationManagementEditor', "Chat Customizations Editor")
|
|
),
|
|
[
|
|
// Note: Using the class directly since we use a singleton pattern
|
|
new SyncDescriptor(AICustomizationManagementEditorInput as unknown as { new(): AICustomizationManagementEditorInput })
|
|
]
|
|
);
|
|
|
|
//#endregion
|
|
|
|
//#region Editor Serializer
|
|
|
|
class AICustomizationManagementEditorInputSerializer implements IEditorSerializer {
|
|
|
|
canSerialize(editorInput: EditorInput): boolean {
|
|
return editorInput instanceof AICustomizationManagementEditorInput;
|
|
}
|
|
|
|
serialize(input: AICustomizationManagementEditorInput): string {
|
|
return '';
|
|
}
|
|
|
|
deserialize(instantiationService: IInstantiationService): AICustomizationManagementEditorInput {
|
|
return AICustomizationManagementEditorInput.getOrCreate();
|
|
}
|
|
}
|
|
|
|
Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer(
|
|
AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID,
|
|
AICustomizationManagementEditorInputSerializer
|
|
);
|
|
|
|
//#endregion
|
|
|
|
//#region Context Menu Actions
|
|
|
|
/**
|
|
* Type for context passed to actions from list context menus.
|
|
* Handles both direct URI arguments and serialized context objects.
|
|
*/
|
|
type AICustomizationContext = {
|
|
uri: URI | string;
|
|
name?: string;
|
|
promptType?: PromptsType;
|
|
storage?: PromptsStorage;
|
|
[key: string]: unknown;
|
|
} | URI | string;
|
|
|
|
/**
|
|
* Extracts a URI from various context formats.
|
|
*/
|
|
function extractURI(context: AICustomizationContext): URI {
|
|
if (URI.isUri(context)) {
|
|
return context;
|
|
}
|
|
if (typeof context === 'string') {
|
|
return URI.parse(context);
|
|
}
|
|
if (URI.isUri(context.uri)) {
|
|
return context.uri;
|
|
}
|
|
return URI.parse(context.uri as string);
|
|
}
|
|
|
|
/**
|
|
* Extracts storage type from context.
|
|
*/
|
|
function extractStorage(context: AICustomizationContext): PromptsStorage | undefined {
|
|
if (URI.isUri(context) || typeof context === 'string') {
|
|
return undefined;
|
|
}
|
|
return context.storage;
|
|
}
|
|
|
|
/**
|
|
* Extracts prompt type from context.
|
|
*/
|
|
function extractPromptType(context: AICustomizationContext): PromptsType | undefined {
|
|
if (URI.isUri(context) || typeof context === 'string') {
|
|
return undefined;
|
|
}
|
|
return context.promptType;
|
|
}
|
|
|
|
// Open file action
|
|
const OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID = 'aiCustomizationManagement.openFile';
|
|
registerAction2(class extends Action2 {
|
|
constructor() {
|
|
super({
|
|
id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID,
|
|
title: localize2('open', "Open"),
|
|
icon: Codicon.goToFile,
|
|
});
|
|
}
|
|
async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise<void> {
|
|
const editorService = accessor.get(IEditorService);
|
|
await editorService.openEditor({
|
|
resource: extractURI(context)
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// Run prompt action
|
|
const RUN_PROMPT_MGMT_ID = 'aiCustomizationManagement.runPrompt';
|
|
registerAction2(class extends Action2 {
|
|
constructor() {
|
|
super({
|
|
id: RUN_PROMPT_MGMT_ID,
|
|
title: localize2('runPrompt', "Run Prompt"),
|
|
icon: Codicon.play,
|
|
});
|
|
}
|
|
async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise<void> {
|
|
const commandService = accessor.get(ICommandService);
|
|
await commandService.executeCommand('workbench.action.chat.run.prompt.current', extractURI(context));
|
|
}
|
|
});
|
|
|
|
// Reveal in Finder/Explorer action
|
|
const REVEAL_IN_OS_LABEL = isWindows
|
|
? localize2('revealInWindows', "Reveal in File Explorer")
|
|
: isMacintosh
|
|
? localize2('revealInMac', "Reveal in Finder")
|
|
: localize2('openContainer', "Open Containing Folder");
|
|
|
|
const REVEAL_AI_CUSTOMIZATION_IN_OS_ID = 'aiCustomizationManagement.revealInOS';
|
|
registerAction2(class extends Action2 {
|
|
constructor() {
|
|
super({
|
|
id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID,
|
|
title: REVEAL_IN_OS_LABEL,
|
|
icon: Codicon.folderOpened,
|
|
});
|
|
}
|
|
async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise<void> {
|
|
const commandService = accessor.get(ICommandService);
|
|
const uri = extractURI(context);
|
|
// Use existing reveal command
|
|
await commandService.executeCommand('revealFileInOS', uri);
|
|
}
|
|
});
|
|
|
|
// Delete action
|
|
const DELETE_AI_CUSTOMIZATION_ID = 'aiCustomizationManagement.delete';
|
|
registerAction2(class extends Action2 {
|
|
constructor() {
|
|
super({
|
|
id: DELETE_AI_CUSTOMIZATION_ID,
|
|
title: localize2('delete', "Delete"),
|
|
icon: Codicon.trash,
|
|
});
|
|
}
|
|
async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise<void> {
|
|
const fileService = accessor.get(IFileService);
|
|
const dialogService = accessor.get(IDialogService);
|
|
|
|
const uri = extractURI(context);
|
|
const storage = extractStorage(context);
|
|
const promptType = extractPromptType(context);
|
|
const isSkill = promptType === PromptsType.skill;
|
|
// For skills, use the parent folder name since skills are structured as <skillname>/SKILL.md.
|
|
const fileName = isSkill ? basename(dirname(uri)) : basename(uri);
|
|
|
|
// Extension and plugin files cannot be deleted
|
|
if (storage === PromptsStorage.extension || storage === PromptsStorage.plugin) {
|
|
await dialogService.info(
|
|
localize('cannotDeleteExtension', "Cannot Delete Extension File"),
|
|
localize('cannotDeleteExtensionDetail', "Files provided by extensions cannot be deleted. You can disable the extension if you no longer want to use this customization.")
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Confirm deletion
|
|
const message = isSkill
|
|
? localize('confirmDeleteSkill', "Are you sure you want to delete skill '{0}' and its folder?", fileName)
|
|
: localize('confirmDelete', "Are you sure you want to delete '{0}'?", fileName);
|
|
const confirmation = await dialogService.confirm({
|
|
message,
|
|
detail: localize('confirmDeleteDetail', "This action cannot be undone."),
|
|
primaryButton: localize('delete', "Delete"),
|
|
type: 'warning',
|
|
});
|
|
|
|
if (confirmation.confirmed) {
|
|
const telemetryService = accessor.get(ITelemetryService);
|
|
telemetryService.publicLog2<CustomizationEditorDeleteItemEvent, CustomizationEditorDeleteItemClassification>('chatCustomizationEditor.deleteItem', {
|
|
promptType: promptType ?? '',
|
|
storage: storage ?? '',
|
|
});
|
|
|
|
// For skills, delete the parent folder (e.g. .github/skills/my-skill/)
|
|
// since each skill is a folder containing SKILL.md.
|
|
const deleteTarget = isSkill ? dirname(uri) : uri;
|
|
await fileService.del(deleteTarget, { useTrash: true, recursive: isSkill });
|
|
|
|
// Commit the deletion to git (sessions: main repo + worktree)
|
|
if (storage === PromptsStorage.local) {
|
|
const workspaceService = accessor.get(IAICustomizationWorkspaceService);
|
|
const projectRoot = workspaceService.getActiveProjectRoot();
|
|
if (projectRoot) {
|
|
await workspaceService.deleteFiles(projectRoot, [deleteTarget]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Context Key for prompt type to conditionally show "Run Prompt"
|
|
const AI_CUSTOMIZATION_ITEM_TYPE_KEY = 'aiCustomizationManagementItemType';
|
|
|
|
// Register context menu items
|
|
MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, {
|
|
command: { id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID, title: localize('open', "Open") },
|
|
group: '1_open',
|
|
order: 1,
|
|
});
|
|
|
|
MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, {
|
|
command: { id: RUN_PROMPT_MGMT_ID, title: localize('runPrompt', "Run Prompt"), icon: Codicon.play },
|
|
group: '2_run',
|
|
order: 1,
|
|
when: ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.prompt),
|
|
});
|
|
|
|
MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, {
|
|
command: { id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID, title: REVEAL_IN_OS_LABEL.value },
|
|
group: '3_file',
|
|
order: 1,
|
|
when: ContextKeyExpr.or(
|
|
ContextKeyExpr.regex('aiCustomizationManagementItemUri', new RegExp(`^${Schemas.file}:`)),
|
|
ContextKeyExpr.regex('aiCustomizationManagementItemUri', new RegExp(`^${Schemas.vscodeUserData}:`))
|
|
),
|
|
});
|
|
|
|
MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, {
|
|
command: { id: DELETE_AI_CUSTOMIZATION_ID, title: localize('delete', "Delete") },
|
|
group: '4_modify',
|
|
order: 1,
|
|
});
|
|
|
|
//#endregion
|
|
|
|
//#region Actions
|
|
|
|
class AICustomizationManagementActionsContribution extends Disposable implements IWorkbenchContribution {
|
|
|
|
static readonly ID = 'workbench.contrib.aiCustomizationManagementActions';
|
|
|
|
constructor() {
|
|
super();
|
|
this.registerActions();
|
|
}
|
|
|
|
private registerActions(): void {
|
|
// Open AI Customizations Editor
|
|
this._register(registerAction2(class extends Action2 {
|
|
constructor() {
|
|
super({
|
|
id: AICustomizationManagementCommands.OpenEditor,
|
|
title: localize2('openAICustomizations', "Open Customizations (Preview)"),
|
|
shortTitle: localize2('aiCustomizations', "Customizations (Preview)"),
|
|
category: CHAT_CATEGORY,
|
|
precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)),
|
|
f1: true,
|
|
});
|
|
}
|
|
|
|
async run(accessor: ServicesAccessor, section?: AICustomizationManagementSection): Promise<void> {
|
|
const editorService = accessor.get(IEditorService);
|
|
const input = AICustomizationManagementEditorInput.getOrCreate();
|
|
const pane = await editorService.openEditor(input, { pinned: true }, MODAL_GROUP);
|
|
if (section && pane instanceof AICustomizationManagementEditor) {
|
|
pane.selectSectionById(section);
|
|
}
|
|
}
|
|
}));
|
|
|
|
}
|
|
}
|
|
|
|
registerWorkbenchContribution2(
|
|
AICustomizationManagementActionsContribution.ID,
|
|
AICustomizationManagementActionsContribution,
|
|
WorkbenchPhase.AfterRestored
|
|
);
|
|
|
|
//#endregion
|