Skip to content

Commit

Permalink
Add install, embeddings enabled notices for post-hoc app setup (#1089)
Browse files Browse the repository at this point in the history
Adds popups to install app, set up embeddings, etc. when using the
simplified onboarding flow.

Automatically switches to app for embeddings, when app has embeddings
available.

Polishes vscode storybook so codicons work and there are more relevant
style variables.

Part of #632.

![Screenshot 2023-09-19 at 22 41
14](https://github.com/sourcegraph/cody/assets/55120/b3b103af-1010-4922-83cf-3631ae8cf11f)

## Caveats

- The app prompts are still displayed on Windows, but there's no Windows
app to download.
- [These
prompts](https://www.figma.com/file/INGthyKM1cYki5H02JPDpW/VS-Code-Beta-2-%3C%3E-App-Onboarding?type=design&node-id=53-374&mode=design&t=ivzuiEzqtVsJtkD8-0)
for Enterprise are not implemented. Enterprise uses the legacy widget.
- There's little/no logging for interactions with this UI.

## Test plan

```
pnpm -C vscode storybook
```

Open http://localhost:6007 and examine the App-less Onboarding
storybooks.

Manual tests:

0. Uninstall Cody App. Remove test VScode state: `rm -rf
/tmp/vscode-cody-extension-dev-host`
1. Run VScode with "Launch VScode Extension (Desktop; separate
instance)" (equivalently,
`--user-data-dir=/tmp/vscode-cody-extension-dev-host`) so you have no
App token cached.
2. Open user settings JSON and add `"testing.simplified-onboarding":
true` and restart VScode.
3. Create a new git repository with `git init /tmp/bar; cd /tmp/bar; git
remote add origin git@host.example:bar/bar.git` . Open `/tmp/bar` in
VScode.
4. Sign in with simplified onboarding.
5. The context underneath the chat box should be red. Click to open the
notification. Check the link and Install App buttons work.
6. Install App. Click the Reload button. The prompt should change to
Embeddings Not Found/Open App.
7. Click Open App, add embeddings for the repo. Wait for embeddings to
complete. Back in VScode, click the Reload button. The indicator should
change to a checked database icon. Or, if App presents a "back to
VScode" button, click it and the indicator should change automatically.
Or simulate that click by going `open
vscode://sourcegraph.cody-ai/app-done' at the console.
8. Close app and wait 20 seconds. The indicator should go red/show a
cross.
9. Open app and wait 20 seconds. The indicator should be checked.
10. Close app. Wait for the indicator to go read. Immediately open app,
pop up the toast, and hit reload. The indicator should be checked
immediately.
11. Clone something indexed by dotcom, for example,
https://github.com/preactjs/preact . Open in VScode and open a file in
the repository. The database icon should be checked (that is, using
dotcom embeddings.)
  • Loading branch information
dominiccooney authored Sep 26, 2023
1 parent d165c77 commit 92fe107
Show file tree
Hide file tree
Showing 28 changed files with 1,000 additions and 31 deletions.
2 changes: 1 addition & 1 deletion lib/shared/src/codebase-context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class CodebaseContext {
private embeddingResultsError = ''
constructor(
private config: Pick<Configuration, 'useContext' | 'serverEndpoint' | 'experimentalLocalSymbols'>,
private codebase: string | undefined,
private readonly codebase: string | undefined,
private embeddings: EmbeddingsSearch | null,
private keywords: KeywordContextFetcher | null,
private filenames: FilenameContextFetcher | null,
Expand Down
51 changes: 51 additions & 0 deletions lib/shared/src/embeddings/EmbeddingsDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { SourcegraphGraphQLAPIClient } from '../sourcegraph-api/graphql/client'

import { SourcegraphEmbeddingsSearchClient } from './client'

// A factory for SourcegraphEmbeddingsSearchClients. Queries the client
// connection and app (if available) in parallel and returns the one with
// embeddings available.
export const EmbeddingsDetector = {
// Creates an embeddings search client with the first client in `clients`
// that has embeddings. If none have embeddings, returns undefined. If all
// fail, returns the first error.
async newEmbeddingsSearchClient(
clients: readonly SourcegraphGraphQLAPIClient[],
codebase: string
): Promise<SourcegraphEmbeddingsSearchClient | Error | undefined> {
let firstError: Error | undefined
let allFailed = true
for (const promise of clients.map(client => this.detectEmbeddings(client, codebase))) {
const result = await promise
const isError = result instanceof Error
allFailed &&= isError
if (isError) {
console.log('EmbeddingsDetector', `Error getting embeddings availability for ${codebase}`, result)
firstError ||= result
continue
}
if (result === undefined) {
continue
}
// We got a result, drop the rest of the promises on the floor.
return result()
}
return allFailed ? firstError : undefined
},

// Detects whether *one* client has embeddings for the specified codebase.
// Returns one of:
// - A thunk to construct an embeddings search client, if embeddings exist.
// - undefined, if the client doesn't have embeddings.
// - An error.
async detectEmbeddings(
client: SourcegraphGraphQLAPIClient,
codebase: string
): Promise<(() => SourcegraphEmbeddingsSearchClient) | Error | undefined> {
const repoId = await client.getRepoIdIfEmbeddingExists(codebase)
if (repoId instanceof Error) {
return repoId
}
return repoId ? () => new SourcegraphEmbeddingsSearchClient(client, repoId) : undefined
},
}
11 changes: 9 additions & 2 deletions lib/shared/src/sourcegraph-api/graphql/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fetch from 'isomorphic-fetch'

import { ConfigurationWithAccessToken } from '../../configuration'
import { isError } from '../../utils'
import { DOTCOM_URL, isDotCom } from '../environments'
import { DOTCOM_URL, isDotCom, isLocalApp } from '../environments'

import {
CURRENT_SITE_CODY_LLM_CONFIGURATION,
Expand Down Expand Up @@ -194,7 +194,10 @@ export interface event {
hashedLicenseKey?: string
}

type GraphQLAPIClientConfig = Pick<ConfigurationWithAccessToken, 'serverEndpoint' | 'accessToken' | 'customHeaders'> &
export type GraphQLAPIClientConfig = Pick<
ConfigurationWithAccessToken,
'serverEndpoint' | 'accessToken' | 'customHeaders'
> &
Pick<Partial<ConfigurationWithAccessToken>, 'telemetryLevel'>

export let customUserAgent: string | undefined
Expand All @@ -219,6 +222,10 @@ export class SourcegraphGraphQLAPIClient {
return isDotCom(this.config.serverEndpoint)
}

public isLocalApp(): boolean {
return isLocalApp(this.config.serverEndpoint)
}

public async getSiteVersion(): Promise<string | Error> {
return this.fetchSourcegraphAPI<APIResponse<SiteVersionResponse>>(CURRENT_SITE_VERSION_QUERY, {}).then(
response =>
Expand Down
2 changes: 1 addition & 1 deletion lib/ui/src/chat/inputContext/ChatInputContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Icon } from '../../utils/Icon'

import styles from './ChatInputContext.module.css'

const formatFilePath = (filePath: string, selection: ChatContextStatus['selectionRange']): string => {
export const formatFilePath = (filePath: string, selection: ChatContextStatus['selectionRange']): string => {
const fileName = basename(filePath)

if (!selection) {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.4.2",
"@mdi/js": "^7.2.96",
"@sentry/browser": "^7.66.0",
"@sentry/core": "^7.66.0",
"@sentry/node": "^7.66.0",
Expand Down
47 changes: 46 additions & 1 deletion vscode/src/chat/ChatViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ import { ChatMessage, UserLocalHistory } from '@sourcegraph/cody-shared/src/chat
import { View } from '../../webviews/NavBar'
import { logDebug } from '../log'
import { AuthProviderSimplified } from '../services/AuthProviderSimplified'
import { LocalAppWatcher } from '../services/LocalAppWatcher'
import * as OnboardingExperiment from '../services/OnboardingExperiment'
import { telemetryService } from '../services/telemetry'

import { MessageProvider, MessageProviderOptions } from './MessageProvider'
import { ExtensionMessage, WebviewMessage } from './protocol'
import {
APP_LANDING_URL,
APP_REPOSITORIES_URL,
archConvertor,
ExtensionMessage,
isOsSupportedByApp,
WebviewMessage,
} from './protocol'

export interface ChatViewProviderWebview extends Omit<vscode.Webview, 'postMessage'> {
postMessage(message: ExtensionMessage): Thenable<boolean>
Expand All @@ -27,6 +35,10 @@ export class ChatViewProvider extends MessageProvider implements vscode.WebviewV
constructor({ extensionUri, ...options }: ChatViewProviderOptions) {
super(options)
this.extensionUri = extensionUri

const localAppWatcher = new LocalAppWatcher()
this.disposables.push(localAppWatcher)
this.disposables.push(localAppWatcher.onChange(appWatcher => this.appWatcherChanged(appWatcher)))
}

private async onDidReceiveMessage(message: WebviewMessage): Promise<void> {
Expand Down Expand Up @@ -133,11 +145,44 @@ export class ChatViewProvider extends MessageProvider implements vscode.WebviewV
: undefined
)
break
case 'simplified-onboarding':
if (message.type === 'install-app') {
void this.simplifiedOnboardingInstallApp()
break
}
if (message.type === 'open-app') {
void this.openExternalLinks(APP_REPOSITORIES_URL.href)
break
}
if (message.type === 'reload-state') {
void this.simplifiedOnboardingReloadEmbeddingsState()
break
}
break
default:
this.handleError('Invalid request type from Webview')
}
}

private async simplifiedOnboardingInstallApp(): Promise<void> {
const os = process.platform
const arch = process.arch
const DOWNLOAD_URL =
os && arch && isOsSupportedByApp(os, arch)
? `https://sourcegraph.com/.api/app/latest?arch=${archConvertor(arch)}&target=${os}`
: APP_LANDING_URL.href
await this.openExternalLinks(DOWNLOAD_URL)
}

