diff --git a/src/vs/sessions/AUTOMATIONS.md b/src/vs/sessions/AUTOMATIONS.md index 6f0df2fbb68240..0f88a69e205dc9 100644 --- a/src/vs/sessions/AUTOMATIONS.md +++ b/src/vs/sessions/AUTOMATIONS.md @@ -88,6 +88,14 @@ Legacy `modelId`, `mode`, and `permissionLevel` fields remain decode and input a At most one non-terminal run may occupy an Automation's active-run slot within one authority. +### Catalogue availability + +Every Automation store exposes whether its complete catalogue is `loading`, `ready`, `unavailable`, or in `error`. An empty catalogue is authoritative only in the `ready` state. `loading` is reserved for initial provider discovery and authoritative snapshots that are still in flight. `unavailable` means a known provider catalogue cannot currently be reached without treating that condition as storage or migration failure. + +Provider stores map their connection and persistence lifecycle into this provider-neutral state. Agent Host stores become ready when an authoritative catalogue snapshot and every source still participating in the projection are readable, independently of migration authority. Known disconnect, disabled capability, and unsupported capability are unavailable rather than perpetually loading. + +`ProviderAutomationService` keeps the initial aggregate loading until all AfterRestored workbench contributions have completed provider registration. A provider-less window then settles to its legacy-store state, so a legacy-only empty catalogue can be authoritative. After provider settlement, the aggregate reports `error` when any current store fails, otherwise `loading` while any store is loading, `unavailable` while any store is unavailable, and `ready` only when all current stores are ready. + ## Multi-host routing VS Code may register one local Agent Host provider and multiple remote Agent Host providers at the same time. A remote connection has its own provider identity, Automation store, AHP catalogue, and migration state. @@ -326,6 +334,7 @@ Updates that do not change the target remain allowed while an active run delays 11. Same-target edits preserve unknown template values unless the user explicitly changes them. 12. Retargeting does not carry a previous provider's template into the new authority. 13. Runtime policy and provider schema are revalidated for every run without treating saved configuration as a grant. +14. Consumers distinguish a confirmed empty catalogue from loading, unavailability, and failure through the provider-neutral catalogue state. ## Concrete behavior diff --git a/src/vs/sessions/browser/parts/customViewNode.ts b/src/vs/sessions/browser/parts/customViewNode.ts index 4a34e358636f16..9be844e12b1d3b 100644 --- a/src/vs/sessions/browser/parts/customViewNode.ts +++ b/src/vs/sessions/browser/parts/customViewNode.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import './media/customViewGridPart.css'; -import { $, isAncestorOfActiveElement } from '../../../base/browser/dom.js'; +import { $, addDisposableListener, EventType, getWindow, isAncestorOfActiveElement, scheduleAtNextAnimationFrame } from '../../../base/browser/dom.js'; import { DomScrollableElement } from '../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { autorun } from '../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../base/common/scrollable.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; @@ -98,6 +98,15 @@ export class CustomViewNode extends Disposable { vertical: ScrollbarVisibility.Auto, useShadows: false, })); + this._register(addDisposableListener(scrollContent, EventType.SCROLL, () => { + this._scrollable.setScrollPosition({ scrollTop: scrollContent.scrollTop }); + })); + const focusScrollSync = this._register(new MutableDisposable()); + this._register(addDisposableListener(scrollContent, EventType.FOCUS_IN, () => { + focusScrollSync.value = scheduleAtNextAnimationFrame(getWindow(scrollContent), () => { + this._scrollable.setScrollPosition({ scrollTop: scrollContent.scrollTop }); + }); + })); this._scrollable.getDomNode().classList.add('custom-view-body'); this.element.appendChild(this._scrollable.getDomNode()); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index b5d2bc183cb45e..d03e3c041ebf32 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -36,6 +36,7 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { Link } from '../../../../platform/opener/browser/link.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { hasNativeContextMenu } from '../../../../platform/window/common/window.js'; @@ -55,7 +56,7 @@ import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../.. import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { AutomationIsolationModel, normalizeAutomationBranchNames } from '../common/isolationGroupModel.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from '../../chat/browser/mobile/mobileWorkspacePickerSheet.js'; import { AutomationInputCompletions } from './automationInputCompletions.js'; @@ -500,6 +501,23 @@ const AUTOMATIONS_HARNESS_CHIP_ACTION_ID = 'workbench.action.chat.renderAutomati const AUTOMATIONS_WORKSPACE_PICKER_ACTION_ID = 'workbench.action.chat.renderAutomationsWorkspacePicker'; const AUTOMATIONS_ISOLATION_GROUP_ACTION_ID = 'workbench.action.chat.renderAutomationsIsolationGroup'; +export function getAutomationTargetHint(target: Pick, sessionTypes: readonly IProviderSessionType[]): string | undefined { + if (!target.isQuickChat && !target.folderUri) { + return localize('automation.form.targetHint.choose', "Choose a workspace or No workspace to see which agents can run this automation."); + } + if (sessionTypes.length === 0) { + return localize('automation.form.targetHint.unavailable', "No agents are currently available for this target."); + } + if (sessionTypes.length === 1 + && sessionTypes[0].sessionType.id === target.sessionTypeId + && (!target.providerId || sessionTypes[0].providerId === target.providerId)) { + return target.isQuickChat + ? localize('automation.form.targetHint.singleQuickChat', "Only {0} is available without a workspace. Choose a workspace to use a different agent.", sessionTypes[0].sessionType.label) + : localize('automation.form.targetHint.singleWorkspace', "Only {0} is available for this workspace. Change the workspace to use a different agent.", sessionTypes[0].sessionType.label); + } + return undefined; +} + type BranchLoadState = 'noFolder' | 'loadingRepository' | 'noRepository' | 'loadingBranches' | 'ready' | 'empty' | 'error'; function setAutomationControlVisible(container: HTMLElement, visible: boolean): void { @@ -950,7 +968,7 @@ registerAction2(class OpenAutomationsWorkspacePickerAction extends Action2 { menu: [{ id: MenuId.ChatInputSecondary, group: 'navigation', - order: 0, + order: -2, when: ChatContextKeys.inAutomationsDialog, }], }); @@ -1079,8 +1097,8 @@ export function renderForm( // The picker is authoritative for the session type const isolationModel = new AutomationIsolationModel(state); - const workspaceControlsVisible = derived(reader => !isolationModel.isQuickChatObs.read(reader)); - const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker', showChevron: false })); + const workspaceControlsVisible = derived(reader => !isolationModel.isQuickChatObs.read(reader) && isolationModel.folderUriObs.read(reader) !== undefined); + const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker' })); sessionTypePicker.setQuickChatSource(isolationModel.isQuickChatObs); sessionTypePicker.setFolderSource(isolationModel.folderUriObs, { initialPick: state.sessionTypeId @@ -1115,6 +1133,7 @@ export function renderForm( const workspacePicker = disposables.add(instantiationService.createInstance(MobileAutomationsWorkspacePicker, { restoreFromSessions: false, + canRestoreWorkspace: () => false, canSelectWorkspace: (folderUri, preferredProviderId) => canSelectAutomationWorkspace(folderUri, preferredProviderId, sessionsManagementService, workspaceTrustRequestService), })); @@ -1188,10 +1207,6 @@ export function renderForm( } })); - if (!state.isQuickChat && !state.folderUri && workspacePicker.selectedFolderUri) { - isolationModel.setWorkspace(workspacePicker.selectedFolderUri); - } - disposables.add(autorun(reader => { isolationModel.isQuickChatObs.read(reader); updateAutomationSessionTarget(); @@ -1339,6 +1354,33 @@ export function renderForm( chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); disposables.add(scopedInstantiationService.createInstance(AutomationInputCompletions, chatInput.inputEditor)); + const targetHint = DOM.append(promptSection, $('.automation-target-hint')); + const targetHintMessage = DOM.append(targetHint, $('span.automation-target-hint-message', { + role: 'status', + 'aria-atomic': 'true', + })); + const chooseWorkspaceContainer = DOM.append(targetHint, $('span.automation-target-hint-action')); + disposables.add(instantiationService.createInstance(Link, chooseWorkspaceContainer, { + label: localize('automation.form.chooseWorkspace', "Choose Workspace"), + href: '#', + }, { + opener: () => workspacePicker.showPicker(), + })); + const selectedSessionTypeChanged = observableSignalFromEvent(targetHint, sessionTypePicker.onDidChangeSelectedPick); + disposables.add(autorun(reader => { + sessionTypesChanged.read(reader); + selectedSessionTypeChanged.read(reader); + const isQuickChat = isolationModel.isQuickChatObs.read(reader); + const folderUri = isolationModel.folderUriObs.read(reader); + const sessionTypes = isQuickChat + ? sessionsManagementService.getQuickChatSessionTypes() + : folderUri ? sessionsManagementService.getSessionTypesForFolder(folderUri) : []; + const message = getAutomationTargetHint(state, sessionTypes); + setAutomationControlVisible(targetHint, message !== undefined); + if (targetHintMessage.textContent !== (message ?? '')) { + targetHintMessage.textContent = message ?? ''; + } + })); const sessionConfigurationRow = DOM.append(promptSection, $('.automation-form-row')); const sessionConfigurationLabel = DOM.append(sessionConfigurationRow, $('span.automation-form-label', { id: 'automation-session-configuration-label', @@ -1383,6 +1425,8 @@ export function renderForm( })); DOM.hide(sessionConfigurationError); disposables.add(autorun(reader => { + const hasTarget = isolationModel.isQuickChatObs.read(reader) || isolationModel.folderUriObs.read(reader) !== undefined; + setAutomationControlVisible(sessionConfigurationRow, hasTarget); const availability = automationSessionDraftSynchronizer.availability.read(reader); const pending = availability === 'pending'; const controlsUnavailable = availability !== 'available'; @@ -1614,7 +1658,7 @@ export class AutomationsWorkspacePicker extends WorkspacePicker { const noWorkspace = this.targetModel?.isQuickChat === true; const label = noWorkspace ? localize('automation.form.noWorkspace', "No workspace") - : workspace?.label ?? localize('pickWorkspace', "workspace"); + : workspace?.label ?? localize('automation.form.selectWorkspace', "Select workspace"); const icon = noWorkspace ? Codicon.commentDiscussion : workspace?.icon ?? Codicon.project; trigger.setAttribute('aria-label', workspace || noWorkspace diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index bfa7451e8d60a7..5530a938905e82 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -22,6 +22,7 @@ import { isAutomationModelConfiguration, } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { + AutomationCatalogueState, type AutomationMutationGuard, assertAutomationSessionTemplateAuthority, IAutomationRunClaim, @@ -122,14 +123,15 @@ export class AutomationStore extends Disposable implements IAutomationStore { private readonly _automations: ISettableObservable; private readonly _runs: ISettableObservable; + private readonly _catalogueState: ISettableObservable; private _now: () => Date; private readonly _runsForCache = new Map>(); private _lastSeenRevision = 0; - private _canCompleteMigration = true; readonly automations: IObservable; readonly runs: IObservable; + readonly catalogueState: IObservable; constructor( private readonly storageKey: string, @@ -144,14 +146,15 @@ export class AutomationStore extends Disposable implements IAutomationStore { const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION)); const initial = result.kind === 'unsupportedSchema' ? EMPTY_LEDGER : result.ledger; - this._canCompleteMigration = result.kind === 'ledger'; if (result.kind !== 'unsupportedSchema') { this._lastSeenRevision = result.revision; } this._automations = observableValue(this, initial.automations); this._runs = observableValue(this, initial.runs); + this._catalogueState = observableValue(this, result.kind === 'ledger' ? 'ready' : 'error'); this.automations = this._automations; this.runs = this._runs; + this.catalogueState = this._catalogueState; this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, this.storageKey, this._store)(() => { this.refreshFromStorage(); @@ -168,7 +171,7 @@ export class AutomationStore extends Disposable implements IAutomationStore { } canCompleteMigration(): boolean { - return this._canCompleteMigration; + return this._catalogueState.get() === 'ready'; } runsFor(automationId: string): IObservable { @@ -475,9 +478,11 @@ export class AutomationStore extends Disposable implements IAutomationStore { while (true) { const readResult = this.readLedger(raw); if (readResult.kind === 'unsupportedSchema') { + this._catalogueState.set('error', undefined); throw new Error('Cannot modify automations: storage was written by a newer version'); } if (readResult.kind === 'invalid') { + this._catalogueState.set('error', undefined); throw new Error('Cannot modify automations: persisted storage contains data this version cannot safely interpret'); } @@ -512,30 +517,33 @@ export class AutomationStore extends Disposable implements IAutomationStore { } } - private acceptLedger(ledger: ILedger, revision: number): void { + private acceptLedger(ledger: ILedger, revision: number, catalogueState: AutomationCatalogueState = 'ready'): void { if (revision < this._lastSeenRevision) { + if (catalogueState === 'error') { + this._catalogueState.set(catalogueState, undefined); + } return; } - this.setLedger(ledger, revision); + this.setLedger(ledger, revision, catalogueState); } - private setLedger(ledger: ILedger, revision: number): void { + private setLedger(ledger: ILedger, revision: number, catalogueState: AutomationCatalogueState = 'ready'): void { this._lastSeenRevision = revision; transaction(tx => { this._automations.set(ledger.automations, tx); this._runs.set(ledger.runs, tx); + this._catalogueState.set(catalogueState, tx); }); } private refreshFromStorage(): void { const result = this.readLedger(this.storageService.get(this.storageKey, StorageScope.APPLICATION)); if (result.kind === 'unsupportedSchema') { - this._canCompleteMigration = false; + this._catalogueState.set('error', undefined); return; } - this._canCompleteMigration = result.kind === 'ledger'; - this.acceptLedger(result.ledger, result.revision); + this.acceptLedger(result.ledger, result.revision, result.kind === 'ledger' ? 'ready' : 'error'); } private readLedger(raw: string | undefined): ReadLedgerResult { diff --git a/src/vs/sessions/contrib/automations/browser/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index eedf811efc0aa1..40d3754581ab02 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -106,7 +106,7 @@ export class ListAutomationsTool implements IToolImpl { icon: Codicon.calendar, displayName: localize('automation.tool.list.displayName', "List Automations"), userDescription: localize('automation.tool.list.userDescription', "List scheduled agent automations"), - modelDescription: 'List all configured scheduled automations and their stable IDs, editable fields, targets, and timing metadata. Use this before configureAutomation, runAutomation, or deleteAutomation when acting on an existing automation. This tool never changes automation state.', + modelDescription: 'List all currently available scheduled automations and their stable IDs, editable fields, targets, and timing metadata. The result includes catalogueState; only "ready" means the list is complete, so never interpret an empty non-ready result as no configured automations. Use this before configureAutomation, runAutomation, or deleteAutomation when acting on an existing automation. This tool never changes automation state.', source: ToolDataSource.Internal, when: automationToolWhen, runsInWorkspace: false, @@ -130,11 +130,14 @@ export class ListAutomationsTool implements IToolImpl { return automationToolError('Automations are disabled.'); } + const catalogueState = this.automationService.catalogueState.get(); const automations = this.automationService.automations.get().map(toAutomationToolOutput); - const result = automationToolResult(JSON.stringify({ automations }, undefined, 2)); - result.toolResultMessage = automations.length === 1 - ? localize('automation.tool.list.result.singular', "Listed 1 automation") - : localize('automation.tool.list.result.plural', "Listed {0} automations", automations.length); + const result = automationToolResult(JSON.stringify({ catalogueState, automations }, undefined, 2)); + result.toolResultMessage = catalogueState !== 'ready' + ? localize('automation.tool.list.result.incomplete', "Listed {0} available automations; catalogue is incomplete", automations.length) + : automations.length === 1 + ? localize('automation.tool.list.result.singular', "Listed 1 automation") + : localize('automation.tool.list.result.plural', "Listed {0} automations", automations.length); return result; } } diff --git a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts index bd7e01248468cb..1a55686c656a3b 100644 --- a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts +++ b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableFromPromise } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IAutomationDialogService } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; @@ -25,8 +27,12 @@ import { AutomationToolsContribution } from './automationTools.js'; import { IAutomationStorageService } from '../common/automationStorageService.js'; import { AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY } from '../../../../platform/agentHost/common/automationMigration.js'; +const initialProvidersSettled = observableFromPromise( + Registry.as(WorkbenchExtensions.Workbench).whenRestored.then(() => true) +).map(result => result.value === true); + registerSingleton(IAutomationStorageService, BrowserAutomationStorageService, InstantiationType.Delayed); -registerSingleton(IAutomationService, ProviderAutomationService, InstantiationType.Delayed); +registerSingleton(IAutomationService, new SyncDescriptor(ProviderAutomationService, [initialProvidersSettled], true)); registerSingleton(IAutomationRunner, AutomationRunner, InstantiationType.Delayed); registerSingleton(IAutomationDialogService, AutomationDialogService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index c5d0e78d6e1bc5..3ddc18cdf411f0 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -249,6 +249,25 @@ gap: var(--vscode-spacing-size100); } +.automation-target-hint { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + font-size: var(--vscode-fontSize-label1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.automation-target-hint-message { + flex: 1 1 240px; + min-width: 0; +} + +.automation-target-hint-action { + flex-shrink: 0; +} + /* * Host for the embedded ChatInputPart. The composer brings its own * background, border, and rounded corners (see chat.css diff --git a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts index bb6c094fee45c9..574401ecc650b2 100644 --- a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts +++ b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts @@ -11,7 +11,7 @@ import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IAutomationDescriptor, IAutomationRun, AutomationRunTrigger } from '../../../../workbench/contrib/chat/common/automations/automation.js'; -import { AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, AutomationMutationGuard, combineAutomationCatalogueStates, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAutomation, ISessionsProviderAutomations } from '../../../services/sessions/common/sessionsProvider.js'; import { AutomationService } from './automationService.js'; @@ -40,8 +40,10 @@ export class ProviderAutomationService extends Disposable implements IAutomation readonly automations: IObservable; readonly runs: IObservable; + readonly catalogueState: IObservable; constructor( + initialProvidersSettled: IObservable, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @@ -49,6 +51,14 @@ export class ProviderAutomationService extends Disposable implements IAutomation super(); this.legacyStore = this._register(instantiationService.createInstance(AutomationService)); this.providersChanged = observableSignalFromEvent(this, sessionsProvidersService.onDidChangeProviders); + this.catalogueState = derived(this, reader => { + this.providersChanged.read(reader); + const states = this.getStores().map(entry => entry.store.catalogueState.read(reader)); + if (!initialProvidersSettled.read(reader)) { + states.push('loading'); + } + return combineAutomationCatalogueStates(states); + }); this.automations = derived(this, reader => { this.providersChanged.read(reader); return distinctById( @@ -202,11 +212,14 @@ export class ProviderAutomationService extends Disposable implements IAutomation return this.migrationPromise; } - private getStores(): IAutomationStoreEntry[] { - const providerStores = this.sessionsProvidersService.getProviders() + private getProviderStores(): IAutomationStoreEntry[] { + return this.sessionsProvidersService.getProviders() .filter(provider => provider.automations) .map(provider => ({ providerId: provider.id, store: provider.automations! })); - return [...providerStores, { providerId: undefined, store: this.legacyStore }]; + } + + private getStores(): IAutomationStoreEntry[] { + return [...this.getProviderStores(), { providerId: undefined, store: this.legacyStore }]; } private getCreationStore(options: ICreateAutomationOptions): ISessionsProviderAutomations { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index 1bd7d33650359e..5d310ff15795a3 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -37,12 +37,49 @@ import { GitRefType, IGitRepository, IGitService } from '../../../../../workbenc import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { ISession, ISessionWorkspace, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { IAutomationSessionConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; -import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; +import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, getAutomationTargetHint, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; import { AutomationIsolationModel } from '../../common/isolationGroupModel.js'; const FOLDER = URI.file('/workspace'); +suite('Automation target guidance', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const cloud: IProviderSessionType = { + providerId: 'cloud-provider', + sessionType: { id: 'cloud-agent', label: 'Cloud', icon: Codicon.cloud, authRequirement: SessionTypeAuthRequirement.GitHub }, + }; + const local: IProviderSessionType = { + providerId: 'local-provider', + sessionType: { id: 'local-agent', label: 'Local', icon: Codicon.terminal, authRequirement: SessionTypeAuthRequirement.GitHub }, + }; + + test('explains missing targets and the workspace dependency of a single available agent', () => { + const cloudTarget = { ...createFormState(), providerId: cloud.providerId, sessionTypeId: cloud.sessionType.id }; + const localTarget = { ...createFormState(), providerId: local.providerId, sessionTypeId: local.sessionType.id }; + assert.deepStrictEqual({ + unselected: getAutomationTargetHint({ ...localTarget, folderUri: undefined }, []), + cloud: getAutomationTargetHint(cloudTarget, [cloud]), + local: getAutomationTargetHint(localTarget, [local]), + quickChat: getAutomationTargetHint({ ...localTarget, isQuickChat: true, folderUri: undefined }, [local]), + unavailable: getAutomationTargetHint(localTarget, []), + multiple: getAutomationTargetHint(localTarget, [cloud, local]), + retainedUnavailableAgent: getAutomationTargetHint(cloudTarget, [local]), + retainedUnavailableProvider: getAutomationTargetHint({ ...localTarget, providerId: 'unavailable' }, [local]), + }, { + unselected: 'Choose a workspace or No workspace to see which agents can run this automation.', + cloud: 'Only Cloud is available for this workspace. Change the workspace to use a different agent.', + local: 'Only Local is available for this workspace. Change the workspace to use a different agent.', + quickChat: 'Only Local is available without a workspace. Choose a workspace to use a different agent.', + unavailable: 'No agents are currently available for this target.', + multiple: undefined, + retainedUnavailableAgent: undefined, + retainedUnavailableProvider: undefined, + }); + }); +}); + function dispatchKey(target: HTMLElement, type: 'keydown' | 'keyup', key: string, shiftKey = false): KeyboardEvent { const event = new KeyboardEvent(type, { key, bubbles: true, cancelable: true, shiftKey }); target.dispatchEvent(event); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts index 37c299e077711a..1b5d7cdbc44e2d 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; @@ -12,7 +13,7 @@ import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../.. import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { AutomationService, AutomationStore } from '../../browser/automationService.js'; import { AutomationRunTrigger, AutomationTarget, AutomationWorkspaceIsolation, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { AutomationActiveRunError, isAutomationActiveRunError } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationActiveRunError, type AutomationCatalogueState, isAutomationActiveRunError } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { createAutomationService, TestAutomationStorageService } from './automationTestUtils.js'; const FOLDER = URI.parse('file:///workspace'); @@ -78,8 +79,15 @@ suite('AutomationService', () => { test('starts with an empty ledger when nothing is persisted', () => { const { service } = createService(); - assert.deepStrictEqual(service.automations.get(), []); - assert.deepStrictEqual(service.runs.get(), []); + assert.deepStrictEqual({ + automations: service.automations.get(), + runs: service.runs.get(), + catalogueState: service.catalogueState.get(), + }, { + automations: [], + runs: [], + catalogueState: 'ready', + }); }); test('provider stores isolate ledgers by storage key', async () => { @@ -811,6 +819,7 @@ suite('AutomationService', () => { // but the service is now in read-only mode. assert.deepStrictEqual(service.automations.get(), []); assert.deepStrictEqual(service.runs.get(), []); + assert.strictEqual(service.catalogueState.get(), 'error'); // A subsequent mutation must be rejected (read-only mode) and must not // destroy the on-disk newer ledger. @@ -836,7 +845,68 @@ suite('AutomationService', () => { // The onDidChangeValue refresh must NOT clear our observables to // empty. We keep displaying what we last knew about. - assert.strictEqual(service.automations.get().length, 1); + assert.deepStrictEqual({ + automationCount: service.automations.get().length, + catalogueState: service.catalogueState.get(), + }, { + automationCount: 1, + catalogueState: 'error', + }); + }); + + test('refreshFromStorage reports malformed storage after a newer valid revision', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const service = teardown.add(createAutomationService(storage, new NullLogService(), NullTelemetryService)); + await service.createAutomation({ name: 'Local', prompt: 'p', schedule: dailySchedule(), target: workspaceTarget() }); + const emissions: Array<{ automationCount: number; catalogueState: AutomationCatalogueState }> = []; + teardown.add(autorun(reader => emissions.push({ + automationCount: service.automations.read(reader).length, + catalogueState: service.catalogueState.read(reader), + }))); + + storage.store('chat.automations.ledger', '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + + assert.deepStrictEqual({ + automationCount: service.automations.get().length, + catalogueState: service.catalogueState.get(), + emissions, + }, { + automationCount: 1, + catalogueState: 'error', + emissions: [ + { automationCount: 1, catalogueState: 'ready' }, + { automationCount: 1, catalogueState: 'error' }, + ], + }); + }); + + test('publishes catalogue contents and readability atomically on refresh and recovery', async () => { + const { service, storage } = createService(); + const initialLedger = JSON.stringify({ + schemaVersion: 4, revision: 5, + automations: [serializeLedgerAutomation('saved', 'Saved')], + runs: [], + }); + storage.store('chat.automations.ledger', initialLedger, StorageScope.APPLICATION, StorageTarget.MACHINE); + const emissions: Array<{ ids: string[]; catalogueState: AutomationCatalogueState; readable: boolean }> = []; + teardown.add(autorun(reader => emissions.push({ + ids: service.automations.read(reader).map(automation => automation.id), + catalogueState: service.catalogueState.read(reader), + readable: service.canCompleteMigration(), + }))); + + storage.store('chat.automations.ledger', JSON.stringify({ + schemaVersion: 4, revision: 6, automations: [], runs: null, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + storage.store('chat.automations.ledger', initialLedger, StorageScope.APPLICATION, StorageTarget.MACHINE); + await service.updateAutomation('saved', { name: 'Recovered' }); + + assert.deepStrictEqual(emissions, [ + { ids: ['saved'], catalogueState: 'ready', readable: true }, + { ids: [], catalogueState: 'error', readable: false }, + { ids: ['saved'], catalogueState: 'ready', readable: true }, + { ids: ['saved'], catalogueState: 'ready', readable: true }, + ]); }); test('persist bumps the revision counter on every write', async () => { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index 04f33cb155ce42..046f98543130db 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -18,7 +18,7 @@ import { NullTelemetryService } from '../../../../../platform/telemetry/common/t import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationRunDispatch, IAutomationRunner, IAutomationRunOperation } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { AutomationSessionTemplateAuthorityError, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationCatalogueState, AutomationSessionTemplateAuthorityError, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IToolImpl, IToolInvocation, IToolResult, ToolProgress } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { IChat, ISession, ISessionType, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; @@ -58,6 +58,7 @@ function createAutomation(overrides?: Partial): IAutomati } class FakeAutomationService extends mock() { + override readonly catalogueState = observableValue(this, 'ready'); override readonly automations = observableValue(this, []); override readonly runs = observableValue(this, []); readonly created: ICreateAutomationOptions[] = []; @@ -434,6 +435,7 @@ suite('AutomationTools', () => { const result = await invoke(tool, {}); assert.deepStrictEqual(JSON.parse(getText(result)), { + catalogueState: 'ready', automations: [{ id: 'automation-1', name: 'Daily review', @@ -456,6 +458,45 @@ suite('AutomationTools', () => { }); }); + test('listAutomations describes when its catalogue is complete', () => { + const description = new ListAutomationsTool(new FakeAutomationService(), createConfigurationService()).getToolData().modelDescription ?? ''; + + assert.deepStrictEqual({ + reportsState: description.includes('catalogueState'), + definesComplete: description.includes('only "ready" means the list is complete'), + warnsAboutFalseEmpty: description.includes('never interpret an empty non-ready result as no configured automations'), + }, { reportsState: true, definesComplete: true, warnsAboutFalseEmpty: true }); + }); + + for (const catalogueState of ['loading', 'unavailable', 'error'] as const) { + test(`listAutomations preserves available rows in an incomplete ${catalogueState} catalogue`, async () => { + const automationService = new FakeAutomationService(); + automationService.catalogueState.set(catalogueState, undefined); + const tool = new ListAutomationsTool(automationService, createConfigurationService()); + const empty = await invoke(tool, {}); + const sessionTemplate = { modelId: 'provider-model', config: { providerOption: true } }; + automationService.automations.set([createAutomation({ sessionTemplate })], undefined); + const populated = await invoke(tool, {}); + const populatedContent = JSON.parse(getText(populated)); + + assert.deepStrictEqual({ + empty: JSON.parse(getText(empty)), + emptyMessage: empty.toolResultMessage, + populated: { + state: populatedContent.catalogueState, + ids: populatedContent.automations.map((automation: { id: string }) => automation.id), + sessionTemplate: populatedContent.automations[0].sessionTemplate, + }, + populatedMessage: populated.toolResultMessage, + }, { + empty: { catalogueState, automations: [] }, + emptyMessage: 'Listed 0 available automations; catalogue is incomplete', + populated: { state: catalogueState, ids: ['automation-1'], sessionTemplate }, + populatedMessage: 'Listed 1 available automations; catalogue is incomplete', + }); + }); + } + test('listAutomations emits flat aliases only for legacy rows', async () => { const automation = createAutomation(); const tool = new ListAutomationsTool(new FakeAutomationService([automation]), createConfigurationService()); diff --git a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts index 2dd0f328d4f849..42363be1f9ee46 100644 --- a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { Emitter } from '../../../../../base/common/event.js'; +import { autorun, type ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -16,7 +17,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAutomation, IAutomationSnapshotImportResult, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; -import { AutomationActiveRunError } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationActiveRunError, AutomationCatalogueState } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { AutomationStore } from '../../browser/automationService.js'; import { ProviderAutomationService } from '../../browser/providerAutomationService.js'; import { AUTOMATION_STORAGE_KEY, IAutomationStorageService, providerAutomationStorageKey } from '../../common/automationStorageService.js'; @@ -32,6 +33,15 @@ class FailingStaleRunRecoveryAutomationStore extends AutomationStore { } } +class MutableCatalogueAutomationStore extends AutomationStore { + private readonly state = observableValue(this, 'ready'); + override readonly catalogueState = this.state; + + setCatalogueState(state: AutomationCatalogueState, tx?: ITransaction): void { + this.state.set(state, tx); + } +} + class MigrationDeferringAutomationStore extends AutomationStore { recoveryCalls = 0; migrationCalls = 0; @@ -127,17 +137,25 @@ class DestinationDeletingTransferAutomationStore extends AutomationStore { suite('ProviderAutomationService', () => { const teardown = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(legacyRaw?: string, providerRaw?: string, providerFailure?: 'staleRunRecovery' | 'migration' | 'transfer' | 'acknowledgement' | 'concurrentMigrationUpdate' | 'concurrentMigrationDelete' | 'concurrentMigrationRun' | 'continuousMigrationUpdate' | 'concurrentTransferRun' | 'destinationDeleteDuringRollback'): { + function createService( + legacyRaw?: string, + providerRaw?: string, + providerFailure?: 'staleRunRecovery' | 'migration' | 'transfer' | 'acknowledgement' | 'concurrentMigrationUpdate' | 'concurrentMigrationDelete' | 'concurrentMigrationRun' | 'continuousMigrationUpdate' | 'concurrentTransferRun' | 'destinationDeleteDuringRollback', + registerDefaultProvider = true, + initialProvidersSettled = true, + ): { readonly service: ProviderAutomationService; readonly providerStore: AutomationStore; readonly storage: InMemoryStorageService; readonly automationStorage: TestAutomationStorageService; readonly addProvider: (provider: ISessionsProvider) => void; + readonly settleInitialProviders: () => void; } { const storage = teardown.add(new InMemoryStorageService()); if (legacyRaw) { storage.store(AUTOMATION_STORAGE_KEY, legacyRaw, StorageScope.APPLICATION, StorageTarget.MACHINE); } + if (providerRaw) { storage.store(providerAutomationStorageKey(PROVIDER_ID), providerRaw, StorageScope.APPLICATION, StorageTarget.MACHINE); } @@ -196,7 +214,7 @@ suite('ProviderAutomationService', () => { order: 0, automations: providerStore, }); - const registeredProviders: ISessionsProvider[] = [provider]; + const registeredProviders: ISessionsProvider[] = registerDefaultProvider ? [provider] : []; const providersChanged = teardown.add(new Emitter()); const providers = upcastPartial({ onDidChangeProviders: providersChanged.event, @@ -210,7 +228,8 @@ suite('ProviderAutomationService', () => { instantiationService.stub(IAutomationStorageService, automationStorage); instantiationService.stub(ISessionsProvidersService, providers); instantiationService.stub(IInstantiationService, instantiationService); - const service = teardown.add(instantiationService.createInstance(ProviderAutomationService)); + const providersSettled = observableValue('initialProvidersSettled', initialProvidersSettled); + const service = teardown.add(instantiationService.createInstance(ProviderAutomationService, providersSettled)); return { service, providerStore, @@ -220,9 +239,142 @@ suite('ProviderAutomationService', () => { registeredProviders.push(addedProvider); providersChanged.fire({ added: [addedProvider], removed: [] }); }, + settleInitialProviders: () => providersSettled.set(true, undefined), }; } + test('aggregates provider catalogue state', () => { + const { service, storage, automationStorage, addProvider } = createService(); + const emissions: AutomationCatalogueState[] = []; + teardown.add(autorun(reader => emissions.push(service.catalogueState.read(reader)))); + const store = teardown.add(new MutableCatalogueAutomationStore( + providerAutomationStorageKey('stateful-provider'), + storage, + new NullLogService(), + NullTelemetryService, + automationStorage, + )); + store.setCatalogueState('loading'); + addProvider(upcastPartial({ id: 'stateful-provider', order: 1, automations: store })); + const loading = service.catalogueState.get(); + store.setCatalogueState('error'); + const error = service.catalogueState.get(); + store.setCatalogueState('unavailable'); + const unavailable = service.catalogueState.get(); + store.setCatalogueState('ready'); + const ready = service.catalogueState.get(); + + assert.deepStrictEqual({ loading, error, unavailable, ready }, { + loading: 'loading', + error: 'error', + unavailable: 'unavailable', + ready: 'ready', + }); + assert.deepStrictEqual(emissions, ['ready', 'loading', 'error', 'unavailable', 'ready']); + }); + + test('settles a provider-less catalogue after initial provider contributions complete', () => { + const { service, providerStore, addProvider, settleInitialProviders } = createService(undefined, undefined, undefined, false, false); + const beforeSettlement = service.catalogueState.get(); + settleInitialProviders(); + const afterSettlement = service.catalogueState.get(); + addProvider(upcastPartial({ id: PROVIDER_ID, order: 0, automations: providerStore })); + + assert.deepStrictEqual({ + beforeSettlement, + afterSettlement, + afterRegistration: service.catalogueState.get(), + }, { + beforeSettlement: 'loading', + afterSettlement: 'ready', + afterRegistration: 'ready', + }); + }); + + test('keeps registered providers loading until initial contributions settle', () => { + const { service, providerStore, addProvider, settleInitialProviders } = createService(undefined, undefined, undefined, false, false); + const emissions: AutomationCatalogueState[] = []; + teardown.add(autorun(reader => emissions.push(service.catalogueState.read(reader)))); + addProvider(upcastPartial({ id: PROVIDER_ID, order: 0, automations: providerStore })); + const beforeSettlement = service.catalogueState.get(); + settleInitialProviders(); + + assert.deepStrictEqual({ beforeSettlement, emissions }, { + beforeSettlement: 'loading', + emissions: ['loading', 'ready'], + }); + }); + + test('legacy rows do not make initial provider discovery authoritative', async () => { + const { service, settleInitialProviders } = createService(undefined, undefined, undefined, false, false); + await service.createAutomation({ + name: 'Legacy only', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'workspace', folderUri: FOLDER, isolation: { kind: 'default' } }, + }); + const beforeSettlement = service.catalogueState.get(); + settleInitialProviders(); + + assert.deepStrictEqual({ + beforeSettlement, + afterSettlement: service.catalogueState.get(), + names: service.automations.get().map(automation => automation.name), + }, { + beforeSettlement: 'loading', + afterSettlement: 'ready', + names: ['Legacy only'], + }); + }); + + test('aggregates error, loading, and unavailable states independently of provider order', () => { + const { service, storage, automationStorage, addProvider } = createService(); + const first = teardown.add(new MutableCatalogueAutomationStore('first', storage, new NullLogService(), NullTelemetryService, automationStorage)); + const second = teardown.add(new MutableCatalogueAutomationStore('second', storage, new NullLogService(), NullTelemetryService, automationStorage)); + addProvider(upcastPartial({ id: 'first', order: 1, automations: first })); + addProvider(upcastPartial({ id: 'second', order: 2, automations: second })); + let observedState: AutomationCatalogueState = 'ready'; + teardown.add(autorun(reader => observedState = service.catalogueState.read(reader))); + const states: readonly AutomationCatalogueState[] = ['ready', 'unavailable', 'loading', 'error']; + const actual = states.map(firstState => states.map(secondState => { + transaction(tx => { + first.setCatalogueState(firstState, tx); + second.setCatalogueState(secondState, tx); + }); + return observedState; + })); + + assert.deepStrictEqual(actual, [ + ['ready', 'unavailable', 'loading', 'error'], + ['unavailable', 'unavailable', 'loading', 'error'], + ['loading', 'loading', 'loading', 'error'], + ['error', 'error', 'error', 'error'], + ]); + }); + + test('does not let provider loading mask a legacy catalogue error', () => { + const { service, storage, automationStorage, addProvider } = createService('{', undefined, undefined, false); + const emissions: AutomationCatalogueState[] = []; + teardown.add(autorun(reader => emissions.push(service.catalogueState.read(reader)))); + const store = teardown.add(new MutableCatalogueAutomationStore( + providerAutomationStorageKey('loading-provider'), + storage, + new NullLogService(), + NullTelemetryService, + automationStorage, + )); + store.setCatalogueState('loading'); + addProvider(upcastPartial({ id: 'loading-provider', order: 1, automations: store })); + + assert.deepStrictEqual({ + catalogueState: service.catalogueState.get(), + emissions, + }, { + catalogueState: 'error', + emissions: ['error'], + }); + }); + test('routes new Automations to their provider store', async () => { const { service, providerStore, storage } = createService(); await service.createAutomation({ @@ -246,6 +398,40 @@ suite('ProviderAutomationService', () => { }); }); + test('an unavailable remote catalogue does not block local automation operations', async () => { + const { service, providerStore, storage, automationStorage, addProvider } = createService(); + const remote = teardown.add(new MutableCatalogueAutomationStore('remote', storage, new NullLogService(), NullTelemetryService, automationStorage)); + remote.setCatalogueState('unavailable'); + addProvider(upcastPartial({ id: 'remote', order: 1, automations: remote })); + + const created = await service.createAutomation({ + name: 'Local review', + prompt: 'Review local changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'workspace', folderUri: FOLDER, providerId: PROVIDER_ID, sessionTypeId: SESSION_TYPE_ID, isolation: { kind: 'default' } }, + }); + await service.updateAutomation(created.id, { name: 'Updated local review' }); + const claim = await service.recordRunStart(created.id, 'manual', 1); + + assert.deepStrictEqual({ + catalogueState: service.catalogueState.get(), + localNames: providerStore.automations.get().map(automation => automation.name), + remoteAutomations: remote.automations.get(), + canRun: service.canRunAutomation(created.id), + canUpdate: service.canUpdateAutomation(created.id), + claimed: claim.claimed, + activeRunId: providerStore.getActiveRunFor(created.id)?.id, + }, { + catalogueState: 'unavailable', + localNames: ['Updated local review'], + remoteAutomations: [], + canRun: true, + canUpdate: true, + claimed: true, + activeRunId: claim.run.id, + }); + }); + test('transfers Automations and runs when updates change store ownership', async () => { const { service, providerStore, storage } = createService(); const legacyTarget = { kind: 'workspace', folderUri: FOLDER, providerId: 'provider-without-storage', sessionTypeId: 'other', isolation: { kind: 'default' } } as const; diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 92d9eba42bb0e6..e096b93fb827e9 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -1823,7 +1823,7 @@ export class WorkspacePicker extends Disposable { } private _restoreSelectedWorkspace(): IRestoredWorkspaceSelection | undefined { - if (this.isNoWorkspaceSelected()) { + if (!this._canRestoreWorkspace()) { return undefined; } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 9773798cdd3160..183e80c9609d7c 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -2562,6 +2562,91 @@ suite('AutomationsWorkspacePicker', () => { ensureNoDisposablesAreLeakedInTestSuite(); + for (const checked of [true, false]) { + test(`does not inherit a ${checked ? 'checked' : 'recent'} cloud workspace when restoration is disabled`, async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const provider = createMockProvider('github'); + const cloudUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [{ uri: cloudUri, providerId: provider.id, checked }]); + providersService.setProviders([provider]); + const originalRecents = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); + const picker = createTestPicker( + disposables, providersService, storage, undefined, TestAutomationsWorkspacePicker, + undefined, undefined, undefined, { restoreFromSessions: false, canRestoreWorkspace: () => false }, + ); + assert.ok(picker instanceof TestAutomationsWorkspacePicker); + picker.setTargetModel(new AutomationIsolationModel({ + isQuickChat: false, folderUri: undefined, isolationMode: undefined, branch: undefined, + })); + const container = document.createElement('div'); + picker.render(container); + const initialUri = picker.selectedFolderUri; + providersService.setProviders([provider]); + await timeout(0); + picker.refreshAutomaticSelection(); + + assert.deepStrictEqual({ + initialUri, + afterRefresh: picker.selectedFolderUri, + label: container.querySelector('.sessions-chat-dropdown-label')?.textContent, + ariaLabel: container.querySelector('.action-label')?.getAttribute('aria-label'), + recentsUnchanged: originalRecents === storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE), + noWorkspaceAvailable: picker.getItems().some(item => item.label === 'No workspace'), + }, { + initialUri: undefined, + afterRefresh: undefined, + label: 'Select workspace', + ariaLabel: 'Pick a workspace for this automation', + recentsUnchanged: true, + noWorkspaceAvailable: true, + }); + }); + } + + test('preserves an explicitly seeded target and allows changing it without restoring another workspace', async () => { + const providersService = disposables.add(new MockSessionsProvidersService()); + const provider = createMockProvider('github'); + const cloudUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); + const localUri = URI.file('/local/project'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [{ uri: localUri, providerId: provider.id, checked: true }]); + providersService.setProviders([provider]); + const originalRecents = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); + const picker = createTestPicker( + disposables, providersService, storage, undefined, TestAutomationsWorkspacePicker, + undefined, undefined, undefined, { restoreFromSessions: false, canRestoreWorkspace: () => false }, + ); + assert.ok(picker instanceof TestAutomationsWorkspacePicker); + const model = new AutomationIsolationModel({ + isQuickChat: false, folderUri: cloudUri, isolationMode: undefined, branch: undefined, + }); + picker.setTargetModel(model); + picker.setSelectedWorkspace(cloudUri, { fireEvent: false, persist: false }); + const container = document.createElement('div'); + picker.render(container); + providersService.setProviders([provider]); + await timeout(0); + const seededTarget = picker.selectedFolderUri?.toString(); + await picker.select('No workspace'); + const quickChat = model.isQuickChat; + await picker.select('local/project'); + + assert.deepStrictEqual({ + seededTarget, + quickChat, + selectedFolder: model.folderUri?.toString(), + isQuickChat: model.isQuickChat, + recentsUnchanged: originalRecents === storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE), + }, { + seededTarget: cloudUri.toString(), + quickChat: true, + selectedFolder: localUri.toString(), + isQuickChat: false, + recentsUnchanged: true, + }); + }); + test('selects No workspace and restores a folder through the same picker', async () => { const providersService = disposables.add(new MockSessionsProvidersService()); const provider = createMockProvider('local-1'); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 7404088a470edf..5ae0f3818a2720 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -5,6 +5,7 @@ import { disposableTimeout, timeout } from '../../../../../base/common/async.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; +import { Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable, type IReference } from '../../../../../base/common/lifecycle.js'; import { autorun, derived, type IObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -23,7 +24,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { assertAutomationSessionTemplate, type AutomationRunTrigger, type AutomationTarget, type IAutomationDescriptor, type IAutomationRun, type IAutomationSchedule, type IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { AutomationActiveRunError, assertAutomationSessionTemplateAuthority, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationActiveRunError, type AutomationCatalogueState, assertAutomationSessionTemplateAuthority, combineAutomationCatalogueStates, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { publishAutomationMigration } from '../../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAutomationStorageService } from '../../../automations/common/automationStorageService.js'; @@ -76,6 +77,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro private readonly _catalogReference: IReference>; private readonly _catalog: IAgentSubscription; private readonly _catalogChanged; + private readonly _catalogError; private readonly _ready = observableValue(this, false); private readonly _runsForCache = new Map>(); private readonly _pendingWaits = this._register(new DisposableMap()); @@ -87,6 +89,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro readonly automations: IObservable; readonly runs: IObservable; + readonly catalogueState: IObservable; constructor( private readonly _providerId: string, @@ -115,6 +118,14 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro )); this._catalog = this._catalogReference.object; this._catalogChanged = observableSignalFromEvent(this, this._catalog.onDidChange); + this._catalogError = observableSignalFromEvent(this, this._catalog.onDidError ?? Event.None); + this.catalogueState = derived(this, reader => { + this._catalogChanged.read(reader); + this._catalogError.read(reader); + const hostState = this._catalog.value instanceof Error ? 'error' : this._catalog.verifiedValue ? 'ready' : 'loading'; + const legacyState = this._ready.read(reader) ? 'ready' : this._legacySource?.catalogueState.read(reader) ?? 'ready'; + return combineAutomationCatalogueStates([hostState, legacyState]); + }); if (this._catalog.onDidError) { this._register(this._catalog.onDidError(error => this._logService.error(`[AgentHostAutomationStore] Catalogue subscription failed: ${error.message}`))); } @@ -125,7 +136,11 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro && (isAgentHostAutomationCatalogMigrated(catalog) || catalog.entries.some(automation => automation.operations.includes(AutomationOperation.Run))) && !catalog.entries.some(automation => isAgentHostLegacyAutomationImportPending(automation.definition)) - && (!this._legacySource || this._legacySource.automations.read(reader).length === 0) + && (!this._legacySource || ( + this._legacySource.catalogueState.read(reader) === 'ready' + && this._legacySource.canCompleteMigration?.() !== false + && this._legacySource.automations.read(reader).length === 0 + )) && !this._migrationPromise && !this._ready.read(reader)) { this._ready.set(true, undefined); @@ -452,9 +467,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro let migratedCount = 0; let failedCount = 0; try { - if (source?.canCompleteMigration?.() === false) { - throw new Error('Legacy Automation storage cannot be migrated safely by this version.'); - } + this._requireLegacySourceReadable(); const failures: Error[] = []; for (const automation of discovered) { try { @@ -480,6 +493,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._requireLegacySourceDrained(); await this._waitForCatalog(() => true); + this._requireLegacySourceDrained(); const resources = discovered.map(automation => automationResource(automation.id)); this._connection.dispatch(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, @@ -497,6 +511,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro // covers reconnect races and cross-provider transfers that stage // pending without a subsequent acknowledgement path. await this._drainPendingImports(); + this._requireLegacySourceDrained(); this._ready.set(true, undefined); const durationMs = Date.now() - startedAt; this._logService.info(`[AgentHostAutomationStore] Automation migration completed: discovered=${discovered.length}, migrated=${resources.length}, failed=0, durationMs=${durationMs}.`); @@ -545,7 +560,14 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } } + private _requireLegacySourceReadable(): void { + if (this._legacySource && (this._legacySource.catalogueState.get() !== 'ready' || this._legacySource.canCompleteMigration?.() === false)) { + throw new Error('Legacy Automation storage cannot be migrated safely by this version.'); + } + } + private _requireLegacySourceDrained(): void { + this._requireLegacySourceReadable(); const remaining = this._legacySource?.automations.get().length ?? 0; if (remaining > 0) { throw new Error(`Automation migration source changed during migration; ${remaining} definition(s) remain.`); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts index 819eb4a17b4549..630edb28eb6e91 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/reconnectableAgentHostAutomationStore.ts @@ -7,12 +7,12 @@ import { disposableTimeout } from '../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../base/common/errors.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, disposableObservableValue, observableSignalFromEvent, observableValue, waitForState, type IObservable } from '../../../../../base/common/observable.js'; +import { autorun, derived, disposableObservableValue, observableSignalFromEvent, observableValue, transaction, waitForState, type IObservable, type ITransaction } from '../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import type { AutomationRunTrigger, IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { isAutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationCatalogueState, isAutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostAutomationStore, type IAgentHostAutomationBoundaryMapper, type IAgentHostAutomationConnection } from './agentHostAutomationStore.js'; import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; @@ -37,6 +37,20 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements readonly automations = derived(this, reader => this._currentStore.read(reader)?.automations.read(reader) ?? this._legacySource?.automations.read(reader) ?? []); readonly runs = derived(this, reader => this._currentStore.read(reader)?.runs.read(reader) ?? this._legacySource?.runs.read(reader) ?? []); + readonly catalogueState: IObservable = derived(this, reader => { + const authorityState = this._authorityState.read(reader); + const legacyState = this._legacySource?.catalogueState.read(reader) ?? 'ready'; + switch (authorityState.kind) { + case 'initializing': + return legacyState === 'error' ? 'error' : 'loading'; + case 'supported': + return authorityState.store.catalogueState.read(reader); + case 'unsupported': + case 'disabled': + case 'disconnected': + return legacyState === 'error' ? 'error' : 'unavailable'; + } + }); constructor( private readonly _providerId: string, @@ -51,20 +65,19 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements } override dispose(): void { - this._connectionBinding.clear(); - this._migrationRetry.clear(); - this._setAuthorityState({ kind: 'disconnected' }); + this.clearConnection(); this._disposeCancellation.cancel(); this._disposeCancellation.dispose(); - this._currentStore.set(undefined, undefined); super.dispose(); } setConnection(connection: IAgentHostAutomationConnection): void { this._connectionBinding.clear(); this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); - this._setAuthorityState({ kind: 'initializing' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'initializing' }, tx); + }); this._connectionBinding.add(autorun(reader => { this._configurationChanged.read(reader); const initializeResult = connection.initializeResult.read(reader); @@ -73,9 +86,11 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements if (!enabled) { if (current) { this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); } - this._setAuthorityState({ kind: 'disabled' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'disabled' }, tx); + }); return; } if (!initializeResult) { @@ -85,15 +100,19 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements if (!initializeResult.automations) { if (current) { this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); } - this._setAuthorityState({ kind: 'unsupported' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'unsupported' }, tx); + }); return; } if (!current) { const store = this._instantiationService.createInstance(AgentHostAutomationStore, this._providerId, connection, this._legacySource, this._boundaryMapper); - this._currentStore.set(store, undefined); - this._setAuthorityState({ kind: 'supported', store }); + transaction(tx => { + this._currentStore.set(store, tx); + this._setAuthorityState({ kind: 'supported', store }, tx); + }); this._completeMigration(store); } else { this._setAuthorityState({ kind: 'supported', store: current }); @@ -104,8 +123,10 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements clearConnection(): void { this._connectionBinding.clear(); this._migrationRetry.clear(); - this._currentStore.set(undefined, undefined); - this._setAuthorityState({ kind: 'disconnected' }); + transaction(tx => { + this._currentStore.set(undefined, tx); + this._setAuthorityState({ kind: 'disconnected' }, tx); + }); } getAutomation(id: string): IAutomationDescriptor | undefined { @@ -224,13 +245,13 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements } } - private _setAuthorityState(state: AutomationAuthorityState): void { + private _setAuthorityState(state: AutomationAuthorityState, tx?: ITransaction): void { const current = this._authorityState.get(); if (current.kind === state.kind && (current.kind !== 'supported' || state.kind !== 'supported' || current.store === state.store)) { return; } - this._authorityState.set(state, undefined); + this._authorityState.set(state, tx); } private _completeMigration(store: AgentHostAutomationStore): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index d212213a957eac..2f5f66c53ae99d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -7,11 +7,11 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, type IReference } from '../../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; +import { autorun, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import type { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; @@ -23,7 +23,7 @@ import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, StateComponents } from '../../. import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { AgentHostAutomationStore } from '../../browser/agentHostAutomationStore.js'; @@ -33,14 +33,18 @@ import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../../workbench/co import { TestAutomationStorageService } from '../../../../automations/test/browser/automationTestUtils.js'; import { AutomationStore } from '../../../../automations/browser/automationService.js'; import { ReconnectableAgentHostAutomationStore } from '../../browser/reconnectableAgentHostAutomationStore.js'; +import type { AutomationCatalogueState } from '../../../../../../workbench/contrib/chat/common/automations/automationService.js'; class TestAutomationConnection { private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; private readonly _onDidCatalogChange = new Emitter(); + private readonly _onDidCatalogError = new Emitter(); private readonly _onDidRootChange = new Emitter(); private _catalog: AutomationState = { entries: [] }; + private _catalogError: Error | undefined; + private _catalogAvailable: boolean; private _root: RootState; private _serverSeq = 0; private _migrationComplete: boolean; @@ -54,8 +58,9 @@ class TestAutomationConnection { updateError: Error | undefined; readonly createRequested = new DeferredPromise(); - constructor(migrationComplete: boolean) { + constructor(migrationComplete: boolean, catalogAvailable = true) { this._migrationComplete = migrationComplete; + this._catalogAvailable = catalogAvailable; this._root = { agents: [], activeSessions: 0, @@ -110,9 +115,10 @@ class TestAutomationConnection { const connection = this; return { object: { - get value() { return connection._catalog; }, - get verifiedValue() { return connection._catalog; }, + get value() { return connection._catalogError ?? (connection._catalogAvailable ? connection._catalog : undefined); }, + get verifiedValue() { return connection._catalogAvailable ? connection._catalog : undefined; }, onDidChange: this._onDidCatalogChange.event, + onDidError: this._onDidCatalogError.event, onWillApplyAction: Event.None, onDidApplyAction: Event.None, }, @@ -120,6 +126,17 @@ class TestAutomationConnection { }; } + setCatalogError(error: Error): void { + this._catalogError = error; + this._onDidCatalogError.fire(error); + } + + setCatalogAvailable(available = true): void { + this._catalogAvailable = available; + this._catalogError = undefined; + this._onDidCatalogChange.fire(this._catalog); + } + dispatch(channel: string, action: Parameters[1]): void { this.dispatched.push({ channel, action }); if (action.type === ActionType.AutomationCreateRequested) { @@ -287,6 +304,7 @@ class TestAutomationConnection { dispose(): void { this._onDidAction.dispose(); this._onDidCatalogChange.dispose(); + this._onDidCatalogError.dispose(); this._onDidRootChange.dispose(); } } @@ -410,6 +428,116 @@ suite('AgentHostAutomationStore', () => { }; } + test('reports loading until the authoritative catalogue is ready', () => { + const connection = new TestAutomationConnection(false, false); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const loading = store.catalogueState.get(); + connection.setCatalogAvailable(); + + assert.deepStrictEqual({ + loading, + afterSnapshot: store.catalogueState.get(), + }, { + loading: 'loading', + afterSnapshot: 'ready', + }); + }); + + test('reports catalogue errors after the ready state was observed', async () => { + const connection = new TestAutomationConnection(true); + disposables.add(connection); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + + const ready = store.catalogueState.get(); + connection.setCatalogError(new Error('catalogue unavailable')); + + assert.deepStrictEqual({ + ready, + afterError: store.catalogueState.get(), + }, { + ready: 'ready', + afterError: 'error', + }); + }); + + for (const hasHostAutomation of [false, true]) { + test(`does not mask an unreadable legacy source with a ${hasHostAutomation ? 'populated' : 'empty'} host snapshot`, async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + if (hasHostAutomation) { + const hostStore = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await hostStore.createAutomation({ + name: 'Known host automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }); + } + const storageKey = providerAutomationStorageKey('local-agent-host'); + storage.store(storageKey, '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + const legacy = disposables.add(new AutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage)); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + await assert.rejects(store.completeMigration(), /cannot be migrated safely/); + + assert.deepStrictEqual({ + legacyState: legacy.catalogueState.get(), + hostState: store.catalogueState.get(), + canCompleteMigration: legacy.canCompleteMigration(), + names: store.automations.get().map(automation => automation.name), + completions: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }, { + legacyState: 'error', + hostState: 'error', + canCompleteMigration: false, + names: hasHostAutomation ? ['Known host automation'] : [], + completions: 0, + }); + }); + } + + test('rechecks legacy readability after waiting for the host catalogue', async () => { + const connection = disposables.add(new TestAutomationConnection(false, false)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const storageKey = providerAutomationStorageKey('local-agent-host'); + const legacy = disposables.add(new AutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage)); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, legacy, undefined, new NullLogService(), storage, NullTelemetryService, automationStorage)); + const migration = assert.rejects(store.completeMigration(), /cannot be migrated safely/); + storage.store(storageKey, '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + connection.setCatalogAvailable(); + await migration; + const afterFailure = { + state: store.catalogueState.get(), + completions: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }; + + storage.store(storageKey, JSON.stringify({ schemaVersion: 4, revision: 1, automations: [], runs: [] }), StorageScope.APPLICATION, StorageTarget.MACHINE); + await store.completeMigration(); + + assert.deepStrictEqual({ + afterFailure, + afterRepair: { + state: store.catalogueState.get(), + completions: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, + }, + }, { + afterFailure: { state: 'error', completions: 0 }, + afterRepair: { state: 'ready', completions: 1 }, + }); + }); + test('uses the exact catalogue channel and projects authoritative creates', async () => { const connection = new TestAutomationConnection(true); disposables.add(connection); @@ -430,6 +558,7 @@ suite('AgentHostAutomationStore', () => { const trigger = create.type === ActionType.AutomationCreateRequested ? create.definition.triggers[0] : undefined; assert.deepStrictEqual({ + catalogueState: store.catalogueState.get(), subscribedChannel: connection.subscribedChannel, dispatchChannel: connection.dispatched[0].channel, definitionMeta: create.type === ActionType.AutomationCreateRequested ? create.definition._meta : undefined, @@ -443,6 +572,7 @@ suite('AgentHostAutomationStore', () => { enabled: automation.enabled, }, }, { + catalogueState: 'ready', subscribedChannel: URI.parse(AUTOMATION_CATALOG_URI).toString(), dispatchChannel: AUTOMATION_CATALOG_URI, definitionMeta: undefined, @@ -2099,6 +2229,169 @@ suite('AgentHostAutomationStore', () => { }); }); + test('reports disconnected authority as non-authoritative without a false-ready emission', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const instantiationService = disposables.add(new TestInstantiationService()); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + legacy, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + const emissions: { automationCount: number; catalogueState: AutomationCatalogueState }[] = []; + disposables.add(autorun(reader => { + emissions.push({ + automationCount: store.automations.read(reader).length, + catalogueState: store.catalogueState.read(reader), + }); + })); + const initiallyDisconnected = store.catalogueState.get(); + const emissionsBeforeConnect = emissions.length; + store.setConnection(connection); + const connectEmissions = emissions.slice(emissionsBeforeConnect); + await store.createAutomation({ + name: 'Host automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const connected = store.catalogueState.get(); + const emissionsBeforeDisconnect = emissions.length; + store.clearConnection(); + const disconnectEmissions = emissions.slice(emissionsBeforeDisconnect); + const afterDisconnect = store.catalogueState.get(); + connection.setCatalogAvailable(false); + store.setConnection(connection); + const duringReconnect = { state: store.catalogueState.get(), count: store.automations.get().length }; + connection.setCatalogAvailable(); + await store.completeMigration(); + + assert.deepStrictEqual({ + initiallyDisconnected, + connectEmissions, + connected, + afterDisconnect, + disconnectEmissions, + duringReconnect, + afterReconnect: { state: store.catalogueState.get(), count: store.automations.get().length }, + }, { + initiallyDisconnected: 'unavailable', + connectEmissions: [ + { automationCount: 0, catalogueState: 'loading' }, + { automationCount: 0, catalogueState: 'ready' }, + ], + connected: 'ready', + afterDisconnect: 'unavailable', + disconnectEmissions: [{ automationCount: 0, catalogueState: 'unavailable' }], + duringReconnect: { state: 'loading', count: 0 }, + afterReconnect: { state: 'ready', count: 1 }, + }); + }); + + test('keeps readable legacy rows visible while their provider is unavailable', async () => { + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const storageKey = providerAutomationStorageKey('remote-agent-host'); + const legacy = disposables.add(new AutomationStore(storageKey, storage, new NullLogService(), NullTelemetryService, automationStorage)); + await legacy.createAutomation({ + name: 'Legacy automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'remote-agent-host', sessionTypeId: 'copilotcli' }, + }); + const instantiationService = disposables.add(new TestInstantiationService()); + const store = disposables.add(new ReconnectableAgentHostAutomationStore('remote-agent-host', legacy, undefined, instantiationService, new NullLogService(), new TestConfigurationService())); + const availableRows = { state: store.catalogueState.get(), names: store.automations.get().map(automation => automation.name) }; + storage.store(storageKey, '{', StorageScope.APPLICATION, StorageTarget.MACHINE); + + assert.deepStrictEqual({ + availableRows, + afterError: { state: store.catalogueState.get(), names: store.automations.get().map(automation => automation.name) }, + }, { + availableRows: { state: 'unavailable', names: ['Legacy automation'] }, + afterError: { state: 'error', names: ['Legacy automation'] }, + }); + }); + + test('transitions to unsupported and disabled authority atomically', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const automationStorage = new TestAutomationStorageService(storage); + const legacy = disposables.add(new AutomationStore(providerAutomationStorageKey('local-agent-host'), storage, new NullLogService(), NullTelemetryService, automationStorage)); + const instantiationService = disposables.add(new TestInstantiationService()); + const configurationService = new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IAutomationStorageService, automationStorage); + const store = disposables.add(new ReconnectableAgentHostAutomationStore( + 'local-agent-host', + legacy, + undefined, + instantiationService, + new NullLogService(), + configurationService, + )); + store.setConnection(connection); + await store.createAutomation({ + name: 'Host automation', + prompt: 'Review changes.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + }); + const emissions: { automationCount: number; catalogueState: AutomationCatalogueState }[] = []; + disposables.add(autorun(reader => { + emissions.push({ + automationCount: store.automations.read(reader).length, + catalogueState: store.catalogueState.read(reader), + }); + })); + + const beforeUnsupported = emissions.length; + connection.initializeResult.set({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + }, undefined); + const unsupportedEmissions = emissions.slice(beforeUnsupported); + + connection.initializeResult.set({ + protocolVersion: '1', + serverSeq: 0, + snapshots: [], + automations: { create: {}, runCancellation: {} }, + }, undefined); + const beforeDisabled = emissions.length; + await configurationService.setUserConfiguration(CHAT_AUTOMATIONS_ENABLED_SETTING, false); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CHAT_AUTOMATIONS_ENABLED_SETTING]), + change: { keys: [CHAT_AUTOMATIONS_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: candidate => candidate === CHAT_AUTOMATIONS_ENABLED_SETTING, + }); + const disabledEmissions = emissions.slice(beforeDisabled); + + assert.deepStrictEqual({ + unsupportedEmissions, + disabledEmissions, + }, { + unsupportedEmissions: [{ automationCount: 0, catalogueState: 'unavailable' }], + disabledEmissions: [{ automationCount: 0, catalogueState: 'unavailable' }], + }); + }); + test('migration remains retryable while connection capabilities are initializing', async () => { const connection = disposables.add(new TestAutomationConnection(false)); connection.initializeResult.set(undefined, undefined); diff --git a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css index 39f62e1f5a60e3..53d68f0d3188be 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css +++ b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css @@ -30,6 +30,9 @@ .automations-cards-header, .automations-cards-grid, .automations-cards-empty, +.automations-cards-state, +.automations-cards-partial-state, +.automations-templates, .automations-history { width: 100%; max-width: 900px; @@ -285,6 +288,166 @@ font-size: 12px; } +/* Loading and error states */ +.automations-cards-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size400) var(--vscode-spacing-size200); + box-sizing: border-box; + text-align: center; +} + +.automations-cards-state-icon { + font-size: var(--vscode-codiconFontSize); + color: var(--vscode-descriptionForeground); +} + +.automations-cards-error .automations-cards-state-icon { + color: var(--vscode-errorForeground); +} + +.automations-cards-state-title { + margin: 0; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.automations-cards-state-description { + margin: 0; + font-size: var(--vscode-fontSize-label1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.automations-cards-state-create-button { + margin-top: var(--vscode-spacing-size120); + max-width: 180px; +} + +.automations-cards-partial-state { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + margin-bottom: var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-small); + box-sizing: border-box; + font-size: var(--vscode-fontSize-label1); + color: var(--vscode-descriptionForeground); +} + +.automations-cards-partial-state-error { + border-color: var(--vscode-inputValidation-warningBorder); + background-color: var(--vscode-inputValidation-warningBackground); + color: var(--vscode-inputValidation-warningForeground); +} + +.automations-cards-partial-state-icon { + flex-shrink: 0; + font-size: var(--vscode-codiconFontSize); +} + +/* Starter templates */ +.automations-templates { + width: 100%; + margin-top: var(--vscode-spacing-size240); + text-align: left; +} + +.automations-templates-title { + margin: 0 0 var(--vscode-spacing-size40); + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.automations-templates-description { + margin: 0 0 var(--vscode-spacing-size120); + font-size: var(--vscode-fontSize-label1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.automations-templates-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); + grid-auto-rows: 1fr; + gap: var(--vscode-spacing-size120); + width: 100%; +} + +.automations-template-card { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--vscode-spacing-size40); + width: 100%; + height: 100%; + min-width: 0; + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.automations-template-card:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.automations-template-card:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.automations-template-card-name { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.automations-template-card-name-text { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-foreground); + white-space: nowrap; + text-overflow: ellipsis; +} + +.automations-template-card-badge, +.automations-template-card-schedule, +.automations-template-card-prompt { + font-size: var(--vscode-fontSize-label1); + color: var(--vscode-descriptionForeground); +} + +.automations-template-card-badge { + flex-shrink: 0; +} + +.automations-template-card-prompt { + display: -webkit-box; + overflow: hidden; + margin-top: var(--vscode-spacing-size20); + line-height: 1.4; + text-overflow: ellipsis; + word-break: break-word; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + /* Run History */ .automations-history { margin-top: 32px; @@ -349,6 +512,8 @@ } .hc-black .automations-card, -.hc-light .automations-card { +.hc-light .automations-card, +.hc-black .automations-template-card, +.hc-light .automations-template-card { border-color: var(--vscode-contrastBorder); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationTemplates.ts b/src/vs/sessions/contrib/sessions/browser/views/automationTemplates.ts new file mode 100644 index 00000000000000..dd1f8508af8164 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/automationTemplates.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import type { IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; + +export interface IAutomationTemplate { + readonly id: string; + readonly name: string; + readonly prompt: string; + readonly schedule: IAutomationSchedule; +} + +export const AUTOMATION_TEMPLATES: readonly IAutomationTemplate[] = [ + { + id: 'issue-triage', + name: localize('automationTemplate.issueTriage.name', "Issue triage"), + prompt: localize('automationTemplate.issueTriage.prompt', "Review new issues, group duplicates, and suggest labels."), + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + }, + { + id: 'pull-request-review', + name: localize('automationTemplate.pullRequestReview.name', "Pull request review"), + prompt: localize('automationTemplate.pullRequestReview.prompt', "Review recent changes for correctness, missing tests, and regressions."), + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + }, + { + id: 'dependency-audit', + name: localize('automationTemplate.dependencyAudit.name', "Dependency audit"), + prompt: localize('automationTemplate.dependencyAudit.prompt', "Check dependencies and summarize recommended updates."), + schedule: { interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 1 }, + }, + { + id: 'release-notes', + name: localize('automationTemplate.releaseNotes.name', "Release notes"), + prompt: localize('automationTemplate.releaseNotes.prompt', "Draft release notes from the week's merged changes."), + schedule: { interval: 'weekly', scheduleHour: 16, scheduleMinute: 0, scheduleDay: 5 }, + }, +]; diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts index 120bb9eea6d1da..f0357de646d7c8 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts @@ -9,13 +9,14 @@ import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { AccessibilityVerbositySettingId } from '../../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; -import { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { DAYS_OF_WEEK } from '../../../../../workbench/contrib/chat/common/automations/schedule.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { AutomationsCustomViewFocusContext } from '../../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { AUTOMATION_TEMPLATES } from './automationTemplates.js'; class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplementation { readonly type = AccessibleViewType.Help; @@ -25,10 +26,15 @@ class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplement getProvider(accessor: ServicesAccessor): AccessibleContentProvider { const layoutService = accessor.get(IAgentWorkbenchLayoutService); + const automationService = accessor.get(IAutomationService); const restoreFocus = createFocusRestorer(layoutService); + const templatesVisible = automationService.automations.get().length === 0; 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. 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.", ''), + localize('automationsCustomView.help.overview', "You are in the Automations view. It contains available automation cards followed by run history. Loading, unavailable, and error messages indicate that the catalogue may be incomplete."), + ...(templatesVisible ? [ + localize('automationsCustomView.help.templates', "Starter templates are available. Tab to a template and press Enter or Space to open a New automation dialog with an editable name, prompt, and schedule."), + ] : []), + localize('automationsCustomView.help.cards', "For saved automations, 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.", ''), 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."), @@ -64,6 +70,7 @@ class AutomationsCustomViewAccessibleView implements IAccessibleViewImplementati || run.status === 'running' || (!!run.sessionResource && !!sessionsManagementService.getSession(run.sessionResource)) ), + automationService.catalogueState.get(), ), restoreFocus, AccessibilityVerbositySettingId.Automations, @@ -82,19 +89,39 @@ function createFocusRestorer(layoutService: IAgentWorkbenchLayoutService): () => }; } -export function buildAutomationsAccessibleContent(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[]): string { +export function buildAutomationsAccessibleContent(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState): string { const lines = [localize('automationsAccessibleView.title', "Automations")]; - if (automations.length === 0) { - lines.push(localize('automationsAccessibleView.empty', "No automations.")); - } else { + if (automations.length > 0) { + if (catalogueState === 'loading') { + lines.push(localize('automationsAccessibleView.partialLoading', "Additional automations are loading.")); + } else if (catalogueState === 'unavailable') { + lines.push(localize('automationsAccessibleView.partialUnavailable', "Some automations are unavailable.")); + } else if (catalogueState === 'error') { + lines.push(localize('automationsAccessibleView.partialLoadError', "Some automations could not be loaded.")); + } for (const automation of automations) { lines.push(''); lines.push(automation.enabled ? localize('automationsAccessibleView.automation', "{0}, enabled", automation.name) : localize('automationsAccessibleView.automationDisabled', "{0}, disabled", automation.name)); - lines.push(localize('automationsAccessibleView.schedule', "Schedule: {0}", formatSchedule(automation))); + lines.push(localize('automationsAccessibleView.schedule', "Schedule: {0}", formatSchedule(automation.schedule))); lines.push(localize('automationsAccessibleView.prompt', "Prompt: {0}", automation.prompt)); } + } else if (catalogueState === 'loading') { + lines.push(localize('automationsAccessibleView.loading', "Loading automations.")); + } else if (catalogueState === 'unavailable') { + lines.push(localize('automationsAccessibleView.unavailable', "Some automations are unavailable. One or more providers are disconnected, disabled, or do not support automations.")); + } else if (catalogueState === 'error') { + lines.push(localize('automationsAccessibleView.loadError', "Unable to load automations.")); + } else { + lines.push(localize('automationsAccessibleView.empty', "No automations.")); + } + if (automations.length === 0) { + lines.push(''); + lines.push(localize('automationsAccessibleView.templates', "Available templates")); + for (const template of AUTOMATION_TEMPLATES) { + lines.push(localize('automationsAccessibleView.template', "{0}, {1}. {2}", template.name, formatSchedule(template.schedule), template.prompt)); + } } lines.push(''); @@ -119,8 +146,7 @@ export function buildAutomationsAccessibleContent(automations: readonly IAutomat return lines.join('\n'); } -function formatSchedule(automation: IAutomationDescriptor): string { - const schedule = automation.schedule; +function formatSchedule(schedule: IAutomationSchedule): string { switch (schedule.interval) { case 'manual': return localize('automationsAccessibleView.manual', "Manual"); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index 4483d44d7261d4..0449d55de98b7b 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -16,15 +16,16 @@ import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDispos import { autorun, constObservable, IObservable, IReader, ISettableObservable, observableSignalFromEvent, observableValue, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; -import type { IAutomationDescriptor, IAutomationRun, AutomationTarget } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import type { IAutomationDescriptor, IAutomationRun, IAutomationSchedule, AutomationTarget } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { type AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { CHAT_AUTOMATIONS_ENABLED_SETTING, ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAutomationRunner } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; +import { type AutomationDialogCreateInitialValues, IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { DAYS_OF_WEEK } from '../../../../../workbench/contrib/chat/common/automations/schedule.js'; import { AgentSessionApprovalModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { basename } from '../../../../../base/common/resources.js'; @@ -54,6 +55,7 @@ import { AutomationsCustomViewFocusContext, AutomationsHasItemsContext, SessionI import { SessionsFlatList, SessionItemStatusContext } from './sessionsList.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; import { ARCHIVE_SESSION_COMMAND_ID, MARK_SESSION_READ_COMMAND_ID, MARK_SESSION_UNREAD_COMMAND_ID, RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { AUTOMATION_TEMPLATES, IAutomationTemplate } from './automationTemplates.js'; const $ = DOM.$; const STOP_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.stopRunSession'; @@ -132,12 +134,13 @@ export class AutomationsCardsWidget extends Disposable { this._register(toDisposable(() => focusContext.reset())); const scrollContent = DOM.append(this.element, $('.automations-cards-scroll-content')); - this.cardsSection = this._register(instantiationService.createInstance(AutomationCardsSection, scrollContent)); + this.cardsSection = this._register(instantiationService.createInstance(AutomationCardsSection, scrollContent, this.element)); this.historySection = this._register(instantiationService.createInstance(AutomationHistorySection, scrollContent, this.element, this.isMarkingAllRead)); this._register(autorun(reader => { + const catalogueState = this.automationService.catalogueState.read(reader); const items = this.automationService.automations.read(reader); - this.cardsSection.render(items); + this.cardsSection.render(items, catalogueState); })); const sessionDeleted = observableSignalFromEvent(this, this.sessionsManagementService.onDidDeleteSession); @@ -187,12 +190,33 @@ class AutomationCardsSection extends Disposable { private readonly container: HTMLElement; private readonly emptyContainer: HTMLElement; + private readonly loadingContainer: HTMLElement; + private readonly unavailableContainer: HTMLElement; + private readonly errorContainer: HTMLElement; + private readonly templatesContainer: HTMLElement; + private readonly partialStateContainer: HTMLElement; + private readonly partialLoadingIcon: HTMLElement; + private readonly partialErrorIcon: HTMLElement; + private readonly partialStateMessage: HTMLElement; private readonly persistentCards = new Map(); private readonly latestAutomations = new Map(); private readonly emptyStateDisposables = this._register(new DisposableStore()); + private readonly stateDisposables = this._register(new DisposableStore()); + private readonly templateAriaId = generateUuid(); + private emptyStateRendered = false; + private templatesRendered = false; + private emptyCreateButton: IButton | undefined; + private readonly loadingCreateButton: IButton; + private readonly unavailableCreateButton: IButton; + private readonly errorCreateButton: IButton; + private visibleContainer: HTMLElement | undefined; + private partialState: AutomationCatalogueState = 'ready'; + private pendingFocusAutomationId: string | undefined; + private focusRequestGeneration = 0; constructor( parent: HTMLElement, + private readonly focusRoot: HTMLElement, @IAutomationService private readonly automationService: IAutomationService, @IAutomationRunner private readonly automationRunner: IAutomationRunner, @IAutomationDialogService private readonly automationDialogService: IAutomationDialogService, @@ -204,11 +228,44 @@ class AutomationCardsSection extends Disposable { @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); + this.partialStateContainer = DOM.append(parent, $('.automations-cards-partial-state')); + this.partialStateContainer.setAttribute('role', 'status'); + this.partialStateContainer.setAttribute('aria-live', 'polite'); + this.partialStateContainer.style.display = 'none'; + this.partialLoadingIcon = DOM.append(this.partialStateContainer, $('span.automations-cards-partial-state-icon')); + this.partialLoadingIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + this.partialLoadingIcon.setAttribute('aria-hidden', 'true'); + this.partialErrorIcon = DOM.append(this.partialStateContainer, $('span.automations-cards-partial-state-icon')); + this.partialErrorIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.warning)); + this.partialErrorIcon.setAttribute('aria-hidden', 'true'); + this.partialStateMessage = DOM.append(this.partialStateContainer, $('span.automations-cards-partial-state-message')); this.container = DOM.append(parent, $('.automations-cards-grid')); + this.container.style.display = 'none'; this.emptyContainer = DOM.append(parent, $('.automations-cards-empty')); this.emptyContainer.style.display = 'none'; - this.renderEmptyState(); + this.loadingContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-loading')); + this.loadingContainer.style.display = 'none'; + this.loadingCreateButton = this.renderLoadingState(); + this.unavailableContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-unavailable')); + this.unavailableContainer.style.display = 'none'; + this.unavailableCreateButton = this.renderUnavailableState(); + this.errorContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-error')); + this.errorContainer.style.display = 'none'; + this.errorCreateButton = this.renderErrorState(); + this.templatesContainer = DOM.append(parent, $('.automations-templates')); + this.templatesContainer.style.display = 'none'; + const focusTracker = this._register(DOM.trackFocus(this.focusRoot)); + this._register(focusTracker.onDidBlur(() => this.clearPendingFocus())); + this._register(DOM.addDisposableListener(this.focusRoot, DOM.EventType.FOCUS_OUT, (event: FocusEvent) => { + if (event.relatedTarget && (!DOM.isHTMLElement(event.relatedTarget) || !this.focusRoot.contains(event.relatedTarget))) { + this.clearPendingFocus(); + } + })); + this._register(DOM.addDisposableListener(DOM.getWindow(this.focusRoot), DOM.EventType.BLUR, () => this.clearPendingFocus())); + this._register(DOM.addDisposableListener(this.focusRoot, DOM.EventType.MOUSE_DOWN, () => this.clearPendingFocus())); + this._register(DOM.addDisposableListener(this.focusRoot, DOM.EventType.KEY_DOWN, () => this.clearPendingFocus())); this._register(toDisposable(() => { + this.clearPendingFocus(); for (const card of this.persistentCards.values()) { card.disposables.dispose(); card.element.remove(); @@ -218,7 +275,11 @@ class AutomationCardsSection extends Disposable { })); } - render(automations: readonly IAutomationDescriptor[]): void { + render(automations: readonly IAutomationDescriptor[], catalogueState: AutomationCatalogueState): void { + const activeElement = DOM.getActiveElement(); + const contentOwnedFocus = DOM.isHTMLElement(activeElement) && ( + this.visibleContainer?.contains(activeElement) || this.templatesContainer.contains(activeElement) + ); const activeAutomationIds = new Set(automations.map(automation => automation.id)); for (const [automationId, card] of this.persistentCards) { if (activeAutomationIds.has(automationId)) { @@ -251,15 +312,53 @@ class AutomationCardsSection extends Disposable { index++; } - if (automations.length === 0) { - this.container.style.display = 'none'; - this.emptyContainer.style.display = ''; - return; + const showTemplates = automations.length === 0; + if (showTemplates) { + if (!this.templatesRendered) { + this.renderTemplates(); + this.templatesRendered = true; + } + this.templatesContainer.style.display = ''; } + const nextContainer = showTemplates ? this.getEmptyStateContainer(catalogueState) : this.container; + const previousContainer = this.visibleContainer; + if (previousContainer !== nextContainer) { + nextContainer.style.display = ''; + this.visibleContainer = nextContainer; + } + this.renderPartialState(automations.length > 0 ? catalogueState : 'ready'); + + // Move focus before hiding its previous container. + if (!this.focusPendingAutomation() && contentOwnedFocus && DOM.isHTMLElement(activeElement)) { + if (!nextContainer.contains(activeElement) && !(showTemplates && this.templatesContainer.contains(activeElement))) { + this.focusVisibleState(automations, catalogueState); + } else if (!DOM.isActiveElement(activeElement)) { + activeElement.focus(); + } + } + if (previousContainer && previousContainer !== nextContainer) { + previousContainer.style.display = 'none'; + } + if (!showTemplates) { + this.templatesContainer.style.display = 'none'; + } + } - this.container.style.display = ''; - this.emptyContainer.style.display = 'none'; - + private getEmptyStateContainer(catalogueState: AutomationCatalogueState): HTMLElement { + switch (catalogueState) { + case 'loading': + return this.loadingContainer; + case 'unavailable': + return this.unavailableContainer; + case 'error': + return this.errorContainer; + case 'ready': + if (!this.emptyStateRendered) { + this.renderEmptyState(); + this.emptyStateRendered = true; + } + return this.emptyContainer; + } } private renderCard(automation: IAutomationDescriptor): IAutomationCardEntry { @@ -376,8 +475,8 @@ class AutomationCardsSection extends Disposable { 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 schedule = formatSchedule(automation.schedule); + const scheduleChanged = !previous || formatSchedule(previous.schedule) !== schedule; const nameChanged = !previous || previous.name !== automation.name; if (nameChanged || scheduleChanged) { card.card.setAttribute('aria-label', localize('automationCard', "{0} — {1}", automation.name, schedule)); @@ -458,24 +557,144 @@ class AutomationCardsSection extends Disposable { ...defaultButtonStyles, title: localize('createAutomation', "Create Automation"), })); + this.emptyCreateButton = createButton; createButton.label = localize('createAutomation', "Create Automation"); createButton.element.classList.add('automations-cards-create-button'); this.emptyStateDisposables.add(createButton.onDidClick(() => this.openCreateDialog())); } - private async openCreateDialog(): Promise { - if (!await this.ensureEnabled()) { + private renderTemplates(): void { + const templatesTitle = DOM.append(this.templatesContainer, $('h3.automations-templates-title')); + templatesTitle.id = 'automations-templates-title'; + templatesTitle.textContent = localize('automationTemplatesTitle', "Start with a template"); + const templatesDescription = DOM.append(this.templatesContainer, $('p.automations-templates-description')); + templatesDescription.textContent = localize('automationTemplatesDescription', "Choose a starting point, then review and customize it before creating."); + const templatesGrid = DOM.append(this.templatesContainer, $('.automations-templates-grid')); + templatesGrid.setAttribute('role', 'group'); + templatesGrid.setAttribute('aria-labelledby', templatesTitle.id); + for (const template of AUTOMATION_TEMPLATES) { + this.renderTemplateCard(templatesGrid, template); + } + } + + private renderLoadingState(): IButton { + this.loadingContainer.setAttribute('role', 'status'); + const icon = DOM.append(this.loadingContainer, $('span.automations-cards-state-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + icon.setAttribute('aria-hidden', 'true'); + const title = DOM.append(this.loadingContainer, $('h3.automations-cards-state-title')); + title.textContent = localize('loadingAutomations', "Loading automations..."); + const description = DOM.append(this.loadingContainer, $('p.automations-cards-state-description')); + description.textContent = localize('loadingAutomationsDescription', "You can create a new automation while existing automations load."); + return this.renderStateCreateButton(this.loadingContainer); + } + + private renderUnavailableState(): IButton { + const icon = DOM.append(this.unavailableContainer, $('span.automations-cards-state-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.debugDisconnect)); + icon.setAttribute('aria-hidden', 'true'); + const title = DOM.append(this.unavailableContainer, $('h3.automations-cards-state-title')); + title.textContent = localize('automationsUnavailable', "Some automations are unavailable"); + const description = DOM.append(this.unavailableContainer, $('p.automations-cards-state-description')); + description.textContent = localize('automationsUnavailableDescription', "One or more providers are disconnected, disabled, or do not support automations."); + return this.renderStateCreateButton(this.unavailableContainer); + } + + private renderErrorState(): IButton { + this.errorContainer.setAttribute('role', 'alert'); + const icon = DOM.append(this.errorContainer, $('span.automations-cards-state-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.error)); + icon.setAttribute('aria-hidden', 'true'); + const title = DOM.append(this.errorContainer, $('h3.automations-cards-state-title')); + title.textContent = localize('automationsLoadError', "Unable to load automations"); + const description = DOM.append(this.errorContainer, $('p.automations-cards-state-description')); + description.textContent = localize('automationsLoadErrorDescription', "The complete automation catalogue could not be read."); + return this.renderStateCreateButton(this.errorContainer); + } + + private renderStateCreateButton(container: HTMLElement): IButton { + const createButton = this.stateDisposables.add(new Button(container, { + ...defaultButtonStyles, + title: localize('createAutomation', "Create Automation"), + })); + createButton.label = localize('createAutomation', "Create Automation"); + createButton.element.classList.add('automations-cards-state-create-button'); + this.stateDisposables.add(createButton.onDidClick(() => this.openCreateDialog())); + return createButton; + } + + private renderPartialState(catalogueState: AutomationCatalogueState): void { + if (this.partialState === catalogueState) { return; } - const result = await this.automationDialogService.showAutomationDialog({}); - if (!result || result.kind !== 'create') { + this.partialState = catalogueState; + if (catalogueState === 'ready') { + this.partialStateContainer.style.display = 'none'; return; } + const isError = catalogueState === 'error'; + const isLoading = catalogueState === 'loading'; + this.partialStateContainer.style.display = ''; + this.partialStateContainer.classList.toggle('automations-cards-partial-state-error', isError); + this.partialLoadingIcon.style.display = isLoading ? '' : 'none'; + this.partialErrorIcon.style.display = isLoading ? 'none' : ''; + this.partialStateMessage.textContent = isError + ? localize('automationsPartialLoadError', "Some automations could not be loaded.") + : catalogueState === 'unavailable' + ? localize('automationsPartialUnavailable', "Some automations are unavailable.") + : localize('automationsPartialLoading', "Loading additional automations..."); + } + + private renderTemplateCard(container: HTMLElement, template: IAutomationTemplate): void { + const card = DOM.append(container, $('button.automations-template-card', { type: 'button' })); + const schedule = formatSchedule(template.schedule); + card.setAttribute('aria-label', localize('useAutomationTemplate', "Use template: {0}, {1}", template.name, schedule)); + + const nameRow = DOM.append(card, $('.automations-template-card-name')); + const name = DOM.append(nameRow, $('span.automations-template-card-name-text')); + name.textContent = template.name; + this.emptyStateDisposables.add(this.hoverService.setupDelayedHover(name, { content: template.name })); + const badge = DOM.append(nameRow, $('span.automations-template-card-badge')); + badge.textContent = localize('automationTemplateBadge', "Template"); + badge.setAttribute('aria-hidden', 'true'); + + const scheduleElement = DOM.append(card, $('span.automations-template-card-schedule')); + scheduleElement.textContent = schedule; + const prompt = DOM.append(card, $('span.automations-template-card-prompt')); + prompt.id = `automations-template-${this.templateAriaId}-${template.id}-description`; + prompt.textContent = template.prompt; + this.emptyStateDisposables.add(this.hoverService.setupDelayedHover(prompt, { content: template.prompt })); + card.setAttribute('aria-describedby', prompt.id); + + this.emptyStateDisposables.add(DOM.addDisposableListener(card, DOM.EventType.CLICK, () => { + void this.openCreateDialog({ + name: template.name, + prompt: template.prompt, + schedule: template.schedule, + }); + })); + } + + private async openCreateDialog(initialValues?: AutomationDialogCreateInitialValues): Promise { + this.clearPendingFocus(); if (!await this.ensureEnabled()) { return; } try { + const result = await this.automationDialogService.showAutomationDialog(initialValues ? { initialValues } : {}); + if (!result || result.kind !== 'create' || this._store.isDisposed) { + return; + } + const restoreFocus = DOM.isAncestorOfActiveElement(this.focusRoot); + const focusRequestGeneration = this.focusRequestGeneration; + if (!await this.ensureEnabled()) { + return; + } const created = await this.automationService.createAutomation(result.value, () => this.throwIfDisabled()); + if (restoreFocus && focusRequestGeneration === this.focusRequestGeneration && !this._store.isDisposed && DOM.isAncestorOfActiveElement(this.focusRoot)) { + this.pendingFocusAutomationId = created.id; + this.focusPendingAutomation(); + } status(localize('automationCreatedStatus', "Created automation {0}", created.name)); } catch (err) { this.logService.error('[AutomationsCards] Failed to create automation', err); @@ -486,6 +705,48 @@ class AutomationCardsSection extends Disposable { } } + private clearPendingFocus(): void { + this.pendingFocusAutomationId = undefined; + this.focusRequestGeneration++; + } + + private focusPendingAutomation(): boolean { + if (!this.pendingFocusAutomationId) { + return false; + } + if (this._store.isDisposed || !DOM.isAncestorOfActiveElement(this.focusRoot)) { + this.clearPendingFocus(); + return false; + } + const card = this.persistentCards.get(this.pendingFocusAutomationId); + if (!card) { + return false; + } + this.pendingFocusAutomationId = undefined; + card.main.focus(); + return true; + } + + private focusVisibleState(automations: readonly IAutomationDescriptor[], catalogueState: AutomationCatalogueState): void { + if (automations.length > 0) { + (this.persistentCards.get(automations[0].id)?.main ?? this.focusRoot).focus(); + return; + } + switch (catalogueState) { + case 'loading': + this.loadingCreateButton.focus(); + return; + case 'unavailable': + this.unavailableCreateButton.focus(); + return; + case 'error': + this.errorCreateButton.focus(); + return; + case 'ready': + (this.emptyCreateButton?.element ?? this.focusRoot).focus(); + } + } + private async openEditDialog(automation: IAutomationDescriptor): Promise { if (!await this.ensureEnabled()) { return; @@ -963,14 +1224,14 @@ function isTemporaryAutomationRun(run: IAutomationRun): boolean { return run.status === 'pending' || run.status === 'running'; } -function formatSchedule(automation: IAutomationDescriptor): string { - const { interval, scheduleHour, scheduleMinute } = automation.schedule; +function formatSchedule(schedule: IAutomationSchedule): string { + const { interval, scheduleHour, scheduleMinute } = schedule; const time = formatHourMinute(scheduleHour, scheduleMinute); switch (interval) { case 'hourly': return localize('scheduleHourly', "Hourly"); case 'daily': return localize('scheduleDailyAt', "Daily at {0}", time); case 'weekly': { - const day = DAYS_OF_WEEK[((automation.schedule.scheduleDay % 7) + 7) % 7]; + const day = DAYS_OF_WEEK[((schedule.scheduleDay % 7) + 7) % 7]; return localize('scheduleWeeklyAt', "{0} at {1}", day, time); } case 'manual': return localize('scheduleManual', "Manual"); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts index 34ba4bae456ee7..2d8c233d401df3 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts @@ -28,7 +28,7 @@ import { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/ import { IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAutomationRunner } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; @@ -79,11 +79,13 @@ class FixtureAutomationService extends mock() { override readonly automations: IObservable; override readonly runs: IObservable; + override readonly catalogueState: IObservable; - constructor(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[]) { + constructor(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState) { super(); this.automations = constObservable(automations); this.runs = constObservable(runs); + this.catalogueState = constObservable(catalogueState); } override async deleteRun(): Promise { } @@ -161,6 +163,7 @@ interface IAutomationsFixtureOptions { readonly width: number; readonly height: number; readonly populated: boolean; + readonly catalogueState?: AutomationCatalogueState; } export default defineThemedFixtureGroup({ path: 'sessions/automations/' }, { @@ -170,8 +173,44 @@ export default defineThemedFixtureGroup({ path: 'sessions/automations/' }, { }), Empty: defineComponentFixture({ labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], render: ctx => renderAutomations(ctx, { width: 1000, height: 520, populated: false }), }), + NarrowEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 520, height: 620, populated: false }), + }), + ShortEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 360, populated: false }), + }), + Loading: defineComponentFixture({ + render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'loading' }), + }), + Unavailable: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], + render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'unavailable' }), + }), + NarrowUnavailable: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 520, height: 620, populated: false, catalogueState: 'unavailable' }), + }), + PartialLoading: defineComponentFixture({ + render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'loading' }), + }), + PartialUnavailable: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'unavailable' }), + }), + PartialError: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'error' }), + }), + Error: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'error' }), + }), Narrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderAutomations(ctx, { width: 520, height: 720, populated: true }), @@ -186,7 +225,7 @@ function renderAutomations(ctx: ComponentFixtureContext, options: IAutomationsFi const contextKeyService = new ContextKeyService(configurationService); const actionViewItemService = new FixtureActionViewItemService(); const customViewService = ctx.disposableStore.add(new CustomViewService(new NullLogService(), ctx.disposableStore.add(new InMemoryStorageService()))); - const automationService = new FixtureAutomationService(data.automations, data.runs); + const automationService = new FixtureAutomationService(data.automations, data.runs, options.catalogueState ?? 'ready'); const sessionsManagementService = new FixtureSessionsManagementService(data.runs); ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts index faf810fec4b0a0..75a22b91b76cdc 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { IContextMenuDelegate } from '../../../../../base/browser/contextmenu.js'; import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; import { GestureEvent, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; +import type { IDelayedHoverOptions } from '../../../../../base/browser/ui/hover/hover.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -17,6 +18,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AccessibleViewRegistry } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -37,8 +39,9 @@ import { IAutomationDescriptor, IAutomationRun, IAutomationSchedule, AutomationR import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAutomationRunDispatch, IAutomationRunner, IAutomationRunOperation } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -46,6 +49,7 @@ import { IActionViewItemService } from '../../../../../platform/actions/browser/ import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AutomationsHasItemsContext } from '../../../../common/contextkeys.js'; import { buildAutomationsAccessibleContent } from '../../browser/views/automationsAccessibility.js'; +import { AUTOMATION_TEMPLATES } from '../../browser/views/automationTemplates.js'; import { AutomationsCardsWidget, AutomationsCustomViewContribution } from '../../browser/views/automationsView.js'; import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; @@ -108,6 +112,14 @@ function dispatchKeydown(element: HTMLElement, init: KeyboardEventInit & { keyCo element.dispatchEvent(event); } +function moveFocus(from: HTMLElement, to: HTMLElement): void { + to.focus(); + // The hidden Electron runner updates activeElement without emitting native focus events. + if (!to.ownerDocument.hasFocus()) { + from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: to })); + } +} + async function waitForSessionActions(): Promise { await timeout(100); } @@ -115,8 +127,10 @@ async function waitForSessionActions(): Promise { class FakeAutomationService extends mock() { private readonly automationValue = observableValue(this, []); private readonly runValue = observableValue(this, []); + private readonly catalogueStateValue = observableValue(this, 'loading'); override readonly automations: IObservable = this.automationValue; override readonly runs: IObservable = this.runValue; + override readonly catalogueState: IObservable = this.catalogueStateValue; updateResult: IGuardedAutomationUpdateResult | undefined; updateCalls = 0; deleteRunCalls = 0; @@ -124,6 +138,9 @@ class FakeAutomationService extends mock() { deleteError: Error | undefined; canDelete = true; canUpdate = true; + publishCreates = true; + createdAutomation: IAutomationDescriptor | undefined; + beforeCreate: (() => Promise) | undefined; readonly createCalls: ICreateAutomationOptions[] = []; readonly deleteCalls: string[] = []; readonly guardedUpdateCalls: { id: string; patch: IUpdateAutomationOptions; expected: IAutomationDescriptor }[] = []; @@ -137,6 +154,10 @@ class FakeAutomationService extends mock() { this.runValue.set(value, undefined); } + setCatalogueState(value: AutomationCatalogueState): void { + this.catalogueStateValue.set(value, undefined); + } + override getAutomation(id: string): IAutomationDescriptor | undefined { return this.automationValue.get().find(item => item.id === id); } @@ -148,6 +169,7 @@ class FakeAutomationService extends mock() { override async createAutomation(options: ICreateAutomationOptions, mutationGuard?: AutomationMutationGuard): Promise { mutationGuard?.(); this.createCalls.push(options); + await this.beforeCreate?.(); if (this.createError) { throw this.createError; } @@ -157,12 +179,16 @@ class FakeAutomationService extends mock() { prompt: options.prompt, schedule: options.schedule, target: options.target, + sessionTemplate: options.sessionTemplate, modelId: options.modelId ?? undefined, mode: options.mode ?? undefined, permissionLevel: options.permissionLevel ?? undefined, enabled: options.enabled ?? true, }); - this.setAutomations([created, ...this.automationValue.get()]); + this.createdAutomation = created; + if (this.publishCreates) { + this.setAutomations([created, ...this.automationValue.get()]); + } return created; } @@ -543,7 +569,7 @@ suite('AutomationsCardsWidget', () => { return !!button && button.style.display !== 'none'; } - function setup(archiveWording: 'archive' | 'done' = 'archive') { + function setup(archiveWording: 'archive' | 'done' = 'archive', hoverService: IHoverService = NullHoverService) { const automationService = new FakeAutomationService(); const automationDialogService = new FakeAutomationDialogService(); const contextMenuService = new TestContextMenuService(); @@ -575,7 +601,7 @@ suite('AutomationsCardsWidget', () => { ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true); instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IKeybindingService, keybindingService); - instantiationService.stub(IHoverService, NullHoverService); + instantiationService.stub(IHoverService, hoverService); instantiationService.stub(ILogService, logService); instantiationService.stub(ISessionsListModelService, new class extends mock() { override readonly onDidChange = Event.None; @@ -817,6 +843,7 @@ suite('AutomationsCardsWidget', () => { test('empty state is rendered once across repeated empty updates', () => { const { automationService, widget } = setup(); + automationService.setCatalogueState('ready'); automationService.setAutomations([]); automationService.setAutomations([]); @@ -824,10 +851,410 @@ suite('AutomationsCardsWidget', () => { titles: widget.element.querySelectorAll('.automations-cards-empty-title').length, descriptions: widget.element.querySelectorAll('.automations-cards-empty-description').length, buttons: widget.element.querySelectorAll('.automations-cards-create-button').length, + templateSections: widget.element.querySelectorAll('.automations-templates').length, + templateCards: widget.element.querySelectorAll('.automations-template-card').length, }, { titles: 1, descriptions: 1, buttons: 1, + templateSections: 1, + templateCards: 4, + }); + }); + + test('keeps templates available while distinguishing incomplete catalogues from confirmed empty', () => { + const { automationService, widget } = setup(); + + const loadingState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + createButton: widget.element.querySelector('.automations-cards-loading .automations-cards-state-create-button')?.textContent, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + automationService.setCatalogueState('error'); + const errorState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + createButton: widget.element.querySelector('.automations-cards-error .automations-cards-state-create-button')?.textContent, + description: widget.element.querySelector('.automations-cards-error .automations-cards-state-description')?.textContent, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + automationService.setCatalogueState('unavailable'); + const unavailableState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + unavailable: widget.element.querySelector('.automations-cards-unavailable')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + createButton: widget.element.querySelector('.automations-cards-unavailable .automations-cards-state-create-button')?.textContent, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + automationService.setCatalogueState('ready'); + const readyState = { + loading: widget.element.querySelector('.automations-cards-loading')?.style.display, + error: widget.element.querySelector('.automations-cards-error')?.style.display, + templates: widget.element.querySelectorAll('.automations-template-card').length, + }; + + assert.deepStrictEqual({ loadingState, errorState, unavailableState, readyState }, { + loadingState: { loading: '', error: 'none', createButton: 'Create Automation', templates: 4 }, + errorState: { loading: 'none', error: '', createButton: 'Create Automation', description: 'The complete automation catalogue could not be read.', templates: 4 }, + unavailableState: { loading: 'none', unavailable: '', error: 'none', createButton: 'Create Automation', templates: 4 }, + readyState: { loading: 'none', error: 'none', templates: 4 }, + }); + }); + + test('surfaces partial catalogue states with saved automations', () => { + const { automationService, widget } = setup(); + automationService.setAutomations([automation()]); + const loadingMessage = widget.element.querySelector('.automations-cards-partial-state')?.textContent; + automationService.setCatalogueState('unavailable'); + const unavailableMessage = widget.element.querySelector('.automations-cards-partial-state')?.textContent; + automationService.setCatalogueState('error'); + + assert.deepStrictEqual({ + loadingMessage, + unavailableMessage, + errorMessage: widget.element.querySelector('.automations-cards-partial-state')?.textContent, + savedCards: widget.element.querySelectorAll('.automations-card').length, + templatesDisplay: widget.element.querySelector('.automations-templates')?.style.display, + }, { + loadingMessage: 'Loading additional automations...', + unavailableMessage: 'Some automations are unavailable.', + errorMessage: 'Some automations could not be loaded.', + savedCards: 1, + templatesDisplay: 'none', + }); + }); + + test('create remains available after a catalogue error', async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.setCatalogueState('error'); + + widget.element.querySelector('.automations-cards-error .automations-cards-state-create-button')?.click(); + await Promise.resolve(); + + assert.strictEqual(automationDialogService.showCalls, 1); + }); + + test('hides templates when saved automations become available', () => { + const { automationService, widget } = setup(); + + automationService.setAutomations([automation()]); + automationService.setCatalogueState('ready'); + + assert.deepStrictEqual({ + savedCards: widget.element.querySelectorAll('.automations-card').length, + templatesDisplay: widget.element.querySelector('.automations-templates')?.style.display, + }, { + savedCards: 1, + templatesDisplay: 'none', + }); + }); + + test('template opens create dialog with target-less initial values', async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.setCatalogueState('ready'); + + const templateCard = widget.element.querySelector('.automations-template-card'); + const describedBy = templateCard?.getAttribute('aria-describedby'); + templateCard?.click(); + await Promise.resolve(); + + assert.deepStrictEqual({ + dialogOptions: automationDialogService.lastOptions, + accessibleDescription: describedBy ? widget.element.querySelector(`#${describedBy}`)?.textContent : undefined, + }, { + dialogOptions: { + initialValues: { + name: 'Issue triage', + prompt: 'Review new issues, group duplicates, and suggest labels.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + }, + }, + accessibleDescription: 'Review new issues, group duplicates, and suggest labels.', + }); + }); + + test('template hovers expose full text once and are disposed with the widget', () => { + const hovers: { target: HTMLElement; content: IDelayedHoverOptions['content']; disposed: boolean }[] = []; + const hoverService: IHoverService = { + ...NullHoverService, + setupDelayedHover: (target, options) => { + const entry = { target, content: (typeof options === 'function' ? options() : options).content, disposed: false }; + hovers.push(entry); + return toDisposable(() => entry.disposed = true); + }, + }; + const { automationService, widget } = setup('archive', hoverService); + const beforeReady = hovers.length; + automationService.setCatalogueState('ready'); + automationService.setAutomations([]); + automationService.setCatalogueState('error'); + automationService.setCatalogueState('ready'); + const contents = hovers.map(hover => ({ target: hover.target.className, content: hover.content })); + widget.dispose(); + + assert.deepStrictEqual({ + beforeReady, + contents, + allDisposed: hovers.every(hover => hover.disposed), + }, { + beforeReady: 8, + contents: AUTOMATION_TEMPLATES.flatMap(template => [ + { target: 'automations-template-card-name-text', content: template.name }, + { target: 'automations-template-card-prompt', content: template.prompt }, + ]), + allDisposed: true, + }); + }); + + test('template creation focuses the newly created automation card', async () => { + const { automationDialogService, automationService, widget } = setup(); + const submitted: ICreateAutomationOptions = { + name: 'Customized issue triage', + prompt: 'Review issues assigned to this project.', + schedule: { interval: 'weekly', scheduleHour: 10, scheduleMinute: 30, scheduleDay: 2 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + enabled: true, + }; + automationDialogService.result = { kind: 'create', value: submitted }; + automationService.setCatalogueState('ready'); + + const templateCard = widget.element.querySelector('.automations-template-card'); + templateCard?.focus(); + templateCard?.click(); + await timeout(0); + + assert.deepStrictEqual({ + createCalls: automationService.createCalls, + activeElementLabel: document.activeElement?.getAttribute('aria-label'), + templateVisible: widget.element.querySelector('.automations-templates')?.style.display, + }, { + createCalls: [submitted], + activeElementLabel: 'Edit automation Customized issue triage', + templateVisible: 'none', + }); + }); + + for (const catalogueState of ['loading', 'unavailable', 'error'] as const) { + test(`creates from a template without requiring a complete ${catalogueState} catalogue`, async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.setCatalogueState(catalogueState); + const submitted: ICreateAutomationOptions = { + name: 'Local review', + prompt: 'Review the local workspace.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }; + automationDialogService.result = { kind: 'create', value: submitted }; + const templates = widget.element.querySelector('.automations-templates'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(templates && template); + const beforeCreate = { + templatesVisible: templates.style.display === '', + emptyClaimVisible: widget.element.querySelector('.automations-cards-empty')?.style.display === '', + catalogueStatusVisible: widget.element.querySelector(`.automations-cards-${catalogueState}`)?.style.display === '', + }; + template.focus(); + template.click(); + await timeout(0); + + assert.deepStrictEqual({ + beforeCreate, + createCalls: automationService.createCalls, + catalogueState: automationService.catalogueState.get(), + templateDisplay: templates.style.display, + cardLabel: widget.element.querySelector('.automations-card-main')?.getAttribute('aria-label'), + warningVisible: widget.element.querySelector('.automations-cards-partial-state')?.style.display === '', + }, { + beforeCreate: { templatesVisible: true, emptyClaimVisible: false, catalogueStatusVisible: true }, + createCalls: [submitted], + catalogueState, + templateDisplay: 'none', + cardLabel: 'Edit automation Local review', + warningVisible: true, + }); + }); + } + + test('catalogue status changes preserve the focused template', () => { + const { automationService, widget } = setup(); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + const states: readonly AutomationCatalogueState[] = ['unavailable', 'error', 'ready', 'loading']; + const focusedStates = states.map(state => { + automationService.setCatalogueState(state); + return document.activeElement === template; + }); + + assert.deepStrictEqual(focusedStates, [true, true, true, true]); + }); + + test('state transitions preserve focus within the Automations view', () => { + const { automationService, widget } = setup(); + const loadingCreate = widget.element.querySelector('.automations-cards-loading .automations-cards-state-create-button'); + assert.ok(loadingCreate); + loadingCreate.focus(); + + const stateFocus = (['error', 'unavailable', 'ready'] as const).map(state => { + automationService.setCatalogueState(state); + const selector = state === 'ready' ? '.automations-cards-empty .automations-cards-create-button' : `.automations-cards-${state} .automations-cards-state-create-button`; + return widget.element.querySelector(selector) === document.activeElement; + }); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + automationService.setAutomations([automation()]); + const afterPopulated = document.activeElement?.getAttribute('aria-label'); + automationService.setCatalogueState('unavailable'); + automationService.setAutomations([]); + + assert.deepStrictEqual({ + stateFocus, + afterPopulated, + afterRemoval: widget.element.querySelector('.automations-cards-unavailable .automations-cards-state-create-button') === document.activeElement, + }, { + stateFocus: [true, true, true], + afterPopulated: 'Edit automation Daily review', + afterRemoval: true, + }); + }); + + test('repeated empty updates retain the focused template', () => { + const { automationService, widget } = setup(); + automationService.setCatalogueState('ready'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + automationService.setAutomations([]); + automationService.setAutomations([]); + + assert.strictEqual(document.activeElement, template); + }); + + test('state changes do not move focus from another view', () => { + const { automationService, widget } = setup(); + const outside = document.createElement('button'); + document.body.append(outside); + disposables.add(toDisposable(() => outside.remove())); + outside.focus(); + automationService.setCatalogueState('unavailable'); + automationService.setCatalogueState('error'); + automationService.setCatalogueState('ready'); + automationService.setAutomations([automation()]); + + assert.deepStrictEqual({ + focusUnchanged: document.activeElement === outside, + cards: widget.element.querySelectorAll('.automations-card-main').length, + }, { focusUnchanged: true, cards: 1 }); + }); + + for (const focusAction of ['stay', 'leave-and-return', 'navigate'] as const) { + test(`delayed creation respects focus ownership: ${focusAction}`, async () => { + const { automationDialogService, automationService, widget } = setup(); + automationService.publishCreates = false; + automationDialogService.result = { + kind: 'create', + value: { + name: 'Delayed automation', + prompt: 'Publish later.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + }, + }; + automationService.setCatalogueState('ready'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + template.click(); + await timeout(0); + + if (focusAction === 'leave-and-return') { + const outside = document.createElement('button'); + document.body.append(outside); + disposables.add(toDisposable(() => outside.remove())); + moveFocus(template, outside); + await timeout(0); + widget.focus(); + } else if (focusAction === 'navigate') { + dispatchKeydown(template, { key: 'Tab', code: 'Tab', keyCode: 9 }); + widget.focus(); + } + const created = automationService.createdAutomation; + assert.ok(created); + automationService.setAutomations([automation(), created]); + + assert.strictEqual( + document.activeElement, + focusAction === 'stay' + ? widget.element.querySelector('[aria-label="Edit automation Delayed automation"]') + : widget.element, + ); + }); + } + + test('a pending create cannot re-arm focus after the user leaves and returns', async () => { + const { automationDialogService, automationService, widget } = setup(); + const createStarted = new DeferredPromise(); + const completeCreate = new DeferredPromise(); + automationService.beforeCreate = async () => { + await createStarted.complete(); + await completeCreate.p; + }; + automationService.publishCreates = false; + automationDialogService.result = { + kind: 'create', + value: { + name: 'Slow create', + prompt: 'Finish later.', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + }, + }; + automationService.setCatalogueState('ready'); + const template = widget.element.querySelector('.automations-template-card'); + assert.ok(template); + template.focus(); + template.click(); + await createStarted.p; + const outside = document.createElement('button'); + document.body.append(outside); + disposables.add(toDisposable(() => outside.remove())); + moveFocus(template, outside); + await timeout(0); + widget.focus(); + await completeCreate.complete(); + await timeout(0); + const created = automationService.createdAutomation; + assert.ok(created); + automationService.setAutomations([created]); + + assert.strictEqual(document.activeElement, widget.element); + }); + + test('template creation honors automations being disabled while the dialog is open', async () => { + const { automationDialogService, automationService, configurationService, dialogService, widget } = setup(); + automationDialogService.result = { + kind: 'create', + value: { + name: 'Issue triage', + prompt: 'Review issues.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, + }, + }; + automationDialogService.beforeReturn = () => configurationService.setUserConfiguration('chat.automations.enabled', false); + automationService.setCatalogueState('ready'); + + widget.element.querySelector('.automations-template-card')?.click(); + await dialogService.infoCalled.p; + + assert.deepStrictEqual({ + info: dialogService.infos, + createCalls: automationService.createCalls, + }, { + info: ['Automations are disabled.'], + createCalls: [], }); }); @@ -1785,11 +2212,88 @@ suite('AutomationsCardsWidget', () => { test('accessible view includes automation and run content', () => { assert.strictEqual( - buildAutomationsAccessibleContent([automation()], [run({ status: 'failed', errorMessage: 'boom' })]).includes('Daily review, Failed'), + buildAutomationsAccessibleContent([automation()], [run({ status: 'failed', errorMessage: 'boom' })], 'ready').includes('Daily review, Failed'), true, ); }); + test('accessible view includes templates when there are no automations', () => { + assert.strictEqual( + buildAutomationsAccessibleContent([], [], 'ready').includes('Issue triage, Daily at 9:00 AM. Review new issues, group duplicates, and suggest labels.'), + true, + ); + }); + + test('accessibility help describes visible templates independently of catalogue completeness', () => { + const { automationService, instantiationService } = setup(); + instantiationService.stub(IAgentWorkbenchLayoutService, new class extends mock() { }); + const help = AccessibleViewRegistry.getImplementations().find(implementation => implementation.name === 'sessions-automations-help'); + assert.ok(help); + const describesTemplates = () => { + const provider = instantiationService.invokeFunction(accessor => help.getProvider(accessor)); + assert.ok(provider); + disposables.add(provider); + return provider.provideContent().includes('Tab to a template'); + }; + const states: readonly AutomationCatalogueState[] = ['loading', 'unavailable', 'error', 'ready']; + const emptyCatalogueHelp = states.map(state => { + automationService.setCatalogueState(state); + return describesTemplates(); + }); + automationService.setAutomations([automation()]); + + assert.deepStrictEqual({ + emptyCatalogueHelp, + populatedCatalogueHelp: describesTemplates(), + }, { + emptyCatalogueHelp: [true, true, true, true], + populatedCatalogueHelp: false, + }); + }); + + test('accessible view distinguishes loading, unavailable, and error from confirmed empty', () => { + assert.deepStrictEqual({ + loading: buildAutomationsAccessibleContent([], [], 'loading').split('\n').slice(0, 2), + unavailable: buildAutomationsAccessibleContent([], [], 'unavailable').split('\n').slice(0, 2), + error: buildAutomationsAccessibleContent([], [], 'error').split('\n').slice(0, 2), + }, { + loading: ['Automations', 'Loading automations.'], + unavailable: ['Automations', 'Some automations are unavailable. One or more providers are disconnected, disabled, or do not support automations.'], + error: ['Automations', 'Unable to load automations.'], + }); + }); + + test('accessible view offers templates without claiming an incomplete catalogue is empty', () => { + const states: readonly AutomationCatalogueState[] = ['loading', 'unavailable', 'error', 'ready']; + const contents = states.map(state => { + const content = buildAutomationsAccessibleContent([], [], state); + return { + state, + claimsEmpty: content.includes('No automations.'), + templatesIncluded: AUTOMATION_TEMPLATES.every(template => content.includes(template.name)), + }; + }); + + assert.deepStrictEqual(contents, [ + { state: 'loading', claimsEmpty: false, templatesIncluded: true }, + { state: 'unavailable', claimsEmpty: false, templatesIncluded: true }, + { state: 'error', claimsEmpty: false, templatesIncluded: true }, + { state: 'ready', claimsEmpty: true, templatesIncluded: true }, + ]); + }); + + test('accessible view reports partial catalogue state with saved automations', () => { + assert.deepStrictEqual({ + loading: buildAutomationsAccessibleContent([automation()], [], 'loading').split('\n').slice(0, 2), + unavailable: buildAutomationsAccessibleContent([automation()], [], 'unavailable').split('\n').slice(0, 2), + error: buildAutomationsAccessibleContent([automation()], [], 'error').split('\n').slice(0, 2), + }, { + loading: ['Automations', 'Additional automations are loading.'], + unavailable: ['Automations', 'Some automations are unavailable.'], + error: ['Automations', 'Some automations could not be loaded.'], + }); + }); + test('running run shows needs-input indicator when session status transitions to NeedsInput', async () => { const { automationService, sessionsManagementService, widget } = setup(); sessionsManagementService.sessionStatus.set(SessionStatus.InProgress, undefined); diff --git a/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts index a16fe115767740..02eb48ab6df112 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IAutomationDescriptor } from './automation.js'; +import { AutomationTarget, IAutomationDescriptor } from './automation.js'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from './automationService.js'; +export type AutomationDialogCreateInitialValues = Omit & { readonly target?: AutomationTarget }; + export type IShowAutomationDialogOptions = | { readonly existing: IAutomationDescriptor; readonly initialValues?: never } - | { readonly existing?: never; readonly initialValues?: ICreateAutomationOptions }; + | { readonly existing?: never; readonly initialValues?: AutomationDialogCreateInitialValues }; export type IAutomationDialogResult = | { readonly kind: 'create'; readonly value: ICreateAutomationOptions } diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts index c82a41098c5ba0..100474cb9b9f76 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -13,6 +13,21 @@ import { IAutomationDescriptor, IAutomationRun, AutomationRunTrigger, IAutomatio export const IAutomationService = createDecorator('automationService'); export const ConfigureAutomationToolReferenceName = 'configureAutomation'; +export type AutomationCatalogueState = 'loading' | 'ready' | 'unavailable' | 'error'; + +export function combineAutomationCatalogueStates(states: readonly AutomationCatalogueState[]): AutomationCatalogueState { + if (states.includes('error')) { + return 'error'; + } + if (states.includes('loading')) { + return 'loading'; + } + if (states.includes('unavailable')) { + return 'unavailable'; + } + return 'ready'; +} + /** Invoked immediately before each storage CAS attempt; throwing aborts before that attempt. */ export type AutomationMutationGuard = () => void; @@ -160,6 +175,9 @@ export interface IAutomationRunClaim { * cross-window propagation, persistence, and observables consistent. */ export interface IAutomationStore { + /** Completeness of the Automation catalogue, independent of individual providers' operation availability. */ + readonly catalogueState: IObservable; + /** All defined automations, newest first. */ readonly automations: IObservable;