Refactor Cody PLG on dotcom (#62035)

* Refactor: extract complex onboarding components
* Refactor: extract complex cody/manage components
This commit is contained in:
David Veszelovszki 2024-04-19 20:42:00 +02:00 committed by GitHub
parent acb197d4b5
commit d5c7dfd01b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 699 additions and 659 deletions

View File

@ -224,10 +224,16 @@ ts_project(
"src/cody/components/ScopeSelector/useRepoSuggestions.ts",
"src/cody/dashboard/CodyDashboardPage.tsx",
"src/cody/dashboard/UpsellImage.tsx",
"src/cody/editorGroups.ts",
"src/cody/featureFlags.ts",
"src/cody/isCodyEnabled.tsx",
"src/cody/management/CodyManagementPage.tsx",
"src/cody/management/SubscriptionStats.tsx",
"src/cody/management/UseCodyInEditorSection.tsx",
"src/cody/onboarding/CodyOnboarding.tsx",
"src/cody/onboarding/EditorStep.tsx",
"src/cody/onboarding/PurposeStep.tsx",
"src/cody/onboarding/WelcomeStep.tsx",
"src/cody/onboarding/instructions/CodyFeatures.tsx",
"src/cody/onboarding/instructions/JetBrains.tsx",
"src/cody/onboarding/instructions/NeoVim.tsx",

View File

@ -0,0 +1,94 @@
import type { IEditor } from './onboarding/CodyOnboarding'
import { JetBrainsInstructions } from './onboarding/instructions/JetBrains'
import { NeoVimInstructions } from './onboarding/instructions/NeoVim'
import { VSCodeInstructions } from './onboarding/instructions/VsCode'
export const editorGroups: IEditor[][] = [
[
{
id: 1,
icon: 'VsCode',
name: 'VS Code',
publisher: 'Microsoft',
releaseStage: 'Stable',
docs: 'https://sourcegraph.com/docs/cody/clients/install-vscode',
instructions: VSCodeInstructions,
},
{
id: 2,
icon: 'IntelliJ',
name: 'IntelliJ IDEA',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 3,
icon: 'PhpStorm',
name: 'PhpStorm ',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 4,
icon: 'PyCharm',
name: 'PyCharm',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
],
[
{
id: 5,
icon: 'WebStorm',
name: 'WebStorm',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 6,
icon: 'RubyMine',
name: 'RubyMine',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 7,
icon: 'GoLand',
name: 'GoLand',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 8,
icon: 'AndroidStudio',
name: 'Android Studio',
publisher: 'Google',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
],
[
{
id: 9,
icon: 'NeoVim',
name: 'Neovim',
publisher: 'Neovim Team',
releaseStage: 'Experimental',
docs: 'https://sourcegraph.com/docs/cody/clients/install-neovim',
instructions: NeoVimInstructions,
},
],
]

View File

@ -1,45 +1,32 @@
import React, { useCallback, useEffect } from 'react'
import { mdiHelpCircleOutline, mdiInformationOutline, mdiOpenInNew, mdiCreditCardOutline } from '@mdi/js'
import { mdiCreditCardOutline } from '@mdi/js'
import classNames from 'classnames'
import { useNavigate } from 'react-router-dom'
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
import { useQuery } from '@sourcegraph/http-client'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import {
ButtonLink,
H1,
H2,
H4,
H5,
Icon,
Link,
LoadingSpinner,
Modal,
PageHeader,
Text,
useSearchParameters,
} from '@sourcegraph/wildcard'
import { ButtonLink, H1, H2, Icon, Link, PageHeader, Text, useSearchParameters } from '@sourcegraph/wildcard'
import type { AuthenticatedUser } from '../../auth'
import { Page } from '../../components/Page'
import { PageTitle } from '../../components/PageTitle'
import type {
UserCodyPlanResult,
UserCodyPlanVariables,
UserCodyUsageResult,
UserCodyUsageVariables,
import {
type UserCodyPlanResult,
type UserCodyPlanVariables,
type UserCodyUsageResult,
type UserCodyUsageVariables,
CodySubscriptionPlan,
} from '../../graphql-operations'
import { CodySubscriptionPlan } from '../../graphql-operations'
import { eventLogger } from '../../tracking/eventLogger'
import { EventName } from '../../util/constants'
import { CodyProIcon, AutocompletesIcon, ChatMessagesIcon, DashboardIcon } from '../components/CodyIcon'
import { CodyProIcon, DashboardIcon } from '../components/CodyIcon'
import { isCodyEnabled } from '../isCodyEnabled'
import { CodyOnboarding, editorGroups, type IEditor } from '../onboarding/CodyOnboarding'
import { ProTierIcon, useCodyPaymentsUrl } from '../subscription/CodySubscriptionPage'
import { CodyOnboarding, type IEditor } from '../onboarding/CodyOnboarding'
import { useCodyPaymentsUrl } from '../subscription/CodySubscriptionPage'
import { USER_CODY_PLAN, USER_CODY_USAGE } from '../subscription/queries'
import { SubscriptionStats } from './SubscriptionStats'
import { UseCodyInEditorSection } from './UseCodyInEditorSection'
import styles from './CodyManagementPage.module.scss'
interface CodyManagementPageProps extends TelemetryV2Props {
@ -62,7 +49,6 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
const utm_source = parameters.get('utm_source')
useEffect(() => {
eventLogger.log(EventName.CODY_MANAGEMENT_PAGE_VIEWED, { utm_source })
telemetryRecorder.recordEvent('cody.management', 'view')
}, [utm_source, telemetryRecorder])
@ -73,12 +59,6 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
{}
)
const stats = usageData?.currentUser
const codyCurrentPeriodChatLimit = stats?.codyCurrentPeriodChatLimit || 0
const codyCurrentPeriodChatUsage = stats?.codyCurrentPeriodChatUsage || 0
const codyCurrentPeriodCodeLimit = stats?.codyCurrentPeriodCodeLimit || 0
const codyCurrentPeriodCodeUsage = stats?.codyCurrentPeriodCodeUsage || 0
const [selectedEditor, setSelectedEditor] = React.useState<IEditor | null>(null)
const [selectedEditorStep, setSelectedEditorStep] = React.useState<EditorStep | null>(null)
@ -107,29 +87,7 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
return null
}
const codeLimitReached = codyCurrentPeriodCodeUsage >= codyCurrentPeriodCodeLimit && codyCurrentPeriodCodeLimit > 0
const chatLimitReached = codyCurrentPeriodChatUsage >= codyCurrentPeriodChatLimit && codyCurrentPeriodChatLimit > 0
const userIsOnProTier = subscription.plan === CodySubscriptionPlan.PRO
// Flag usage limits as resetting based on the current subscription's billing cycle.
//
// BUG: The usage limit refresh should be independent of a user's subscription data.
// e.g. if we offered an annual billing plan, we'd want to reset usage more often.
// sourcegraph#59990 is related, and required for the times to line up with the
// behavior from Cody Gateway.
//
// BUG: If the subscription is canceled, this will be in the past and therefore invalid.
// This data should be fetched from the SSC backend, and like above, separeate
// from the user's subscription billing cycle.
const usageRefreshTime = subscription.currentPeriodEndAt
// Time when the user's current subscription will end.
//
// BUG: If the subscription is in the canceled state, this will be in the past. We need
// to update the UI to simply say "subscription canceled" or "you are on the free"
// plan, you don't have any subscription billing cycle anchors".
//
const codyProSubscriptionEndTime = subscription.currentPeriodEndAt
const isUserOnProTier = subscription.plan === CodySubscriptionPlan.PRO
return (
<>
@ -143,14 +101,14 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
</PageHeader.Heading>
</PageHeader>
{!userIsOnProTier && <UpgradeToProBanner onClick={onClickUpgradeToProCTA} />}
{!isUserOnProTier && <UpgradeToProBanner onClick={onClickUpgradeToProCTA} />}
<div className={classNames('p-4 border bg-1 mt-4', styles.container)}>
<div className="d-flex justify-content-between align-items-center border-bottom pb-3">
<div>
<H2>My subscription</H2>
<Text className="text-muted mb-0">
{userIsOnProTier ? (
{isUserOnProTier ? (
'You are on the Pro tier.'
) : (
<span>
@ -160,7 +118,7 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
)}
</Text>
</div>
{userIsOnProTier && (
{isUserOnProTier && (
<div>
<ButtonLink
variant="primary"
@ -168,7 +126,6 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
href={manageSubscriptionRedirectURL}
onClick={event => {
event.preventDefault()
eventLogger.log(EventName.CODY_MANAGE_SUBSCRIPTION_CLICKED)
telemetryRecorder.recordEvent('cody.manageSubscription', 'click')
window.location.href = manageSubscriptionRedirectURL
}}
@ -179,242 +136,19 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
</div>
)}
</div>
<div className={classNames('d-flex align-items-center mt-3', styles.responsiveContainer)}>
<div className="d-flex flex-column align-items-center flex-grow-1 p-3">
{userIsOnProTier ? (
<ProTierIcon />
) : (
<Text className={classNames(styles.planName, 'mb-0')}>Free</Text>
)}
<Text className="text-muted mb-0" size="small">
tier
</Text>
{userIsOnProTier && subscription.cancelAtPeriodEnd && (
<Text className="text-muted mb-0 mt-4" size="small">
Subscription ends <Timestamp date={codyProSubscriptionEndTime} />
</Text>
)}
</div>
<div className="d-flex flex-column align-items-center flex-grow-1 p-3 border-left border-right">
<AutocompletesIcon />
<div className="mb-2 mt-3">
{subscription.applyProRateLimits ? (
<Text weight="bold" className={classNames('d-inline mb-0', styles.counter)}>
Unlimited
</Text>
) : usageData?.currentUser ? (
<>
<Text
weight="bold"
className={classNames(
'd-inline mb-0',
styles.counter,
codeLimitReached ? 'text-danger' : 'text-muted'
)}
>
{Math.min(codyCurrentPeriodCodeUsage, codyCurrentPeriodCodeLimit)} /
</Text>{' '}
<Text
className={classNames(
'd-inline b-0',
codeLimitReached ? 'text-danger' : 'text-muted'
)}
size="small"
>
{codyCurrentPeriodCodeLimit}
</Text>
</>
) : (
<LoadingSpinner />
)}
</div>
<H4 className={classNames('mb-0', codeLimitReached ? 'text-danger' : 'text-muted')}>
Autocomplete suggestions
</H4>
{!subscription.applyProRateLimits &&
(codeLimitReached ? (
<Text className="text-danger mb-0" size="small">
Renews in <Timestamp date={usageRefreshTime} />
</Text>
) : (
<Text className="text-muted mb-0" size="small">
this month
</Text>
))}
</div>
<div className="d-flex flex-column align-items-center flex-grow-1 p-3">
<ChatMessagesIcon />
<div className="mb-2 mt-3">
{subscription.applyProRateLimits ? (
<Text weight="bold" className={classNames('d-inline mb-0', styles.counter)}>
Unlimited
</Text>
) : usageData?.currentUser ? (
<>
<Text
weight="bold"
className={classNames(
'd-inline mb-0',
styles.counter,
chatLimitReached ? 'text-danger' : 'text-muted'
)}
>
{Math.min(codyCurrentPeriodChatUsage, codyCurrentPeriodChatLimit)} /
</Text>{' '}
<Text
className={classNames(
'd-inline b-0',
chatLimitReached ? 'text-danger' : 'text-muted'
)}
size="small"
>
{codyCurrentPeriodChatLimit}
</Text>
</>
) : (
<LoadingSpinner />
)}
</div>
<H4 className={classNames('mb-0', chatLimitReached ? 'text-danger' : 'text-muted')}>
Chat messages and commands
</H4>
{!subscription.applyProRateLimits &&
(chatLimitReached && subscription.currentPeriodEndAt ? (
<Text className="text-danger mb-0" size="small">
Renews <Timestamp date={usageRefreshTime} />
</Text>
) : (
<Text className="text-muted mb-0" size="small">
this month
</Text>
))}
</div>
</div>
<SubscriptionStats {...{ subscription, usageData }} />
</div>
<div className={classNames('p-4 border bg-1 mt-4 mb-5', styles.container)}>
<div className="d-flex justify-content-between align-items-center border-bottom pb-3">
<div>
<H2>Use Cody directly in your editor</H2>
<Text className="text-muted mb-0">
Download the Cody extension in your editor to start using Cody.
</Text>
</div>
{userIsOnProTier ? (
<div>
<Link
to="https://help.sourcegraph.com/"
target="_blank"
rel="noreferrer"
className="text-muted text-sm"
>
<Icon svgPath={mdiHelpCircleOutline} className="mr-1" aria-hidden={true} />
Join our community, read our docs, or get product/billing support
</Link>
</div>
) : null}
</div>
{editorGroups.map((group, index) => (
<div
key={group.map(editor => editor.name).join('-')}
className={classNames('d-flex mt-3', styles.responsiveContainer, {
'border-bottom pb-3': index < group.length - 1,
})}
>
{group.map((editor, index) => (
<div
key={editor.name}
className={classNames('d-flex flex-column flex-1 pt-3 px-3', {
'border-left': index !== 0,
})}
>
<div
className={classNames('d-flex mb-3 align-items-center', styles.ideHeader)}
onClick={() => {
setSelectedEditor(editor)
setSelectedEditorStep(EditorStep.SetupInstructions)
}}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter') {
setSelectedEditor(editor)
setSelectedEditorStep(EditorStep.SetupInstructions)
}
}}
>
<div>
<img
alt={editor.name}
src={`https://storage.googleapis.com/sourcegraph-assets/ideIcons/ideIcon${editor.icon}.svg`}
width={34}
className="mr-3"
/>
</div>
<div>
<Text className="text-muted mb-0" size="small">
{editor.publisher}
</Text>
<Text className={classNames('mb-0', styles.ideName)}>{editor.name}</Text>
<H5 className={styles.releaseStage}>{editor.releaseStage}</H5>
</div>
</div>
{editor.instructions && (
<Link
to="#"
className="mb-2 text-muted d-flex align-items-center"
onClick={() => {
setSelectedEditor(editor)
setSelectedEditorStep(EditorStep.SetupInstructions)
}}
>
<Icon svgPath={mdiInformationOutline} aria-hidden={true} className="mr-1" />{' '}
Quickstart guide
</Link>
)}
{editor.docs && (
<Link
to={editor.docs}
target="_blank"
rel="noopener"
className="text-muted d-flex align-items-center"
>
<Icon svgPath={mdiOpenInNew} aria-hidden={true} className="mr-1" />{' '}
Documentation
</Link>
)}
{selectedEditor?.name === editor.name &&
selectedEditorStep !== null &&
editor.instructions && (
<Modal
key={editor.name + '-modal'}
isOpen={true}
aria-label={`${editor.name} Info`}
className={styles.modal}
position="center"
>
<editor.instructions
showStep={selectedEditorStep}
onClose={() => {
setSelectedEditor(null)
setSelectedEditorStep(null)
}}
telemetryRecorder={telemetryRecorder}
/>
</Modal>
)}
</div>
))}
{group.length < 4
? [...new Array(4 - group.length)].map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="flex-1 p-3" />
))
: null}
</div>
))}
</div>
<UseCodyInEditorSection
{...{
selectedEditor,
setSelectedEditor,
selectedEditorStep,
setSelectedEditorStep,
isUserOnProTier,
telemetryRecorder,
}}
/>
</Page>
<CodyOnboarding authenticatedUser={authenticatedUser} telemetryRecorder={telemetryRecorder} />
</>

View File

@ -0,0 +1,164 @@
import React from 'react'
import classNames from 'classnames'
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
import { type CodySubscriptionStatus, CodySubscriptionPlan } from '@sourcegraph/shared/src/graphql-operations'
import { Text, LoadingSpinner, H4 } from '@sourcegraph/wildcard'
import type { UserCodyUsageResult } from '../../graphql-operations'
import { AutocompletesIcon, ChatMessagesIcon } from '../components/CodyIcon'
import { ProTierIcon } from '../subscription/CodySubscriptionPage'
import styles from './CodyManagementPage.module.scss'
interface SubscriptionStatsProps {
subscription: {
status: CodySubscriptionStatus
plan: CodySubscriptionPlan
applyProRateLimits: boolean
currentPeriodStartAt: string
currentPeriodEndAt: string
cancelAtPeriodEnd: boolean
}
usageData: UserCodyUsageResult | undefined
}
export const SubscriptionStats: React.FunctionComponent<SubscriptionStatsProps> = ({
subscription,
usageData,
}: SubscriptionStatsProps) => {
const stats = usageData?.currentUser
const codyCurrentPeriodChatLimit = stats?.codyCurrentPeriodChatLimit || 0
const codyCurrentPeriodChatUsage = stats?.codyCurrentPeriodChatUsage || 0
const codyCurrentPeriodCodeLimit = stats?.codyCurrentPeriodCodeLimit || 0
const codyCurrentPeriodCodeUsage = stats?.codyCurrentPeriodCodeUsage || 0
const codeLimitReached = codyCurrentPeriodCodeUsage >= codyCurrentPeriodCodeLimit && codyCurrentPeriodCodeLimit > 0
const chatLimitReached = codyCurrentPeriodChatUsage >= codyCurrentPeriodChatLimit && codyCurrentPeriodChatLimit > 0
const isUserOnProTier = subscription.plan === CodySubscriptionPlan.PRO
// Flag usage limits as resetting based on the current subscription's billing cycle.
//
// BUG: The usage limit refresh should be independent of a user's subscription data.
// e.g. if we offered an annual billing plan, we'd want to reset usage more often.
// sourcegraph#59990 is related, and required for the times to line up with the
// behavior from Cody Gateway.
//
// BUG: If the subscription is canceled, this will be in the past and therefore invalid.
// This data should be fetched from the SSC backend, and like above, separate
// from the user's subscription billing cycle.
const usageRefreshTime = subscription.currentPeriodEndAt
// Time when the user's current subscription will end.
//
// BUG: If the subscription is in the canceled state, this will be in the past. We need
// to update the UI to simply say "subscription canceled" or "you are on the free"
// plan, you don't have any subscription billing cycle anchors".
//
const codyProSubscriptionEndTime = subscription.currentPeriodEndAt
return (
<div className={classNames('d-flex align-items-center mt-3', styles.responsiveContainer)}>
<div className="d-flex flex-column align-items-center flex-grow-1 p-3">
{isUserOnProTier ? <ProTierIcon /> : <Text className={classNames(styles.planName, 'mb-0')}>Free</Text>}
<Text className="text-muted mb-0" size="small">
tier
</Text>
{isUserOnProTier && subscription.cancelAtPeriodEnd && (
<Text className="text-muted mb-0 mt-4" size="small">
Subscription ends <Timestamp date={codyProSubscriptionEndTime} />
</Text>
)}
</div>
<div className="d-flex flex-column align-items-center flex-grow-1 p-3 border-left border-right">
<AutocompletesIcon />
<div className="mb-2 mt-3">
{subscription.applyProRateLimits ? (
<Text weight="bold" className={classNames('d-inline mb-0', styles.counter)}>
Unlimited
</Text>
) : usageData?.currentUser ? (
<>
<Text
weight="bold"
className={classNames(
'd-inline mb-0',
styles.counter,
codeLimitReached ? 'text-danger' : 'text-muted'
)}
>
{Math.min(codyCurrentPeriodCodeUsage, codyCurrentPeriodCodeLimit)} /
</Text>{' '}
<Text
className={classNames('d-inline b-0', codeLimitReached ? 'text-danger' : 'text-muted')}
size="small"
>
{codyCurrentPeriodCodeLimit}
</Text>
</>
) : (
<LoadingSpinner />
)}
</div>
<H4 className={classNames('mb-0', codeLimitReached ? 'text-danger' : 'text-muted')}>
Autocomplete suggestions
</H4>
{!subscription.applyProRateLimits &&
(codeLimitReached ? (
<Text className="text-danger mb-0" size="small">
Renews in <Timestamp date={usageRefreshTime} />
</Text>
) : (
<Text className="text-muted mb-0" size="small">
this month
</Text>
))}
</div>
<div className="d-flex flex-column align-items-center flex-grow-1 p-3">
<ChatMessagesIcon />
<div className="mb-2 mt-3">
{subscription.applyProRateLimits ? (
<Text weight="bold" className={classNames('d-inline mb-0', styles.counter)}>
Unlimited
</Text>
) : usageData?.currentUser ? (
<>
<Text
weight="bold"
className={classNames(
'd-inline mb-0',
styles.counter,
chatLimitReached ? 'text-danger' : 'text-muted'
)}
>
{Math.min(codyCurrentPeriodChatUsage, codyCurrentPeriodChatLimit)} /
</Text>{' '}
<Text
className={classNames('d-inline b-0', chatLimitReached ? 'text-danger' : 'text-muted')}
size="small"
>
{codyCurrentPeriodChatLimit}
</Text>
</>
) : (
<LoadingSpinner />
)}
</div>
<H4 className={classNames('mb-0', chatLimitReached ? 'text-danger' : 'text-muted')}>
Chat messages and commands
</H4>
{!subscription.applyProRateLimits &&
(chatLimitReached && usageRefreshTime ? (
<Text className="text-danger mb-0" size="small">
Renews <Timestamp date={usageRefreshTime} />
</Text>
) : (
<Text className="text-muted mb-0" size="small">
this month
</Text>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,145 @@
import React from 'react'
import { mdiHelpCircleOutline, mdiInformationOutline, mdiOpenInNew } from '@mdi/js'
import classNames from 'classnames'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { H2, Text, Link, Icon, H5, Modal } from '@sourcegraph/wildcard'
import { editorGroups } from '../editorGroups'
import type { IEditor } from '../onboarding/CodyOnboarding'
import { EditorStep } from './CodyManagementPage'
import styles from './CodyManagementPage.module.scss'
interface UseCodyInEditorSectionProps extends TelemetryV2Props {
selectedEditor: IEditor | null
setSelectedEditor: (editor: IEditor | null) => void
selectedEditorStep: EditorStep | null
setSelectedEditorStep: (step: EditorStep | null) => void
isUserOnProTier: boolean
}
export const UseCodyInEditorSection: React.FunctionComponent<UseCodyInEditorSectionProps> = props => (
<div className={classNames('p-4 border bg-1 mt-4 mb-5', styles.container)}>
<div className="d-flex justify-content-between align-items-center border-bottom pb-3">
<div>
<H2>Use Cody directly in your editor</H2>
<Text className="text-muted mb-0">Download the Cody extension in your editor to start using Cody.</Text>
</div>
{props.isUserOnProTier ? (
<div>
<Link
to="https://help.sourcegraph.com/"
target="_blank"
rel="noreferrer"
className="text-muted text-sm"
>
<Icon svgPath={mdiHelpCircleOutline} className="mr-1" aria-hidden={true} />
Join our community, read our docs, or get product/billing support
</Link>
</div>
) : null}
</div>
{editorGroups.map((group, index) => (
<div
key={group.map(editor => editor.name).join('-')}
className={classNames('d-flex mt-3', styles.responsiveContainer, {
'border-bottom pb-3': index < group.length - 1,
})}
>
{group.map((editor, index) => (
<div
key={editor.name}
className={classNames('d-flex flex-column flex-1 pt-3 px-3', {
'border-left': index !== 0,
})}
>
<div
className={classNames('d-flex mb-3 align-items-center', styles.ideHeader)}
onClick={() => {
props.setSelectedEditor(editor)
props.setSelectedEditorStep(EditorStep.SetupInstructions)
}}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter') {
props.setSelectedEditor(editor)
props.setSelectedEditorStep(EditorStep.SetupInstructions)
}
}}
>
<div>
<img
alt={editor.name}
src={`https://storage.googleapis.com/sourcegraph-assets/ideIcons/ideIcon${editor.icon}.svg`}
width={34}
className="mr-3"
/>
</div>
<div>
<Text className="text-muted mb-0" size="small">
{editor.publisher}
</Text>
<Text className={classNames('mb-0', styles.ideName)}>{editor.name}</Text>
<H5 className={styles.releaseStage}>{editor.releaseStage}</H5>
</div>
</div>
{editor.instructions && (
<Link
to="#"
className="mb-2 text-muted d-flex align-items-center"
onClick={() => {
props.setSelectedEditor(editor)
props.setSelectedEditorStep(EditorStep.SetupInstructions)
}}
>
<Icon svgPath={mdiInformationOutline} aria-hidden={true} className="mr-1" /> Quickstart
guide
</Link>
)}
{editor.docs && (
<Link
to={editor.docs}
target="_blank"
rel="noopener"
className="text-muted d-flex align-items-center"
>
<Icon svgPath={mdiOpenInNew} aria-hidden={true} className="mr-1" /> Documentation
</Link>
)}
{props.selectedEditor?.name === editor.name &&
props.selectedEditorStep !== null &&
editor.instructions && (
<Modal
key={editor.name + '-modal'}
isOpen={true}
aria-label={`${editor.name} Info`}
className={styles.modal}
position="center"
>
<editor.instructions
showStep={props.selectedEditorStep}
onClose={() => {
props.setSelectedEditor(null)
props.setSelectedEditorStep(null)
}}
telemetryRecorder={props.telemetryRecorder}
/>
</Modal>
)}
</div>
))}
{group.length < 4
? [...new Array(4 - group.length)].map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="flex-1 p-3" />
))
: null}
</div>
))}
</div>
)

View File

@ -1,22 +1,17 @@
import React, { useEffect, useState } from 'react'
import classNames from 'classnames'
import { useNavigate } from 'react-router-dom'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
import type { TelemetryRecorder, TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { useIsLightTheme } from '@sourcegraph/shared/src/theme'
import { Button, H2, H5, Modal, Text, useSearchParameters } from '@sourcegraph/wildcard'
import { Modal, useSearchParameters } from '@sourcegraph/wildcard'
import type { AuthenticatedUser } from '../../auth'
import { useFeatureFlag } from '../../featureFlags/useFeatureFlag'
import { HubSpotForm } from '../../marketing/components/HubSpotForm'
import { eventLogger } from '../../tracking/eventLogger'
import { EventName } from '../../util/constants'
import { JetBrainsInstructions } from './instructions/JetBrains'
import { NeoVimInstructions } from './instructions/NeoVim'
import { VSCodeInstructions } from './instructions/VsCode'
import { EditorStep } from './EditorStep'
import { PurposeStep } from './PurposeStep'
import { WelcomeStep } from './WelcomeStep'
import styles from './CodyOnboarding.module.scss'
@ -35,96 +30,6 @@ export interface IEditor {
}>
}
export const editorGroups: IEditor[][] = [
[
{
id: 1,
icon: 'VsCode',
name: 'VS Code',
publisher: 'Microsoft',
releaseStage: 'Stable',
docs: 'https://sourcegraph.com/docs/cody/clients/install-vscode',
instructions: VSCodeInstructions,
},
{
id: 2,
icon: 'IntelliJ',
name: 'IntelliJ IDEA',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 3,
icon: 'PhpStorm',
name: 'PhpStorm ',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 4,
icon: 'PyCharm',
name: 'PyCharm',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
],
[
{
id: 5,
icon: 'WebStorm',
name: 'WebStorm',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 6,
icon: 'RubyMine',
name: 'RubyMine',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 7,
icon: 'GoLand',
name: 'GoLand',
publisher: 'JetBrains',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
{
id: 8,
icon: 'AndroidStudio',
name: 'Android Studio',
publisher: 'Google',
releaseStage: 'Beta',
docs: 'https://sourcegraph.com/docs/cody/clients/install-jetbrains',
instructions: JetBrainsInstructions,
},
],
[
{
id: 9,
icon: 'NeoVim',
name: 'Neovim',
publisher: 'Neovim Team',
releaseStage: 'Experimental',
docs: 'https://sourcegraph.com/docs/cody/clients/install-neovim',
instructions: NeoVimInstructions,
},
],
]
interface CodyOnboardingProps extends TelemetryV2Props {
authenticatedUser: AuthenticatedUser | null
}
@ -178,8 +83,9 @@ export function CodyOnboarding({ authenticatedUser, telemetryRecorder }: CodyOnb
}
setOnboardingStep(currentsStep => (currentsStep || 0) + 2)
handleShowLastStep()
const metadata = { variant: 'control' }
eventLogger.log(EventName.CODY_HANDRAISER_TEST_ENROLLMENT, metadata, metadata)
telemetryRecorder.recordEvent('cody.onboarding.hubspotForm.fromWorkPersonalToHandRaiserTest', 'enroll', {
metadata: { controlVariant: 1 },
})
}
return (
@ -216,266 +122,3 @@ export function CodyOnboarding({ authenticatedUser, telemetryRecorder }: CodyOnb
</Modal>
)
}
function WelcomeStep({
onNext,
pro,
telemetryRecorder,
}: {
onNext: () => void
pro: boolean
telemetryRecorder: TelemetryRecorder
}): JSX.Element {
const [show, setShow] = useState(false)
const isLightTheme = useIsLightTheme()
useEffect(() => {
eventLogger.log(
EventName.CODY_ONBOARDING_WELCOME_VIEWED,
{ tier: pro ? 'pro' : 'free' },
{ tier: pro ? 'pro' : 'free' }
)
telemetryRecorder.recordEvent('cody.onboarding.welcome', 'view', { metadata: { tier: pro ? 1 : 0 } })
}, [pro, telemetryRecorder])
useEffect(() => {
// theme is not ready on first render, it defaults to system theme.
// so we need to wait a bit before showing the welcome video.
setTimeout(() => {
setShow(true)
}, 500)
}, [])
return (
<div className={classNames('d-flex flex-column align-items-center p-5')}>
{show ? (
<>
<video width="180" className={classNames('mb-5', styles.welcomeVideo)} autoPlay={true} muted={true}>
<source
src={
isLightTheme
? 'https://storage.googleapis.com/sourcegraph-assets/hiCodyWhite.mp4'
: 'https://storage.googleapis.com/sourcegraph-assets/hiCodyDark.mp4'
}
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
<Text className={classNames('mb-4 pb-4', styles.fadeIn, styles.fadeSecond, styles.welcomeSubtitle)}>
Ready to breeze through the basics and get comfortable with Cody
{pro ? ' Pro' : ''}?
</Text>
<Button
onClick={onNext}
variant="primary"
size="lg"
className={classNames(styles.fadeIn, styles.fadeThird)}
>
Sure, let's dive in!
</Button>
</>
) : (
<div className={styles.blankPlaceholder} />
)}
</div>
)
}
function PurposeStep({
onNext,
pro,
authenticatedUser,
telemetryRecorder,
}: {
onNext: () => void
pro: boolean
authenticatedUser: AuthenticatedUser
telemetryRecorder: TelemetryRecorder
}): JSX.Element {
useEffect(() => {
eventLogger.log(
EventName.CODY_ONBOARDING_PURPOSE_VIEWED,
{ tier: pro ? 'pro' : 'free' },
{ tier: pro ? 'pro' : 'free' }
)
telemetryRecorder.recordEvent('cody.onboarding.purpose', 'view', { metadata: { tier: pro ? 1 : 0 } })
}, [pro, telemetryRecorder])
const primaryEmail = authenticatedUser.emails.find(email => email.isPrimary)?.email
const handleFormSubmit = (form: HTMLFormElement): void => {
const choice = form[0].querySelector<HTMLInputElement>('input[name="cody_form_hand_raiser"]')
if (choice) {
const metadata = { variant: 'treatment' }
eventLogger.log(EventName.CODY_HANDRAISER_TEST_ENROLLMENT, metadata, metadata)
telemetryRecorder.recordEvent('cody.onboarding.purpose', 'select', {
metadata: { onboardingCall: choice.checked ? 1 : 0 },
})
}
}
return (
<>
<div className="border-bottom pb-3 mb-3">
<H2 className="mb-1">Would you like to learn more about Cody for enterprise?</H2>
<Text className="mb-0 text-muted" size="small">
If you're not ready for a conversation, we'll stick to sharing Cody onboarding resources.
</Text>
</div>
<div className="d-flex align-items-center border-bottom mb-3 pb-3 justify-content-center">
<HubSpotForm
formId="19f34edd-1a98-4fc9-9b2b-c1edca727720"
onFormSubmitted={() => {
onNext()
}}
onFormLoadError={() => {
onNext()
}}
userId={authenticatedUser.id}
userEmail={primaryEmail}
masterFormName="qualificationSurvey"
onFormSubmit={handleFormSubmit}
/>
</div>
</>
)
}
function EditorStep({
onCompleted,
pro,
telemetryRecorder,
}: {
onCompleted: () => void
pro: boolean
telemetryRecorder: TelemetryRecorder
}): JSX.Element {
useEffect(() => {
eventLogger.log(
EventName.CODY_ONBOARDING_CHOOSE_EDITOR_VIEWED,
{ tier: pro ? 'pro' : 'free' },
{ tier: pro ? 'pro' : 'free' }
)
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'view', { metadata: { tier: pro ? 1 : 0 } })
}, [pro, telemetryRecorder])
const [editor, setEditor] = useState<null | IEditor>(null)
const onBack = (): void => setEditor(null)
if (editor?.instructions) {
const Instructions = editor.instructions
return <Instructions onBack={onBack} onClose={onCompleted} telemetryRecorder={telemetryRecorder} />
}
return (
<>
<div className="border-bottom pb-3 mb-3">
<H2 className="mb-1">Choose your editor</H2>
<Text className="mb-0 text-muted" size="small">
Most of Cody experience happens in the IDE. Let's get that set up.
</Text>
</div>
<div className="mb-3 border-bottom pb-3">
{editorGroups.map((group, index) => (
<div
key={index}
className={classNames('d-flex mt-3', styles.responsiveContainer, {
'border-bottom pb-3': index < group.length - 1,
})}
>
{group.map((editor, index) => (
<div
key={index}
className={classNames('d-flex flex-column flex-1 p-3 cursor-pointer', styles.ideGrid, {
'border-left': index !== 0,
})}
role="button"
tabIndex={0}
onKeyDown={() => {
setEditor(editor)
eventLogger.log(
EventName.CODY_ONBOARDING_CHOOSE_EDITOR_SELECTED,
{
tier: pro ? 'pro' : 'free',
editor,
},
{
tier: pro ? 'pro' : 'free',
editor,
}
)
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'select', {
metadata: { tier: pro ? 1 : 0, editor: editor.id },
})
}}
onClick={() => {
eventLogger.log(
EventName.CODY_ONBOARDING_CHOOSE_EDITOR_SELECTED,
{
tier: pro ? 'pro' : 'free',
editor,
},
{
tier: pro ? 'pro' : 'free',
editor,
}
)
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'select', {
metadata: { tier: pro ? 1 : 0, editor: editor.id },
})
setEditor(editor)
}}
>
<div className="d-flex align-items-center">
<div>
<img
alt={editor.name}
src={`https://storage.googleapis.com/sourcegraph-assets/ideIcons/ideIcon${editor.icon}.svg`}
width={34}
className="mr-3"
/>
</div>
<div>
<Text className="text-muted mb-0 text-truncate" size="small">
{editor.publisher}
</Text>
<Text className={classNames('mb-0', styles.ideName)}>{editor.name}</Text>
<H5 className={styles.releaseStage}>{editor.releaseStage}</H5>
</div>
</div>
</div>
))}
{group.length < 4
? [...new Array(4 - group.length)].map((_, index) => (
<div key={index} className="flex-1 p-3" />
))
: null}
</div>
))}
</div>
<div className="d-flex justify-content-end align-items-center">
<Text
className="mb-0 text-muted cursor-pointer"
size="small"
onClick={() => {
onCompleted()
eventLogger.log(
EventName.CODY_ONBOARDING_CHOOSE_EDITOR_SKIPPED,
{ tier: pro ? 'pro' : 'free' },
{ tier: pro ? 'pro' : 'free' }
)
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'skip', {
metadata: { tier: pro ? 1 : 0 },
})
}}
>
Skip for now
</Text>
</div>
</>
)
}

View File

@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import classNames from 'classnames'
import type { TelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { H2, Text, H5 } from '@sourcegraph/wildcard'
import { editorGroups } from '../editorGroups'
import type { IEditor } from './CodyOnboarding'
import styles from './CodyOnboarding.module.scss'
export function EditorStep({
onCompleted,
pro,
telemetryRecorder,
}: {
onCompleted: () => void
pro: boolean
telemetryRecorder: TelemetryRecorder
}): JSX.Element {
useEffect(() => {
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'view', { metadata: { tier: pro ? 1 : 0 } })
}, [pro, telemetryRecorder])
const [editor, setEditor] = useState<null | IEditor>(null)
const onBack = (): void => setEditor(null)
if (editor?.instructions) {
const Instructions = editor.instructions
return <Instructions onBack={onBack} onClose={onCompleted} telemetryRecorder={telemetryRecorder} />
}
return (
<>
<div className="border-bottom pb-3 mb-3">
<H2 className="mb-1">Choose your editor</H2>
<Text className="mb-0 text-muted" size="small">
Most of Cody experience happens in the IDE. Let's get that set up.
</Text>
</div>
<div className="mb-3 border-bottom pb-3">
{editorGroups.map((group, groupIndex) => (
<div
key={group[0].id}
className={classNames('d-flex mt-3', styles.responsiveContainer, {
'border-bottom pb-3': groupIndex < group.length - 1,
})}
>
{group.map((editor, editorIndex) => (
<div
key={editor.id}
className={classNames('d-flex flex-column flex-1 p-3 cursor-pointer', styles.ideGrid, {
'border-left': editorIndex !== 0,
})}
role="button"
tabIndex={0}
onKeyDown={() => {
setEditor(editor)
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'select', {
metadata: { tier: pro ? 1 : 0, editor: editor.id },
})
}}
onClick={() => {
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'select', {
metadata: { tier: pro ? 1 : 0, editor: editor.id },
})
setEditor(editor)
}}
>
<div className="d-flex align-items-center">
<div>
<img
alt={editor.name}
src={`https://storage.googleapis.com/sourcegraph-assets/ideIcons/ideIcon${editor.icon}.svg`}
width={34}
className="mr-3"
/>
</div>
<div>
<Text className="text-muted mb-0 text-truncate" size="small">
{editor.publisher}
</Text>
<Text className={classNames('mb-0', styles.ideName)}>{editor.name}</Text>
<H5 className={styles.releaseStage}>{editor.releaseStage}</H5>
</div>
</div>
</div>
))}
{group.length < 4
? Array.from(new Array(4 - group.length).keys()).map(item => (
<div key={item} className="flex-1 p-3" />
))
: null}
</div>
))}
</div>
<div className="d-flex justify-content-end align-items-center">
<Text
className="mb-0 text-muted cursor-pointer"
size="small"
onClick={() => {
onCompleted()
telemetryRecorder.recordEvent('cody.onboarding.chooseEditor', 'skip', {
metadata: { tier: pro ? 1 : 0 },
})
}}
>
Skip for now
</Text>
</div>
</>
)
}

