From 8c6ed5081766482275ceb8fa4f0592c32f14a511 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Sun, 8 Dec 2024 20:41:43 +0100 Subject: [PATCH 1/2] chat - resolve entitlements after potential upgrade (#235580) --- .../chatContentParts/chatQuotaExceededPart.ts | 8 +- .../contrib/chat/browser/chatQuotasService.ts | 41 ++-------- .../contrib/chat/browser/chatSetup.ts | 75 ++++++++++++++++++- 3 files changed, 78 insertions(+), 46 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts index bd584402ca04b..e3e06c9b05a13 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatQuotaExceededPart.ts @@ -11,11 +11,9 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { assertType } from '../../../../../base/common/types.js'; -import { URI } from '../../../../../base/common/uri.js'; import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { asCssVariable, textLinkForeground } from '../../../../../platform/theme/common/colorRegistry.js'; import { IChatResponseViewModel } from '../../common/chatViewModel.js'; @@ -34,8 +32,7 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar element: IChatResponseViewModel, renderer: MarkdownRenderer, @IChatWidgetService chatWidgetService: IChatWidgetService, - @ICommandService commandService: ICommandService, - @IProductService productService: IProductService, + @ICommandService commandService: ICommandService ) { super(); @@ -56,8 +53,7 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar let didAddSecondary = false; this._register(button1.onDidClick(async () => { - const url = productService.defaultChatAgent?.upgradePlanUrl; - await commandService.executeCommand('vscode.open', url ? URI.parse(url) : undefined); + await commandService.executeCommand('workbench.action.chat.upgradePlan'); if (!didAddSecondary) { didAddSecondary = true; diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts b/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts index efa9304c9742f..467affc78991b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts @@ -11,17 +11,16 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { createDecorator, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import product from '../../../../platform/product/common/product.js'; -import { URI } from '../../../../base/common/uri.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; export const IChatQuotasService = createDecorator('chatQuotasService'); @@ -102,35 +101,6 @@ export class ChatQuotasService extends Disposable implements IChatQuotasService private registerActions(): void { const that = this; - class UpgradePlanAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.upgradePlan', - title: localize2('managePlan', "Upgrade to Copilot Pro"), - category: localize2('chat.category', 'Chat'), - f1: true, - precondition: ChatContextKeys.enabled, - menu: { - id: MenuId.ChatCommandCenter, - group: 'a_first', - order: 1, - when: ContextKeyExpr.and( - ChatContextKeys.Setup.installed, - ContextKeyExpr.or( - ChatContextKeys.chatQuotaExceeded, - ChatContextKeys.completionsQuotaExceeded - ) - ) - } - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const openerService = accessor.get(IOpenerService); - openerService.open(URI.parse(product.defaultChatAgent?.upgradePlanUrl ?? '')); - } - } - class ShowLimitReachedDialogAction extends Action2 { constructor() { @@ -141,7 +111,7 @@ export class ChatQuotasService extends Disposable implements IChatQuotasService } override async run(accessor: ServicesAccessor) { - const openerService = accessor.get(IOpenerService); + const commandService = accessor.get(ICommandService); const dialogService = accessor.get(IDialogService); const dateFormatter = safeIntl.DateTimeFormat(language, { year: 'numeric', month: 'long', day: 'numeric' }); @@ -168,7 +138,7 @@ export class ChatQuotasService extends Disposable implements IChatQuotasService buttons: [ { label: localize('managePlan', "Upgrade to Copilot Pro"), - run: () => { openerService.open(URI.parse(product.defaultChatAgent?.upgradePlanUrl ?? '')); } + run: () => commandService.executeCommand('workbench.action.chat.upgradePlan') }, ], custom: { @@ -211,7 +181,6 @@ export class ChatQuotasService extends Disposable implements IChatQuotasService } } - registerAction2(UpgradePlanAction); registerAction2(ShowLimitReachedDialogAction); if (product.quality !== 'stable') { registerAction2(SimulateCopilotQuotaExceeded); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index d31113c0d6b19..6d5104baddf58 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -7,7 +7,7 @@ import './media/chatViewSetup.css'; import { $, getActiveElement, setVisibility } from '../../../../base/browser/dom.js'; import { Button, ButtonWithDropdown } from '../../../../base/browser/ui/button/button.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { IAction, toAction } from '../../../../base/common/actions.js'; +import { IAction, toAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../base/common/actions.js'; import { Barrier, timeout } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -15,7 +15,7 @@ import { isCancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IRequestContext } from '../../../../base/parts/request/common/request.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { MarkdownRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; @@ -56,6 +56,9 @@ import { CHAT_EDITING_SIDEBAR_PANEL_ID, CHAT_SIDEBAR_PANEL_ID } from './chatView import { ChatViewsWelcomeExtensions, IChatViewsWelcomeContributionRegistry } from './viewsWelcome/chatViewsWelcome.js'; import { IChatQuotasService } from './chatQuotasService.js'; import { mainWindow } from '../../../../base/browser/window.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IHostService } from '../../../services/host/browser/host.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', @@ -231,6 +234,61 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr } } + const windowFocusListener = this._register(new MutableDisposable()); + class UpgradePlanAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.upgradePlan', + title: localize2('managePlan', "Upgrade to Copilot Pro"), + category: localize2('chat.category', 'Chat'), + f1: true, + precondition: ChatContextKeys.enabled, + menu: { + id: MenuId.ChatCommandCenter, + group: 'a_first', + order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.Setup.installed, + ContextKeyExpr.or( + ChatContextKeys.chatQuotaExceeded, + ChatContextKeys.completionsQuotaExceeded + ) + ) + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const openerService = accessor.get(IOpenerService); + const productService = accessor.get(IProductService); + const telemetryService = accessor.get(ITelemetryService); + const hostService = accessor.get(IHostService); + const commandService = accessor.get(ICommandService); + + telemetryService.publicLog2('workbenchActionExecuted', { id: this.desc.id, from: 'chat' }); + + openerService.open(URI.parse(productService.defaultChatAgent?.upgradePlanUrl ?? '')); + + const entitlement = that.context.state.entitlement; + if (entitlement !== ChatEntitlement.Pro) { + // If the user is not yet Pro, we listen to window focus to refresh the token + // when the user has come back to the window assuming the user signed up. + windowFocusListener.value = hostService.onDidChangeFocus(focus => this.onWindowFocus(focus, commandService)); + } + } + + private async onWindowFocus(focus: boolean, commandService: ICommandService): Promise { + if (focus) { + windowFocusListener.clear(); + + const entitlement = await that.requests.forceResolveEntitlement(undefined); + if (entitlement === ChatEntitlement.Pro) { + commandService.executeCommand('github.copilot.refreshToken'); // ugly, but we need to signal to the extension that entitlements changed + } + } + } + } + async function hideSetupView(viewsDescriptorService: IViewDescriptorService, layoutService: IWorkbenchLayoutService): Promise { const location = viewsDescriptorService.getViewLocationById(ChatViewId); @@ -246,6 +304,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr registerAction2(ChatSetupTriggerAction); registerAction2(ChatSetupHideAction); + registerAction2(UpgradePlanAction); } } @@ -507,7 +566,15 @@ class ChatSetupRequests extends Disposable { } } - async forceResolveEntitlement(session: AuthenticationSession): Promise { + async forceResolveEntitlement(session: AuthenticationSession | undefined): Promise { + if (!session) { + session = await this.findMatchingProviderSession(CancellationToken.None); + } + + if (!session) { + return undefined; + } + return this.resolveEntitlement(session, CancellationToken.None); } @@ -798,7 +865,7 @@ class ChatSetupWelcomeContent extends Disposable { } // Limited SKU - const limitedSkuHeader = localize({ key: 'limitedSkuHeader', comment: ['{Locked="[]({0})"}'] }, "$(sparkle-filled) We now offer [Copilot for free]({0}) with 50 chat messages and 2000 code completions per month.", defaultChat.skusDocumentationUrl); + const limitedSkuHeader = localize({ key: 'limitedSkuHeader', comment: ['{Locked="[]({0})"}'] }, "$(sparkle-filled) We now offer [Copilot for free]({0}) with 2,000 code completions and 50 chat messages per month.", defaultChat.skusDocumentationUrl); const limitedSkuHeaderContainer = this.element.appendChild($('p')); limitedSkuHeaderContainer.appendChild(this._register(markdown.render(new MarkdownString(limitedSkuHeader, { isTrusted: true, supportThemeIcons: true }))).element); From aecdc7401cd96023a7ab4f1e6b13a257120ee64b Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 9 Dec 2024 11:31:45 +0100 Subject: [PATCH 2/2] chat - setup tweaks (#235600) --- .../contrib/chat/browser/chatSetup.ts | 39 +++++++------------ .../chat/browser/media/chatViewSetup.css | 8 ++-- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index 6d5104baddf58..485d1489dfd0d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -23,7 +23,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IExtensionManagementService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; @@ -67,6 +67,7 @@ const defaultChat = { termsStatementUrl: product.defaultChatAgent?.termsStatementUrl ?? '', privacyStatementUrl: product.defaultChatAgent?.privacyStatementUrl ?? '', skusDocumentationUrl: product.defaultChatAgent?.skusDocumentationUrl ?? '', + upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '', providerId: product.defaultChatAgent?.providerId ?? '', providerName: product.defaultChatAgent?.providerName ?? '', providerScopes: product.defaultChatAgent?.providerScopes ?? [[]], @@ -260,14 +261,13 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr override async run(accessor: ServicesAccessor): Promise { const openerService = accessor.get(IOpenerService); - const productService = accessor.get(IProductService); const telemetryService = accessor.get(ITelemetryService); const hostService = accessor.get(IHostService); const commandService = accessor.get(ICommandService); telemetryService.publicLog2('workbenchActionExecuted', { id: this.desc.id, from: 'chat' }); - openerService.open(URI.parse(productService.defaultChatAgent?.upgradePlanUrl ?? '')); + openerService.open(URI.parse(defaultChat.upgradePlanUrl)); const entitlement = that.context.state.entitlement; if (entitlement !== ChatEntitlement.Pro) { @@ -869,12 +869,6 @@ class ChatSetupWelcomeContent extends Disposable { const limitedSkuHeaderContainer = this.element.appendChild($('p')); limitedSkuHeaderContainer.appendChild(this._register(markdown.render(new MarkdownString(limitedSkuHeader, { isTrusted: true, supportThemeIcons: true }))).element); - // Terms - const terms = localize({ key: 'termsLabel', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "By continuing, you agree to our [Terms]({0}) and [Privacy Policy]({1}).", defaultChat.termsStatementUrl, defaultChat.privacyStatementUrl); - const termsContainer = this.element.appendChild($('p')); - termsContainer.classList.add('terms-container'); - termsContainer.appendChild(this._register(markdown.render(new MarkdownString(terms, { isTrusted: true }))).element); - // Setup Button const actions: IAction[] = []; if (this.context.state.installed) { @@ -882,6 +876,7 @@ class ChatSetupWelcomeContent extends Disposable { actions.push(toAction({ id: 'chatSetup.signInGhe', label: localize('signInGhe', "Sign in with a GHE.com Account"), run: () => this.commandService.executeCommand('github.copilotChat.signInGHE') })); } const buttonContainer = this.element.appendChild($('p')); + buttonContainer.classList.add('button-container'); const button = this._register(actions.length === 0 ? new Button(buttonContainer, { supportIcons: true, ...defaultButtonStyles @@ -894,6 +889,10 @@ class ChatSetupWelcomeContent extends Disposable { })); this._register(button.onDidClick(() => this.controller.setup())); + // Terms + const terms = localize({ key: 'termsLabel', comment: ['{Locked="["}', '{Locked="]({0})"}', '{Locked="]({1})"}'] }, "By continuing, you agree to the [Terms]({0}) and [Privacy Policy]({1}).", defaultChat.termsStatementUrl, defaultChat.privacyStatementUrl); + this.element.appendChild($('p')).appendChild(this._register(markdown.render(new MarkdownString(terms, { isTrusted: true }))).element); + // Update based on model state this._register(Event.runAndSubscribe(this.controller.onDidChange, () => this.update(limitedSkuHeaderContainer, button))); } @@ -1056,23 +1055,13 @@ class ChatSetupContext extends Disposable { this.storageService.remove('interactive.sessions', this.workspaceContextService.getWorkspace().folders.length ? StorageScope.WORKSPACE : StorageScope.APPLICATION); } - let changed = false; - changed = this.updateContextKey(this.signedOutContextKey, this._state.entitlement === ChatEntitlement.Unknown) || changed; - changed = this.updateContextKey(this.canSignUpContextKey, this._state.entitlement === ChatEntitlement.Available) || changed; - changed = this.updateContextKey(this.limitedContextKey, this._state.entitlement === ChatEntitlement.Limited) || changed; - changed = this.updateContextKey(this.triggeredContext, !!this._state.triggered) || changed; - changed = this.updateContextKey(this.installedContext, !!this._state.installed) || changed; + this.signedOutContextKey.set(this._state.entitlement === ChatEntitlement.Unknown); + this.canSignUpContextKey.set(this._state.entitlement === ChatEntitlement.Available); + this.limitedContextKey.set(this._state.entitlement === ChatEntitlement.Limited); + this.triggeredContext.set(!!this._state.triggered); + this.installedContext.set(!!this._state.installed); - if (changed) { - this._onDidChange.fire(); - } - } - - private updateContextKey(contextKey: IContextKey, value: boolean): boolean { - const current = contextKey.get(); - contextKey.set(value); - - return current !== value; + this._onDidChange.fire(); } suspend(): void { diff --git a/src/vs/workbench/contrib/chat/browser/media/chatViewSetup.css b/src/vs/workbench/contrib/chat/browser/media/chatViewSetup.css index 97a06eac5be10..8b3a81f428e78 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatViewSetup.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatViewSetup.css @@ -17,10 +17,6 @@ background-color: var(--vscode-chat-requestBackground); } - .terms-container { - padding-top: 5px; - } - .chat-feature-container { display: flex; align-items: center; @@ -38,6 +34,10 @@ vertical-align: bottom; } + .button-container { + padding-top: 20px; + } + /** Dropdown Button */ .monaco-button-dropdown { width: 100%;