microsoft/vscode · #333226
automations: add context menu actions for automation cards
src/vs/sessions/browser/menus.ts2 + / 0 −
@@ -42,6 +42,8 @@ export const Menus = { /** Header actions of the Automations custom view. */ CustomViewAutomations: new MenuId('SessionsCustomViewAutomations'),+ /** Context menu actions for an Automation definition card. */+ AutomationCardContext: new MenuId('SessionsAutomationCardContext'), /** Unified toolbar for all session-backed Automation history rows. Actions are conditionally shown via sessionItem.status context key. */ AutomationsHistoryItem: new MenuId('SessionsAutomationsHistoryItem'), /** Context menu for session-backed Automation history rows. */src/vs/sessions/contrib/automations/browser/automationDialogService.ts5 + / 4 −
@@ -82,8 +82,9 @@ export class AutomationDialogService implements IAutomationDialogService { async showAutomationDialog(options: IShowAutomationDialogOptions): Promise<IAutomationDialogResult | undefined> { const disposables = new DisposableStore(); - const initial = options.existing;- const isEdit = !!initial;+ const existing = options.existing;+ const initial = existing ?? options.initialValues;+ const isEdit = !!existing; const initialTarget = initial?.target; const initialWorkspaceTarget = initialTarget?.kind === 'workspace' ? initialTarget : undefined; @@ -228,7 +229,7 @@ export class AutomationDialogService implements IAutomationDialogService { return undefined; } - if (isEdit && initial) {+ if (existing) { const patch: IUpdateAutomationOptions = { name: state.name, prompt,@@ -239,7 +240,7 @@ export class AutomationDialogService implements IAutomationDialogService { permissionLevel: permissionLevel ?? null, enabled: state.enabled, };- return { kind: 'update', id: initial.id, value: patch };+ return { kind: 'update', id: existing.id, value: patch }; } const create: ICreateAutomationOptions = {src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts1 + / 1 −
@@ -28,7 +28,7 @@ class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplement const restoreFocus = createFocusRestorer(layoutService); const content = [ localize('automationsCustomView.help.overview', "You are in the Automations view. It contains automation cards followed by run history."),- localize('automationsCustomView.help.cards', "Tab to a card's Edit control and action buttons. Use Left Arrow and Right Arrow to move between Run now and Delete. Press Enter or Space to activate a control. Edit, or clicking anywhere else on the card, opens the automation dialog. Run now starts a session immediately. Delete asks for confirmation."),+ localize('automationsCustomView.help.cards', "Tab to a card's Edit control and action buttons. Use Left Arrow and Right Arrow to move between Run now and Delete. Press Enter or Space to activate a control. Edit, or clicking anywhere else on the card, opens the automation dialog. Open a card's context menu{0} (for example Shift+F10). Duplicate opens a prefilled New automation dialog, Disable prevents scheduled runs, and Delete asks for confirmation. Run now starts a session immediately.", '<keybinding:editor.action.showContextMenu>'), localize('automationsCustomView.help.history', "Run history is grouped by date. While a run is waiting for its session, a lightweight row shows the automation name with a Working... description. Once the session is available, use Up Arrow and Down Arrow to navigate the Sessions list, Enter to open, and Tab to reach Stop, the configured Archive or Mark as Done action, or Delete when available. Open a row's context menu, for example with Shift+F10, to rename it, change its active or read state, or delete it. Delete permanently deletes the session and removes it from run history after confirmation."), localize('automationsCustomView.help.read', "Completed and failed runs that have not been opened are announced as unread. Use Mark all as read to clear all available unread runs."), localize('automationsCustomView.help.accessibleView', "Use Open Accessible View to read the current automations and run history as text."),src/vs/sessions/contrib/sessions/browser/views/automationsView.ts213 + / 25 −
@@ -6,6 +6,7 @@ import '../media/automationsCards.css'; import './automationsAccessibility.js'; import * as DOM from '../../../../../base/browser/dom.js';+import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Button, ButtonBar, IButton } from '../../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';@@ -31,7 +32,8 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';-import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';+import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';+import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Gesture, GestureEvent, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';@@ -56,6 +58,8 @@ import { ARCHIVE_SESSION_COMMAND_ID, MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_ const $ = DOM.$; const STOP_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.stopRunSession'; const DELETE_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.deleteRunSession';+const AutomationCardCanDeleteContext = new RawContextKey<boolean>('sessionsAutomationCardCanDelete', false);+const AutomationCardCanDisableContext = new RawContextKey<boolean>('sessionsAutomationCardCanDisable', false); interface IAutomationCardEntry { readonly element: HTMLElement;@@ -64,6 +68,8 @@ interface IAutomationCardEntry { readonly actions: HTMLElement; readonly runButton: IButton; readonly deleteButton: IButton;+ readonly canDeleteContext: IContextKey<boolean>;+ readonly canDisableContext: IContextKey<boolean>; readonly nameText: HTMLElement; readonly scheduleEl: HTMLElement; readonly folderEl: HTMLElement;@@ -195,6 +201,8 @@ class AutomationCardsSection extends Disposable { @ILogService private readonly logService: ILogService, @IDialogService private readonly dialogService: IDialogService, @IConfigurationService private readonly configurationService: IConfigurationService,+ @IContextKeyService private readonly contextKeyService: IContextKeyService,+ @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); this.container = DOM.append(parent, $('.automations-cards-grid'));@@ -260,7 +268,24 @@ class AutomationCardsSection extends Disposable { const wrapper = $('.automations-card-wrapper'); const card = DOM.append(wrapper, $('.automations-card')); card.setAttribute('role', 'group');+ const cardContextKeyService = disposables.add(this.contextKeyService.createScoped(card));+ const canDeleteContext = AutomationCardCanDeleteContext.bindTo(cardContextKeyService);+ const canDisableContext = AutomationCardCanDisableContext.bindTo(cardContextKeyService); disposables.add(Gesture.addTarget(card));+ disposables.add(DOM.addDisposableListener(card, DOM.EventType.CONTEXT_MENU, (event: MouseEvent) => {+ const currentAutomation = this.latestAutomations.get(automation.id);+ if (!currentAutomation) {+ return;+ }+ event.preventDefault();+ event.stopPropagation();+ this.contextMenuService.showContextMenu({+ menuId: Menus.AutomationCardContext,+ menuActionOptions: { shouldForwardArgs: true, arg: currentAutomation },+ getAnchor: () => DOM.isMouseEvent(event) ? new StandardMouseEvent(DOM.getWindow(card), event) : card,+ contextKeyService: cardContextKeyService,+ });+ })); const main = DOM.append(card, $<HTMLButtonElement>('button.automations-card-main', { type: 'button',@@ -332,6 +357,8 @@ class AutomationCardsSection extends Disposable { actions, runButton: runBtn, deleteButton: deleteBtn,+ canDeleteContext,+ canDisableContext, nameText: nameTextEl, scheduleEl, folderEl,@@ -348,6 +375,8 @@ class AutomationCardsSection extends Disposable { card.main.disabled = this.automationService.canUpdateAutomation?.(automation.id) === false; card.runButton.enabled = this.automationService.canRunAutomation?.(automation.id) !== false; card.deleteButton.enabled = this.automationService.canDeleteAutomation?.(automation.id) !== false;+ card.canDeleteContext.set(this.automationService.canDeleteAutomation?.(automation.id) !== false);+ card.canDisableContext.set(automation.enabled && this.automationService.canUpdateAutomation?.(automation.id) !== false); const schedule = formatSchedule(automation); const scheduleChanged = !previous || formatSchedule(previous) !== schedule; const nameChanged = !previous || previous.name !== automation.name;@@ -487,30 +516,7 @@ class AutomationCardsSection extends Disposable { } private async confirmDelete(automation: IAutomationDescriptor): Promise<void> {- if (!await this.ensureEnabled()) {- return;- }- const confirmed = await this.dialogService.confirm({- message: localize('confirmDeleteAutomation', "Delete automation \"{0}\"?", automation.name),- detail: localize('confirmDeleteDetail', "This will permanently delete the automation and its run history."),- primaryButton: localize('delete', "Delete"),- });- if (!confirmed.confirmed) {- return;- }- if (!await this.ensureEnabled()) {- return;- }- try {- await this.automationService.deleteAutomation(automation.id, () => this.throwIfDisabled());- status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name));- } catch (err) {- this.logService.error('[AutomationsCards] Failed to delete automation', err);- await this.dialogService.error(- localize('automationDeleteFailed', "Failed to delete automation."),- getErrorMessage(err),- );- }+ await confirmAndDeleteAutomation(automation, this.automationService, this.configurationService, this.dialogService, this.logService); } private isEnabled(): boolean {@@ -1089,6 +1095,49 @@ async function showAutomationsDisabled(dialogService: IDialogService): Promise<v ); } +async function confirmAndDeleteAutomation(+ automation: IAutomationDescriptor,+ automationService: IAutomationService,+ configurationService: IConfigurationService,+ dialogService: IDialogService,+ logService: ILogService,+): Promise<void> {+ if (automationService.canDeleteAutomation?.(automation.id) === false) {+ return;+ }+ const isEnabled = () => configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true;+ if (!isEnabled()) {+ await showAutomationsDisabled(dialogService);+ return;+ }+ const confirmed = await dialogService.confirm({+ message: localize('confirmDeleteAutomation', "Delete automation \"{0}\"?", automation.name),+ detail: localize('confirmDeleteDetail', "This will permanently delete the automation and its run history."),+ primaryButton: localize('delete', "Delete"),+ });+ if (!confirmed.confirmed) {+ return;+ }+ if (!isEnabled()) {+ await showAutomationsDisabled(dialogService);+ return;+ }+ try {+ await automationService.deleteAutomation(automation.id, () => {+ if (!isEnabled()) {+ throw new Error(localize('automationsDisabledBeforeDelete', "Automations were disabled before the automation could be deleted."));+ }+ });+ status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name));+ } catch (error) {+ logService.error('[AutomationsCards] Failed to delete automation', error);+ await dialogService.error(+ localize('automationDeleteFailed', "Failed to delete automation."),+ getErrorMessage(error),+ );+ }+}+ //#endregion //#region AutomationsView (Custom View)@@ -1362,4 +1411,143 @@ registerAction2(class NewAutomationAction extends Action2 { } }); +registerAction2(class DuplicateAutomationAction extends Action2 {+ constructor() {+ super({+ id: 'sessions.automations.duplicate',+ title: localize2('duplicateAutomation', "Duplicate"),+ precondition: ChatAutomationsEnabledContext,+ menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 1, when: ChatAutomationsEnabledContext }],+ });+ }++ override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise<void> {+ const automationDialogService = accessor.get(IAutomationDialogService);+ const automationService = accessor.get(IAutomationService);+ const configurationService = accessor.get(IConfigurationService);+ const dialogService = accessor.get(IDialogService);+ const logService = accessor.get(ILogService);+ const isEnabled = () => configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true;+ if (!isEnabled()) {+ await showAutomationsDisabled(dialogService);+ return;+ }++ try {+ const name = getDuplicateAutomationName(automation.name, automationService.automations.get());+ const result = await automationDialogService.showAutomationDialog({+ initialValues: {+ name,+ prompt: automation.prompt,+ schedule: automation.schedule,+ target: automation.target,+ modelId: automation.modelId,+ mode: automation.mode,+ permissionLevel: automation.permissionLevel,+ enabled: automation.enabled,+ },+ });+ if (!result || result.kind !== 'create') {+ return;+ }+ if (!isEnabled()) {+ await showAutomationsDisabled(dialogService);+ return;+ }+ const duplicate = await automationService.createAutomation(result.value, () => {+ if (!isEnabled()) {+ throw new Error(localize('automationsDisabledBeforeDuplicate', "Automations were disabled before the duplicate could be saved."));+ }+ });+ status(localize('automationDuplicatedStatus', "Created duplicate automation {0}", duplicate.name));+ } catch (error) {+ logService.error('[Automations] Failed to duplicate automation', error);+ await dialogService.error(+ localize('automationDuplicateFailed', "Failed to duplicate automation."),+ getErrorMessage(error),+ );+ }+ }+});++registerAction2(class DeleteAutomationAction extends Action2 {+ constructor() {+ super({+ id: 'sessions.automations.delete',+ title: localize2('deleteAutomationContextMenu', "Delete"),+ precondition: ContextKeyExpr.and(ChatAutomationsEnabledContext, AutomationCardCanDeleteContext),+ menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 3, when: ChatAutomationsEnabledContext }],+ });+ }++ override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise<void> {+ await confirmAndDeleteAutomation(+ automation,+ accessor.get(IAutomationService),+ accessor.get(IConfigurationService),+ accessor.get(IDialogService),+ accessor.get(ILogService),+ );+ }+});++registerAction2(class DisableAutomationAction extends Action2 {+ constructor() {+ super({+ id: 'sessions.automations.disable',+ title: localize2('disableAutomationContextMenu', "Disable"),+ precondition: ContextKeyExpr.and(ChatAutomationsEnabledContext, AutomationCardCanDisableContext),+ menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 2, when: ChatAutomationsEnabledContext }],+ });+ }++ override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise<void> {+ const automationService = accessor.get(IAutomationService);+ if (!automation.enabled || automationService.canUpdateAutomation?.(automation.id) === false) {+ return;+ }+ const configurationService = accessor.get(IConfigurationService);+ const dialogService = accessor.get(IDialogService);+ const logService = accessor.get(ILogService);+ const isEnabled = () => configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true;+ if (!isEnabled()) {+ await showAutomationsDisabled(dialogService);+ return;+ }+ try {+ const result = await automationService.updateAutomationIfUnchanged(automation.id, { enabled: false }, automation, () => {+ if (!isEnabled()) {+ throw new Error(localize('automationsDisabledBeforeDisable', "Automations were disabled before the automation could be updated."));+ }+ });+ if (result.kind === 'conflict') {+ throw new Error(result.current+ ? localize('automationChangedDuringDisable', "This automation changed before it could be disabled. Try again.")+ : localize('automationDeletedDuringDisable', "This automation was deleted before it could be disabled."));+ }+ status(localize('automationDisabledStatus', "Disabled automation {0}", automation.name));+ } catch (error) {+ logService.error('[Automations] Failed to disable automation', error);+ await dialogService.error(+ localize('automationDisableFailed', "Failed to disable automation."),+ getErrorMessage(error),+ );+ }+ }+});++function getDuplicateAutomationName(name: string, automations: readonly IAutomationDescriptor[]): string {+ const existingNames = new Set(automations.map(automation => automation.name));+ const copyName = localize('automationCopyName', "{0} Copy", name);+ if (!existingNames.has(copyName)) {+ return copyName;+ }+ for (let index = 2; ; index++) {+ const indexedCopyName = localize('automationIndexedCopyName', "{0} Copy {1}", name, index);+ if (!existingNames.has(indexedCopyName)) {+ return indexedCopyName;+ }+ }+}+ //#endregionsrc/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts438 + / 19 −
@@ -21,10 +21,10 @@ import { IAccessibilityService } from '../../../../../platform/accessibility/com import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';-import { ICommandService } from '../../../../../platform/commands/common/commands.js';+import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';-import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';+import { IContextMenuMenuDelegate, IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IConfirmation, IConfirmationResult, IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js';@@ -52,8 +52,9 @@ import { ISessionsListModelService } from '../../../../services/sessions/browser import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';-import { IMenuService, registerAction2 } from '../../../../../platform/actions/common/actions.js';+import { IMenuService, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { MenuService } from '../../../../../platform/actions/common/menuService.js';+import { Menus } from '../../../../browser/menus.js'; import { ArchiveSessionAction } from '../../browser/views/sessionsViewActions.js'; import { MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_UNREAD_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { TestCommandService } from './sessionsListTestUtils.js';@@ -119,6 +120,13 @@ class FakeAutomationService extends mock<IAutomationService>() { updateResult: IGuardedAutomationUpdateResult | undefined; updateCalls = 0; deleteRunCalls = 0;+ createError: Error | undefined;+ deleteError: Error | undefined;+ canDelete = true;+ canUpdate = true;+ readonly createCalls: ICreateAutomationOptions[] = [];+ readonly deleteCalls: string[] = [];+ readonly guardedUpdateCalls: { id: string; patch: IUpdateAutomationOptions; expected: IAutomationDescriptor }[] = []; readonly deleteRunCompleted = new DeferredPromise<void>(); setAutomations(value: readonly IAutomationDescriptor[]): void {@@ -139,8 +147,12 @@ class FakeAutomationService extends mock<IAutomationService>() { override async createAutomation(options: ICreateAutomationOptions, mutationGuard?: AutomationMutationGuard): Promise<IAutomationDescriptor> { mutationGuard?.();+ this.createCalls.push(options);+ if (this.createError) {+ throw this.createError;+ } const created = automation({- id: AUTOMATION_ID,+ id: `created-automation-${this.createCalls.length}`, name: options.name, prompt: options.prompt, schedule: options.schedule,@@ -175,15 +187,29 @@ class FakeAutomationService extends mock<IAutomationService>() { return updated; } - override async updateAutomationIfUnchanged(id: string, patch: IUpdateAutomationOptions, _expected: IAutomationDescriptor, mutationGuard?: AutomationMutationGuard): Promise<IGuardedAutomationUpdateResult> {+ override async updateAutomationIfUnchanged(id: string, patch: IUpdateAutomationOptions, expected: IAutomationDescriptor, mutationGuard?: AutomationMutationGuard): Promise<IGuardedAutomationUpdateResult> { this.updateCalls++;+ this.guardedUpdateCalls.push({ id, patch, expected }); mutationGuard?.(); return this.updateResult ?? { kind: 'updated', automation: await this.updateAutomation(id, patch) }; } override async deleteAutomation(id: string, mutationGuard?: AutomationMutationGuard): Promise<void> { mutationGuard?.();+ this.deleteCalls.push(id);+ if (this.deleteError) {+ throw this.deleteError;+ } this.setAutomations(this.automationValue.get().filter(item => item.id !== id));+ this.setRuns(this.runValue.get().filter(run => run.automationId !== id));+ }++ override canDeleteAutomation(): boolean {+ return this.canDelete;+ }++ override canUpdateAutomation(): boolean {+ return this.canUpdate; } override async recordRunStart(): Promise<IAutomationRunClaim> {@@ -201,6 +227,25 @@ class FakeAutomationService extends mock<IAutomationService>() { } } +class TestContextMenuService extends mock<IContextMenuService>() {+ override readonly onDidShowContextMenu = Event.None;+ override readonly onDidHideContextMenu = Event.None;+ delegate: IContextMenuDelegate | undefined;+ menuDelegate: IContextMenuMenuDelegate | undefined;++ override showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {+ if (this.isMenuDelegate(delegate)) {+ this.menuDelegate = delegate;+ } else {+ this.delegate = delegate;+ }+ }++ private isMenuDelegate(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): delegate is IContextMenuMenuDelegate {+ return (<IContextMenuMenuDelegate>delegate).menuId instanceof MenuId;+ }+}+ class FakeAutomationDialogService extends mock<IAutomationDialogService>() { result: IAutomationDialogResult | undefined; error: Error | undefined;@@ -251,16 +296,6 @@ class FakeDialogService extends mock<IDialogService>() { } } -class TestContextMenuService extends mock<IContextMenuService>() {- override readonly onDidShowContextMenu = Event.None;- override readonly onDidHideContextMenu = Event.None;- delegate: IContextMenuDelegate | undefined;-- override showContextMenu(delegate: IContextMenuDelegate): void {- this.delegate = delegate;- }-}- class TestKeybindingService extends MockKeybindingService { readonly lookupCalls: { commandId: string; context: IContextKeyService | undefined; enforceContextCheck: boolean | undefined }[] = []; @@ -511,13 +546,13 @@ suite('AutomationsCardsWidget', () => { function setup(archiveWording: 'archive' | 'done' = 'archive') { const automationService = new FakeAutomationService(); const automationDialogService = new FakeAutomationDialogService();+ const contextMenuService = new TestContextMenuService(); const dialogService = new FakeDialogService(); const runner = new FakeRunner(); const sessionsManagementService = disposables.add(new FakeSessionsManagementService()); const sessionsService = new FakeSessionsService(() => sessionsManagementService.markRead(sessionsManagementService.session)); const configurationService = new TestConfigurationService({ chat: { automations: { enabled: true }, experimental: { sessionArchiveActionWording: archiveWording } } }); const logService = new TestLogService();- const contextMenuService = new TestContextMenuService(); const commandService = new TestCommandService(); const keybindingService = new TestKeybindingService(); const store = disposables.add(new DisposableStore());@@ -530,13 +565,15 @@ suite('AutomationsCardsWidget', () => { instantiationService.stub(IMenuService, store.add(instantiationService.createInstance(MenuService))); instantiationService.stub(IAutomationService, automationService); instantiationService.stub(IAutomationDialogService, automationDialogService);+ instantiationService.stub(IContextMenuService, contextMenuService); instantiationService.stub(IDialogService, dialogService); instantiationService.stub(IAutomationRunner, runner); instantiationService.stub(ISessionsService, sessionsService); instantiationService.stub(ISessionsManagementService, sessionsManagementService); instantiationService.stub(IConfigurationService, configurationService);- instantiationService.stub(IContextKeyService, store.add(new ContextKeyService(configurationService)));- instantiationService.stub(IContextMenuService, contextMenuService);+ const contextKeyService = store.add(new ContextKeyService(configurationService));+ ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true);+ instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IKeybindingService, keybindingService); instantiationService.stub(IHoverService, NullHoverService); instantiationService.stub(ILogService, logService);@@ -570,7 +607,7 @@ suite('AutomationsCardsWidget', () => { const widget = disposables.add(instantiationService.createInstance(AutomationsCardsWidget)); document.body.append(widget.element); disposables.add(toDisposable(() => widget.element.remove()));- return { automationService, automationDialogService, commandService, configurationService, contextMenuService, dialogService, instantiationService, keybindingService, logService, runner, sessionsManagementService, sessionsService, widget };+ return { automationService, automationDialogService, commandService, configurationService, contextKeyService, contextMenuService, dialogService, instantiationService, keybindingService, logService, runner, sessionsManagementService, sessionsService, widget }; } function dispatchContextMenu(target: HTMLElement): void {@@ -817,6 +854,388 @@ suite('AutomationsCardsWidget', () => { }); }); + test('card context menu opens create mode seeded with all editable fields', async () => {+ const { automationDialogService, automationService, contextKeyService, contextMenuService, instantiationService, widget } = setup();+ const source = automation({+ name: 'Daily review',+ prompt: 'Review all open issues',+ schedule: { interval: 'weekly', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 },+ target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' },+ modelId: 'model',+ mode: 'agent',+ permissionLevel: 'autopilot',+ enabled: false,+ });+ automationService.setAutomations([source]);+ automationService.setRuns([run()]);+ const sourceCard = widget.element.querySelector<HTMLElement>('.automations-card');+ assert.ok(sourceCard);++ sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }));+ const delegate = contextMenuService.menuDelegate;+ assert.ok(delegate);+ assert.strictEqual(delegate.menuId, Menus.AutomationCardContext);+ assert.strictEqual(delegate.menuActionOptions?.arg, source);+ const menuActions = instantiationService.get(IMenuService).getMenuActions(+ Menus.AutomationCardContext,+ delegate.contextKeyService ?? contextKeyService,+ delegate.menuActionOptions,+ ).flatMap(([, actions]) => actions);+ assert.deepStrictEqual(menuActions.map(action => ({ id: action.id, enabled: action.enabled })), [+ { id: 'sessions.automations.duplicate', enabled: true },+ { id: 'sessions.automations.disable', enabled: false },+ { id: 'sessions.automations.delete', enabled: true },+ ]);+ const command = CommandsRegistry.getCommand('sessions.automations.duplicate');+ assert.ok(command);+ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));++ assert.deepStrictEqual({+ dialogOptions: automationDialogService.lastOptions,+ createCalls: automationService.createCalls,+ automationNames: automationService.automations.get().map(item => item.name),+ runCount: automationService.runs.get().length,+ }, {+ dialogOptions: {+ initialValues: {+ name: 'Daily review Copy',+ prompt: 'Review all open issues',+ schedule: source.schedule,+ target: source.target,+ modelId: 'model',+ mode: 'agent',+ permissionLevel: 'autopilot',+ enabled: false,+ },+ },+ createCalls: [],+ automationNames: ['Daily review'],+ runCount: 1,+ });+ });++ test('duplicate creates the automation with values submitted from the dialog', async () => {+ const { automationDialogService, automationService, instantiationService } = setup();+ const source = automation({ name: 'Daily review' });+ const existingCopy = automation({ id: 'copy', name: 'Daily review Copy' });+ automationService.setAutomations([source, existingCopy]);+ const submitted: ICreateAutomationOptions = {+ name: 'Customized copy',+ prompt: 'Customized prompt',+ schedule: { interval: 'hourly', scheduleHour: 10, scheduleMinute: 15, scheduleDay: 2 },+ target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' },+ modelId: 'custom-model',+ mode: 'ask',+ permissionLevel: 'default',+ enabled: true,+ };+ automationDialogService.result = { kind: 'create', value: submitted };+ const command = CommandsRegistry.getCommand('sessions.automations.duplicate');+ assert.ok(command);++ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));++ assert.deepStrictEqual({+ initialName: automationDialogService.lastOptions?.initialValues?.name,+ createCalls: automationService.createCalls,+ createdNames: automationService.automations.get().map(item => item.name),+ }, {+ initialName: 'Daily review Copy 2',+ createCalls: [submitted],+ createdNames: ['Customized copy', 'Daily review', 'Daily review Copy'],+ });+ });++ test('duplicate dialog failures are logged and reported to the user', async () => {+ const { automationDialogService, automationService, dialogService, instantiationService, logService } = setup();+ const source = automation();+ const error = new Error('dialog failed');+ automationDialogService.error = error;+ automationService.setAutomations([source]);+ const command = CommandsRegistry.getCommand('sessions.automations.duplicate');+ assert.ok(command);++ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));+ await dialogService.errorCalled.p;++ assert.deepStrictEqual({+ createCalls: automationService.createCalls,+ loggedErrors: logService.errors,+ dialogErrors: dialogService.errors,+ }, {+ createCalls: [],+ loggedErrors: [{+ message: '[Automations] Failed to duplicate automation',+ args: [error],+ }],+ dialogErrors: [{+ message: 'Failed to duplicate automation.',+ detail: 'dialog failed',+ }],+ });+ });++ test('duplicate creation failures are logged and reported to the user', async () => {+ const { automationDialogService, automationService, contextKeyService, contextMenuService, dialogService, instantiationService, logService, widget } = setup();+ const source = automation();+ const error = new Error('create failed');+ automationService.createError = error;+ automationDialogService.result = {+ kind: 'create',+ value: {+ name: 'Daily review Copy',+ prompt: source.prompt,+ schedule: source.schedule,+ target: source.target,+ modelId: source.modelId,+ mode: source.mode,+ permissionLevel: source.permissionLevel,+ enabled: source.enabled,+ },+ };+ automationService.setAutomations([source]);+ const sourceCard = widget.element.querySelector<HTMLElement>('.automations-card');+ assert.ok(sourceCard);++ sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }));+ const delegate = contextMenuService.menuDelegate;+ assert.ok(delegate);+ const duplicateActions = instantiationService.get(IMenuService).getMenuActions(+ Menus.AutomationCardContext,+ delegate.contextKeyService ?? contextKeyService,+ delegate.menuActionOptions,+ ).flatMap(([, actions]) => actions);+ assert.deepStrictEqual(duplicateActions.map(action => action.id), ['sessions.automations.duplicate', 'sessions.automations.disable', 'sessions.automations.delete']);+ const command = CommandsRegistry.getCommand('sessions.automations.duplicate');+ assert.ok(command);+ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));+ await dialogService.errorCalled.p;++ assert.deepStrictEqual({+ loggedErrors: logService.errors,+ dialogErrors: dialogService.errors,+ }, {+ loggedErrors: [{+ message: '[Automations] Failed to duplicate automation',+ args: [error],+ }],+ dialogErrors: [{+ message: 'Failed to duplicate automation.',+ detail: 'create failed',+ }],+ });+ });++ test('disable context menu action disables the automation and then becomes unavailable', async () => {+ const { automationService, contextKeyService, contextMenuService, instantiationService, widget } = setup();+ const source = automation();+ automationService.setAutomations([source]);+ const sourceCard = widget.element.querySelector<HTMLElement>('.automations-card');+ assert.ok(sourceCard);+ const getDisableAction = () => {+ sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }));+ const delegate = contextMenuService.menuDelegate;+ assert.ok(delegate);+ return instantiationService.get(IMenuService).getMenuActions(+ Menus.AutomationCardContext,+ delegate.contextKeyService ?? contextKeyService,+ delegate.menuActionOptions,+ ).flatMap(([, actions]) => actions).find(action => action.id === 'sessions.automations.disable');+ };+ const command = CommandsRegistry.getCommand('sessions.automations.disable');+ assert.ok(command);++ const initiallyEnabled = getDisableAction()?.enabled;+ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));+ const enabledAfterDisable = getDisableAction()?.enabled;++ assert.deepStrictEqual({+ initiallyEnabled,+ enabledAfterDisable,+ updateCalls: automationService.guardedUpdateCalls,+ automationEnabled: automationService.automations.get()[0].enabled,+ }, {+ initiallyEnabled: true,+ enabledAfterDisable: false,+ updateCalls: [{ id: source.id, patch: { enabled: false }, expected: source }],+ automationEnabled: false,+ });+ });++ test('disable context menu action is unavailable when updates are unsupported', async () => {+ const { automationService, contextKeyService, contextMenuService, instantiationService, widget } = setup();+ automationService.canUpdate = false;+ const source = automation();+ automationService.setAutomations([source]);+ const sourceCard = widget.element.querySelector<HTMLElement>('.automations-card');+ assert.ok(sourceCard);+ sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }));+ const delegate = contextMenuService.menuDelegate;+ assert.ok(delegate);+ const disableAction = instantiationService.get(IMenuService).getMenuActions(+ Menus.AutomationCardContext,+ delegate.contextKeyService ?? contextKeyService,+ delegate.menuActionOptions,+ ).flatMap(([, actions]) => actions).find(action => action.id === 'sessions.automations.disable');+ const command = CommandsRegistry.getCommand('sessions.automations.disable');+ assert.ok(command);+ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));++ assert.deepStrictEqual({+ actionEnabled: disableAction?.enabled,+ updateCalls: automationService.guardedUpdateCalls,+ }, {+ actionEnabled: false,+ updateCalls: [],+ });+ });++ test('disable conflicts are logged and reported to the user', async () => {+ const { automationService, dialogService, instantiationService, logService } = setup();+ const source = automation();+ automationService.setAutomations([source]);+ automationService.updateResult = { kind: 'conflict', current: automation({ prompt: 'Changed' }) };+ const command = CommandsRegistry.getCommand('sessions.automations.disable');+ assert.ok(command);++ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));+ await dialogService.errorCalled.p;++ assert.deepStrictEqual({+ automationEnabled: automationService.automations.get()[0].enabled,+ loggedErrors: logService.errors.map(entry => entry.message),+ dialogErrors: dialogService.errors,+ }, {+ automationEnabled: true,+ loggedErrors: ['[Automations] Failed to disable automation'],+ dialogErrors: [{+ message: 'Failed to disable automation.',+ detail: 'This automation changed before it could be disabled. Try again.',+ }],+ });+ });++ test('disable reports when the automation was deleted concurrently', async () => {+ const { automationService, dialogService, instantiationService } = setup();+ const source = automation();+ automationService.setAutomations([source]);+ automationService.updateResult = { kind: 'conflict', current: undefined };+ const command = CommandsRegistry.getCommand('sessions.automations.disable');+ assert.ok(command);++ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));+ await dialogService.errorCalled.p;++ assert.deepStrictEqual(dialogService.errors, [{+ message: 'Failed to disable automation.',+ detail: 'This automation was deleted before it could be disabled.',+ }]);+ });++ test('delete context menu action confirms before deleting the automation and its history', async () => {+ const { automationService, contextMenuService, dialogService, instantiationService, widget } = setup();+ const source = automation();+ automationService.setAutomations([source]);+ automationService.setRuns([run({ automationId: source.id })]);+ const sourceCard = widget.element.querySelector<HTMLElement>('.automations-card');+ assert.ok(sourceCard);+ sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }));+ const delegate = contextMenuService.menuDelegate;+ assert.ok(delegate);+ const command = CommandsRegistry.getCommand('sessions.automations.delete');+ assert.ok(command);++ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));+ dialogService.confirmResult = { confirmed: true };+ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));++ assert.deepStrictEqual({+ confirmations: dialogService.confirmations,+ deleteCalls: automationService.deleteCalls,+ automations: automationService.automations.get(),+ runs: automationService.runs.get(),+ }, {+ confirmations: [{+ message: 'Delete automation "Daily review"?',+ detail: 'This will permanently delete the automation and its run history.',+ primaryButton: 'Delete',+ }, {+ message: 'Delete automation "Daily review"?',+ detail: 'This will permanently delete the automation and its run history.',+ primaryButton: 'Delete',+ }],+ deleteCalls: [source.id],+ automations: [],+ runs: [],+ });+ });++ test('delete context menu action is disabled when deletion is unsupported', async () => {+ const { automationService, contextKeyService, contextMenuService, dialogService, instantiationService, widget } = setup();+ automationService.canDelete = false;+ const source = automation();+ automationService.setAutomations([source]);+ const sourceCard = widget.element.querySelector<HTMLElement>('.automations-card');+ assert.ok(sourceCard);+ sourceCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 }));+ const delegate = contextMenuService.menuDelegate;+ assert.ok(delegate);+ const menuActions = instantiationService.get(IMenuService).getMenuActions(+ Menus.AutomationCardContext,+ delegate.contextKeyService ?? contextKeyService,+ delegate.menuActionOptions,+ ).flatMap(([, actions]) => actions);+ const command = CommandsRegistry.getCommand('sessions.automations.delete');+ assert.ok(command);+ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));++ assert.deepStrictEqual({+ actions: menuActions.map(action => ({ id: action.id, enabled: action.enabled })),+ confirmations: dialogService.confirmations.length,+ deleteCalls: automationService.deleteCalls,+ }, {+ actions: [+ { id: 'sessions.automations.duplicate', enabled: true },+ { id: 'sessions.automations.disable', enabled: true },+ { id: 'sessions.automations.delete', enabled: false },+ ],+ confirmations: 0,+ deleteCalls: [],+ });+ });++ test('delete context menu failures are logged and reported to the user', async () => {+ const { automationService, dialogService, instantiationService, logService } = setup();+ const source = automation();+ const error = new Error('delete failed');+ automationService.deleteError = error;+ automationService.setAutomations([source]);+ dialogService.confirmResult = { confirmed: true };+ const command = CommandsRegistry.getCommand('sessions.automations.delete');+ assert.ok(command);++ await instantiationService.invokeFunction(accessor => command.handler(accessor, source));+ await dialogService.errorCalled.p;++ assert.deepStrictEqual({+ deleteCalls: automationService.deleteCalls,+ automations: automationService.automations.get(),+ loggedErrors: logService.errors,+ dialogErrors: dialogService.errors,+ }, {+ deleteCalls: [source.id],+ automations: [source],+ loggedErrors: [{+ message: '[AutomationsCards] Failed to delete automation',+ args: [error],+ }],+ dialogErrors: [{+ message: 'Failed to delete automation.',+ detail: 'delete failed',+ }],+ });+ });+ test('automation action buttons support arrow navigation and keyboard activation', async () => { const { automationService, runner, widget } = setup(); automationService.setAutomations([automation()]);src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts3 + / 3 −
@@ -7,9 +7,9 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IAutomationDescriptor } from './automation.js'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from './automationService.js'; -export interface IShowAutomationDialogOptions {- readonly existing?: IAutomationDescriptor;-}+export type IShowAutomationDialogOptions =+ | { readonly existing: IAutomationDescriptor; readonly initialValues?: never }+ | { readonly existing?: never; readonly initialValues?: ICreateAutomationOptions }; export type IAutomationDialogResult = | { readonly kind: 'create'; readonly value: ICreateAutomationOptions }