diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ClientCapabilities.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ClientCapabilities.kt index caf0f29cec4a..e7aff7ebe3e7 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ClientCapabilities.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ClientCapabilities.kt @@ -18,6 +18,7 @@ data class ClientCapabilities( val ignore: IgnoreEnum? = null, // Oneof: none, enabled val codeActions: CodeActionsEnum? = null, // Oneof: none, enabled val disabledMentionsProviders: List? = 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 @@ -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`, diff --git a/lib/shared/src/configuration/clientCapabilities.ts b/lib/shared/src/configuration/clientCapabilities.ts index 0fdeba5282a1..d54c77c932a0 100644 --- a/lib/shared/src/configuration/clientCapabilities.ts +++ b/lib/shared/src/configuration/clientCapabilities.ts @@ -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 diff --git a/vscode/src/auth/auth.ts b/vscode/src/auth/auth.ts index 584baec4e809..872c32df9b51 100644 --- a/vscode/src/auth/auth.ts +++ b/vscode/src/auth/auth.ts @@ -370,7 +370,7 @@ export async function showSignOutMenu(): Promise { /** * Log user out of the selected endpoint (remove token from secret). */ -async function signOut(endpoint: string): Promise { +export async function signOut(endpoint: string): Promise { 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 @@ -380,7 +380,7 @@ async function signOut(endpoint: string): Promise { await graphqlClient.DeleteAccessToken(token) } await secretStorage.deleteToken(endpoint) - await localStorage.deleteEndpoint() + await localStorage.deleteEndpoint(endpoint) authProvider.refresh() } diff --git a/vscode/src/chat/chat-view/ChatController.ts b/vscode/src/chat/chat-view/ChatController.ts index fe8d4922ea52..dee03bb2ee17 100644 --- a/vscode/src/chat/chat-view/ChatController.ts +++ b/vscode/src/chat/chat-view/ChatController.ts @@ -1,4 +1,5 @@ import { + type AuthStatus, type ChatModel, type ClientActionBroadcast, type CodyClientConfig, @@ -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, @@ -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, @@ -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) }) ), @@ -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') { @@ -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(), @@ -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 { - await this.sendConfig() + await this.sendConfig(currentAuthStatus()) } - private async sendConfig(): Promise { - const authStatus = currentAuthStatus() - + private async sendConfig(authStatus: AuthStatus): Promise { // Don't emit config if we're verifying auth status to avoid UI auth flashes on the client if (authStatus.pendingValidation) { return diff --git a/vscode/src/chat/protocol.ts b/vscode/src/chat/protocol.ts index 615e4e136de1..561feb4e265e 100644 --- a/vscode/src/chat/protocol.ts +++ b/vscode/src/chat/protocol.ts @@ -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 } /** diff --git a/vscode/src/services/LocalStorageProvider.ts b/vscode/src/services/LocalStorageProvider.ts index dd986f33c0a9..19ab9ef2688f 100644 --- a/vscode/src/services/LocalStorageProvider.ts +++ b/vscode/src/services/LocalStorageProvider.ts @@ -97,7 +97,7 @@ class LocalStorage implements LocalStorageForModelPreferences { const endpoint = this.storage.get(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 @@ -135,17 +135,29 @@ class LocalStorage implements LocalStorageForModelPreferences { this.onChange.fire() } - public async deleteEndpoint(): Promise { - await this.set(this.LAST_USED_ENDPOINT, null) + public async deleteEndpoint(endpoint: string): Promise { + await this.set(endpoint, null) + await this.deleteEndpointFromHistory(endpoint) } // Deletes and returns the endpoint history public async deleteEndpointHistory(): Promise { 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 { + 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(this.CODY_ENDPOINT_HISTORY) } diff --git a/vscode/webviews/App.tsx b/vscode/webviews/App.tsx index 0de766745f03..534fc2a59acb 100644 --- a/vscode/webviews/App.tsx +++ b/vscode/webviews/App.tsx @@ -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 ?? []} /> ) : ( diff --git a/vscode/webviews/AuthPage.tsx b/vscode/webviews/AuthPage.tsx index 88abc4dad79f..f145a94dc716 100644 --- a/vscode/webviews/AuthPage.tsx +++ b/vscode/webviews/AuthPage.tsx @@ -124,6 +124,7 @@ interface LoginProps { uiKindIsWeb: boolean vscodeAPI: VSCodeWrapper codyIDE: CodyIDE + endpoints: string[] } const WebLogin: React.FunctionComponent< diff --git a/vscode/webviews/CodyPanel.story.tsx b/vscode/webviews/CodyPanel.story.tsx index 9baadd57ba71..324358d1b2e5 100644 --- a/vscode/webviews/CodyPanel.story.tsx +++ b/vscode/webviews/CodyPanel.story.tsx @@ -26,6 +26,10 @@ const meta: Meta = { config: {} as any, clientCapabilities: CLIENT_CAPABILITIES_FIXTURE, authStatus: AUTH_STATUS_FIXTURE_AUTHED, + isDotComUser: true, + userProductSubscription: { + userCanUpgrade: true, + }, }, }, decorators: [VSCodeWebview], @@ -42,6 +46,10 @@ export const NetworkError: StoryObj = { ...AUTH_STATUS_FIXTURE_UNAUTHED, showNetworkError: true, }, + isDotComUser: true, + userProductSubscription: { + userCanUpgrade: true, + }, }, }, } diff --git a/vscode/webviews/CodyPanel.tsx b/vscode/webviews/CodyPanel.tsx index 1b3e9211de95..c5572980813b 100644 --- a/vscode/webviews/CodyPanel.tsx +++ b/vscode/webviews/CodyPanel.tsx @@ -5,6 +5,7 @@ import { CodyIDE, FeatureFlag, type Guardrails, + type UserProductSubscription, firstValueFrom, } from '@sourcegraph/cody-shared' import { useExtensionAPI, useObservable } from '@sourcegraph/prompt-editor' @@ -30,6 +31,8 @@ interface CodyPanelProps { config: LocalEnv & ConfigurationSubsetForWebview clientCapabilities: ClientCapabilitiesWithLegacyFields authStatus: AuthStatus + isDotComUser: boolean + userProductSubscription?: UserProductSubscription | null | undefined } errorMessages: string[] attributionEnabled: boolean @@ -51,7 +54,7 @@ interface CodyPanelProps { export const CodyPanel: FunctionComponent = ({ view, setView, - configuration: { config, clientCapabilities, authStatus }, + configuration: { config, clientCapabilities, authStatus, isDotComUser, userProductSubscription }, errorMessages, setErrorMessages, attributionEnabled, @@ -142,7 +145,15 @@ export const CodyPanel: FunctionComponent = ({ isPromptsV2Enabled={isPromptsV2Enabled} /> )} - {view === View.Account && } + {view === View.Account && ( + + )} {view === View.Settings && } diff --git a/vscode/webviews/components/AccountSwitcher.tsx b/vscode/webviews/components/AccountSwitcher.tsx new file mode 100644 index 000000000000..0a2ebb713984 --- /dev/null +++ b/vscode/webviews/components/AccountSwitcher.tsx @@ -0,0 +1,249 @@ +import { ChevronDown, ChevronRight, ChevronsUpDown, CircleMinus, Plus } from 'lucide-react' +import type * as React from 'react' +import { type KeyboardEvent, useCallback, useState } from 'react' +import { isSourcegraphToken } from '../../src/chat/protocol' +import { Badge } from '../components/shadcn/ui/badge' +import { + Form, + FormControl, + FormField, + FormLabel, + FormMessage, + FormSubmit, +} from '../components/shadcn/ui/form' +import { getVSCodeAPI } from '../utils/VSCodeApi' +import { Button } from './shadcn/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './shadcn/ui/collapsible' +import { Popover, PopoverContent, PopoverTrigger } from './shadcn/ui/popover' + +interface AccountSwitcherProps { + activeEndpoint: string + endpoints: string[] + setLoading: (loading: boolean) => void +} + +export const AccountSwitcher: React.FC = ({ + activeEndpoint, + endpoints, + setLoading, +}) => { + type PopoverView = 'switch' | 'remove' | 'add' + const [getPopoverView, serPopoverView] = useState('switch') + const [isOpen, setIsOpen] = useState(false) + + const [endpointToRemove, setEndpointToRemove] = useState(null) + const [addFormData, setAddFormData] = useState({ + endpoint: 'https://', + accessToken: '', + }) + + const onKeyDownInPopoverContent = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && isOpen) { + onOpenChange(false) + } + } + + const onOpenChange = (open: boolean): void => { + setIsOpen(open) + if (!open) { + setEndpointToRemove(null) + serPopoverView('switch') + setAddFormData(() => ({ + endpoint: 'https://', + accessToken: '', + })) + } + } + + const popoverEndpointsList = endpoints.map(endpoint => ( + + + + + )) + + const popoverSwitchAccountPanel = ( +
+
+ + Active + + {activeEndpoint} +
+
+ {popoverEndpointsList} +
+ +
+ ) + + const popoverRemoveAccountPanel = ( +
+ Remove Account? +
{endpointToRemove}
+ +
+ ) + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const { name, value } = e.target + setAddFormData(prev => ({ ...prev, [name]: value })) + }, []) + + function addAndSwitchAccount() { + getVSCodeAPI().postMessage({ + command: 'auth', + authKind: 'signin', + endpoint: addFormData.endpoint, + value: addFormData.accessToken, + }) + onOpenChange(false) + } + + const popoverAddAccountPanel = ( +
+ Account Details +
+ + + + Invalid URL. + URL is required. + + + + + + + + + + !isSourcegraphToken(addFormData.accessToken)}> + Invalid access token. + + Access token is required. + + + + + + +
+
+ ) + + function getPopoverContent() { + switch (getPopoverView) { + case 'add': + return popoverAddAccountPanel + case 'remove': + return popoverRemoveAccountPanel + case 'switch': + return popoverSwitchAccountPanel + default: + return null + } + } + + return ( + + setIsOpen(!isOpen)}> + + + +
{getPopoverContent()}
+
+
+ ) +} diff --git a/vscode/webviews/tabs/AccountTab.story.tsx b/vscode/webviews/tabs/AccountTab.story.tsx index d3c2df1ab1ab..1361a0f4e865 100644 --- a/vscode/webviews/tabs/AccountTab.story.tsx +++ b/vscode/webviews/tabs/AccountTab.story.tsx @@ -16,11 +16,7 @@ const meta: Meta = { story =>
{story()}
, VSCodeStandaloneComponent, ], - args: { - setView: () => { - console.log('setView called') - }, - }, + args: {}, } export default meta diff --git a/vscode/webviews/tabs/AccountTab.tsx b/vscode/webviews/tabs/AccountTab.tsx index b11c5985bbba..0f78545f29c7 100644 --- a/vscode/webviews/tabs/AccountTab.tsx +++ b/vscode/webviews/tabs/AccountTab.tsx @@ -1,83 +1,112 @@ -import { CodyIDE } from '@sourcegraph/cody-shared' -import { useCallback } from 'react' +import { + type AuthStatus, + type AuthenticatedAuthStatus, + type ClientCapabilitiesWithLegacyFields, + type UserProductSubscription, + isCodyProUser, +} from '@sourcegraph/cody-shared' +import { useCallback, useEffect, useState } from 'react' import { URI } from 'vscode-uri' -import { ACCOUNT_UPGRADE_URL, ACCOUNT_USAGE_URL } from '../../src/chat/protocol' +import { + ACCOUNT_UPGRADE_URL, + ACCOUNT_USAGE_URL, + type ConfigurationSubsetForWebview, + type LocalEnv, +} from '../../src/chat/protocol' +import { AccountSwitcher } from '../components/AccountSwitcher' import { UserAvatar } from '../components/UserAvatar' import { Button } from '../components/shadcn/ui/button' import { getVSCodeAPI } from '../utils/VSCodeApi' -import { useConfig, useUserAccountInfo } from '../utils/useConfig' -import { View } from './types' -interface AccountAction { - text: string - onClick: () => void -} interface AccountTabProps { - setView: (view: View) => void + config: LocalEnv & ConfigurationSubsetForWebview + clientCapabilities: ClientCapabilitiesWithLegacyFields + authStatus: AuthStatus + isDotComUser: boolean + userProductSubscription: UserProductSubscription | null | undefined } // TODO: Implement the AccountTab component once the design is ready. -export const AccountTab: React.FC = ({ setView }) => { - const config = useConfig() - const userInfo = useUserAccountInfo() - const { user, isCodyProUser, isDotComUser } = userInfo - const { displayName, username, primaryEmail, endpoint } = user - +export const AccountTab: React.FC = ({ + config, + clientCapabilities, + authStatus, + isDotComUser, + userProductSubscription, +}) => { // We open the native system pop-up for VS Code. - if (config.clientCapabilities.isVSCode) { + if (clientCapabilities.isVSCode) { return null } - const actions: AccountAction[] = [] - - if (isDotComUser && !isCodyProUser) { - actions.push({ - text: 'Upgrade', - onClick: () => - getVSCodeAPI().postMessage({ command: 'links', value: ACCOUNT_UPGRADE_URL.toString() }), - }) + if (!authStatus.authenticated || userProductSubscription === undefined) { + return null } - if (isDotComUser) { - actions.push({ - text: 'Manage Account', - onClick: useCallback(() => { - if (userInfo.user.username) { - const uri = URI.parse(ACCOUNT_USAGE_URL.toString()).with({ - query: `cody_client_user=${encodeURIComponent(userInfo.user.username)}`, - }) - getVSCodeAPI().postMessage({ command: 'links', value: uri.toString() }) - } - }, [userInfo]), - }) + + const [isLoading, setIsLoading] = useState(true) + useEffect(() => { + setIsLoading(!authStatus.authenticated) + }, [authStatus]) + + const { displayName, username, primaryEmail, endpoint } = authStatus as AuthenticatedAuthStatus + const isProUser = isCodyProUser(authStatus, userProductSubscription) + + function createButton(text: string, onClick: () => void) { + return ( + + ) } - actions.push({ - text: 'Settings', - onClick: () => - getVSCodeAPI().postMessage({ command: 'command', id: 'cody.status-bar.interacted' }), - }) - actions.push({ - text: 'Sign Out', - onClick: () => { - getVSCodeAPI().postMessage({ command: 'auth', authKind: 'signout' }) - // TODO: Remove when JB moves to agent based auth - // Set the view to the Chat tab so that if the user signs back in, they will be - // automatically redirected to the Chat tab, rather than the accounts tab. - // This is only for JB as the signout call is captured by the extension and not - // passed through to the agent. - if (config.clientCapabilities.agentIDE === CodyIDE.JetBrains) { - setView(View.Chat) + + const endpoints: string[] = config.endpointHistory ?? [] + const switchableEndpoints = endpoints.filter(e => e !== endpoint) + const accountSwitcher = ( + + ) + + const upgradeButton = createButton('Upgrade', () => + getVSCodeAPI().postMessage({ command: 'links', value: ACCOUNT_UPGRADE_URL.toString() }) + ) + + const manageAccountButton = createButton( + 'Manage Account', + useCallback(() => { + if (username) { + const uri = URI.parse(ACCOUNT_USAGE_URL.toString()).with({ + query: `cody_client_user=${encodeURIComponent(username)}`, + }) + getVSCodeAPI().postMessage({ command: 'links', value: uri.toString() }) } - }, - }) + }, [username]) + ) + + const settingButton = createButton('Settings', () => + getVSCodeAPI().postMessage({ command: 'command', id: 'cody.status-bar.interacted' }) + ) + + const signOutButton = createButton('Sign Out', () => + getVSCodeAPI().postMessage({ command: 'auth', authKind: 'signout' }) + ) - return ( + const accountPanelView = (