View File

@ -0,0 +1,61 @@
import { useEffect } from 'react'
import type { TelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { H2, Text } from '@sourcegraph/wildcard'
import type { AuthenticatedUser } from '../../auth'
import { HubSpotForm } from '../../marketing/components/HubSpotForm'
export function PurposeStep({
onNext,
pro,
authenticatedUser,
telemetryRecorder,
}: {
onNext: () => void
pro: boolean
authenticatedUser: AuthenticatedUser
telemetryRecorder: TelemetryRecorder
}): JSX.Element {
useEffect(() => {
telemetryRecorder.recordEvent('cody.onboarding.purpose', 'view', { metadata: { tier: pro ? 1 : 0 } })
}, [pro, telemetryRecorder])
const primaryEmail = authenticatedUser.emails.find(email => email.isPrimary)?.email
const handleFormSubmit = (form: HTMLFormElement): void => {
const choice = form[0].querySelector<HTMLInputElement>('input[name="cody_form_hand_raiser"]')
if (choice) {
telemetryRecorder.recordEvent('cody.onboarding.purpose', 'select', {
metadata: { onboardingCall: choice.checked ? 1 : 0 },
})
}
}
return (
<>
<div className="border-bottom pb-3 mb-3">
<H2 className="mb-1">Would you like to learn more about Cody for enterprise?</H2>
<Text className="mb-0 text-muted" size="small">
If you're not ready for a conversation, we'll stick to sharing Cody onboarding resources.
</Text>
</div>
<div className="d-flex align-items-center border-bottom mb-3 pb-3 justify-content-center">
<HubSpotForm
formId="19f34edd-1a98-4fc9-9b2b-c1edca727720"
onFormSubmitted={() => {
onNext()
}}
onFormLoadError={() => {
onNext()
}}
userId={authenticatedUser.id}
userEmail={primaryEmail}
masterFormName="qualificationSurvey"
onFormSubmit={handleFormSubmit}
/>
</div>
</>
)
}

View File

@ -0,0 +1,75 @@
import { useState, useEffect } from 'react'
import classNames from 'classnames'
import type { TelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { useIsLightTheme } from '@sourcegraph/shared/src/theme'
import { Text, Button } from '@sourcegraph/wildcard'
import { eventLogger } from '../../tracking/eventLogger'
import { EventName } from '../../util/constants'
import styles from './CodyOnboarding.module.scss'
export function WelcomeStep({
onNext,
pro,
telemetryRecorder,
}: {
onNext: () => void
pro: boolean
telemetryRecorder: TelemetryRecorder
}): JSX.Element {
const [show, setShow] = useState(false)
const isLightTheme = useIsLightTheme()
useEffect(() => {
eventLogger.log(
EventName.CODY_ONBOARDING_WELCOME_VIEWED,
{ tier: pro ? 'pro' : 'free' },
{ tier: pro ? 'pro' : 'free' }
)
telemetryRecorder.recordEvent('cody.onboarding.welcome', 'view', { metadata: { tier: pro ? 1 : 0 } })
}, [pro, telemetryRecorder])
useEffect(() => {
// theme is not ready on first render, it defaults to system theme.
// so we need to wait a bit before showing the welcome video.
setTimeout(() => {
setShow(true)
}, 500)
}, [])
return (
<div className={classNames('d-flex flex-column align-items-center p-5')}>
{show ? (
<>
<video width="180" className={classNames('mb-5', styles.welcomeVideo)} autoPlay={true} muted={true}>
<source
src={
isLightTheme
? 'https://storage.googleapis.com/sourcegraph-assets/hiCodyWhite.mp4'
: 'https://storage.googleapis.com/sourcegraph-assets/hiCodyDark.mp4'
}
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
<Text className={classNames('mb-4 pb-4', styles.fadeIn, styles.fadeSecond, styles.welcomeSubtitle)}>
Ready to breeze through the basics and get comfortable with Cody
{pro ? ' Pro' : ''}?
</Text>
<Button
onClick={onNext}
variant="primary"
size="lg"
className={classNames(styles.fadeIn, styles.fadeThird)}
>
Sure, let's dive in!
</Button>
</>
) : (
<div className={styles.blankPlaceholder} />
)}
</div>
)
}