Skip to content

Commit

Permalink
Add account switcher component in the Accounts webview tab (#6159)
Browse files Browse the repository at this point in the history
## Changes

That PR:
1. Fixes webview crashes when switching accounts with Account tab open
2. Add `Switch Account` button on the Accounts tab when
`accountSwitchingInWebview` capability is enabled in client.

## Test plan

Testing with jetbrains `main`:

**Fixes https://linear.app/sourcegraph/issue/QA-150**

1. Open Accounts tab
3. Click Cody icon, then `Manage Accounts`
4. Switch the account 
5. Account tab should re-render properly (after a 1-2s delay) showing
new account details

**Testing with jetbrains [migrated to webview account
management](sourcegraph/jetbrains#2382

Account switching:

1. Open Accounts tab
2. Click Switch Account
3. Select account you want to switch to from the list
4. Account tab should re-render properly (after a 1-2s delay) showing
new account details

Signing out:

1. Open Accounts tab
2. Click Sign Out
3. You should be moved to the Sign In panel

Account adding:

1. Open Accounts tab
2. Click 'Switch Account', and then 'Add another account'
3. Type url of your endpoint
4. You should be redirected to the web page to confirm adding the token
5. Account should be added and displayed as active

Note: If wrong endpoint is provided web redirection will fail, but no
error is displayed in the UI.
This definitely can be improved.

Account adding with token:

1. Open Accounts tab
2. Click 'Switch Account', and then 'Add another account'
3. Type url of your endpoint
4. Click 'Access Token (optional)'
5. Type incorrect access token and click 'Add and Switch'
6. You should get error saying `Invalid access token.`
7. Type correct access token and click 'Add and Switch'
9. Account should be added and displayed as active


![image](https://github.com/user-attachments/assets/cbd9e85d-8075-45ec-a4ba-2b3a68c07fdb)

![image](https://github.com/user-attachments/assets/10b6d94f-c15d-4fe3-ae70-5b968bb8d251)

![image](https://github.com/user-attachments/assets/0d617e8b-0cc4-48d2-98b7-8e5d548f3f6c)

![image](https://github.com/user-attachments/assets/0ad531f2-5880-4d7a-a66d-31abc2112a79)
  • Loading branch information
pkukielka authored Nov 25, 2024
1 parent 90af132 commit 2b3163f
Show file tree
Hide file tree
Showing 14 changed files with 441 additions and 96 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data class ClientCapabilities(
val ignore: IgnoreEnum? = null, // Oneof: none, enabled
val codeActions: CodeActionsEnum? = null, // Oneof: none, enabled
val disabledMentionsProviders: List<ContextMentionProviderID>? = null,
val accountSwitchingInWebview: AccountSwitchingInWebviewEnum? = null, // Oneof: none, enabled
val webviewMessages: WebviewMessagesEnum? = null, // Oneof: object-encoded, string-encoded
val globalState: GlobalStateEnum? = null, // Oneof: stateless, server-managed, client-managed
val secrets: SecretsEnum? = null, // Oneof: stateless, client-managed
Expand Down Expand Up @@ -89,6 +90,11 @@ data class ClientCapabilities(
@SerializedName("enabled") Enabled,
}

enum class AccountSwitchingInWebviewEnum {
@SerializedName("none") None,
@SerializedName("enabled") Enabled,
}

enum class WebviewMessagesEnum {
@SerializedName("object-encoded") `Object-encoded`,
@SerializedName("string-encoded") `String-encoded`,
Expand Down
1 change: 1 addition & 0 deletions lib/shared/src/configuration/clientCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface ClientCapabilities {
ignore?: 'none' | 'enabled' | undefined | null
codeActions?: 'none' | 'enabled' | undefined | null
disabledMentionsProviders?: ContextMentionProviderID[] | undefined | null
accountSwitchingInWebview?: 'none' | 'enabled' | undefined | null

/**
* When 'object-encoded' (default), the server uses the `webview/postMessage` method to send
Expand Down
4 changes: 2 additions & 2 deletions vscode/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export async function showSignOutMenu(): Promise<void> {
/**
* Log user out of the selected endpoint (remove token from secret).
*/
async function signOut(endpoint: string): Promise<void> {
export async function signOut(endpoint: string): Promise<void> {
const token = await secretStorage.getToken(endpoint)
const tokenSource = await secretStorage.getTokenSource(endpoint)
// Delete the access token from the Sourcegraph instance on signout if it was created
Expand All @@ -380,7 +380,7 @@ async function signOut(endpoint: string): Promise<void> {
await graphqlClient.DeleteAccessToken(token)
}
await secretStorage.deleteToken(endpoint)
await localStorage.deleteEndpoint()
await localStorage.deleteEndpoint(endpoint)
authProvider.refresh()
}

Expand Down
48 changes: 35 additions & 13 deletions vscode/src/chat/chat-view/ChatController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type AuthStatus,
type ChatModel,
type ClientActionBroadcast,
type CodyClientConfig,
Expand Down Expand Up @@ -85,7 +86,7 @@ import type { TelemetryEventParameters } from '@sourcegraph/telemetry'
import { Subject, map } from 'observable-fns'
import type { URI } from 'vscode-uri'
import { View } from '../../../webviews/tabs/types'
import { redirectToEndpointLogin, showSignInMenu, showSignOutMenu } from '../../auth/auth'
import { redirectToEndpointLogin, showSignInMenu, showSignOutMenu, signOut } from '../../auth/auth'
import {
closeAuthProgressIndicator,
startAuthProgressIndicator,
Expand All @@ -106,6 +107,7 @@ import { publicRepoMetadataIfAllWorkspaceReposArePublic } from '../../repository
import { authProvider } from '../../services/AuthProvider'
import { AuthProviderSimplified } from '../../services/AuthProviderSimplified'
import { localStorage } from '../../services/LocalStorageProvider'
import { secretStorage } from '../../services/SecretStorageProvider'
import { recordExposedExperimentsToSpan } from '../../services/open-telemetry/utils'
import {
handleCodeFromInsertAtCursor,
Expand Down Expand Up @@ -240,10 +242,10 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv

this.disposables.push(
subscriptionDisposable(
authStatus.subscribe(() => {
authStatus.subscribe(authStatus => {
// Run this async because this method may be called during initialization
// and awaiting on this.postMessage may result in a deadlock
void this.sendConfig()
void this.sendConfig(authStatus)
})
),

Expand Down Expand Up @@ -444,15 +446,34 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
}
break
}
if (message.authKind === 'signin' && message.endpoint && message.value) {
await localStorage.saveEndpointAndToken({
serverEndpoint: message.endpoint,
accessToken: message.value,
})
if (message.authKind === 'signin' && message.endpoint) {
const serverEndpoint = message.endpoint
const accessToken = message.value
? message.value
: await secretStorage.getToken(serverEndpoint)
if (accessToken) {
const tokenSource = message.value
? 'paste'
: await secretStorage.getTokenSource(serverEndpoint)
const validationResult = await authProvider.validateAndStoreCredentials(
{ serverEndpoint, accessToken, tokenSource },
'always-store'
)
if (validationResult.authStatus.authenticated) {
break
}
} else {
redirectToEndpointLogin(message.endpoint)
}
break
}
if (message.authKind === 'signout') {
await showSignOutMenu()
const serverEndpoint = message.endpoint
if (serverEndpoint) {
await signOut(serverEndpoint)
} else {
await showSignOutMenu()
}
break
}
if (message.authKind === 'switch') {
Expand Down Expand Up @@ -516,9 +537,12 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv
const isEditorViewType = this.webviewPanelOrView?.viewType === 'cody.editorPanel'
const webviewType = isEditorViewType && !sidebarViewOnly ? 'editor' : 'sidebar'
const uiKindIsWeb = (cenv.CODY_OVERRIDE_UI_KIND ?? vscode.env.uiKind) === vscode.UIKind.Web
const endpoints = localStorage.getEndpointHistory() ?? []

return {
uiKindIsWeb,
serverEndpoint: auth.serverEndpoint,
endpointHistory: [...endpoints],
experimentalNoodle: configuration.experimentalNoodle,
smartApply: this.isSmartApplyEnabled(),
hasEditCapability: this.hasEditCapability(),
Expand All @@ -534,12 +558,10 @@ export class ChatController implements vscode.Disposable, vscode.WebviewViewProv

// When the webview sends the 'ready' message, respond by posting the view config
private async handleReady(): Promise<void> {
await this.sendConfig()
await this.sendConfig(currentAuthStatus())
}

private async sendConfig(): Promise<void> {
const authStatus = currentAuthStatus()

private async sendConfig(authStatus: AuthStatus): Promise<void> {
// Don't emit config if we're verifying auth status to avoid UI auth flashes on the client
if (authStatus.pendingValidation) {
return
Expand Down
1 change: 1 addition & 0 deletions vscode/src/chat/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export interface ConfigurationSubsetForWebview
webviewType?: WebviewType | undefined | null
// Whether support running multiple webviews (e.g. sidebar w/ multiple editor panels).
multipleWebviewsEnabled?: boolean | undefined | null
endpointHistory?: string[] | undefined | null
}

/**
Expand Down
20 changes: 16 additions & 4 deletions vscode/src/services/LocalStorageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class LocalStorage implements LocalStorageForModelPreferences {
const endpoint = this.storage.get<string | null>(this.LAST_USED_ENDPOINT, null)
// Clear last used endpoint if it is a Sourcegraph token
if (endpoint && isSourcegraphToken(endpoint)) {
this.deleteEndpoint()
this.deleteEndpoint(endpoint)
return null
}
return endpoint
Expand Down Expand Up @@ -135,17 +135,29 @@ class LocalStorage implements LocalStorageForModelPreferences {
this.onChange.fire()
}

public async deleteEndpoint(): Promise<void> {
await this.set(this.LAST_USED_ENDPOINT, null)
public async deleteEndpoint(endpoint: string): Promise<void> {
await this.set(endpoint, null)
await this.deleteEndpointFromHistory(endpoint)
}

// Deletes and returns the endpoint history
public async deleteEndpointHistory(): Promise<string[]> {
const history = this.getEndpointHistory()
await Promise.all([this.deleteEndpoint(), this.set(this.CODY_ENDPOINT_HISTORY, null)])
await Promise.all([
this.deleteEndpoint(this.LAST_USED_ENDPOINT),
this.set(this.CODY_ENDPOINT_HISTORY, null),
])
return history || []
}

// Deletes and returns the endpoint history
public async deleteEndpointFromHistory(endpoint: string): Promise<void> {
const history = this.getEndpointHistory()
const historySet = new Set(history)
historySet.delete(endpoint)
await this.set(this.CODY_ENDPOINT_HISTORY, [...historySet])
}

public getEndpointHistory(): string[] | null {
return this.get<string[] | null>(this.CODY_ENDPOINT_HISTORY)
}
Expand Down
1 change: 1 addition & 0 deletions vscode/webviews/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vsc
uiKindIsWeb={config.config.uiKindIsWeb}
vscodeAPI={vscodeAPI}
codyIDE={config.clientCapabilities.agentIDE}
endpoints={config.config.endpointHistory ?? []}
/>
</div>
) : (
Expand Down
1 change: 1 addition & 0 deletions vscode/webviews/AuthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ interface LoginProps {
uiKindIsWeb: boolean
vscodeAPI: VSCodeWrapper
codyIDE: CodyIDE
endpoints: string[]
}

const WebLogin: React.FunctionComponent<
Expand Down
8 changes: 8 additions & 0 deletions vscode/webviews/CodyPanel.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const meta: Meta<typeof CodyPanel> = {
config: {} as any,
clientCapabilities: CLIENT_CAPABILITIES_FIXTURE,
authStatus: AUTH_STATUS_FIXTURE_AUTHED,
isDotComUser: true,
userProductSubscription: {
userCanUpgrade: true,
},
},
},
decorators: [VSCodeWebview],
Expand All @@ -42,6 +46,10 @@ export const NetworkError: StoryObj<typeof meta> = {
...AUTH_STATUS_FIXTURE_UNAUTHED,
showNetworkError: true,
},
isDotComUser: true,
userProductSubscription: {
userCanUpgrade: true,
},
},
},
}
15 changes: 13 additions & 2 deletions vscode/webviews/CodyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CodyIDE,
FeatureFlag,
type Guardrails,
type UserProductSubscription,
firstValueFrom,
} from '@sourcegraph/cody-shared'
import { useExtensionAPI, useObservable } from '@sourcegraph/prompt-editor'
Expand All @@ -30,6 +31,8 @@ interface CodyPanelProps {
config: LocalEnv & ConfigurationSubsetForWebview
clientCapabilities: ClientCapabilitiesWithLegacyFields
authStatus: AuthStatus
isDotComUser: boolean
userProductSubscription?: UserProductSubscription | null | undefined
}
errorMessages: string[]
attributionEnabled: boolean
Expand All @@ -51,7 +54,7 @@ interface CodyPanelProps {
export const CodyPanel: FunctionComponent<CodyPanelProps> = ({
view,
setView,
configuration: { config, clientCapabilities, authStatus },
configuration: { config, clientCapabilities, authStatus, isDotComUser, userProductSubscription },
errorMessages,
setErrorMessages,
attributionEnabled,
Expand Down Expand Up @@ -142,7 +145,15 @@ export const CodyPanel: FunctionComponent<CodyPanelProps> = ({
isPromptsV2Enabled={isPromptsV2Enabled}
/>
)}
{view === View.Account && <AccountTab setView={setView} />}
{view === View.Account && (
<AccountTab
config={config}
clientCapabilities={clientCapabilities}
authStatus={authStatus}
isDotComUser={isDotComUser}
userProductSubscription={userProductSubscription}
/>
)}
{view === View.Settings && <SettingsTab />}
</TabContainer>
<StateDebugOverlay />
Expand Down
Loading

0 comments on commit 2b3163f

Please sign in to comment.