diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md index 3fe8ee20b8d..bddc857c0d8 100644 --- a/vscode/CHANGELOG.md +++ b/vscode/CHANGELOG.md @@ -6,6 +6,8 @@ This is a log of all notable changes to Cody for VS Code. [Unreleased] changes a ### Added +- Added support links for Cody Pro and Enterprise users. [pull/3330](https://github.com/sourcegraph/cody/pull/3330) + ### Fixed ### Changed diff --git a/vscode/src/chat/protocol.ts b/vscode/src/chat/protocol.ts index 8b9424c590c..f96d77bcb95 100644 --- a/vscode/src/chat/protocol.ts +++ b/vscode/src/chat/protocol.ts @@ -184,6 +184,7 @@ export const CODY_DOC_URL = new URL('https://sourcegraph.com/docs/cody') // Community and support export const DISCORD_URL = new URL('https://discord.gg/s2qDtYGnAE') export const CODY_FEEDBACK_URL = new URL('https://github.com/sourcegraph/cody/issues/new/choose') +export const CODY_SUPPORT_URL = new URL('https://help.sourcegraph.com/hc/en-us/requests/new') // Account export const ACCOUNT_UPGRADE_URL = new URL('https://sourcegraph.com/cody/subscription') export const ACCOUNT_USAGE_URL = new URL('https://sourcegraph.com/cody/manage') diff --git a/vscode/src/main.ts b/vscode/src/main.ts index 8015e64f5d5..cce6770ccce 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -276,6 +276,8 @@ const register = async ( ) } + const statusBar = createStatusBar() + // Adds a change listener to the auth provider that syncs the auth status authProvider.addChangeListener(async (authStatus: AuthStatus) => { // Chat Manager uses Simple Context Provider @@ -299,10 +301,13 @@ const register = async ( parallelPromises.push(setupAutocomplete()) await Promise.all(parallelPromises) + statusBar.syncAuthStatus(authStatus) }) + // Sync initial auth status await chatManager.syncAuthStatus(authProvider.getAuthStatus()) ModelProvider.onConfigChange(initialConfig.experimentalOllamaChat) + statusBar.syncAuthStatus(authProvider.getAuthStatus()) const commandsManager = platform.createCommandsProvider?.() setCommandController(commandsManager) @@ -349,8 +354,6 @@ const register = async ( vscode.commands.registerCommand('cody.command.explain-output', a => executeExplainOutput(a)) ) - const statusBar = createStatusBar() - disposables.push( // Tests // Access token - this is only used in configuration tests diff --git a/vscode/src/services/FeedbackOptions.ts b/vscode/src/services/FeedbackOptions.ts index 051430cb7d3..dc7dcff19ae 100644 --- a/vscode/src/services/FeedbackOptions.ts +++ b/vscode/src/services/FeedbackOptions.ts @@ -1,20 +1,30 @@ import * as vscode from 'vscode' -import { CODY_DOC_URL, CODY_FEEDBACK_URL, DISCORD_URL } from '../chat/protocol' +import { CODY_DOC_URL, CODY_FEEDBACK_URL, CODY_SUPPORT_URL, DISCORD_URL } from '../chat/protocol' -export const FeedbackOptionItems = [ +// Support items for paid users (e.g Enterprise Users and Cody Pro Users) +export const PremiumSupportItems = [ { - label: '$(feedback) Cody Feedback', + label: '$(question) Cody Support', async onSelect(): Promise { - await vscode.env.openExternal(vscode.Uri.parse(CODY_FEEDBACK_URL.href)) + await vscode.env.openExternal(vscode.Uri.parse(CODY_SUPPORT_URL.href)) }, }, +] + +export const FeedbackOptionItems = [ { label: '$(remote-explorer-documentation) Cody Documentation', async onSelect(): Promise { await vscode.env.openExternal(vscode.Uri.parse(CODY_DOC_URL.href)) }, }, + { + label: '$(feedback) Cody Feedback', + async onSelect(): Promise { + await vscode.env.openExternal(vscode.Uri.parse(CODY_FEEDBACK_URL.href)) + }, + }, { label: '$(organization) Cody Discord Channel', async onSelect(): Promise { diff --git a/vscode/src/services/SidebarCommands.ts b/vscode/src/services/SidebarCommands.ts index 12951f7253d..1fb5d9d3df9 100644 --- a/vscode/src/services/SidebarCommands.ts +++ b/vscode/src/services/SidebarCommands.ts @@ -6,6 +6,7 @@ import { ACCOUNT_USAGE_URL, CODY_DOC_URL, CODY_FEEDBACK_URL, + CODY_SUPPORT_URL, DISCORD_URL, } from '../chat/protocol' import { releaseNotesURL } from '../release' @@ -60,6 +61,10 @@ export function registerSidebarCommands(): vscode.Disposable[] { logSidebarClick('documentation') void vscode.commands.executeCommand('vscode.open', CODY_DOC_URL.href) }), + vscode.commands.registerCommand('cody.sidebar.support', () => { + logSidebarClick('support') + void vscode.commands.executeCommand('vscode.open', CODY_SUPPORT_URL.href) + }), vscode.commands.registerCommand('cody.sidebar.feedback', () => { logSidebarClick('feedback') void vscode.commands.executeCommand('vscode.open', CODY_FEEDBACK_URL.href) diff --git a/vscode/src/services/StatusBar.ts b/vscode/src/services/StatusBar.ts index 781de97e1f6..712cd30f4ad 100644 --- a/vscode/src/services/StatusBar.ts +++ b/vscode/src/services/StatusBar.ts @@ -1,11 +1,11 @@ import * as vscode from 'vscode' -import { type Configuration, isCodyIgnoredFile } from '@sourcegraph/cody-shared' +import { type AuthStatus, type Configuration, isCodyIgnoredFile } from '@sourcegraph/cody-shared' import { getConfiguration } from '../configuration' import { getGhostHintEnablement } from '../commands/GhostHintDecorator' -import { FeedbackOptionItems } from './FeedbackOptions' +import { FeedbackOptionItems, PremiumSupportItems } from './FeedbackOptions' import { enableDebugMode } from './utils/export-logs' interface StatusBarError { @@ -27,6 +27,7 @@ export interface CodyStatusBar { ): () => void addError(error: StatusBarError): () => void hasError(error: StatusBarErrorName): boolean + syncAuthStatus(newStatus: AuthStatus): void } const DEFAULT_TEXT = '$(cody-logo-heavy)' @@ -50,6 +51,7 @@ export function createStatusBar(): CodyStatusBar { statusBarItem.command = 'cody.status-bar.interacted' statusBarItem.show() + let authStatus: AuthStatus | undefined const command = vscode.commands.registerCommand(statusBarItem.command, async () => { const workspaceConfig = vscode.workspace.getConfiguration() const config = getConfiguration(workspaceConfig) @@ -86,6 +88,13 @@ export function createStatusBar(): CodyStatusBar { } } + function createFeedbackAndSupportItems(): StatusBarItem[] { + const isPaidUser = authStatus?.isLoggedIn && !authStatus?.userCanUpgrade + const paidSupportItems = isPaidUser ? PremiumSupportItems : [] + // Display to paid users (e.g. Enterprise users or Cody Pro uers) only + return [...paidSupportItems, ...FeedbackOptionItems] + } + if (errors.length > 0) { errors.map(error => error.error.onShow?.()) } @@ -179,7 +188,7 @@ export function createStatusBar(): CodyStatusBar { }, }, { label: 'feedback & support', kind: vscode.QuickPickItemKind.Separator }, - ...FeedbackOptionItems, + ...createFeedbackAndSupportItems(), ] quickPick.title = 'Cody Settings' quickPick.placeholder = 'Choose an option' @@ -308,6 +317,9 @@ export function createStatusBar(): CodyStatusBar { hasError(errorName: StatusBarErrorName): boolean { return errors.some(e => e.error.errorType === errorName) }, + syncAuthStatus(newStatus: AuthStatus) { + authStatus = newStatus + }, dispose() { statusBarItem.dispose() command.dispose() diff --git a/vscode/src/services/tree-views/TreeViewProvider.ts b/vscode/src/services/tree-views/TreeViewProvider.ts index 2dd3ab06d4c..108732b70f2 100644 --- a/vscode/src/services/tree-views/TreeViewProvider.ts +++ b/vscode/src/services/tree-views/TreeViewProvider.ts @@ -80,6 +80,10 @@ export class TreeViewProvider implements vscode.TreeDataProvider({ dotcomUrl: mockServer.SERVER_URL }) + +test.extend({ + // list of events we expect this test to log, add to this list as needed + expectedEvents: [ + 'CodyInstalled', + 'CodyVSCodeExtension:Auth:failed', + 'CodyVSCodeExtension:auth:clickOtherSignInOptions', + 'CodyVSCodeExtension:login:clicked', + 'CodyVSCodeExtension:auth:selectSigninMenu', + 'CodyVSCodeExtension:auth:fromToken', + 'CodyVSCodeExtension:Auth:connected', + ], +})('shows no support link for free users', async ({ page, sidebar }) => { + await sidebarSignin(page, sidebar) + + const supportLocator = page.getByRole('treeitem', { name: 'Support' }).locator('a') + expect(supportLocator).not.toBeVisible() + + // Check it's not in treeview + + const supportButton = page.getByLabel('Cody Support, feedback & support').locator('div') + expect(supportButton).not.toBeVisible() + + // Check it's not in settings quickpick + + const statusBarButton = page.getByRole('button', { name: 'cody-logo-heavy, Cody Settings' }) + await statusBarButton.click() + + const input = page.getByPlaceholder('Choose an option') + await input.fill('support') + + const supportItem = page.getByLabel('question Cody Support') + expect(supportItem).not.toBeVisible() +}) + +test.extend({ + // list of events we expect this test to log, add to this list as needed + expectedEvents: [ + 'CodyInstalled', + 'CodyVSCodeExtension:Auth:failed', + 'CodyVSCodeExtension:auth:clickOtherSignInOptions', + 'CodyVSCodeExtension:login:clicked', + 'CodyVSCodeExtension:auth:selectSigninMenu', + 'CodyVSCodeExtension:auth:fromToken', + 'CodyVSCodeExtension:Auth:connected', + ], +})('shows support link for pro users', async ({ page, sidebar }) => { + await fetch(`${mockServer.SERVER_URL}/.test/currentUser/codyProEnabled`, { method: 'POST' }) + + await sidebarSignin(page, sidebar) + + // Check it's in treeview + + const supportLocator = page.getByRole('treeitem', { name: 'Support' }).locator('a') + expect(supportLocator).toBeVisible() + + // Check it's in settings quickpick + + const statusBarButton = page.getByRole('button', { name: 'cody-logo-heavy, Cody Settings' }) + await statusBarButton.click() + + const input = page.getByPlaceholder('Choose an option') + await input.fill('support') + + const supportItem = page.getByLabel('question Cody Support') + expect(supportItem).toBeVisible() +}) diff --git a/vscode/test/fixtures/mock-server.ts b/vscode/test/fixtures/mock-server.ts index 4516f2f04b0..6886fdc0e28 100644 --- a/vscode/test/fixtures/mock-server.ts +++ b/vscode/test/fixtures/mock-server.ts @@ -294,6 +294,7 @@ export class MockServer { }) let attribution = false + let codyPro = false app.post('/.api/graphql', (req, res) => { if (req.headers.authorization !== `token ${VALID_TOKEN}`) { res.sendStatus(401) @@ -328,12 +329,29 @@ export class MockServer { }) ) break + case 'CurrentUserCodySubscription': + res.send( + JSON.stringify({ + data: { + currentUser: { + codySubscription: { + status: 'ACTIVE', + plan: codyPro ? 'PRO' : 'FREE', + applyProRateLimits: codyPro, + currentPeriodStartAt: '2021-01-01T00:00:00Z', + currentPeriodEndAt: '2022-01-01T00:00:00Z', + }, + }, + }, + }) + ) + break case 'CurrentUserCodyProEnabled': res.send( JSON.stringify({ data: { currentUser: { - codyProEnabled: false, + codyProEnabled: codyPro, }, }, }) @@ -421,6 +439,10 @@ export class MockServer { } }) + app.post('/.test/currentUser/codyProEnabled', (req, res) => { + codyPro = true + res.sendStatus(200) + }) app.post('/.test/attribution/enable', (req, res) => { attribution = true res.sendStatus(200)