mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
Growth chat session for new users (#295229)
* Revert Growth agent isReadOnly infrastructure Remove the isReadOnly extension point property and Growth-specific infrastructure from the chat sessions API that was added in #294255. This was scaffolding for a growth session approach via the extension chatSessions API, which is being replaced with a core-side implementation. Removed: - isReadOnly on IChatSessionsExtensionPoint and its schema - isReadOnly filtering in session target/delegation pickers - contribution?.isReadOnly parameter from getAgentCanContinueIn - Growth from getAgentSessionProvider (prevents cache persistence) - Growth-specific and isReadOnly tests Kept: - AgentSessionProviders.Growth enum value (used by new implementation) - ChatSessionStatus.NeedsInput in proposed API + ext host types - Growth cases in name/icon/description switch statements * growth notification for new chat users * hygiene skill * Update src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupGrowthSession.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR review feedback --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
25
.github/skills/hygiene/SKILL.md
vendored
Normal file
25
.github/skills/hygiene/SKILL.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Hygiene Checks
|
||||
|
||||
VS Code runs a hygiene check as a git pre-commit hook. Commits will be rejected if hygiene fails.
|
||||
|
||||
## What it checks
|
||||
|
||||
The hygiene linter scans all staged `.ts` files for issues including (but not limited to):
|
||||
|
||||
- **Unicode characters**: Non-ASCII characters (em-dashes, curly quotes, emoji, etc.) are rejected. Use ASCII equivalents in comments and code.
|
||||
- **Double-quoted strings**: Only use `"double quotes"` for externalized (localized) strings. Use `'single quotes'` everywhere else.
|
||||
- **Copyright headers**: All files must include the Microsoft copyright header.
|
||||
|
||||
## How it runs
|
||||
|
||||
The git pre-commit hook (via husky) runs `npm run precommit`, which executes:
|
||||
|
||||
```bash
|
||||
node --experimental-strip-types build/hygiene.ts
|
||||
```
|
||||
|
||||
This scans only **staged files** (from `git diff --cached`). To run it manually:
|
||||
|
||||
```bash
|
||||
npm run precommit
|
||||
```
|
||||
@@ -9,7 +9,6 @@ import { URI } from '../../../../../base/common/uri.js';
|
||||
import { ThemeIcon } from '../../../../../base/common/themables.js';
|
||||
import { observableValue } from '../../../../../base/common/observable.js';
|
||||
import { IChatSessionTiming } from '../../common/chatService/chatService.js';
|
||||
import { IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js';
|
||||
import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js';
|
||||
import { getChatSessionType } from '../../common/model/chatUri.js';
|
||||
|
||||
@@ -37,7 +36,6 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes
|
||||
case AgentSessionProviders.Cloud:
|
||||
case AgentSessionProviders.Claude:
|
||||
case AgentSessionProviders.Codex:
|
||||
case AgentSessionProviders.Growth:
|
||||
return type;
|
||||
default:
|
||||
return undefined;
|
||||
@@ -97,11 +95,7 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentCanContinueIn(provider: AgentSessionProviders, contribution?: IChatSessionsExtensionPoint): boolean {
|
||||
// Read-only sessions (e.g., Growth) are passive/informational and cannot be delegation targets
|
||||
if (contribution?.isReadOnly) {
|
||||
return false;
|
||||
}
|
||||
export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean {
|
||||
switch (provider) {
|
||||
case AgentSessionProviders.Local:
|
||||
case AgentSessionProviders.Background:
|
||||
@@ -127,7 +121,7 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide
|
||||
case AgentSessionProviders.Codex:
|
||||
return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view.");
|
||||
case AgentSessionProviders.Growth:
|
||||
return localize('chat.session.providerDescription.growth', "Educational messages to help you learn Copilot features.");
|
||||
return localize('chat.session.providerDescription.growth', "Learn about Copilot features.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1101,6 +1101,15 @@ configurationRegistry.registerConfiguration({
|
||||
mode: 'auto'
|
||||
}
|
||||
},
|
||||
[ChatConfiguration.GrowthNotificationEnabled]: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('chat.growthNotification', "Controls whether to show a growth notification in the agent sessions view to encourage new users to try Copilot."),
|
||||
default: false,
|
||||
tags: ['experimental'],
|
||||
experiment: {
|
||||
mode: 'auto'
|
||||
}
|
||||
},
|
||||
[ChatConfiguration.RestoreLastPanelSession]: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('chat.restoreLastPanelSession', "Controls whether the last session is restored in panel after restart."),
|
||||
|
||||
@@ -204,11 +204,6 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint<IChatSessionsEx
|
||||
type: 'boolean',
|
||||
default: false
|
||||
},
|
||||
isReadOnly: {
|
||||
description: localize('chatSessionsExtPoint.isReadOnly', 'Whether this session type is for read-only agents that do not support interactive chat. This flag is incompatible with \'canDelegate\'.'),
|
||||
type: 'boolean',
|
||||
default: false
|
||||
},
|
||||
customAgentTarget: {
|
||||
description: localize('chatSessionsExtPoint.customAgentTarget', 'When set, the chat session will show a filtered mode picker that prefers custom agents whose target property matches this value. Custom agents without a target property are still shown in all session types. This enables the use of standard agent/mode with contributed sessions.'),
|
||||
type: 'string'
|
||||
@@ -657,7 +652,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
|
||||
private _enableContribution(contribution: IChatSessionsExtensionPoint, ext: IRelaxedExtensionDescription): void {
|
||||
const disposableStore = new DisposableStore();
|
||||
this._contributionDisposables.set(contribution.type, disposableStore);
|
||||
if (contribution.isReadOnly || contribution.canDelegate) {
|
||||
if (contribution.canDelegate) {
|
||||
disposableStore.add(this._registerAgent(contribution, ext));
|
||||
disposableStore.add(this._registerCommands(contribution));
|
||||
}
|
||||
|
||||
@@ -41,12 +41,14 @@ import { IPreferencesService } from '../../../../services/preferences/common/pre
|
||||
import { IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js';
|
||||
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
|
||||
import { IChatModeService } from '../../common/chatModes.js';
|
||||
import { IChatSessionsService } from '../../common/chatSessionsService.js';
|
||||
import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js';
|
||||
import { CHAT_CATEGORY, CHAT_SETUP_ACTION_ID, CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../actions/chatActions.js';
|
||||
import { ChatViewContainerId, IChatWidgetService } from '../chat.js';
|
||||
import { chatViewsWelcomeRegistry } from '../viewsWelcome/chatViewsWelcome.js';
|
||||
import { ChatSetupAnonymous } from './chatSetup.js';
|
||||
import { ChatSetupController } from './chatSetupController.js';
|
||||
import { GrowthSessionController, registerGrowthSession } from './chatSetupGrowthSession.js';
|
||||
import { AICodeActionsHelper, AINewSymbolNamesProvider, ChatCodeActionsProvider, SetupAgent } from './chatSetupProviders.js';
|
||||
import { ChatSetup } from './chatSetupRunner.js';
|
||||
|
||||
@@ -71,6 +73,8 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr
|
||||
@IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService,
|
||||
@IExtensionService private readonly extensionService: IExtensionService,
|
||||
@IEnvironmentService private readonly environmentService: IEnvironmentService,
|
||||
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -83,6 +87,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr
|
||||
const controller = new Lazy(() => this._register(this.instantiationService.createInstance(ChatSetupController, context, requests)));
|
||||
|
||||
this.registerSetupAgents(context, controller);
|
||||
this.registerGrowthSession(chatEntitlementService);
|
||||
this.registerActions(context, requests, controller);
|
||||
this.registerUrlLinkHandler();
|
||||
this.checkExtensionInstallation(context);
|
||||
@@ -171,6 +176,37 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr
|
||||
this._register(Event.runAndSubscribe(context.onDidChange, () => updateRegistration()));
|
||||
}
|
||||
|
||||
private registerGrowthSession(chatEntitlementService: ChatEntitlementService): void {
|
||||
const growthSessionDisposables = markAsSingleton(new MutableDisposable());
|
||||
|
||||
const updateGrowthSession = () => {
|
||||
const experimentEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.GrowthNotificationEnabled) === true;
|
||||
// Show for users who don't have the Copilot extension installed yet.
|
||||
// Additional conditions (e.g., anonymous, entitlement) can be layered here.
|
||||
const shouldShow = experimentEnabled && !chatEntitlementService.sentiment.installed;
|
||||
if (shouldShow && !growthSessionDisposables.value) {
|
||||
const disposables = new DisposableStore();
|
||||
const controller = disposables.add(this.instantiationService.createInstance(GrowthSessionController));
|
||||
if (!controller.isDismissed) {
|
||||
disposables.add(registerGrowthSession(this.chatSessionsService, controller));
|
||||
// Fully unregister when dismissed to prevent cached session from
|
||||
// appearing during filtered model updates from other providers.
|
||||
disposables.add(controller.onDidDismiss(() => {
|
||||
growthSessionDisposables.clear();
|
||||
}));
|
||||
growthSessionDisposables.value = disposables;
|
||||
} else {
|
||||
disposables.dispose();
|
||||
}
|
||||
} else if (!shouldShow) {
|
||||
growthSessionDisposables.clear();
|
||||
}
|
||||
};
|
||||
|
||||
this._register(chatEntitlementService.onDidChangeSentiment(() => updateGrowthSession()));
|
||||
updateGrowthSession();
|
||||
}
|
||||
|
||||
private registerActions(context: ChatEntitlementContext, requests: ChatEntitlementRequests, controller: Lazy<ChatSetupController>): void {
|
||||
|
||||
//#region Global Chat Setup Actions
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Codicon } from '../../../../../base/common/codicons.js';
|
||||
import { Emitter, Event } from '../../../../../base/common/event.js';
|
||||
import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
|
||||
import { localize, localize2 } from '../../../../../nls.js';
|
||||
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
|
||||
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
|
||||
import { ILogService } from '../../../../../platform/log/common/log.js';
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
|
||||
import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js';
|
||||
import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionsService } from '../../common/chatSessionsService.js';
|
||||
import { AgentSessionProviders } from '../agentSessions/agentSessions.js';
|
||||
import { IAgentSession } from '../agentSessions/agentSessionsModel.js';
|
||||
import { ISessionOpenerParticipant, ISessionOpenOptions, sessionOpenerRegistry } from '../agentSessions/agentSessionsOpener.js';
|
||||
import { IChatWidgetService } from '../chat.js';
|
||||
import { CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../actions/chatActions.js';
|
||||
|
||||
/**
|
||||
* Core-side growth session controller that shows a single "attention needed"
|
||||
* session item in the agent sessions view for anonymous/new users.
|
||||
*
|
||||
* When the user clicks the session, we open the chat panel (which triggers the
|
||||
* anonymous setup flow). When the user opens chat at all, the badge is cleared.
|
||||
*
|
||||
* The session is shown at most once, tracked via a storage flag.
|
||||
*/
|
||||
export class GrowthSessionController extends Disposable implements IChatSessionItemController {
|
||||
|
||||
static readonly STORAGE_KEY = 'chat.growthSession.dismissed';
|
||||
|
||||
private static readonly SESSION_URI = URI.from({ scheme: AgentSessionProviders.Growth, path: '/growth-welcome' });
|
||||
|
||||
private readonly _onDidChangeChatSessionItems = this._register(new Emitter<void>());
|
||||
readonly onDidChangeChatSessionItems: Event<void> = this._onDidChangeChatSessionItems.event;
|
||||
|
||||
private readonly _onDidDismiss = this._register(new Emitter<void>());
|
||||
readonly onDidDismiss: Event<void> = this._onDidDismiss.event;
|
||||
|
||||
private readonly _created = Date.now();
|
||||
|
||||
private _dismissed: boolean;
|
||||
get isDismissed(): boolean { return this._dismissed; }
|
||||
|
||||
constructor(
|
||||
@IStorageService private readonly storageService: IStorageService,
|
||||
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
|
||||
@ILifecycleService private readonly lifecycleService: ILifecycleService,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._dismissed = this.storageService.getBoolean(GrowthSessionController.STORAGE_KEY, StorageScope.APPLICATION, false);
|
||||
|
||||
// Dismiss the growth session when the user opens chat.
|
||||
// Wait until the workbench is fully restored so we skip widgets
|
||||
// that were restored from a previous session at startup.
|
||||
this.lifecycleService.when(LifecyclePhase.Restored).then(() => {
|
||||
if (this._store.isDisposed || this._dismissed) {
|
||||
return;
|
||||
}
|
||||
this._register(this.chatWidgetService.onDidAddWidget(() => {
|
||||
this.dismiss();
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
get items(): readonly IChatSessionItem[] {
|
||||
if (this._dismissed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
resource: GrowthSessionController.SESSION_URI,
|
||||
label: localize('growthSession.label', "Try Copilot"),
|
||||
description: localize('growthSession.description', "GitHub Copilot is available. Try it for free."),
|
||||
status: ChatSessionStatus.NeedsInput,
|
||||
iconPath: Codicon.lightbulb,
|
||||
timing: {
|
||||
created: this._created,
|
||||
lastRequestStarted: undefined,
|
||||
lastRequestEnded: undefined,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
// Nothing to refresh -- this is a static, local-only session item
|
||||
}
|
||||
|
||||
private dismiss(): void {
|
||||
if (this._dismissed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.trace('[GrowthSession] Dismissing growth session');
|
||||
this._dismissed = true;
|
||||
this.storageService.store(GrowthSessionController.STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER);
|
||||
|
||||
// Fire change event first so that listeners (like the model) see empty items
|
||||
this._onDidChangeChatSessionItems.fire();
|
||||
// Then fire dismiss event which triggers unregistration of the controller.
|
||||
this._onDidDismiss.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicks on the growth session item in the agent sessions view.
|
||||
* Opens a new local chat session with a pre-seeded welcome message.
|
||||
* The user can then send messages that go through the normal agent.
|
||||
*/
|
||||
export class GrowthSessionOpenerParticipant implements ISessionOpenerParticipant {
|
||||
|
||||
async handleOpenSession(accessor: ServicesAccessor, session: IAgentSession, _openOptions?: ISessionOpenOptions): Promise<boolean> {
|
||||
if (session.providerType !== AgentSessionProviders.Growth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const opts: IChatViewOpenOptions = {
|
||||
query: '',
|
||||
isPartialQuery: true,
|
||||
previousRequests: [{
|
||||
request: localize('growthSession.previousRequest', "Tell me about GitHub Copilot!"),
|
||||
// allow-any-unicode-next-line
|
||||
response: localize('growthSession.previousResponse', "Welcome to GitHub Copilot, your AI coding assistant! Here are some things you can try:\n\n- 🐛 *\"Help me debug this error\"* — paste an error message and get a fix\n- 🧪 *\"Write tests for my function\"* — select code and ask for unit tests\n- 💡 *\"Explain this code\"* — highlight something unfamiliar and ask what it does\n- 🚀 *\"Scaffold a REST API\"* — describe what you want and let Agent mode build it\n- 🎨 *\"Refactor this to be more readable\"* — select messy code and clean it up\n\nType anything below to get started!"),
|
||||
}],
|
||||
};
|
||||
await commandService.executeCommand(CHAT_OPEN_ACTION_ID, opts);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the growth session controller and opener participant.
|
||||
* Returns a disposable that cleans up all registrations.
|
||||
*/
|
||||
export function registerGrowthSession(chatSessionsService: IChatSessionsService, growthController: GrowthSessionController): IDisposable {
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
// Register as session item controller so it appears in the sessions view
|
||||
disposables.add(chatSessionsService.registerChatSessionItemController(AgentSessionProviders.Growth, growthController));
|
||||
|
||||
// Register opener participant so clicking the growth session opens chat
|
||||
disposables.add(sessionOpenerRegistry.registerParticipant(new GrowthSessionOpenerParticipant()));
|
||||
|
||||
return disposables;
|
||||
}
|
||||
|
||||
// #region Developer Actions
|
||||
|
||||
registerAction2(class ResetGrowthSessionAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'workbench.action.chat.resetGrowthSession',
|
||||
title: localize2('resetGrowthSession', "Reset Growth Session Notification"),
|
||||
category: localize2('developer', "Developer"),
|
||||
f1: true,
|
||||
});
|
||||
}
|
||||
|
||||
run(accessor: ServicesAccessor): void {
|
||||
const storageService = accessor.get(IStorageService);
|
||||
storageService.remove(GrowthSessionController.STORAGE_KEY, StorageScope.APPLICATION);
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
@@ -132,7 +132,7 @@ export class ChatSuggestNextWidget extends Disposable {
|
||||
return false;
|
||||
}
|
||||
const provider = getAgentSessionProvider(c.type);
|
||||
return provider !== undefined && getAgentCanContinueIn(provider, c);
|
||||
return provider !== undefined && getAgentCanContinueIn(provider);
|
||||
});
|
||||
|
||||
if (showContinueOn && availableContributions.length > 0) {
|
||||
|
||||
@@ -54,8 +54,7 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt
|
||||
return true; // Always show active session type
|
||||
}
|
||||
|
||||
const contribution = this.chatSessionsService.getChatSessionContribution(type);
|
||||
return getAgentCanContinueIn(type, contribution);
|
||||
return getAgentCanContinueIn(type);
|
||||
}
|
||||
|
||||
protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) {
|
||||
|
||||
@@ -163,10 +163,6 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem {
|
||||
|
||||
const contributions = this.chatSessionsService.getAllChatSessionContributions();
|
||||
for (const contribution of contributions) {
|
||||
if (contribution.isReadOnly) {
|
||||
continue; // Read-only sessions are not interactive and should not appear in session target picker
|
||||
}
|
||||
|
||||
const agentSessionType = getAgentSessionProvider(contribution.type);
|
||||
if (!agentSessionType) {
|
||||
continue;
|
||||
|
||||
@@ -85,7 +85,6 @@ export interface IChatSessionsExtensionPoint {
|
||||
readonly capabilities?: IChatAgentAttachmentCapabilities;
|
||||
readonly commands?: IChatSessionCommandContribution[];
|
||||
readonly canDelegate?: boolean;
|
||||
readonly isReadOnly?: boolean;
|
||||
/**
|
||||
* When set, the chat session will show a filtered mode picker with custom agents
|
||||
* that have a matching `target` property. This enables contributed chat sessions
|
||||
|
||||
@@ -49,6 +49,7 @@ export enum ChatConfiguration {
|
||||
ExitAfterDelegation = 'chat.exitAfterDelegation',
|
||||
AgentsControlClickBehavior = 'chat.agentsControl.clickBehavior',
|
||||
ExplainChangesEnabled = 'chat.editing.explainChanges.enabled',
|
||||
GrowthNotificationEnabled = 'chat.growthNotification.enabled',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2100,13 +2100,8 @@ suite('AgentSessions', () => {
|
||||
suite('AgentSessionsViewModel - getAgentCanContinueIn', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('should return false when contribution.isReadOnly is true', () => {
|
||||
const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: true });
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
test('should return true for Cloud when contribution is not read-only', () => {
|
||||
const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: false });
|
||||
test('should return true for Cloud provider', () => {
|
||||
const result = getAgentCanContinueIn(AgentSessionProviders.Cloud);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { Emitter } from '../../../../../../base/common/event.js';
|
||||
import { DisposableStore } from '../../../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { Codicon } from '../../../../../../base/common/codicons.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js';
|
||||
import { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js';
|
||||
import { ChatSessionStatus } from '../../../common/chatSessionsService.js';
|
||||
import { AgentSessionProviders } from '../../../browser/agentSessions/agentSessions.js';
|
||||
import { IAgentSession } from '../../../browser/agentSessions/agentSessionsModel.js';
|
||||
import { GrowthSessionController, GrowthSessionOpenerParticipant } from '../../../browser/chatSetup/chatSetupGrowthSession.js';
|
||||
import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js';
|
||||
import { MockChatWidgetService } from '../widget/mockChatWidget.js';
|
||||
import { ServicesAccessor } from '../../../../../../editor/browser/editorExtensions.js';
|
||||
|
||||
class TestMockChatWidgetService extends MockChatWidgetService {
|
||||
|
||||
private readonly _onDidAddWidget = new Emitter<IChatWidget>();
|
||||
override readonly onDidAddWidget = this._onDidAddWidget.event;
|
||||
|
||||
fireDidAddWidget(): void {
|
||||
this._onDidAddWidget.fire(undefined!);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._onDidAddWidget.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
suite('GrowthSessionController', () => {
|
||||
|
||||
const disposables = new DisposableStore();
|
||||
let instantiationService: TestInstantiationService;
|
||||
let mockWidgetService: TestMockChatWidgetService;
|
||||
|
||||
setup(() => {
|
||||
instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables));
|
||||
mockWidgetService = new TestMockChatWidgetService();
|
||||
disposables.add({ dispose: () => mockWidgetService.dispose() });
|
||||
const mockLifecycleService = disposables.add(new TestLifecycleService());
|
||||
instantiationService.stub(IChatWidgetService, mockWidgetService);
|
||||
instantiationService.stub(ILifecycleService, mockLifecycleService);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
disposables.clear();
|
||||
});
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('should return a single NeedsInput session item', () => {
|
||||
const controller = disposables.add(instantiationService.createInstance(GrowthSessionController));
|
||||
|
||||
const items = controller.items;
|
||||
assert.strictEqual(items.length, 1);
|
||||
assert.strictEqual(items[0].status, ChatSessionStatus.NeedsInput);
|
||||
assert.strictEqual(items[0].label, 'Try Copilot');
|
||||
assert.ok(items[0].resource.scheme === AgentSessionProviders.Growth);
|
||||
});
|
||||
|
||||
test('should return empty items after dismiss', async () => {
|
||||
const controller = disposables.add(instantiationService.createInstance(GrowthSessionController));
|
||||
assert.strictEqual(controller.items.length, 1);
|
||||
|
||||
// Allow the lifecycle.when() promise to resolve and register the listener
|
||||
await new Promise<void>(r => setTimeout(r, 0));
|
||||
|
||||
// Fire widget add — should dismiss
|
||||
mockWidgetService.fireDidAddWidget();
|
||||
assert.strictEqual(controller.items.length, 0);
|
||||
});
|
||||
|
||||
test('should fire onDidChangeChatSessionItems on dismiss', async () => {
|
||||
const controller = disposables.add(instantiationService.createInstance(GrowthSessionController));
|
||||
|
||||
let fired = false;
|
||||
disposables.add(controller.onDidChangeChatSessionItems(() => {
|
||||
fired = true;
|
||||
}));
|
||||
|
||||
await new Promise<void>(r => setTimeout(r, 0));
|
||||
|
||||
mockWidgetService.fireDidAddWidget();
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
test('should not fire onDidChangeChatSessionItems twice', async () => {
|
||||
const controller = disposables.add(instantiationService.createInstance(GrowthSessionController));
|
||||
|
||||
let fireCount = 0;
|
||||
disposables.add(controller.onDidChangeChatSessionItems(() => {
|
||||
fireCount++;
|
||||
}));
|
||||
|
||||
await new Promise<void>(r => setTimeout(r, 0));
|
||||
|
||||
mockWidgetService.fireDidAddWidget();
|
||||
mockWidgetService.fireDidAddWidget();
|
||||
assert.strictEqual(fireCount, 1);
|
||||
});
|
||||
|
||||
test('refresh is a no-op', async () => {
|
||||
const controller = disposables.add(instantiationService.createInstance(GrowthSessionController));
|
||||
await controller.refresh();
|
||||
assert.strictEqual(controller.items.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
suite('GrowthSessionOpenerParticipant', () => {
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('should return false for non-Growth sessions', async () => {
|
||||
const participant = new GrowthSessionOpenerParticipant();
|
||||
const session: IAgentSession = {
|
||||
providerType: AgentSessionProviders.Local,
|
||||
providerLabel: 'Local',
|
||||
resource: URI.parse('local://session-1'),
|
||||
status: ChatSessionStatus.Completed,
|
||||
label: 'Test Session',
|
||||
icon: Codicon.vm,
|
||||
timing: { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined },
|
||||
isArchived: () => false,
|
||||
setArchived: () => { },
|
||||
isRead: () => true,
|
||||
setRead: () => { },
|
||||
};
|
||||
|
||||
// The participant checks providerType before touching the accessor,
|
||||
// so a stub accessor is sufficient for this test path.
|
||||
const stubAccessor = { get: () => undefined } as unknown as ServicesAccessor;
|
||||
const result = await participant.handleOpenSession(stubAccessor, session);
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user