Account

@@ -85,12 +114,13 @@ export const AccountTab: React.FC = ({ setView }) => {

{displayName ?? username}

{primaryEmail}

+ {clientCapabilities.accountSwitchingInWebview === 'enabled' && accountSwitcher}
Plan:
- {isDotComUser ? (isCodyProUser ? 'Cody Pro' : 'Cody Free') : 'Enterprise'} + {isDotComUser ? (isProUser ? 'Cody Pro' : 'Cody Free') : 'Enterprise'}
Endpoint:
@@ -100,17 +130,19 @@ export const AccountTab: React.FC = ({ setView }) => {
- {actions.map(a => ( - - ))} + {isDotComUser && !isProUser && upgradeButton} + {isDotComUser && manageAccountButton} + {settingButton} + {signOutButton}
) + + const loadingView = ( +
+
+
Switching Account...
+
+ ) + + return isLoading ? loadingView : accountPanelView } diff --git a/vscode/webviews/themes/jetbrains.css b/vscode/webviews/themes/jetbrains.css index 512bc925ceb6..e7b6b6aef20c 100644 --- a/vscode/webviews/themes/jetbrains.css +++ b/vscode/webviews/themes/jetbrains.css @@ -264,7 +264,7 @@ html[data-ide='JetBrains'] { --vscode-input-background: var(--jetbrains-TextField-background); --vscode-input-border: var(--jetbrains-TextArea-inactiveForeground); --vscode-input-foreground: var(--jetbrains-TextField-foreground); - --vscode-input-placeholderForeground: var(--jetbrains-TextField-foreground); + --vscode-input-placeholderForeground: var(--jetbrains-TextArea-caretForeground); --vscode-inputOption-activeBackground: var(--jetbrains-ToolWindow-HeaderTab-underlineColor); --vscode-inputOption-activeBorder: var(--jetbrains-TextField-borderColor); --vscode-inputOption-activeForeground: var(--jetbrains-UIDesigner-Label-foreground); @@ -664,6 +664,11 @@ html[data-ide='JetBrains'] .tw-bg-muted-transparent:hover { background-color: var(--jetbrains-ActionButton-hoverBackground); } +html[data-ide='JetBrains'] .tw-border-input-border { + border-style: solid; + border-width: thin; +} + /* Custom rule for cmdk heading: * VSCode uses input background for headings. * JetBrains needs different styling to distinguish headings because input background are the same as pane background. */