public async simplifiedOnboardingReloadEmbeddingsState(): Promise<void> {
await this.contextProvider.forceUpdateCodebaseContext()
}

private appWatcherChanged(appWatcher: LocalAppWatcher): void {
void this.webview?.postMessage({ type: 'app-state', isInstalled: appWatcher.isInstalled })
void this.simplifiedOnboardingReloadEmbeddingsState()
}

private async onHumanMessageSubmitted(text: string, submitType: 'user' | 'suggestion' | 'example'): Promise<void> {
logDebug('ChatViewProvider:onHumanMessageSubmitted', '', { verbose: { text, submitType } })
telemetryService.log('CodyVSCodeExtension:chat:submitted', { source: 'sidebar' })
Expand Down
77 changes: 65 additions & 12 deletions vscode/src/chat/ContextProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { ChatClient } from '@sourcegraph/cody-shared/src/chat/chat'
import { CodebaseContext } from '@sourcegraph/cody-shared/src/codebase-context'
import { ConfigurationWithAccessToken } from '@sourcegraph/cody-shared/src/configuration'
import { Editor } from '@sourcegraph/cody-shared/src/editor'
import { SourcegraphEmbeddingsSearchClient } from '@sourcegraph/cody-shared/src/embeddings/client'
import { EmbeddingsDetector } from '@sourcegraph/cody-shared/src/embeddings/EmbeddingsDetector'
import { IndexedKeywordContextFetcher } from '@sourcegraph/cody-shared/src/local-context'
import { isLocalApp } from '@sourcegraph/cody-shared/src/sourcegraph-api/environments'
import { isLocalApp, LOCAL_APP_URL } from '@sourcegraph/cody-shared/src/sourcegraph-api/environments'
import { SourcegraphGraphQLAPIClient } from '@sourcegraph/cody-shared/src/sourcegraph-api/graphql'
import { GraphQLAPIClientConfig } from '@sourcegraph/cody-shared/src/sourcegraph-api/graphql/client'
import { convertGitCloneURLToCodebaseName, isError } from '@sourcegraph/cody-shared/src/utils'

import { getFullConfig } from '../configuration'
Expand All @@ -19,6 +20,7 @@ import { getRerankWithLog } from '../logged-rerank'
import { repositoryRemoteUrl } from '../repository/repositoryHelpers'
import { AuthProvider } from '../services/AuthProvider'
import * as OnboardingExperiment from '../services/OnboardingExperiment'
import { secretStorage } from '../services/SecretStorageProvider'
import { telemetryService } from '../services/telemetry'

import { ChatViewProviderWebview } from './ChatViewProvider'
Expand Down Expand Up @@ -101,6 +103,11 @@ export class ContextProvider implements vscode.Disposable {
this.configurationChangeEvent.fire()
}

public async forceUpdateCodebaseContext(): Promise<void> {
this.currentWorkspaceRoot = ''
return this.syncAuthStatus()
}

private async updateCodebaseContext(): Promise<void> {
if (!this.editor.getActiveTextEditor() && vscode.window.visibleTextEditors.length !== 0) {
// these are ephemeral
Expand All @@ -118,7 +125,8 @@ export class ContextProvider implements vscode.Disposable {
this.symf,
this.editor,
this.chat,
this.platform
this.platform,
await this.getEmbeddingClientCandidates(this.config)
)
if (!codebaseContext) {
return
Expand Down Expand Up @@ -148,7 +156,8 @@ export class ContextProvider implements vscode.Disposable {
this.symf,
this.editor,
this.chat,
this.platform
this.platform,
await this.getEmbeddingClientCandidates(newConfig)
)
if (codebaseContext) {
this.codebaseContext = codebaseContext
Expand Down Expand Up @@ -233,6 +242,50 @@ export class ContextProvider implements vscode.Disposable {
}
this.disposables = []
}

// If set, a client to talk to app directly.
private appClient?: SourcegraphGraphQLAPIClient

// Tries to get a GraphQL client config to talk to app. If there's no app
// token, we can't do that; in that case, returns `undefined`. Caches the
// client.
private async maybeAppClient(): Promise<SourcegraphGraphQLAPIClient | undefined> {
if (this.appClient) {
return this.appClient
}

// App access tokens are written to secret storage by LocalAppDetector.
// Retrieve this token here.
const accessToken = await secretStorage.get(LOCAL_APP_URL.href)
if (!accessToken) {
return undefined
}
const clientConfig = {
serverEndpoint: LOCAL_APP_URL.href,
accessToken,
customHeaders: {},
}
return (this.appClient = new SourcegraphGraphQLAPIClient(clientConfig))
}

// Gets a list of GraphQL clients to interrogate for embeddings
// availability.
private async getEmbeddingClientCandidates(config: GraphQLAPIClientConfig): Promise<SourcegraphGraphQLAPIClient[]> {
const result = [new SourcegraphGraphQLAPIClient(config)]
if (isLocalApp(config.serverEndpoint)) {
// We will just talk to app.
return result
}
// The other client is talking to non-app (dotcom, etc.) so create a
// client to talk to app.
const appClient = await this.maybeAppClient()
if (appClient) {
// By putting the app client first, we prefer to talk to app if
// both app and server have embeddings.
result.unshift(appClient)
}
return result
}
}

/**
Expand All @@ -243,15 +296,15 @@ export class ContextProvider implements vscode.Disposable {
* @param editor Editor instance
* @returns CodebaseContext if a codebase can be determined, else null
*/
export async function getCodebaseContext(
async function getCodebaseContext(
config: Config,
rgPath: string | null,
symf: IndexedKeywordContextFetcher | undefined,
editor: Editor,
chatClient: ChatClient,
platform: PlatformContext
platform: PlatformContext,
embeddingsClientCandidates: readonly SourcegraphGraphQLAPIClient[]
): Promise<CodebaseContext | null> {
const client = new SourcegraphGraphQLAPIClient(config)
const workspaceRoot = editor.getWorkspaceRootUri()
if (!workspaceRoot) {
return null
Expand All @@ -262,19 +315,19 @@ export async function getCodebaseContext(
if (!codebase) {
return null
}
// Check if repo is embedded in endpoint
const repoId = await client.getRepoIdIfEmbeddingExists(codebase)
if (isError(repoId)) {

// Find an embeddings client
const embeddingsSearch = await EmbeddingsDetector.newEmbeddingsSearchClient(embeddingsClientCandidates, codebase)
if (isError(embeddingsSearch)) {
const infoMessage = `Cody could not find embeddings for '${codebase}' on your Sourcegraph instance.\n`
console.info(infoMessage)
return null
}

const embeddingsSearch = repoId && !isError(repoId) ? new SourcegraphEmbeddingsSearchClient(client, repoId) : null
return new CodebaseContext(
config,
codebase,
embeddingsSearch,
embeddingsSearch || null,
rgPath ? platform.createLocalKeywordContextFetcher?.(rgPath, editor, chatClient) ?? null : null,
rgPath ? platform.createFilenameContextFetcher?.(rgPath, editor, chatClient) ?? null : null,
new GraphContextProvider(editor),
Expand Down
5 changes: 5 additions & 0 deletions vscode/src/chat/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export type WebviewMessage =
| { command: 'abort' }
| { command: 'custom-prompt'; title: string; value?: CustomCommandType }
| { command: 'reload' }
| {
command: 'simplified-onboarding'
type: 'install-app' | 'open-app' | 'reload-state'
}

/**
* A message sent from the extension host to the webview.
Expand Down Expand Up @@ -88,6 +92,7 @@ export const CODY_FEEDBACK_URL = new URL(
// APP
export const APP_LANDING_URL = new URL('https://about.sourcegraph.com/app')
export const APP_CALLBACK_URL = new URL('sourcegraph://user/settings/tokens/new/callback')
export const APP_REPOSITORIES_URL = new URL('sourcegraph://users/admin/app-settings/local-repositories')

/**
* The status of a users authentication, whether they're authenticated and have a
Expand Down
6 changes: 5 additions & 1 deletion vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,11 @@ const register = async (
// Register URI Handler (vscode://sourcegraph.cody-ai)
vscode.window.registerUriHandler({
handleUri: async (uri: vscode.Uri) => {
await authProvider.tokenCallbackHandler(uri, config.customHeaders)
if (uri.path === '/app-done') {
await sidebarChatProvider.simplifiedOnboardingReloadEmbeddingsState()
} else {
await authProvider.tokenCallbackHandler(uri, config.customHeaders)
}
},
}),
statusBar,
Expand Down
Loading

0 comments on commit 92fe107

Please sign in to comment.