From a2da7d582efd17b1a901d695aa0ee64a42e9b392 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 18 Sep 2026 16:40:24 +0200 Subject: [PATCH] automations: fix: surface unavailable providers Treat every unavailable Sessions provider as making the aggregate Automation catalogue incomplete. Expose provider descriptors for presentation, identify affected providers, and place the contextual warning after saved definitions with matching accessible content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 29a5d516-b4bf-4a7c-ba34-81afa14d8c6a --- src/vs/sessions/AUTOMATIONS.md | 2 +- .../automations/browser/automationService.ts | 4 +- .../browser/providerAutomationService.ts | 19 ++++++--- .../browser/providerAutomationService.test.ts | 6 ++- .../browser/media/automationsCards.css | 2 +- .../views/automationCataloguePresentation.ts | 17 ++++++++ .../browser/views/automationsAccessibility.ts | 27 +++++++----- .../sessions/browser/views/automationsView.ts | 37 +++++++++++------ .../test/browser/automationsView.fixture.ts | 13 +++--- .../test/browser/automationsView.test.ts | 41 ++++++++++++++----- .../common/automations/automationService.ts | 7 ++++ 11 files changed, 126 insertions(+), 49 deletions(-) create mode 100644 src/vs/sessions/contrib/sessions/browser/views/automationCataloguePresentation.ts diff --git a/src/vs/sessions/AUTOMATIONS.md b/src/vs/sessions/AUTOMATIONS.md index e4a426b1feb249..2b78fbfe3868e3 100644 --- a/src/vs/sessions/AUTOMATIONS.md +++ b/src/vs/sessions/AUTOMATIONS.md @@ -107,7 +107,7 @@ Every Automation store exposes whether its complete catalogue is `loading`, `rea 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. +`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 current provider store is unavailable, and `ready` only when all current stores are ready. ## Multi-host routing diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index 21377b7f28cf32..d1133f2f6f2ad4 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -22,6 +22,7 @@ import { } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { AutomationCatalogueState, + IAutomationProviderDescriptor, type AutomationMutationGuard, assertAutomationSessionTemplateAuthority, IAutomationRunClaim, @@ -598,6 +599,7 @@ export class AutomationStore extends Disposable implements IAutomationStore { export class AutomationService extends AutomationStore implements IAutomationService { declare readonly _serviceBrand: undefined; + readonly unavailableProviders = constObservable([]); constructor( @IStorageService storageService: IStorageService, diff --git a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts index 574401ecc650b2..25cc1f8d110237 100644 --- a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts +++ b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts @@ -11,13 +11,13 @@ 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 { AutomationCatalogueState, AutomationMutationGuard, combineAutomationCatalogueStates, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, AutomationMutationGuard, combineAutomationCatalogueStates, IAutomationProviderDescriptor, 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'; interface IAutomationStoreEntry { - readonly providerId: string | undefined; + readonly provider: IAutomationProviderDescriptor | undefined; readonly store: ISessionsProviderAutomations; } @@ -41,6 +41,7 @@ export class ProviderAutomationService extends Disposable implements IAutomation readonly automations: IObservable; readonly runs: IObservable; readonly catalogueState: IObservable; + readonly unavailableProviders: IObservable; constructor( initialProvidersSettled: IObservable, @@ -59,6 +60,12 @@ export class ProviderAutomationService extends Disposable implements IAutomation } return combineAutomationCatalogueStates(states); }); + this.unavailableProviders = derived(this, reader => { + this.providersChanged.read(reader); + return this.getProviderStores() + .filter(entry => entry.store.catalogueState.read(reader) === 'unavailable') + .flatMap(entry => entry.provider === undefined ? [] : [entry.provider]); + }); this.automations = derived(this, reader => { this.providersChanged.read(reader); return distinctById( @@ -185,7 +192,7 @@ export class ProviderAutomationService extends Disposable implements IAutomation for (let index = 0; index < results.length; index++) { const result = results[index]; if (result.status === 'rejected') { - const providerId = stores[index].providerId ?? 'legacy'; + const providerId = stores[index].provider?.id ?? 'legacy'; this.logService.error(`[ProviderAutomationService] Failed to recover stale Automation runs for '${providerId}'.`, result.reason); } } @@ -215,11 +222,11 @@ export class ProviderAutomationService extends Disposable implements IAutomation private getProviderStores(): IAutomationStoreEntry[] { return this.sessionsProvidersService.getProviders() .filter(provider => provider.automations) - .map(provider => ({ providerId: provider.id, store: provider.automations! })); + .map(provider => ({ provider: { id: provider.id, label: provider.label }, store: provider.automations! })); } private getStores(): IAutomationStoreEntry[] { - return [...this.getProviderStores(), { providerId: undefined, store: this.legacyStore }]; + return [...this.getProviderStores(), { provider: undefined, store: this.legacyStore }]; } private getCreationStore(options: ICreateAutomationOptions): ISessionsProviderAutomations { @@ -389,7 +396,7 @@ export class ProviderAutomationService extends Disposable implements IAutomation this.recoveredStores.add(entry.store); } } catch (error) { - const providerId = entry.providerId ?? 'legacy'; + const providerId = entry.provider?.id ?? 'legacy'; this.logService.error(`[ProviderAutomationService] Failed to recover stale Automation runs for '${providerId}'.`, error); } } 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 4d45f197efd950..7c7e0cc9ff68b9 100644 --- a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts @@ -393,11 +393,11 @@ suite('ProviderAutomationService', () => { }); }); - test('an unavailable remote catalogue does not block local automation operations', async () => { + test('reports unavailable providers while keeping available provider operations usable', async () => { const { service, providerStore, storage, automationStorage, addProvider } = createService(); const remote = teardown.add(new MutableCatalogueAutomationStore('remote', storage, new NullLogService(), automationStorage)); remote.setCatalogueState('unavailable'); - addProvider(upcastPartial({ id: 'remote', order: 1, automations: remote })); + addProvider(upcastPartial({ id: 'remote', label: 'Remote build host', order: 1, automations: remote })); const created = await service.createAutomation({ name: 'Local review', @@ -410,6 +410,7 @@ suite('ProviderAutomationService', () => { assert.deepStrictEqual({ catalogueState: service.catalogueState.get(), + unavailableProviders: service.unavailableProviders.get(), localNames: providerStore.automations.get().map(automation => automation.name), remoteAutomations: remote.automations.get(), canRun: service.canRunAutomation(created.id), @@ -418,6 +419,7 @@ suite('ProviderAutomationService', () => { activeRunId: providerStore.getActiveRunFor(created.id)?.id, }, { catalogueState: 'unavailable', + unavailableProviders: [{ id: 'remote', label: 'Remote build host' }], localNames: ['Updated local review'], remoteAutomations: [], canRun: true, diff --git a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css index 09d157acd2ad6d..77cb653a476820 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css +++ b/src/vs/sessions/contrib/sessions/browser/media/automationsCards.css @@ -369,7 +369,7 @@ display: flex; align-items: center; gap: var(--vscode-spacing-size80); - margin-bottom: var(--vscode-spacing-size120); + margin-top: 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); diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationCataloguePresentation.ts b/src/vs/sessions/contrib/sessions/browser/views/automationCataloguePresentation.ts new file mode 100644 index 00000000000000..254835a8e7bdc3 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/automationCataloguePresentation.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IAutomationProviderDescriptor } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; + +export function formatUnavailableAutomationsMessage(unavailableProviders: readonly IAutomationProviderDescriptor[]): string { + if (unavailableProviders.length === 1) { + return localize('automationsUnavailableProvider', "Automations from {0} are unavailable.", unavailableProviders[0].label); + } + if (unavailableProviders.length > 1) { + return localize('automationsUnavailableProviders', "Automations from these providers are unavailable: {0}.", unavailableProviders.map(provider => provider.label).join(', ')); + } + return localize('automationsPartialUnavailable', "Some automations are unavailable."); +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts index e3721020a625db..67cfe040e814b3 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsAccessibility.ts @@ -10,7 +10,7 @@ import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../ import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { AccessibilityVerbositySettingId } from '../../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.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 { AutomationCatalogueState, type IAutomationProviderDescriptor, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { DAYS_OF_WEEK } from '../../../../../workbench/contrib/chat/common/automations/schedule.js'; import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; @@ -18,6 +18,7 @@ import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { AutomationsCustomViewFocusContext } from '../../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IAutomationTemplate, readAutomationTemplates } from './automationTemplates.js'; +import { formatUnavailableAutomationsMessage } from './automationCataloguePresentation.js'; class AutomationsCustomViewAccessibilityHelp implements IAccessibleViewImplementation { readonly type = AccessibleViewType.Help; @@ -84,6 +85,7 @@ class AutomationsCustomViewAccessibleView implements IAccessibleViewImplementati ), automationService.catalogueState.get(), readAutomationTemplates(agentPluginService.plugins.get()), + automationService.unavailableProviders.get(), ), restoreFocus, AccessibilityVerbositySettingId.Automations, @@ -102,18 +104,11 @@ function createFocusRestorer(layoutService: IAgentWorkbenchLayoutService): () => }; } -export function buildAutomationsAccessibleContent(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState, templates: readonly IAutomationTemplate[] = readAutomationTemplates([])): string { +export function buildAutomationsAccessibleContent(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState, templates: readonly IAutomationTemplate[] = readAutomationTemplates([]), unavailableProviders: readonly IAutomationProviderDescriptor[] = []): string { const lines = [localize('automationsAccessibleView.title', "Automations")]; const builtInTemplates = templates.filter(template => !template.source); const pluginTemplates = templates.filter(template => !!template.source); 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 @@ -122,10 +117,22 @@ export function buildAutomationsAccessibleContent(automations: readonly IAutomat lines.push(localize('automationsAccessibleView.schedule', "Schedule: {0}", formatSchedule(automation.schedule))); lines.push(localize('automationsAccessibleView.prompt', "Prompt: {0}", automation.prompt)); } + if (catalogueState === 'loading') { + lines.push(''); + lines.push(localize('automationsAccessibleView.partialLoading', "Additional automations are loading.")); + } else if (catalogueState === 'unavailable') { + lines.push(''); + lines.push(formatUnavailableAutomationsMessage(unavailableProviders)); + } else if (catalogueState === 'error') { + lines.push(''); + lines.push(localize('automationsAccessibleView.partialLoadError', "Some automations could not be loaded.")); + } } 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.")); + lines.push(unavailableProviders.length > 0 + ? formatUnavailableAutomationsMessage(unavailableProviders) + : 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 { diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index a2dc8a54f519b6..018fb0ed610575 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -22,7 +22,7 @@ import { IInstantiationService, ServicesAccessor } from '../../../../../platform import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.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 { type AutomationCatalogueState, type IAutomationProviderDescriptor, 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 { type AutomationDialogCreateInitialValues, IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; @@ -61,6 +61,7 @@ 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 { IAutomationTemplate, readAutomationTemplates } from './automationTemplates.js'; +import { formatUnavailableAutomationsMessage } from './automationCataloguePresentation.js'; const $ = DOM.$; const STOP_AUTOMATION_RUN_SESSION_COMMAND_ID = 'sessions.automations.stopRunSession'; @@ -191,9 +192,10 @@ export class AutomationsCardsWidget extends Disposable { this._register(autorun(reader => { const catalogueState = this.automationService.catalogueState.read(reader); + const unavailableProviders = this.automationService.unavailableProviders.read(reader); const items = this.automationService.automations.read(reader); const templates = readAutomationTemplates(this.agentPluginService.plugins.read(reader), reader); - this.cardsSection.render(items, catalogueState, templates); + this.cardsSection.render(items, catalogueState, unavailableProviders, templates); })); const sessionDeleted = observableSignalFromEvent(this, this.sessionsManagementService.onDidDeleteSession); @@ -273,9 +275,11 @@ class AutomationCardsSection extends Disposable { private emptyCreateButton: IButton | undefined; private readonly loadingCreateButton: IButton; private readonly unavailableCreateButton: IButton; + private readonly unavailableDescription: HTMLElement; private readonly errorCreateButton: IButton; private visibleContainer: HTMLElement | undefined; private partialState: AutomationCatalogueState = 'ready'; + private partialUnavailableProviders: readonly IAutomationProviderDescriptor[] = []; private pendingFocusAutomationId: string | undefined; private focusRequestGeneration = 0; @@ -300,6 +304,8 @@ class AutomationCardsSection extends Disposable { this.seenPluginTemplateIds = this.readSeenPluginTemplateIds(); this.updatePluginTemplateUnreadState(false); })); + this.container = DOM.append(parent, $('.automations-cards-grid')); + this.container.style.display = 'none'; this.partialStateContainer = DOM.append(parent, $('.automations-cards-partial-state')); this.partialStateContainer.setAttribute('role', 'status'); this.partialStateContainer.setAttribute('aria-live', 'polite'); @@ -311,8 +317,6 @@ class AutomationCardsSection extends Disposable { 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.loadingContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-loading')); @@ -320,7 +324,9 @@ class AutomationCardsSection extends Disposable { this.loadingCreateButton = this.renderLoadingState(); this.unavailableContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-unavailable')); this.unavailableContainer.style.display = 'none'; - this.unavailableCreateButton = this.renderUnavailableState(); + const unavailableState = this.renderUnavailableState(); + this.unavailableCreateButton = unavailableState.createButton; + this.unavailableDescription = unavailableState.description; this.errorContainer = DOM.append(parent, $('.automations-cards-state.automations-cards-error')); this.errorContainer.style.display = 'none'; this.errorCreateButton = this.renderErrorState(); @@ -404,7 +410,7 @@ class AutomationCardsSection extends Disposable { ); } - render(automations: readonly IAutomationDescriptor[], catalogueState: AutomationCatalogueState, templates: readonly IAutomationTemplate[]): void { + render(automations: readonly IAutomationDescriptor[], catalogueState: AutomationCatalogueState, unavailableProviders: readonly IAutomationProviderDescriptor[], templates: readonly IAutomationTemplate[]): void { const activeElement = DOM.getActiveElement(); const contentOwnedFocus = DOM.isHTMLElement(activeElement) && ( this.visibleContainer?.contains(activeElement) || this.templatesContainer.contains(activeElement) @@ -472,13 +478,16 @@ class AutomationCardsSection extends Disposable { const showTemplateSections = templates.length > 0; this.templatesContainer.style.display = showTemplateSections ? '' : 'none'; + this.unavailableDescription.textContent = unavailableProviders.length > 0 + ? formatUnavailableAutomationsMessage(unavailableProviders) + : localize('automationsUnavailableDescription', "One or more providers are disconnected, disabled, or do not support automations."); const nextContainer = automations.length === 0 ? 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'); + this.renderPartialState(automations.length > 0 ? catalogueState : 'ready', unavailableProviders); // Move focus before hiding its previous container. if (!this.focusPendingAutomation() && contentOwnedFocus && DOM.isHTMLElement(activeElement)) { @@ -865,7 +874,7 @@ class AutomationCardsSection extends Disposable { return this.renderStateCreateButton(this.loadingContainer); } - private renderUnavailableState(): IButton { + private renderUnavailableState(): { readonly createButton: IButton; readonly description: HTMLElement } { const icon = DOM.append(this.unavailableContainer, $('span.automations-cards-state-icon')); icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.debugDisconnect)); icon.setAttribute('aria-hidden', 'true'); @@ -873,7 +882,7 @@ class AutomationCardsSection extends Disposable { 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); + return { createButton: this.renderStateCreateButton(this.unavailableContainer), description }; } private renderErrorState(): IButton { @@ -899,11 +908,15 @@ class AutomationCardsSection extends Disposable { return createButton; } - private renderPartialState(catalogueState: AutomationCatalogueState): void { - if (this.partialState === catalogueState) { + private renderPartialState(catalogueState: AutomationCatalogueState, unavailableProviders: readonly IAutomationProviderDescriptor[]): void { + const unavailableProvidersChanged = catalogueState === 'unavailable' + && (unavailableProviders.length !== this.partialUnavailableProviders.length + || unavailableProviders.some((provider, index) => provider.id !== this.partialUnavailableProviders[index].id || provider.label !== this.partialUnavailableProviders[index].label)); + if (this.partialState === catalogueState && !unavailableProvidersChanged) { return; } this.partialState = catalogueState; + this.partialUnavailableProviders = catalogueState === 'unavailable' ? [...unavailableProviders] : []; if (catalogueState === 'ready') { this.partialStateContainer.style.display = 'none'; return; @@ -917,7 +930,7 @@ class AutomationCardsSection extends Disposable { this.partialStateMessage.textContent = isError ? localize('automationsPartialLoadError', "Some automations could not be loaded.") : catalogueState === 'unavailable' - ? localize('automationsPartialUnavailable', "Some automations are unavailable.") + ? formatUnavailableAutomationsMessage(unavailableProviders) : localize('automationsPartialLoading', "Loading additional automations..."); } 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 55e74a1d5c8fc4..a0e7c060c11cc3 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.fixture.ts @@ -30,7 +30,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 { AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, IAutomationProviderDescriptor, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ContributionEnablementState } from '../../../../../workbench/contrib/chat/common/enablement.js'; import { IAgentPlugin, IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; @@ -84,12 +84,14 @@ class FixtureAutomationService extends mock() { override readonly automations: IObservable; override readonly runs: IObservable; override readonly catalogueState: IObservable; + override readonly unavailableProviders: IObservable; - constructor(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState) { + constructor(automations: readonly IAutomationDescriptor[], runs: readonly IAutomationRun[], catalogueState: AutomationCatalogueState, unavailableProviders: readonly IAutomationProviderDescriptor[]) { super(); this.automations = constObservable(automations); this.runs = constObservable(runs); this.catalogueState = constObservable(catalogueState); + this.unavailableProviders = constObservable(unavailableProviders); } override async deleteRun(): Promise { } @@ -168,6 +170,7 @@ interface IAutomationsFixtureOptions { readonly height: number; readonly populated: boolean; readonly catalogueState?: AutomationCatalogueState; + readonly unavailableProviders?: readonly IAutomationProviderDescriptor[]; readonly pluginTemplate?: boolean; readonly showDropTarget?: boolean; } @@ -208,7 +211,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/automations/' }, { Unavailable: defineComponentFixture({ labels: { kind: 'screenshot' }, additionalThemes: ['darkHighContrast'], - render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'unavailable' }), + render: ctx => renderAutomations(ctx, { width: 1000, height: 620, populated: false, catalogueState: 'unavailable', unavailableProviders: [{ id: 'remote-build-host', label: 'Remote build host' }] }), }), NarrowUnavailable: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -219,7 +222,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/automations/' }, { }), PartialUnavailable: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'unavailable' }), + render: ctx => renderAutomations(ctx, { width: 1000, height: 720, populated: true, catalogueState: 'unavailable', unavailableProviders: [{ id: 'remote-build-host', label: 'Remote build host' }] }), }), PartialError: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -243,7 +246,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, options.catalogueState ?? 'ready'); + const automationService = new FixtureAutomationService(data.automations, data.runs, options.catalogueState ?? 'ready', options.unavailableProviders ?? []); const sessionsManagementService = new FixtureSessionsManagementService(data.runs); const agentPluginService = new class extends mock() { override readonly plugins = constObservable(options.pluginTemplate ? [ 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 8662c7f08bc7ee..a3a9fe7e80fc86 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -43,7 +43,7 @@ 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 { AutomationCatalogueState, AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationCatalogueState, AutomationMutationGuard, IAutomationProviderDescriptor, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ContributionEnablementState } from '../../../../../workbench/contrib/chat/common/enablement.js'; import { IAgentPlugin, IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; import { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; @@ -138,9 +138,11 @@ class FakeAutomationService extends mock() { private readonly automationValue = observableValue(this, []); private readonly runValue = observableValue(this, []); private readonly catalogueStateValue = observableValue(this, 'loading'); + private readonly unavailableProvidersValue = observableValue(this, []); override readonly automations: IObservable = this.automationValue; override readonly runs: IObservable = this.runValue; override readonly catalogueState: IObservable = this.catalogueStateValue; + override readonly unavailableProviders: IObservable = this.unavailableProvidersValue; updateResult: IGuardedAutomationUpdateResult | undefined; updateCalls = 0; deleteRunCalls = 0; @@ -168,6 +170,10 @@ class FakeAutomationService extends mock() { this.catalogueStateValue.set(value, undefined); } + setUnavailableProviders(value: readonly IAutomationProviderDescriptor[]): void { + this.unavailableProvidersValue.set(value, undefined); + } + override getAutomation(id: string): IAutomationDescriptor | undefined { return this.automationValue.get().find(item => item.id === id); } @@ -1358,8 +1364,10 @@ suite('AutomationsCardsWidget', () => { const { automationService, widget } = setup(); automationService.setAutomations([automation()]); const loadingMessage = widget.element.querySelector('.automations-cards-partial-state')?.textContent; + automationService.setUnavailableProviders([{ id: 'remote-build-host', label: 'Remote build host' }]); automationService.setCatalogueState('unavailable'); - const unavailableMessage = widget.element.querySelector('.automations-cards-partial-state')?.textContent; + const partialState = widget.element.querySelector('.automations-cards-partial-state'); + const unavailableMessage = partialState?.textContent; automationService.setCatalogueState('error'); assert.deepStrictEqual({ @@ -1367,12 +1375,14 @@ suite('AutomationsCardsWidget', () => { unavailableMessage, errorMessage: widget.element.querySelector('.automations-cards-partial-state')?.textContent, savedCards: widget.element.querySelectorAll('.automations-card').length, + appearsAfterCards: partialState?.previousElementSibling?.classList.contains('automations-cards-grid'), templatesDisplay: widget.element.querySelector('.automations-templates')?.style.display, }, { loadingMessage: 'Loading additional automations...', - unavailableMessage: 'Some automations are unavailable.', + unavailableMessage: 'Automations from Remote build host are unavailable.', errorMessage: 'Some automations could not be loaded.', savedCards: 1, + appearsAfterCards: true, templatesDisplay: '', }); }); @@ -2825,11 +2835,11 @@ suite('AutomationsCardsWidget', () => { 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), + unavailable: buildAutomationsAccessibleContent([], [], 'unavailable', undefined, [{ id: 'remote-build-host', label: 'Remote build host' }]).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.'], + unavailable: ['Automations', 'Automations from Remote build host are unavailable.'], error: ['Automations', 'Unable to load automations.'], }); }); @@ -2854,14 +2864,23 @@ suite('AutomationsCardsWidget', () => { }); test('accessible view reports partial catalogue state with saved automations', () => { + const loadingContent = buildAutomationsAccessibleContent([automation()], [], 'loading'); + const content = buildAutomationsAccessibleContent([automation()], [], 'unavailable', undefined, [{ id: 'remote-build-host', label: 'Remote build host' }]); + const errorContent = buildAutomationsAccessibleContent([automation()], [], 'error'); 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), + loadingIncluded: loadingContent.includes('Additional automations are loading.'), + loadingAfterAutomation: loadingContent.indexOf('Daily review, enabled') < loadingContent.indexOf('Additional automations are loading.'), + unavailableIncluded: content.includes('Automations from Remote build host are unavailable.'), + unavailableAfterAutomation: content.indexOf('Daily review, enabled') < content.indexOf('Automations from Remote build host are unavailable.'), + errorIncluded: errorContent.includes('Some automations could not be loaded.'), + errorAfterAutomation: errorContent.indexOf('Daily review, enabled') < errorContent.indexOf('Some automations could not be loaded.'), }, { - loading: ['Automations', 'Additional automations are loading.'], - unavailable: ['Automations', 'Some automations are unavailable.'], - error: ['Automations', 'Some automations could not be loaded.'], + loadingIncluded: true, + loadingAfterAutomation: true, + unavailableIncluded: true, + unavailableAfterAutomation: true, + errorIncluded: true, + errorAfterAutomation: true, }); }); diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts index 100474cb9b9f76..5fc079ced10624 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -225,6 +225,8 @@ export interface IAutomationStore { export interface IAutomationService extends IAutomationStore { readonly _serviceBrand: undefined; + /** Providers whose Automation catalogues are currently unavailable. */ + readonly unavailableProviders: IObservable; canRunAutomation?(automationId: string): boolean; canUpdateAutomation?(automationId: string): boolean; canDeleteAutomation?(automationId: string): boolean; @@ -235,3 +237,8 @@ export interface IAutomationService extends IAutomationStore { /** Stops leader-scoped stale-run recovery. */ stopStaleRunRecovery(): void; } + +export interface IAutomationProviderDescriptor { + readonly id: string; + readonly label: string; +}