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 42363be1f9ee46..1315fe079d8ea3 100644 --- a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts @@ -1257,13 +1257,16 @@ suite('ProviderAutomationService', () => { runs: [], }); const { service, providerStore, storage } = createService(futureLedger); + const catalogueState = service.catalogueState.get(); await assert.rejects(service.waitForMigrationForTesting(), /cannot be migrated safely/); assert.deepStrictEqual({ + catalogueState, providerAutomations: providerStore.automations.get(), persisted: storage.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION), }, { + catalogueState: 'error', providerAutomations: [], persisted: futureLedger, }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 6f746ec0c4c3bc..aa4eb29977d3d7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -264,6 +264,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide }; bindConnection(); this._register(this._agentHostService.onAgentHostStart(bindConnection)); + this._register(this._agentHostService.onAgentHostExit(() => { + connectionListeners.clear(); + automations.clearConnection(); + })); // Eagerly populate the session cache once authentication has settled. // Without this, the sidebar would only call `getSessions()` after some diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index fda8c79070652a..48e67ec5b05544 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -18,7 +18,9 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -38,6 +40,7 @@ import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, Resour import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatModelReference, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService, isIChatSessionFileChange2 } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; @@ -88,7 +91,9 @@ class MockAgentHostService extends mock() { override get rootState(): IAgentSubscription { return this._rootStateSubscription; } private readonly _onAgentHostStart = new Emitter(); override readonly onAgentHostStart = this._onAgentHostStart.event; - override readonly initializeResult = constObservable({ + private readonly _onAgentHostExit = new Emitter(); + override readonly onAgentHostExit = this._onAgentHostExit.event; + override readonly initializeResult = observableValue(this, { protocolVersion: '1', serverSeq: 0, snapshots: [], @@ -97,6 +102,7 @@ class MockAgentHostService extends mock() { override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); + public automationCatalog: AutomationState = { entries: [] }; public disposedSessions: URI[] = []; public onDisposeSession: ((session: URI) => void) | undefined; public failDisposeSessionFor: string | undefined; @@ -275,7 +281,7 @@ class MockAgentHostService extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); if (isAhpAutomationCatalogChannel(key) && !this._sessionStateValues.has(key)) { - this._sessionStateValues.set(key, { entries: [] }); + this._sessionStateValues.set(key, this.automationCatalog); } return this._getSubscription(key); } @@ -381,6 +387,10 @@ class MockAgentHostService extends mock() { this._onAgentHostStart.fire(); } + fireAgentHostExit(): void { + this._onAgentHostExit.fire(0); + } + setRootStateError(): void { const error = new Error('root state failed'); this._rootStateValue = error; @@ -401,6 +411,7 @@ class MockAgentHostService extends mock() { this._onDidRootStateChange.dispose(); this._onDidRootStateError.dispose(); this._onAgentHostStart.dispose(); + this._onAgentHostExit.dispose(); for (const emitter of this._sessionStateEmitters.values()) { emitter.dispose(); } @@ -672,6 +683,29 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Provider identity ------- + test('Automation catalogue state follows local Agent Host connection lifetime', () => { + agentHost.automationCatalog = { entries: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } }; + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), automations: { create: {} } }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { + configurationService: new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }), + }); + const initial = provider.automations.catalogueState.get(); + + agentHost.fireAgentHostExit(); + const disconnected = provider.automations.catalogueState.get(); + agentHost.fireAgentHostStart(); + + assert.deepStrictEqual({ + initial, + disconnected, + reconnected: provider.automations.catalogueState.get(), + }, { + initial: 'ready', + disconnected: 'unavailable', + reconnected: 'ready', + }); + }); + test('has correct id, label, and sessionType from rootState agents', () => { const provider = createProvider(disposables, agentHost); diff --git a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts index 62445fc9f2a4b3..bd41074595a2e7 100644 --- a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts +++ b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, observableValue } from '../../../../base/common/observable.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { IConfigurationService, isConfigured } from '../../../../platform/configuration/common/configuration.js'; @@ -12,7 +12,9 @@ import { observableMemento, type ObservableMemento } from '../../../../platform/ import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; import { ICustomViewService } from '../../../services/customView/browser/customViewService.js'; +import { ISessionsWindowUsageService } from '../../../services/sessions/browser/sessionsWindowUsageService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from './automationsConstants.js'; export const AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY = 'sessions.automations.newBadgeSeen'; @@ -23,6 +25,8 @@ export type AutomationsNewBadgeStyle = 'accent' | 'soft' | 'outline'; const DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE: AutomationsNewBadgeStyle = 'outline'; +type AutomationsNewBadgeStartupDecision = 'pending' | 'eligible' | 'suppressed'; + const automationsNewBadgeSeenMemento = observableMemento({ key: AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, defaultValue: false, @@ -35,10 +39,21 @@ export class AutomationsNewBadgeState extends Disposable { private readonly seen: ObservableMemento; private readonly resolvedStyle = observableValue(this, undefined); - private observingActiveView = false; + private readonly forcePreview = observableValue(this, false); + private readonly startupDecision = observableValue(this, 'pending'); + private readonly automationEvidenceObserver = this._register(new MutableDisposable()); + private observersRegistered = false; private initializationPromise: Promise | undefined; private styleRequest = 0; - readonly presentation = derived(this, reader => this.seen.read(reader) ? undefined : this.resolvedStyle.read(reader)); + readonly presentation = derived(this, reader => { + if (this.seen.read(reader)) { + return undefined; + } + if (this.forcePreview.read(reader)) { + return this.resolvedStyle.read(reader); + } + return this.startupDecision.read(reader) === 'eligible' ? this.resolvedStyle.read(reader) : undefined; + }); readonly showNewBadge = derived(this, reader => this.presentation.read(reader) !== undefined); constructor( @@ -48,19 +63,43 @@ export class AutomationsNewBadgeState extends Disposable { @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, + @ISessionsWindowUsageService private readonly sessionsWindowUsageService: ISessionsWindowUsageService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, ) { super(); this.seen = this._register(automationsNewBadgeSeenMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, storageService)); } initialize(): Promise { - if (!this.observingActiveView) { - this.observingActiveView = true; + if (!this.observersRegistered) { + this.observersRegistered = true; + if (!this.seen.get()) { + const evidenceObserver = autorun(reader => { + if (this.forcePreview.read(reader)) { + return; + } + if (this.automationService.automations.read(reader).length > 0 || this.automationService.runs.read(reader).length > 0) { + this.markSeen(); + } + }); + this.automationEvidenceObserver.value = evidenceObserver; + if (this.seen.get()) { + this.automationEvidenceObserver.clear(); + } + } this._register(autorun(reader => { if (this.customViewService.activeCustomView.read(reader)?.id === AUTOMATIONS_CUSTOM_VIEW_ID) { this.markSeen(); } })); + this._register(autorun(reader => { + if (this.forcePreview.read(reader) || this.seen.read(reader) || this.startupDecision.read(reader) !== 'eligible') { + return; + } + if (this.automationService.catalogueState.read(reader) !== 'ready') { + this.startupDecision.set('suppressed', undefined); + } + })); } if (!this.initializationPromise) { this.initializationPromise = this.doInitialize(); @@ -77,6 +116,7 @@ export class AutomationsNewBadgeState extends Disposable { } async reset(): Promise { + this.forcePreview.set(true, undefined); this.seen.set(false, undefined); this.storageService.remove(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION); this.resolvedStyle.set(undefined, undefined); @@ -84,19 +124,35 @@ export class AutomationsNewBadgeState extends Disposable { } private async doInitialize(): Promise { - const hasPriorUse = this.seen.get() - || this.automationService.automations.get().length > 0 - || this.automationService.runs.get().length > 0; - if (hasPriorUse) { + if (this._store.isDisposed || this.seen.get() || this.forcePreview.get()) { + return; + } + + if (!this.sessionsWindowUsageService.hadPriorWindowOpen) { + this.startupDecision.set('suppressed', undefined); + return; + } + + await this.lifecycleService.when(LifecyclePhase.Eventually); + if (this._store.isDisposed || this.seen.get() || this.forcePreview.get()) { + return; + } + + if (this.automationService.catalogueState.get() !== 'ready') { + this.startupDecision.set('suppressed', undefined); + return; + } + if (this.automationService.automations.get().length > 0 || this.automationService.runs.get().length > 0) { this.markSeen(); return; } + this.startupDecision.set('eligible', undefined); await this.updateStyle(); } private async updateStyle(): Promise { - if (this.seen.get()) { + if (!this.canResolveStyle()) { return; } @@ -112,12 +168,16 @@ export class AutomationsNewBadgeState extends Disposable { this.logService.warn(`[AutomationsNewBadgeState] Failed to resolve badge style treatment; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`, error); } } - if (request !== this.styleRequest || this.seen.get()) { + if (request !== this.styleRequest || !this.canResolveStyle()) { return; } this.resolvedStyle.set(this.normalizeStyle(value), undefined); } + private canResolveStyle(): boolean { + return !this._store.isDisposed && !this.seen.get() && (this.forcePreview.get() || this.startupDecision.get() === 'eligible'); + } + private normalizeStyle(value: string | undefined): AutomationsNewBadgeStyle { if (value === undefined || value === DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE) { return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE; @@ -130,8 +190,10 @@ export class AutomationsNewBadgeState extends Disposable { } private markSeen(): void { + this.forcePreview.set(false, undefined); if (!this.seen.get()) { this.seen.set(true, undefined); } + this.automationEvidenceObserver.clear(); } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts index 573881ac43a972..6e89fc2dbd02bf 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts @@ -12,8 +12,6 @@ import { ISession } from '../../../services/sessions/common/session.js'; import { getPullRequestStatusFromIcon, PullRequestStatus } from '../../github/common/types.js'; import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js'; -/** Storage key for the cumulative number of times this client has been launched. */ -const APP_LAUNCH_COUNT_KEY = 'agentSessions.telemetry.summary.appLaunchCount'; /** Storage key for the per-session lifecycle stats map (JSON encoded). Exported for tests. */ export const SESSIONS_KEY = 'agentSessions.telemetry.summary.sessions'; /** Storage key for the cumulative number of sessions started from the Agents window across all workspaces and providers. */ @@ -238,13 +236,13 @@ export class SessionsLifecycleTracker extends Disposable { private readonly _appLaunchCount: number; private readonly _stats: Map; - constructor(private readonly _storageService: IStorageService) { + constructor( + private readonly _storageService: IStorageService, + appLaunchCount: number, + ) { super(); - const previousAppLaunches = this._storageService.getNumber(APP_LAUNCH_COUNT_KEY, StorageScope.APPLICATION, 0); - this._appLaunchCount = previousAppLaunches + 1; - this._storageService.store(APP_LAUNCH_COUNT_KEY, this._appLaunchCount, StorageScope.APPLICATION, StorageTarget.MACHINE); - + this._appLaunchCount = appLaunchCount; this._stats = this._load(); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts index 02c2dd7ec96f34..f8168a193c1724 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts @@ -26,6 +26,7 @@ import { ISendRequestOptions, ISessionsProvider } from '../../../services/sessio import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; +import { ISessionsWindowUsageService } from '../../../services/sessions/browser/sessionsWindowUsageService.js'; import { ISessionLifecycleSummary, SessionDoneReason, SessionsLifecycleTracker } from './sessionsLifecycleTracker.js'; import { ITypedCharactersEntry, SessionsTypedCharactersTracker } from './sessionsTypedCharactersTracker.js'; @@ -66,10 +67,11 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @ISessionsTasksService private readonly _sessionsTasksService: ISessionsTasksService, @IModelService modelService: IModelService, + @ISessionsWindowUsageService sessionsWindowUsageService: ISessionsWindowUsageService, ) { super(); - this._lifecycleTracker = new SessionsLifecycleTracker(this._storageService); + this._lifecycleTracker = new SessionsLifecycleTracker(this._storageService, sessionsWindowUsageService.windowOpenCount); // Registered after the lifecycle tracker is created but before it is // registered: disposing flushes buffered typing, which needs a live // lifecycle tracker to attribute it to. diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts index a95823f476db64..2046a48bc132c8 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../base/common/async.js'; import { Emitter } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -13,10 +14,12 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationCatalogueState, IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; +import { ISessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; import { AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, AUTOMATIONS_NEW_BADGE_STYLE_SETTING, AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT, AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js'; @@ -50,6 +53,9 @@ suite('AutomationsNewBadgeState', () => { readonly runs?: readonly IAutomationRun[]; readonly activeView?: ICustomViewDescriptor; readonly seen?: boolean; + readonly hadPriorWindowOpen?: boolean; + readonly catalogueState?: AutomationCatalogueState; + readonly eventuallyReady?: boolean; readonly style?: AutomationsNewBadgeStyle; readonly configuredStyle?: AutomationsNewBadgeStyle; readonly treatmentError?: Error; @@ -61,9 +67,11 @@ suite('AutomationsNewBadgeState', () => { const automations = observableValue(disposables, options.automations ?? []); const runs = observableValue(disposables, options.runs ?? []); const activeView = observableValue(disposables, options.activeView); + const catalogueState = observableValue(disposables, options.catalogueState ?? 'ready'); const automationService = new class extends mock() { override readonly automations = automations; override readonly runs = runs; + override readonly catalogueState = catalogueState; }; const customViewService = new class extends mock() { override readonly activeCustomView = activeView; @@ -74,6 +82,20 @@ suite('AutomationsNewBadgeState', () => { if (options.configuredStyle) { void configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, options.configuredStyle); } + const sessionsWindowUsageService = new class extends mock() { + override readonly hadPriorWindowOpen = options.hadPriorWindowOpen ?? true; + override readonly windowOpenCount = this.hadPriorWindowOpen ? 2 : 1; + }; + const eventually = new DeferredPromise(); + const lifecycleService = new class extends mock() { + override when(phase: LifecyclePhase): Promise { + assert.strictEqual(phase, LifecyclePhase.Eventually); + return eventually.p; + } + }; + if (options.eventuallyReady !== false) { + void eventually.complete(); + } const state = disposables.add(new AutomationsNewBadgeState( automationService, customViewService, @@ -81,38 +103,42 @@ suite('AutomationsNewBadgeState', () => { assignmentService, configurationService, new NullLogService(), + sessionsWindowUsageService, + lifecycleService, )); - return { state, storageService, automations, runs, activeView, assignmentService, configurationService, refetchAssignments }; + return { + state, + storageService, + automations, + runs, + activeView, + assignmentService, + configurationService, + refetchAssignments, + catalogueState, + completeEventually: () => eventually.complete(), + }; } - test('keeps the resolved style stable until Automations is activated', async () => { - const { state, storageService, automations, runs, activeView } = createState(); - - await state.initialize(); - automations.set([upcastPartial({ id: 'late-automation' })], undefined); - runs.set([upcastPartial({ id: 'late-run' })], undefined); - const beforeActivation = { - showNewBadge: state.showNewBadge.get(), - style: state.presentation.get(), - stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }; + test('stays hidden on the first Agents window open without reading the treatment', async () => { + const fixture = createState({ hadPriorWindowOpen: false, style: 'accent' }); - activeView.set(upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); - const afterActivation = { - showNewBadge: state.showNewBadge.get(), - stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }; + await fixture.state.initialize(); + fixture.refetchAssignments.fire(); + await Promise.resolve(); - assert.deepStrictEqual({ beforeActivation, afterActivation }, { - beforeActivation: { showNewBadge: true, style: 'outline', stored: undefined }, - afterActivation: { - showNewBadge: false, - stored: 'true', - }, + assert.deepStrictEqual({ + showNewBadge: fixture.state.showNewBadge.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + treatments: fixture.assignmentService.treatments, + }, { + showNewBadge: false, + stored: undefined, + treatments: [], }); }); - test('resolves accent, soft, and outline from the hidden treatment', async () => { + test('resolves accent, soft, and outline for eligible returning users', async () => { const snapshots = []; for (const style of ['accent', 'soft', 'outline'] as const) { const fixture = createState({ style }); @@ -130,20 +156,116 @@ suite('AutomationsNewBadgeState', () => { ]); }); - test('falls back to outline when treatment resolution fails', async () => { - const fixture = createState({ treatmentError: new Error('Unavailable') }); + test('never reveals after initial catalogue discovery is suppressed', async () => { + const snapshots = []; + for (const initialState of ['loading', 'unavailable', 'error'] as const) { + const fixture = createState({ catalogueState: initialState, style: 'accent' }); + await fixture.state.initialize(); + fixture.catalogueState.set('ready', undefined); + await fixture.configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'soft'); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ + affectsConfiguration: key => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, + })); + fixture.refetchAssignments.fire(); + await Promise.resolve(); + snapshots.push({ + initialState, + showNewBadge: fixture.state.showNewBadge.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + treatments: fixture.assignmentService.treatments, + }); + } + + assert.deepStrictEqual(snapshots, [ + { initialState: 'loading', showNewBadge: false, stored: undefined, treatments: [] }, + { initialState: 'unavailable', showNewBadge: false, stored: undefined, treatments: [] }, + { initialState: 'error', showNewBadge: false, stored: undefined, treatments: [] }, + ]); + }); + + test('waits for the bounded startup phase before deciding eligibility', async () => { + const fixture = createState({ eventuallyReady: false, style: 'accent' }); + const initialization = fixture.state.initialize(); + await Promise.resolve(); + const beforeEventually = fixture.state.presentation.get(); + + await fixture.completeEventually(); + await initialization; + + assert.deepStrictEqual({ + beforeEventually, + afterEventually: fixture.state.presentation.get(), + }, { + beforeEventually: undefined, + afterEventually: 'accent', + }); + }); + + test('retires the badge when Automation evidence appears after presentation', async () => { + const fixture = createState(); await fixture.state.initialize(); + const beforeEvidence = fixture.state.presentation.get(); + + fixture.automations.set([upcastPartial({ id: 'late-automation' })], undefined); + fixture.automations.set([], undefined); assert.deepStrictEqual({ - style: fixture.state.presentation.get(), - treatments: fixture.assignmentService.treatments, + beforeEvidence, + afterEvidence: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), }, { - style: 'outline', - treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT], + beforeEvidence: 'outline', + afterEvidence: undefined, + stored: 'true', + }); + }); + + test('suppresses for the window when the aggregate catalogue starts loading after presentation', async () => { + const fixture = createState(); + await fixture.state.initialize(); + const beforeDiscoveryChange = fixture.state.presentation.get(); + + fixture.catalogueState.set('loading', undefined); + fixture.catalogueState.set('ready', undefined); + fixture.refetchAssignments.fire(); + await Promise.resolve(); + + assert.deepStrictEqual({ + beforeDiscoveryChange, + afterDiscoveryChange: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, { + beforeDiscoveryChange: 'outline', + afterDiscoveryChange: undefined, + stored: undefined, }); }); + test('suppresses for the window when the aggregate catalogue becomes unavailable or errors', async () => { + const snapshots = []; + for (const catalogueState of ['unavailable', 'error'] as const) { + const fixture = createState(); + await fixture.state.initialize(); + const beforeDiscoveryChange = fixture.state.presentation.get(); + + fixture.catalogueState.set(catalogueState, undefined); + fixture.catalogueState.set('ready', undefined); + + snapshots.push({ + catalogueState, + beforeDiscoveryChange, + afterDiscoveryChange: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }); + } + + assert.deepStrictEqual(snapshots, [ + { catalogueState: 'unavailable', beforeDiscoveryChange: 'outline', afterDiscoveryChange: undefined, stored: undefined }, + { catalogueState: 'error', beforeDiscoveryChange: 'outline', afterDiscoveryChange: undefined, stored: undefined }, + ]); + }); + test('lets the hidden setting override and live-update the treatment', async () => { const fixture = createState({ style: 'outline', configuredStyle: 'soft' }); await fixture.state.initialize(); @@ -151,7 +273,7 @@ suite('AutomationsNewBadgeState', () => { await fixture.configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'accent'); fixture.configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ - affectsConfiguration: (key: string) => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, + affectsConfiguration: key => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, })); assert.deepStrictEqual({ @@ -165,49 +287,51 @@ suite('AutomationsNewBadgeState', () => { }); }); - test('resets seen state for development even when prior Automation evidence exists', async () => { - const fixture = createState({ - automations: [upcastPartial({ id: 'existing-automation' })], - style: 'accent', - }); - await fixture.state.initialize(); + test('falls back to outline when treatment resolution fails', async () => { + const fixture = createState({ treatmentError: new Error('Unavailable') }); - await fixture.state.reset(); + await fixture.state.initialize(); assert.deepStrictEqual({ - showNewBadge: fixture.state.showNewBadge.get(), style: fixture.state.presentation.get(), - stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + treatments: fixture.assignmentService.treatments, }, { - showNewBadge: true, - style: 'accent', - stored: undefined, + style: 'outline', + treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT], }); }); - test('suppresses the badge when synchronous Automation evidence exists', async () => { - const definition = createState({ + test('force preview bypasses first-use and Automation evidence until activation', async () => { + const fixture = createState({ + hadPriorWindowOpen: false, automations: [upcastPartial({ id: 'existing-automation' })], + style: 'accent', }); - const run = createState({ - runs: [upcastPartial({ id: 'existing-run' })], - }); + await fixture.state.initialize(); - await definition.state.initialize(); - await run.state.initialize(); + await fixture.state.reset(); + fixture.runs.set([upcastPartial({ id: 'running' })], undefined); + const preview = { + style: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }; + fixture.activeView.set(upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); assert.deepStrictEqual({ - definition: { - showNewBadge: definition.state.showNewBadge.get(), - stored: definition.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }, - run: { - showNewBadge: run.state.showNewBadge.get(), - stored: run.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + preview, + afterActivation: { + showNewBadge: fixture.state.showNewBadge.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), }, }, { - definition: { showNewBadge: false, stored: 'true' }, - run: { showNewBadge: false, stored: 'true' }, + preview: { + style: 'accent', + stored: undefined, + }, + afterActivation: { + showNewBadge: false, + stored: 'true', + }, }); }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts index 95005e25a49172..c33384b044fe5c 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts @@ -10,6 +10,7 @@ import { constObservable, IObservable, observableValue } from '../../../../../ba import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { SessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { IChat, IGitHubInfo, IGitHubPullRequestRef, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { MAX_TRACKED_SESSIONS, MAX_TYPED_FILES_PER_SESSION, SESSIONS_KEY, SessionsLifecycleTracker } from '../../browser/sessionsLifecycleTracker.js'; @@ -99,9 +100,14 @@ suite('SessionsLifecycleTracker', () => { let storage: InMemoryStorageService; let tracker: SessionsLifecycleTracker; + function createTracker(): SessionsLifecycleTracker { + const usage = new SessionsWindowUsageService(storage); + return disposables.add(new SessionsLifecycleTracker(storage, usage.windowOpenCount)); + } + setup(() => { storage = disposables.add(new InMemoryStorageService()); - tracker = disposables.add(new SessionsLifecycleTracker(storage)); + tracker = createTracker(); }); test('starts untracked until a user interaction is recorded', () => { @@ -144,7 +150,7 @@ suite('SessionsLifecycleTracker', () => { tracker.recordNewChatRequestSent(session); tracker.bumpCounter(session, 'feedbackAdded'); - const secondTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const secondTracker = createTracker(); assert.strictEqual(secondTracker.isTracked(session.sessionId), true); const summary = secondTracker.finalize(session.sessionId, 'archived', session); @@ -265,7 +271,7 @@ suite('SessionsLifecycleTracker', () => { stored[session.sessionId].typedFileHashes = [123, 456]; storage.store(SESSIONS_KEY, JSON.stringify(stored), StorageScope.APPLICATION, StorageTarget.MACHINE); - const reloaded = disposables.add(new SessionsLifecycleTracker(storage)); + const reloaded = createTracker(); reloaded.addTypedCharacters(session.sessionId, URI.parse('file:///repo/a.ts'), 2); const summary = reloaded.finalize(session.sessionId, 'archived', session); @@ -566,7 +572,7 @@ suite('SessionsLifecycleTracker', () => { tracker.recordNewChatRequestSent(session); tracker.recordFirstRequestTaskInfo(session, { hasWorktreeCreatedTask: false, configuredTasksCount: 2 }); - const secondTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const secondTracker = createTracker(); const summary = secondTracker.finalize(session.sessionId, 'archived', session); assert.ok(summary); @@ -660,7 +666,7 @@ suite('SessionsLifecycleTracker', () => { tracker.incrementAndGetUserRequestCounters(session); tracker.incrementAndGetUserRequestCounters(session); - const secondTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const secondTracker = createTracker(); assert.deepStrictEqual(secondTracker.incrementAndGetUserRequestCounters(session), { userSessionsTotal: 3, userSessionsInWorkspace: 3, userSessionsForProvider: 3 }); }); @@ -708,7 +714,7 @@ suite('SessionsLifecycleTracker', () => { test('tracker treats corrupted storage as empty', () => { storage.store(SESSIONS_KEY, '{not valid json', StorageScope.APPLICATION, StorageTarget.MACHINE); - const recoveredTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const recoveredTracker = createTracker(); assert.deepStrictEqual(recoveredTracker.getTrackedIds(), []); }); @@ -745,7 +751,7 @@ suite('SessionsLifecycleTracker', () => { } storage.store(SESSIONS_KEY, JSON.stringify(stored), StorageScope.APPLICATION, StorageTarget.MACHINE); - const capTracker = disposables.add(new SessionsLifecycleTracker(storage)); + const capTracker = createTracker(); assert.strictEqual(capTracker.getTrackedIds().length, MAX_TRACKED_SESSIONS); const newSession = createSession('brand-new'); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index d7c5604b9a7962..56650591abe704 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -275,7 +275,7 @@ suite('Sessions - SessionsList', () => { }); }); - test('updates the Automations row accessible label when the new badge is dismissed', () => { + test('updates the Automations row accessible label when the new badge is dismissed', async () => { const activeCustomView = observableValue(disposables, undefined); const harness = createListHarness(disposables, [], instantiationService => { ChatAutomationsEnabledContext.bindTo(instantiationService.get(IContextKeyService)).set(true); @@ -283,6 +283,7 @@ suite('Sessions - SessionsList', () => { instantiationService.stub(IAutomationService, new class extends mock() { override readonly automations = constObservable([]); override readonly runs = constObservable([]); + override readonly catalogueState = constObservable('ready' as const); }); instantiationService.stub(ICustomViewService, new class extends mock() { override readonly activeCustomView = activeCustomView; @@ -295,6 +296,7 @@ suite('Sessions - SessionsList', () => { onSessionOpen: () => { }, })); list.layout(300, 400); + await list.resetAutomationsNewBadge(); const row = container.querySelector('.monaco-list-row'); const before = row?.getAttribute('aria-label'); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index dee26dab895c5d..6d6e799682aa43 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -24,6 +24,7 @@ import { ISessionsListModelService, SessionSortMode } from '../../../../services import { ISessionSectionOrderService } from '../../../../services/sessions/browser/sessionSectionOrderService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IChat, ISession, ISessionCapabilities, ISessionChangesSummary, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IDeleteChatOptions } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -245,6 +246,10 @@ export function createListHarness(disposables: Pick, ses override getProviders() { return []; } override getProvider() { return undefined; } }); + instantiationService.stub(ISessionsWindowUsageService, new class extends mock() { + override readonly hadPriorWindowOpen = true; + override readonly windowOpenCount = 2; + }); instantiationService.stub(IVoicePlaybackService, new class extends mock() { override readonly pendingResponseVersion = constObservable(0); override hasPendingResponse() { return false; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts index 2a028c51bc1afb..7cabddb04aadc7 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts @@ -30,6 +30,7 @@ import { IActiveSession, ISendRequestSentEvent, ISessionsManagementService } fro import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { SessionsTelemetryContribution } from '../../browser/sessionsTelemetry.contribution.js'; interface IRequestSentTelemetry { @@ -213,6 +214,10 @@ suite('SessionsTelemetryContribution', () => { providersService, tasksService, modelService, + new class extends mock() { + override readonly hadPriorWindowOpen = false; + override readonly windowOpenCount = 1; + }(), )); return { telemetryService, storageService, onDidSendRequest, onDidArchiveSession, onModelAdded }; diff --git a/src/vs/sessions/services/sessions/browser/sessionsWindowUsageService.ts b/src/vs/sessions/services/sessions/browser/sessionsWindowUsageService.ts new file mode 100644 index 00000000000000..9949f79d05af9f --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionsWindowUsageService.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const AGENTS_WINDOW_OPEN_COUNT_KEY = 'agentSessions.telemetry.summary.appLaunchCount'; + +export const ISessionsWindowUsageService = createDecorator('sessionsWindowUsageService'); + +export interface ISessionsWindowUsageService { + readonly _serviceBrand: undefined; + readonly hadPriorWindowOpen: boolean; + readonly windowOpenCount: number; +} + +export class SessionsWindowUsageService implements ISessionsWindowUsageService { + + declare readonly _serviceBrand: undefined; + + readonly hadPriorWindowOpen: boolean; + readonly windowOpenCount: number; + + constructor(@IStorageService storageService: IStorageService) { + const previousWindowOpenCount = storageService.getNumber(AGENTS_WINDOW_OPEN_COUNT_KEY, StorageScope.APPLICATION, 0); + this.hadPriorWindowOpen = previousWindowOpenCount > 0; + this.windowOpenCount = previousWindowOpenCount + 1; + storageService.store(AGENTS_WINDOW_OPEN_COUNT_KEY, this.windowOpenCount, StorageScope.APPLICATION, StorageTarget.MACHINE); + } +} + +registerSingleton(ISessionsWindowUsageService, SessionsWindowUsageService, InstantiationType.Eager); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsWindowUsageService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsWindowUsageService.test.ts new file mode 100644 index 00000000000000..0836681a0d785c --- /dev/null +++ b/src/vs/sessions/services/sessions/test/browser/sessionsWindowUsageService.test.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { SessionsWindowUsageService } from '../../browser/sessionsWindowUsageService.js'; + +suite('SessionsWindowUsageService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('snapshots prior use before recording each window open', () => { + const storageService = disposables.add(new InMemoryStorageService()); + const firstWindow = new SessionsWindowUsageService(storageService); + const secondWindow = new SessionsWindowUsageService(storageService); + + assert.deepStrictEqual({ + firstWindow: { + hadPriorWindowOpen: firstWindow.hadPriorWindowOpen, + windowOpenCount: firstWindow.windowOpenCount, + }, + secondWindow: { + hadPriorWindowOpen: secondWindow.hadPriorWindowOpen, + windowOpenCount: secondWindow.windowOpenCount, + }, + storedCount: storageService.getNumber('agentSessions.telemetry.summary.appLaunchCount', StorageScope.APPLICATION), + machineKeys: storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE), + }, { + firstWindow: { + hadPriorWindowOpen: false, + windowOpenCount: 1, + }, + secondWindow: { + hadPriorWindowOpen: true, + windowOpenCount: 2, + }, + storedCount: 2, + machineKeys: ['agentSessions.telemetry.summary.appLaunchCount'], + }); + }); + + test('recognizes launches recorded before the badge without session history', () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('agentSessions.telemetry.summary.appLaunchCount', 7, StorageScope.APPLICATION, StorageTarget.MACHINE); + + const usage = new SessionsWindowUsageService(storageService); + + assert.deepStrictEqual({ + hadPriorWindowOpen: usage.hadPriorWindowOpen, + windowOpenCount: usage.windowOpenCount, + storedCount: storageService.getNumber('agentSessions.telemetry.summary.appLaunchCount', StorageScope.APPLICATION), + }, { + hadPriorWindowOpen: true, + windowOpenCount: 8, + storedCount: 8, + }); + }); +}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index d7d5a085f33222..a958637e752924 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -456,6 +456,7 @@ import './browser/paneCompositePartService.js'; import './browser/parts/editorParts.js'; import './browser/parts/sessionsParts.js'; import './browser/parts/customViewGridParts.js'; +import './services/sessions/browser/sessionsWindowUsageService.js'; import './services/sessions/browser/sessionsService.js'; import './services/workspaceFolderLabel/browser/workspaceFolderLabelService.js'; import './services/customView/browser/customViewService.js'; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index a47526872711a0..e4b6a37a1bbe12 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -41,6 +41,8 @@ import { ISessionsListModelService } from '../../../../../sessions/services/sess // eslint-disable-next-line local/code-import-patterns import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; // eslint-disable-next-line local/code-import-patterns +import { ISessionsWindowUsageService } from '../../../../../sessions/services/sessions/browser/sessionsWindowUsageService.js'; +// eslint-disable-next-line local/code-import-patterns import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; // eslint-disable-next-line local/code-import-patterns import { ICustomViewService } from '../../../../../sessions/services/customView/browser/customViewService.js'; @@ -72,6 +74,7 @@ import { IChatService } from '../../../../contrib/chat/common/chatService/chatSe import { IChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; import { IVoicePlaybackService } from '../../../../contrib/chat/common/voicePlaybackService.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; import { TestProductService } from '../../../common/workbenchTestServices.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; @@ -350,6 +353,14 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender override getProviders() { return []; } override getProvider() { return undefined; } }()); + reg.defineInstance(ISessionsWindowUsageService, new class extends mock() { + override readonly hadPriorWindowOpen = true; + override readonly windowOpenCount = 2; + }()); + reg.defineInstance(ILifecycleService, new class extends mock() { + override phase = LifecyclePhase.Eventually; + override when(): Promise { return Promise.resolve(); } + }()); reg.defineInstance(IVoicePlaybackService, new class extends mock() { override readonly pendingResponseVersion: IObservable = constObservable(0); override hasPendingResponse() { return false; } @@ -357,6 +368,7 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender reg.defineInstance(IAutomationService, new class extends mock() { override readonly automations = constObservable([]); override readonly runs = automationRuns; + override readonly catalogueState = constObservable('ready' as const); }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override readonly onDidRefetchAssignments = Event.None; @@ -438,6 +450,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender })); list.layout(options.phone ? 260 : showHeader ? 180 : 220, width); + if (options.showAutomations) { + await list.resetAutomationsNewBadge(); + } if (options.automationRunStatus) { automationRuns.set([{ id: 'fixture-run',