Skip to content

Commit 8bd6fec

Browse files
automations: add context menu actions for automation cards (#333226)
* Agent Host changes for agents/automation-card-duplicate-option * sessions: handle duplicate dialog failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a713c12c-1a43-4f7f-9337-fca7f8dfe89f * Fix automation context menu test typing Copilot-Session: a713c12c-1a43-4f7f-9337-fca7f8dfe89f
1 parent 95d9f12 commit 8bd6fec

6 files changed

Lines changed: 662 additions & 52 deletions

File tree

src/vs/sessions/browser/menus.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const Menus = {
4242

4343
/** Header actions of the Automations custom view. */
4444
CustomViewAutomations: new MenuId('SessionsCustomViewAutomations'),
45+
/** Context menu actions for an Automation definition card. */
46+
AutomationCardContext: new MenuId('SessionsAutomationCardContext'),
4547
/** Unified toolbar for all session-backed Automation history rows. Actions are conditionally shown via sessionItem.status context key. */
4648
AutomationsHistoryItem: new MenuId('SessionsAutomationsHistoryItem'),
4749
/** Context menu for session-backed Automation history rows. */

src/vs/sessions/contrib/automations/browser/automationDialogService.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ export class AutomationDialogService implements IAutomationDialogService {
8282
async showAutomationDialog(options: IShowAutomationDialogOptions): Promise<IAutomationDialogResult | undefined> {
8383
const disposables = new DisposableStore();
8484

85-
const initial = options.existing;
86-
const isEdit = !!initial;
85+
const existing = options.existing;
86+
const initial = existing ?? options.initialValues;
87+
const isEdit = !!existing;
8788
const initialTarget = initial?.target;
8889
const initialWorkspaceTarget = initialTarget?.kind === 'workspace' ? initialTarget : undefined;
8990

@@ -228,7 +229,7 @@ export class AutomationDialogService implements IAutomationDialogService {
228229
return undefined;
229230
}
230231

231-
if (isEdit && initial) {
232+
if (existing) {
232233
const patch: IUpdateAutomationOptions = {
233234
name: state.name,
234235
prompt,
@@ -239,7 +240,7 @@ export class AutomationDialogService implements IAutomationDialogService {
239240
permissionLevel: permissionLevel ?? null,
240241
enabled: state.enabled,
241242
};
242-
return { kind: 'update', id: initial.id, value: patch };
243+
return { kind: 'update', id: existing.id, value: patch };
243244
}
244245

245246
const create: ICreateAutomationOptions = {

src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplement
2828
const restoreFocus = createFocusRestorer(layoutService);
2929
const content = [
3030
localize('automationsCustomView.help.overview', "You are in the Automations view. It contains automation cards followed by run history."),
31-
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."),
31+
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>'),
3232
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."),
3333
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."),
3434
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.ts

Lines changed: 213 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import '../media/automationsCards.css';
77
import './automationsAccessibility.js';
88
import * as DOM from '../../../../../base/browser/dom.js';
9+
import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js';
910
import { Button, ButtonBar, IButton } from '../../../../../base/browser/ui/button/button.js';
1011
import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js';
1112
import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';
@@ -31,7 +32,8 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js';
3132
import { ILogService } from '../../../../../platform/log/common/log.js';
3233
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
3334
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
34-
import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
35+
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';
36+
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
3537
import { status } from '../../../../../base/browser/ui/aria/aria.js';
3638
import { Gesture, GestureEvent, EventType as TouchEventType } from '../../../../../base/browser/touch.js';
3739
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
@@ -56,6 +58,8 @@ import { ARCHIVE_SESSION_COMMAND_ID, MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_
5658
const $ = DOM.$;
5759
const STOP_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.stopRunSession';
5860
const DELETE_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.deleteRunSession';
61+
const AutomationCardCanDeleteContext = new RawContextKey<boolean>('sessionsAutomationCardCanDelete', false);
62+
const AutomationCardCanDisableContext = new RawContextKey<boolean>('sessionsAutomationCardCanDisable', false);
5963

6064
interface IAutomationCardEntry {
6165
readonly element: HTMLElement;
@@ -64,6 +68,8 @@ interface IAutomationCardEntry {
6468
readonly actions: HTMLElement;
6569
readonly runButton: IButton;
6670
readonly deleteButton: IButton;
71+
readonly canDeleteContext: IContextKey<boolean>;
72+
readonly canDisableContext: IContextKey<boolean>;
6773
readonly nameText: HTMLElement;
6874
readonly scheduleEl: HTMLElement;
6975
readonly folderEl: HTMLElement;
@@ -195,6 +201,8 @@ class AutomationCardsSection extends Disposable {
195201
@ILogService private readonly logService: ILogService,
196202
@IDialogService private readonly dialogService: IDialogService,
197203
@IConfigurationService private readonly configurationService: IConfigurationService,
204+
@IContextKeyService private readonly contextKeyService: IContextKeyService,
205+
@IContextMenuService private readonly contextMenuService: IContextMenuService,
198206
) {
199207
super();
200208
this.container = DOM.append(parent, $('.automations-cards-grid'));
@@ -260,7 +268,24 @@ class AutomationCardsSection extends Disposable {
260268
const wrapper = $('.automations-card-wrapper');
261269
const card = DOM.append(wrapper, $('.automations-card'));
262270
card.setAttribute('role', 'group');
271+
const cardContextKeyService = disposables.add(this.contextKeyService.createScoped(card));
272+
const canDeleteContext = AutomationCardCanDeleteContext.bindTo(cardContextKeyService);
273+
const canDisableContext = AutomationCardCanDisableContext.bindTo(cardContextKeyService);
263274
disposables.add(Gesture.addTarget(card));
275+
disposables.add(DOM.addDisposableListener(card, DOM.EventType.CONTEXT_MENU, (event: MouseEvent) => {
276+
const currentAutomation = this.latestAutomations.get(automation.id);
277+
if (!currentAutomation) {
278+
return;
279+
}
280+
event.preventDefault();
281+
event.stopPropagation();
282+
this.contextMenuService.showContextMenu({
283+
menuId: Menus.AutomationCardContext,
284+
menuActionOptions: { shouldForwardArgs: true, arg: currentAutomation },
285+
getAnchor: () => DOM.isMouseEvent(event) ? new StandardMouseEvent(DOM.getWindow(card), event) : card,
286+
contextKeyService: cardContextKeyService,
287+
});
288+
}));
264289

265290
const main = DOM.append(card, $<HTMLButtonElement>('button.automations-card-main', {
266291
type: 'button',
@@ -332,6 +357,8 @@ class AutomationCardsSection extends Disposable {
332357
actions,
333358
runButton: runBtn,
334359
deleteButton: deleteBtn,
360+
canDeleteContext,
361+
canDisableContext,
335362
nameText: nameTextEl,
336363
scheduleEl,
337364
folderEl,
@@ -348,6 +375,8 @@ class AutomationCardsSection extends Disposable {
348375
card.main.disabled = this.automationService.canUpdateAutomation?.(automation.id) === false;
349376
card.runButton.enabled = this.automationService.canRunAutomation?.(automation.id) !== false;
350377
card.deleteButton.enabled = this.automationService.canDeleteAutomation?.(automation.id) !== false;
378+
card.canDeleteContext.set(this.automationService.canDeleteAutomation?.(automation.id) !== false);
379+
card.canDisableContext.set(automation.enabled && this.automationService.canUpdateAutomation?.(automation.id) !== false);
351380
const schedule = formatSchedule(automation);
352381
const scheduleChanged = !previous || formatSchedule(previous) !== schedule;
353382
const nameChanged = !previous || previous.name !== automation.name;
@@ -487,30 +516,7 @@ class AutomationCardsSection extends Disposable {
487516
}
488517

489518
private async confirmDelete(automation: IAutomationDescriptor): Promise<void> {
490-
if (!await this.ensureEnabled()) {
491-
return;
492-
}
493-
const confirmed = await this.dialogService.confirm({
494-
message: localize('confirmDeleteAutomation', "Delete automation \"{0}\"?", automation.name),
495-
detail: localize('confirmDeleteDetail', "This will permanently delete the automation and its run history."),
496-
primaryButton: localize('delete', "Delete"),
497-
});
498-
if (!confirmed.confirmed) {
499-
return;
500-
}
501-
if (!await this.ensureEnabled()) {
502-
return;
503-
}
504-
try {
505-
await this.automationService.deleteAutomation(automation.id, () => this.throwIfDisabled());
506-
status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name));
507-
} catch (err) {
508-
this.logService.error('[AutomationsCards] Failed to delete automation', err);
509-
await this.dialogService.error(
510-
localize('automationDeleteFailed', "Failed to delete automation."),
511-
getErrorMessage(err),
512-
);
513-
}
519+
await confirmAndDeleteAutomation(automation, this.automationService, this.configurationService, this.dialogService, this.logService);
514520
}
515521

516522
private isEnabled(): boolean {
@@ -1089,6 +1095,49 @@ async function showAutomationsDisabled(dialogService: IDialogService): Promise<v
10891095
);
10901096
}
10911097

1098+
async function confirmAndDeleteAutomation(
1099+
automation: IAutomationDescriptor,
1100+
automationService: IAutomationService,
1101+
configurationService: IConfigurationService,
1102+
dialogService: IDialogService,
1103+
logService: ILogService,
1104+
): Promise<void> {
1105+
if (automationService.canDeleteAutomation?.(automation.id) === false) {
1106+
return;
1107+
}
1108+
const isEnabled = () => configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true;
1109+
if (!isEnabled()) {
1110+
await showAutomationsDisabled(dialogService);
1111+
return;
1112+
}
1113+
const confirmed = await dialogService.confirm({
1114+
message: localize('confirmDeleteAutomation', "Delete automation \"{0}\"?", automation.name),
1115+
detail: localize('confirmDeleteDetail', "This will permanently delete the automation and its run history."),
1116+
primaryButton: localize('delete', "Delete"),
1117+
});
1118+
if (!confirmed.confirmed) {
1119+
return;
1120+
}
1121+
if (!isEnabled()) {
1122+
await showAutomationsDisabled(dialogService);
1123+
return;
1124+
}
1125+
try {
1126+
await automationService.deleteAutomation(automation.id, () => {
1127+
if (!isEnabled()) {
1128+
throw new Error(localize('automationsDisabledBeforeDelete', "Automations were disabled before the automation could be deleted."));
1129+
}
1130+
});
1131+
status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name));
1132+
} catch (error) {
1133+
logService.error('[AutomationsCards] Failed to delete automation', error);
1134+
await dialogService.error(
1135+
localize('automationDeleteFailed', "Failed to delete automation."),
1136+
getErrorMessage(error),
1137+
);
1138+
}
1139+
}
1140+
10921141
//#endregion
10931142

10941143
//#region AutomationsView (Custom View)
@@ -1362,4 +1411,143 @@ registerAction2(class NewAutomationAction extends Action2 {
13621411
}
13631412
});
13641413

1414+
registerAction2(class DuplicateAutomationAction extends Action2 {
1415+
constructor() {
1416+
super({
1417+
id: 'sessions.automations.duplicate',
1418+
title: localize2('duplicateAutomation', "Duplicate"),
1419+
precondition: ChatAutomationsEnabledContext,
1420+
menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 1, when: ChatAutomationsEnabledContext }],
1421+
});
1422+
}
1423+
1424+
override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise<void> {
1425+
const automationDialogService = accessor.get(IAutomationDialogService);
1426+
const automationService = accessor.get(IAutomationService);
1427+
const configurationService = accessor.get(IConfigurationService);
1428+
const dialogService = accessor.get(IDialogService);
1429+
const logService = accessor.get(ILogService);
1430+
const isEnabled = () => configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true;
1431+
if (!isEnabled()) {
1432+
await showAutomationsDisabled(dialogService);
1433+
return;
1434+
}
1435+
1436+
try {
1437+
const name = getDuplicateAutomationName(automation.name, automationService.automations.get());
1438+
const result = await automationDialogService.showAutomationDialog({
1439+
initialValues: {
1440+
name,
1441+
prompt: automation.prompt,
1442+
schedule: automation.schedule,
1443+
target: automation.target,
1444+
modelId: automation.modelId,
1445+
mode: automation.mode,
1446+
permissionLevel: automation.permissionLevel,
1447+
enabled: automation.enabled,
1448+
},
1449+
});
1450+
if (!result || result.kind !== 'create') {
1451+
return;
1452+
}
1453+
if (!isEnabled()) {
1454+
await showAutomationsDisabled(dialogService);
1455+
return;
1456+
}
1457+
const duplicate = await automationService.createAutomation(result.value, () => {
1458+
if (!isEnabled()) {
1459+
throw new Error(localize('automationsDisabledBeforeDuplicate', "Automations were disabled before the duplicate could be saved."));
1460+
}
1461+
});
1462+
status(localize('automationDuplicatedStatus', "Created duplicate automation {0}", duplicate.name));
1463+
} catch (error) {
1464+
logService.error('[Automations] Failed to duplicate automation', error);
1465+
await dialogService.error(
1466+
localize('automationDuplicateFailed', "Failed to duplicate automation."),
1467+
getErrorMessage(error),
1468+
);
1469+
}
1470+
}
1471+
});
1472+
1473+
registerAction2(class DeleteAutomationAction extends Action2 {
1474+
constructor() {
1475+
super({
1476+
id: 'sessions.automations.delete',
1477+
title: localize2('deleteAutomationContextMenu', "Delete"),
1478+
precondition: ContextKeyExpr.and(ChatAutomationsEnabledContext, AutomationCardCanDeleteContext),
1479+
menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 3, when: ChatAutomationsEnabledContext }],
1480+
});
1481+
}
1482+
1483+
override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise<void> {
1484+
await confirmAndDeleteAutomation(
1485+
automation,
1486+
accessor.get(IAutomationService),
1487+
accessor.get(IConfigurationService),
1488+
accessor.get(IDialogService),
1489+
accessor.get(ILogService),
1490+
);
1491+
}
1492+
});
1493+
1494+
registerAction2(class DisableAutomationAction extends Action2 {
1495+
constructor() {
1496+
super({
1497+
id: 'sessions.automations.disable',
1498+
title: localize2('disableAutomationContextMenu', "Disable"),
1499+
precondition: ContextKeyExpr.and(ChatAutomationsEnabledContext, AutomationCardCanDisableContext),
1500+
menu: [{ id: Menus.AutomationCardContext, group: 'navigation', order: 2, when: ChatAutomationsEnabledContext }],
1501+
});
1502+
}
1503+
1504+
override async run(accessor: ServicesAccessor, automation: IAutomationDescriptor): Promise<void> {
1505+
const automationService = accessor.get(IAutomationService);
1506+
if (!automation.enabled || automationService.canUpdateAutomation?.(automation.id) === false) {
1507+
return;
1508+
}
1509+
const configurationService = accessor.get(IConfigurationService);
1510+
const dialogService = accessor.get(IDialogService);
1511+
const logService = accessor.get(ILogService);
1512+
const isEnabled = () => configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true;
1513+
if (!isEnabled()) {
1514+
await showAutomationsDisabled(dialogService);
1515+
return;
1516+
}
1517+
try {
1518+
const result = await automationService.updateAutomationIfUnchanged(automation.id, { enabled: false }, automation, () => {
1519+
if (!isEnabled()) {
1520+
throw new Error(localize('automationsDisabledBeforeDisable', "Automations were disabled before the automation could be updated."));
1521+
}
1522+
});
1523+
if (result.kind === 'conflict') {
1524+
throw new Error(result.current
1525+
? localize('automationChangedDuringDisable', "This automation changed before it could be disabled. Try again.")
1526+
: localize('automationDeletedDuringDisable', "This automation was deleted before it could be disabled."));
1527+
}
1528+
status(localize('automationDisabledStatus', "Disabled automation {0}", automation.name));
1529+
} catch (error) {
1530+
logService.error('[Automations] Failed to disable automation', error);
1531+
await dialogService.error(
1532+
localize('automationDisableFailed', "Failed to disable automation."),
1533+
getErrorMessage(error),
1534+
);
1535+
}
1536+
}
1537+
});
1538+
1539+
function getDuplicateAutomationName(name: string, automations: readonly IAutomationDescriptor[]): string {
1540+
const existingNames = new Set(automations.map(automation => automation.name));
1541+
const copyName = localize('automationCopyName', "{0} Copy", name);
1542+
if (!existingNames.has(copyName)) {
1543+
return copyName;
1544+
}
1545+
for (let index = 2; ; index++) {
1546+
const indexedCopyName = localize('automationIndexedCopyName', "{0} Copy {1}", name, index);
1547+
if (!existingNames.has(indexedCopyName)) {
1548+
return indexedCopyName;
1549+
}
1550+
}
1551+
}
1552+
13651553
//#endregion

0 commit comments

Comments
 (0)