Cloud account switcher (#8223)

* Cloud account switcher

* Bare metal evals fixes (#8224)

Co-authored-by: Roo Code <roomote@roocode.com>

* Rounded icons in chat account switcher

* Visual tweaks to CloudView

* Remove hardcoded timeout

* Safer check

* PR feedback

* Fix test

* Check for org mismatches in handleCredentialsChange

* Cloud: use the existing auth event flow to handle org switching

* Cleanup: broadcast() might be confusingly named.

---------

Co-authored-by: Chris Estreich <cestreich@gmail.com>
Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Bruno Bergher <bruno@roocode.com>
Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
This commit is contained in:
Matt Rubens 2025-09-23 18:18:28 -04:00 committed by GitHub
parent 382ab63ea1
commit 0682629ac8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 529 additions and 45 deletions

View file

@ -8,6 +8,7 @@ import type {
AuthService,
SettingsService,
CloudUserInfo,
CloudOrganizationMembership,
OrganizationAllowList,
OrganizationSettings,
ShareVisibility,
@ -242,6 +243,21 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements Di
return this.authService!.handleCallback(code, state, organizationId)
}
public async switchOrganization(organizationId: string | null): Promise<void> {
this.ensureInitialized()
// Perform the organization switch
// StaticTokenAuthService will throw an error if organization switching is not supported
await this.authService!.switchOrganization(organizationId)
}
public async getOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
this.ensureInitialized()
// StaticTokenAuthService will throw an error if organization memberships are not supported
return await this.authService!.getOrganizationMemberships()
}
// SettingsService
public getAllowList(): OrganizationAllowList {

View file

@ -63,6 +63,14 @@ export class StaticTokenAuthService extends EventEmitter<AuthServiceEvents> impl
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
}
public async switchOrganization(_organizationId: string | null): Promise<void> {
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
}
public async getOrganizationMemberships(): Promise<import("@roo-code/types").CloudOrganizationMembership[]> {
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
}
public getState(): AuthState {
return this.state
}

View file

@ -141,7 +141,8 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
if (
this.credentials === null ||
this.credentials.clientToken !== credentials.clientToken ||
this.credentials.sessionId !== credentials.sessionId
this.credentials.sessionId !== credentials.sessionId ||
this.credentials.organizationId !== credentials.organizationId
) {
this.transitionToAttemptingSession(credentials)
}
@ -174,6 +175,7 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
this.changeState("attempting-session")
this.timer.stop()
this.timer.start()
}
@ -469,6 +471,42 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
return this.credentials?.organizationId || null
}
/**
* Switch to a different organization context
* @param organizationId The organization ID to switch to, or null for personal account
*/
public async switchOrganization(organizationId: string | null): Promise<void> {
if (!this.credentials) {
throw new Error("Cannot switch organization: not authenticated")
}
// Update the stored credentials with the new organization ID
const updatedCredentials: AuthCredentials = {
...this.credentials,
organizationId: organizationId,
}
// Store the updated credentials, handleCredentialsChange will handle the update
await this.storeCredentials(updatedCredentials)
}
/**
* Get all organization memberships for the current user
* @returns Array of organization memberships
*/
public async getOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
if (!this.credentials) {
return []
}
try {
return await this.clerkGetOrganizationMemberships()
} catch (error) {
this.log(`[auth] Failed to get organization memberships: ${error}`)
return []
}
}
private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
const formData = new URLSearchParams()
formData.append("strategy", "ticket")
@ -653,9 +691,14 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
}
private async clerkGetOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
if (!this.credentials) {
this.log("[auth] Cannot get organization memberships: missing credentials")
return []
}
const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
headers: {
Authorization: `Bearer ${this.credentials!.clientToken}`,
Authorization: `Bearer ${this.credentials.clientToken}`,
"User-Agent": this.userAgent(),
},
signal: AbortSignal.timeout(10000),

View file

@ -242,6 +242,7 @@ export interface AuthService extends EventEmitter<AuthServiceEvents> {
login(landingPageSlug?: string): Promise<void>
logout(): Promise<void>
handleCallback(code: string | null, state: string | null, organizationId?: string | null): Promise<void>
switchOrganization(organizationId: string | null): Promise<void>
// State methods
getState(): AuthState
@ -253,6 +254,9 @@ export interface AuthService extends EventEmitter<AuthServiceEvents> {
getSessionToken(): string | undefined
getUserInfo(): CloudUserInfo | null
getStoredOrganizationId(): string | null
// Organization management
getOrganizationMemberships(): Promise<CloudOrganizationMembership[]>
}
/**

View file

@ -30,6 +30,7 @@ import {
type TerminalActionPromptType,
type HistoryItem,
type CloudUserInfo,
type CloudOrganizationMembership,
type CreateTaskOptions,
type TokenUsage,
RooCodeEventName,
@ -1815,6 +1816,16 @@ export class ClineProvider
featureRoomoteControlEnabled,
} = await this.getState()
let cloudOrganizations: CloudOrganizationMembership[] = []
try {
cloudOrganizations = await CloudService.instance.getOrganizationMemberships()
} catch (error) {
console.error(
`[getStateToPostToWebview] failed to get cloud organizations: ${error instanceof Error ? error.message : String(error)}`,
)
}
const telemetryKey = process.env.POSTHOG_API_KEY
const machineId = vscode.env.machineId
const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands)
@ -1916,6 +1927,7 @@ export class ClineProvider
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
cloudOrganizations,
sharingEnabled: sharingEnabled ?? false,
organizationAllowList,
organizationSettingsVersion,

View file

@ -2379,6 +2379,38 @@ export const webviewMessageHandler = async (
break
}
case "switchOrganization": {
try {
const organizationId = message.organizationId ?? null
// Switch to the new organization context
await CloudService.instance.switchOrganization(organizationId)
// Refresh the state to update UI
await provider.postStateToWebview()
// Send success response back to webview
await provider.postMessageToWebview({
type: "organizationSwitchResult",
success: true,
organizationId: organizationId,
})
} catch (error) {
provider.log(`Organization switch failed: ${error}`)
const errorMessage = error instanceof Error ? error.message : String(error)
// Send error response back to webview
await provider.postMessageToWebview({
type: "organizationSwitchResult",
success: false,
error: errorMessage,
organizationId: message.organizationId ?? null,
})
vscode.window.showErrorMessage(`Failed to switch organization: ${errorMessage}`)
}
break
}
case "saveCodeIndexSettingsAtomic": {
if (!message.codeIndexSettings) {

View file

@ -10,6 +10,7 @@ import type {
MarketplaceItem,
TodoItem,
CloudUserInfo,
CloudOrganizationMembership,
OrganizationAllowList,
ShareVisibility,
QueuedMessage,
@ -124,6 +125,7 @@ export interface ExtensionMessage {
| "commands"
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "organizationSwitchResult"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@ -202,6 +204,7 @@ export interface ExtensionMessage {
commands?: Command[]
queuedMessages?: QueuedMessage[]
list?: string[] // For dismissedUpsells
organizationId?: string | null // For organizationSwitchResult
}
export type ExtensionState = Pick<
@ -327,6 +330,7 @@ export type ExtensionState = Pick<
cloudUserInfo: CloudUserInfo | null
cloudIsAuthenticated: boolean
cloudApiUrl?: string
cloudOrganizations?: CloudOrganizationMembership[]
sharingEnabled: boolean
organizationAllowList: OrganizationAllowList
organizationSettingsVersion?: number

View file

@ -184,6 +184,7 @@ export interface WebviewMessage {
| "cloudLandingPageSignIn"
| "rooCloudSignOut"
| "rooCloudManualUrl"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
| "startIndexing"
@ -274,6 +275,7 @@ export interface WebviewMessage {
checkOnly?: boolean // For deleteCustomMode check
upsellId?: string // For dismissUpsell
list?: string[] // For dismissedUpsells response
organizationId?: string | null // For organization switching
codeIndexSettings?: {
// Global state settings
codebaseIndexEnabled: boolean

View file

@ -76,6 +76,7 @@ const App = () => {
cloudUserInfo,
cloudIsAuthenticated,
cloudApiUrl,
cloudOrganizations,
renderContext,
mdmCompliant,
} = useExtensionState()
@ -267,6 +268,7 @@ const App = () => {
userInfo={cloudUserInfo}
isAuthenticated={cloudIsAuthenticated}
cloudApiUrl={cloudApiUrl}
organizations={cloudOrganizations}
onDone={() => switchTab("chat")}
/>
)}

View file

@ -31,6 +31,7 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { usePromptHistory } from "./hooks/usePromptHistory"
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
interface ChatTextAreaProps {
inputValue: string
@ -87,6 +88,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
taskHistory,
clineMessages,
commands,
cloudUserInfo,
cloudOrganizations,
} = useExtensionState()
// Find the ID and display text for the currently selected API configuration.
@ -1232,7 +1235,13 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
</div>
<div className="flex flex-shrink-0 items-center gap-0.5 pr-2">
<div
className={cn(
"flex flex-shrink-0 items-center gap-0.5",
!isEditMode && cloudOrganizations && cloudOrganizations.length > 0 && cloudUserInfo
? ""
: "pr-2",
)}>
{isTtsPlaying && (
<StandardTooltip content={t("chat:stopTts")}>
<button
@ -1254,6 +1263,9 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</StandardTooltip>
)}
{!isEditMode ? <IndexingStatusBadge /> : null}
{!isEditMode && cloudOrganizations && cloudOrganizations.length > 0 && cloudUserInfo && (
<CloudAccountSwitcher />
)}
</div>
</div>
</div>

View file

@ -0,0 +1,149 @@
import { useState, useEffect } from "react"
import { Building2 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectSeparator } from "@/components/ui/select"
import { StandardTooltip } from "@src/components/ui"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { cn } from "@src/lib/utils"
export const CloudAccountSwitcher = () => {
const { t } = useAppTranslation()
const { cloudUserInfo, cloudOrganizations = [] } = useExtensionState()
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(cloudUserInfo?.organizationId || null)
const [isLoading, setIsLoading] = useState(false)
// Update selected org when userInfo changes
useEffect(() => {
setSelectedOrgId(cloudUserInfo?.organizationId || null)
}, [cloudUserInfo?.organizationId])
// Don't show the switcher if user has no organizations
if (!cloudOrganizations || cloudOrganizations.length === 0 || !cloudUserInfo) {
return null
}
const handleOrganizationChange = async (value: string) => {
const newOrgId = value === "personal" ? null : value
// Don't do anything if selecting the same organization
if (newOrgId === selectedOrgId) {
return
}
setIsLoading(true)
// Send message to switch organization
vscode.postMessage({
type: "switchOrganization",
organizationId: newOrgId,
})
// Update local state optimistically
setSelectedOrgId(newOrgId)
// Reset loading state after a delay
setTimeout(() => {
setIsLoading(false)
}, 1000)
}
const currentValue = selectedOrgId || "personal"
const currentOrg = cloudOrganizations.find((org) => org.organization.id === selectedOrgId)
// Render the account icon based on current context
const renderAccountIcon = () => {
if (selectedOrgId && currentOrg?.organization.image_url) {
// Organization with logo
return (
<img
src={currentOrg.organization.image_url}
alt={currentOrg.organization.name}
className="w-5 h-5 rounded object-cover"
/>
)
} else if (selectedOrgId) {
// Organization without logo
return <Building2 className="w-4.5 h-4.5" />
} else if (cloudUserInfo.picture) {
// Personal account with avatar
return (
<img
src={cloudUserInfo.picture}
alt={cloudUserInfo.name || cloudUserInfo.email}
className="w-5 h-5 rounded-full object-cover"
/>
)
} else {
// Personal account without avatar - show initials
const initial = cloudUserInfo.name?.charAt(0) || cloudUserInfo.email?.charAt(0) || "?"
return (
<div className="w-5 h-5 rounded-full flex items-center justify-center bg-vscode-button-background text-vscode-button-foreground text-xs">
{initial}
</div>
)
}
}
return (
<StandardTooltip content={t("cloud:switchAccount")}>
<div className="inline-block ml-1">
<Select value={currentValue} onValueChange={handleOrganizationChange} disabled={isLoading}>
<SelectTrigger
className={cn(
"h-4.5 w-4.5 p-0 gap-0",
"bg-transparent opacity-90 hover:opacity-50",
"flex items-center justify-center",
"rounded-lg overflow-clip",
"border border-vscode-dropdown-border",
"[&>svg]:hidden", // Hide the default chevron/caret
isLoading && "opacity-50",
)}
aria-label={selectedOrgId ? currentOrg?.organization.name : t("cloud:personalAccount")}>
{renderAccountIcon()}
</SelectTrigger>
<SelectContent>
{/* Personal Account Option */}
<SelectItem value="personal">
<div className="flex items-center gap-2">
{cloudUserInfo.picture ? (
<img
src={cloudUserInfo.picture}
alt={cloudUserInfo.name || cloudUserInfo.email}
className="w-4.5 h-4.5 rounded-full object-cover overflow-clip"
/>
) : (
<div className="w-4.5 h-4.5 rounded-full flex items-center justify-center bg-vscode-button-background text-vscode-button-foreground text-xs">
{cloudUserInfo.name?.charAt(0) || cloudUserInfo.email?.charAt(0) || "?"}
</div>
)}
<span>{t("cloud:personalAccount")}</span>
</div>
</SelectItem>
{cloudOrganizations.length > 0 && <SelectSeparator />}
{/* Organization Options */}
{cloudOrganizations.map((org) => (
<SelectItem key={org.organization.id} value={org.organization.id}>
<div className="flex items-center gap-2">
{org.organization.image_url ? (
<img
src={org.organization.image_url}
alt=""
className="w-4.5 h-4.5 rounded-full object-cover overflow-clip"
/>
) : (
<Building2 className="w-4.5 h-4.5" />
)}
<span className="truncate">{org.organization.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</StandardTooltip>
)
}

View file

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"
import { VSCodeButton, VSCodeProgressRing, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { type CloudUserInfo, TelemetryEventName } from "@roo-code/types"
import { type CloudUserInfo, type CloudOrganizationMembership, TelemetryEventName } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
@ -9,10 +9,12 @@ import { vscode } from "@src/utils/vscode"
import { telemetryClient } from "@src/utils/TelemetryClient"
import { ToggleSwitch } from "@/components/ui/toggle-switch"
import { renderCloudBenefitsContent } from "./CloudUpsellDialog"
import { TriangleAlert } from "lucide-react"
import { CircleAlert, Info, Lock, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { Button } from "@/components/ui/button"
import { OrganizationSwitcher } from "./OrganizationSwitcher"
import { StandardTooltip } from "../ui"
// Define the production URL constant locally to avoid importing from cloud package in tests
const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com"
@ -22,9 +24,10 @@ type CloudViewProps = {
isAuthenticated: boolean
cloudApiUrl?: string
onDone: () => void
organizations?: CloudOrganizationMembership[]
}
export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: CloudViewProps) => {
export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone, organizations = [] }: CloudViewProps) => {
const { t } = useAppTranslation()
const {
remoteControlEnabled,
@ -161,11 +164,11 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
<Button onClick={onDone}>{t("settings:common.done")}</Button>
</TabHeader>
<TabContent>
<TabContent className="pt-10">
{isAuthenticated ? (
<>
{userInfo && (
<div className="flex flex-col items-center mb-6">
<div className="flex flex-col items-start ml-4 mb-6">
<div className="w-16 h-16 mb-3 rounded-full overflow-hidden">
{userInfo?.picture ? (
<img
@ -185,23 +188,18 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
{userInfo?.email && (
<p className="text-sm text-vscode-descriptionForeground my-0">{userInfo?.email}</p>
)}
{userInfo?.organizationName && (
<div className="flex items-center gap-2 text-sm text-vscode-descriptionForeground mt-2">
{userInfo.organizationImageUrl && (
<img
src={userInfo.organizationImageUrl}
alt={userInfo.organizationName}
className="w-4 h-4 rounded object-cover"
/>
)}
<span>{userInfo.organizationName}</span>
{/* Organization Switcher - moved below email */}
{organizations && organizations.length > 0 && (
<div className="w-full max-w-60 mt-4">
<OrganizationSwitcher userInfo={userInfo} organizations={organizations} />
</div>
)}
</div>
)}
{/* Task Sync Toggle - Always shown when authenticated */}
<div className="border-t border-vscode-widget-border pt-4 mt-4">
<div className="mt-4 p-4 border-b border-t border-vscode-widget-border pl-4 max-w-140">
<div className="flex items-center gap-3 mb-2">
<ToggleSwitch
checked={taskSyncEnabled}
@ -211,21 +209,25 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
data-testid="task-sync-toggle"
disabled={!!userInfo?.organizationId}
/>
<span className="font-medium text-vscode-foreground">{t("cloud:taskSync")}</span>
<span className="font-medium text-vscode-foreground flex items-center">
{t("cloud:taskSync")}
{userInfo?.organizationId && (
<StandardTooltip content={t("cloud:taskSyncManagedByOrganization")}>
<div className="bg-vscode-badge-background text-vscode-badge-foreground/80 p-1.5 ml-2 -mb-2 relative -top-1 rounded-full inline-block cursor-help">
<Lock className="size-3 block" />
</div>
</StandardTooltip>
)}
</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-4 ml-8">
<div className="text-vscode-descriptionForeground text-sm mt-1 ml-8">
{t("cloud:taskSyncDescription")}
</div>
{userInfo?.organizationId && (
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-4 ml-8 italic">
{t("cloud:taskSyncManagedByOrganization")}
</div>
)}
{/* Remote Control Toggle - Only shown when both extensionBridgeEnabled and featureRoomoteControlEnabled are true */}
{userInfo?.extensionBridgeEnabled && featureRoomoteControlEnabled && (
<>
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-3 mt-4 mb-2">
<ToggleSwitch
checked={remoteControlEnabled}
onChange={handleRemoteControlToggle}
@ -238,37 +240,42 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
{t("cloud:remoteControl")}
</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-4 ml-8">
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-2 ml-8">
{t("cloud:remoteControlDescription")}
{!taskSyncEnabled && (
<div className="text-vscode-errorForeground mt-2">
<div className="text-vscode-editorWarning-foreground mt-2">
<CircleAlert className="inline size-3 mr-1 mb-0.5 text-vscode-editorWarning-foreground" />
{t("cloud:remoteControlRequiresTaskSync")}
</div>
)}
</div>
</>
)}
{/* Info text about usage metrics */}
<div className="text-vscode-descriptionForeground text-sm mt-4 mb-4 ml-8 italic">
{t("cloud:usageMetricsAlwaysReported")}
</div>
<hr className="border-vscode-widget-border mb-4" />
</div>
<div className="flex flex-col gap-2 mt-4">
<VSCodeButton appearance="secondary" onClick={handleVisitCloudWebsite} className="w-full">
<div className="text-vscode-descriptionForeground text-sm mt-4 mb-8 pl-4">
<Info className="inline size-3 mr-1 mb-0.5 text-vscode-descriptionForeground" />
{t("cloud:usageMetricsAlwaysReported")}
</div>
<div className="flex flex-col gap-2 mt-4 pl-4">
<VSCodeButton
appearance="secondary"
onClick={handleVisitCloudWebsite}
className="w-full max-w-80">
{t("cloud:visitCloudWebsite")}
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={handleLogoutClick} className="w-full">
<VSCodeButton
appearance="secondary"
onClick={handleLogoutClick}
className="w-full max-w-80">
{t("cloud:logOut")}
</VSCodeButton>
</div>
</>
) : (
<>
<div className="flex flex-col items-start gap-4 px-8">
<div className="flex flex-col items-start gap-4 px-8 max-w-100">
<div className={cn(authInProgress && "opacity-50")}>{renderCloudBenefitsContent(t)}</div>
{!authInProgress && (
@ -323,10 +330,10 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, onDone }: Cl
</>
)}
{cloudApiUrl && cloudApiUrl !== PRODUCTION_ROO_CODE_API_URL && (
<div className="ml-8 mt-6 flex justify-start">
<div className="ml-4 mt-6 flex">
<div className="inline-flex items-center gap-2 text-xs">
<TriangleAlert className="size-4 text-vscode-descriptionForeground" />
<span className="text-vscode-foreground/75">{t("cloud:cloudUrlPillLabel")}: </span>
<TriangleAlert className="size-3 text-vscode-descriptionForeground" />
<span className="text-vscode-foreground/75">{t("cloud:cloudUrlPillLabel")} </span>
<button
onClick={handleOpenCloudUrl}
className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground underline cursor-pointer bg-transparent border-none p-0">

View file

@ -0,0 +1,146 @@
import { useState, useEffect } from "react"
import { Building2, User } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectSeparator } from "@/components/ui/select"
import { type CloudUserInfo, type CloudOrganizationMembership } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { vscode } from "@src/utils/vscode"
import { type ExtensionMessage } from "@roo/ExtensionMessage"
type OrganizationSwitcherProps = {
userInfo: CloudUserInfo
organizations: CloudOrganizationMembership[]
onOrganizationChange?: (organizationId: string | null) => void
}
export const OrganizationSwitcher = ({ userInfo, organizations, onOrganizationChange }: OrganizationSwitcherProps) => {
const { t } = useAppTranslation()
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(userInfo.organizationId || null)
const [isLoading, setIsLoading] = useState(false)
// Update selected org when userInfo changes
useEffect(() => {
setSelectedOrgId(userInfo.organizationId || null)
}, [userInfo.organizationId])
// Listen for organization switch results
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data as ExtensionMessage
if (message.type === "organizationSwitchResult") {
// Reset loading state when we receive the result
setIsLoading(false)
if (message.success) {
// Update selected org based on the result
setSelectedOrgId(message.organizationId ?? null)
} else {
// Revert to the previous organization on error
setSelectedOrgId(userInfo.organizationId || null)
}
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [userInfo.organizationId])
const handleOrganizationChange = async (value: string) => {
const newOrgId = value === "personal" ? null : value
// Don't do anything if selecting the same organization
if (newOrgId === selectedOrgId) {
return
}
setIsLoading(true)
// Send message to switch organization
vscode.postMessage({
type: "switchOrganization",
organizationId: newOrgId,
})
// Update local state optimistically
setSelectedOrgId(newOrgId)
// Call the callback if provided
if (onOrganizationChange) {
onOrganizationChange(newOrgId)
}
}
// If user has no organizations, don't show the switcher
if (!organizations || organizations.length === 0) {
return null
}
const currentValue = selectedOrgId || "personal"
return (
<div className="w-full">
<Select value={currentValue} onValueChange={handleOrganizationChange} disabled={isLoading}>
<SelectTrigger className="w-full">
<SelectValue>
<div className="flex items-center gap-2">
{selectedOrgId ? (
<>
{organizations.find((org) => org.organization.id === selectedOrgId)?.organization
.image_url ? (
<img
src={
organizations.find((org) => org.organization.id === selectedOrgId)
?.organization.image_url
}
alt=""
className="w-4.5 h-4.5 rounded-full object-cover overflow-clip"
/>
) : (
<Building2 className="w-4.5 h-4.5" />
)}
<span className="truncate">
{
organizations.find((org) => org.organization.id === selectedOrgId)
?.organization.name
}
</span>
</>
) : (
<>
<div className="p-0.5 bg-vscode-button-background rounded-full flex items-center justify-center text-vscode-button-foreground text-xs">
<User className="w-4 h-4 text-vscode-button-foreground" />
</div>
<span>{t("cloud:personalAccount")}</span>
</>
)}
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="personal">
<div className="flex items-center gap-2">
<User className="w-4.5 h-4.5" />
<span>{t("cloud:personalAccount")}</span>
</div>
</SelectItem>
{organizations.length > 0 && <SelectSeparator />}
{organizations.map((org) => (
<SelectItem key={org.organization.id} value={org.organization.id}>
<div className="flex items-center gap-2">
{org.organization.image_url ? (
<img
src={org.organization.image_url}
alt=""
className="w-4.5 h-4.5 rounded-full object-cover overflow-clip"
/>
) : (
<Building2 className="w-4.5 h-4.5" />
)}
<span className="truncate">{org.organization.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -308,8 +308,15 @@ describe("CloudView", () => {
expect(taskSyncToggle).toBeInTheDocument()
expect(taskSyncToggle).toHaveAttribute("tabindex", "-1")
// Check that the organization message is displayed
expect(screen.getByText("Task sync is managed by your organization")).toBeInTheDocument()
// Check that the lock icon is displayed (indicating organization control)
const lockIcon = screen.getByTestId("task-sync-toggle").parentElement?.querySelector(".lucide-lock")
expect(lockIcon).toBeInTheDocument()
// Check that the tooltip trigger is present (which contains the organization message)
const tooltipTrigger = screen
.getByTestId("task-sync-toggle")
.parentElement?.querySelector('[data-slot="tooltip-trigger"]')
expect(tooltipTrigger).toBeInTheDocument()
})
it("should enable task sync toggle for non-organization users", () => {

View file

@ -9,6 +9,7 @@ import {
type TodoItem,
type TelemetrySetting,
type OrganizationAllowList,
type CloudOrganizationMembership,
ORGANIZATION_ALLOW_ALL,
} from "@roo-code/types"
@ -39,6 +40,7 @@ export interface ExtensionStateContextType extends ExtensionState {
organizationAllowList: OrganizationAllowList
organizationSettingsVersion: number
cloudIsAuthenticated: boolean
cloudOrganizations?: CloudOrganizationMembership[]
sharingEnabled: boolean
maxConcurrentFileReads?: number
mdmCompliant?: boolean
@ -240,6 +242,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
historyPreviewCollapsed: false, // Initialize the new state (default to expanded)
cloudUserInfo: null,
cloudIsAuthenticated: false,
cloudOrganizations: [],
sharingEnabled: false,
organizationAllowList: ORGANIZATION_ALLOW_ALL,
organizationSettingsVersion: -1,
@ -428,6 +431,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
screenshotQuality: state.screenshotQuality,
routerModels: extensionRouterModels,
cloudIsAuthenticated: state.cloudIsAuthenticated ?? false,
cloudOrganizations: state.cloudOrganizations ?? [],
organizationSettingsVersion: state.organizationSettingsVersion ?? -1,
marketplaceItems,
marketplaceInstalledMetadata,

View file

@ -23,6 +23,8 @@
"havingTrouble": "Tens problemes?",
"pasteCallbackUrl": "Copia l'URL de redirect del teu navegador i enganxa-la aquí:",
"startOver": "Torna a començar",
"personalAccount": "Compte Personal",
"switchAccount": "Canviar Compte de Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Donant-li una mica d'independència a Roo? Controla'l des de qualsevol lloc amb Roo Code Cloud. <learnMoreLink>Més informació</learnMoreLink>.",
"longRunningTask": "Això pot trigar una estona. Continua des de qualsevol lloc amb Cloud.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Probleme?",
"pasteCallbackUrl": "Kopiere die Redirect-URL aus deinem Browser und füge sie hier ein:",
"startOver": "Von vorne beginnen",
"personalAccount": "Persönliches Konto",
"switchAccount": "Roo Code Cloud Konto wechseln",
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "Roo etwas Unabhängigkeit geben? Kontrolliere es von überall mit Roo Code Cloud. <learnMoreLink>Mehr erfahren</learnMoreLink>.",

View file

@ -23,6 +23,8 @@
"havingTrouble": "Having trouble?",
"pasteCallbackUrl": "Copy the redirect URL from your browser and paste it here:",
"startOver": "Start over",
"personalAccount": "Personal Account",
"switchAccount": "Switch Roo Code Cloud Account",
"upsell": {
"autoApprovePowerUser": "Giving Roo some independence? Control it from anywhere with Roo Code Cloud. <learnMoreLink>Learn more</learnMoreLink>.",
"longRunningTask": "This might take a while. Continue from anywhere with Cloud.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "¿Tienes problemas?",
"pasteCallbackUrl": "Copia la URL de redirect desde tu navegador y pégala aquí:",
"startOver": "Empezar de nuevo",
"personalAccount": "Cuenta Personal",
"switchAccount": "Cambiar Cuenta de Roo Code Cloud",
"cloudUrlPillLabel": "URL de Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "¿Dándole a Roo un poco de independencia? Contrólalo desde cualquier lugar con Roo Code Cloud. <learnMoreLink>Saber más</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Des difficultés ?",
"pasteCallbackUrl": "Copie l'URL de redirect depuis ton navigateur et colle-la ici :",
"startOver": "Recommencer",
"personalAccount": "Compte Personnel",
"switchAccount": "Changer de Compte Roo Code Cloud",
"cloudUrlPillLabel": "URL de Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Donner à Roo un peu d'indépendance ? Contrôlez-le de n'importe où avec Roo Code Cloud. <learnMoreLink>En savoir plus</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "समस्या हो रही है?",
"pasteCallbackUrl": "अपने ब्राउज़र से redirect URL कॉपी करें और यहाँ पेस्ट करें:",
"startOver": "फिर से शुरू करें",
"personalAccount": "व्यक्तिगत खाता",
"switchAccount": "Roo Code Cloud खाता बदलें",
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "रू को थोड़ी स्वतंत्रता दे रहे हैं? रू कोड क्लाउड के साथ इसे कहीं से भी नियंत्रित करें। <learnMoreLink>और जानें</learnMoreLink>।",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Ada masalah?",
"pasteCallbackUrl": "Salin URL redirect dari browser dan tempel di sini:",
"startOver": "Mulai dari awal",
"personalAccount": "Akun Pribadi",
"switchAccount": "Ganti Akun Roo Code Cloud",
"cloudUrlPillLabel": "URL Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Memberi Roo sedikit kebebasan? Kendalikan dari mana saja dengan Roo Code Cloud. <learnMoreLink>Pelajari lebih lanjut</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Hai problemi?",
"pasteCallbackUrl": "Copia l'URL di redirect dal tuo browser e incollalo qui:",
"startOver": "Ricomincia",
"personalAccount": "Account Personale",
"switchAccount": "Cambia Account Roo Code Cloud",
"cloudUrlPillLabel": "URL di Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Vuoi dare un po' di indipendenza a Roo? Controllalo da qualsiasi luogo con Roo Code Cloud. <learnMoreLink>Scopri di più</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "問題が発生していますか?",
"pasteCallbackUrl": "ブラウザからリダイレクトURLをコピーし、ここに貼り付けてください",
"startOver": "最初からやり直す",
"personalAccount": "個人アカウント",
"switchAccount": "Roo Code Cloud アカウントを切り替え",
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "Rooに少し独立性を与えませんかRoo Code Cloudでどこからでもコントロールできます。<learnMoreLink>詳細</learnMoreLink>。",

View file

@ -22,6 +22,8 @@
"havingTrouble": "문제가 있나요?",
"pasteCallbackUrl": "브라우저에서 리다이렉트 URL을 복사하여 여기에 붙여넣으세요:",
"startOver": "다시 시작",
"personalAccount": "개인 계정",
"switchAccount": "Roo Code Cloud 계정 전환",
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "Roo에게 약간의 독립성을 부여하시겠습니까? Roo Code Cloud로 어디서든 제어하세요. <learnMoreLink>더 알아보기</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Problemen?",
"pasteCallbackUrl": "Kopieer de redirect-URL uit je browser en plak hem hier:",
"startOver": "Opnieuw beginnen",
"personalAccount": "Persoonlijk Account",
"switchAccount": "Wissel van Roo Code Cloud Account",
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "Roo wat onafhankelijkheid geven? Bedien het overal met Roo Code Cloud. <learnMoreLink>Meer informatie</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Masz problemy?",
"pasteCallbackUrl": "Skopiuj URL redirect z przeglądarki i wklej tutaj:",
"startOver": "Zacznij od nowa",
"personalAccount": "Konto Osobiste",
"switchAccount": "Przełącz Konto Roo Code Cloud",
"cloudUrlPillLabel": "URL Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Dać Roo trochę niezależności? Kontroluj go z dowolnego miejsca dzięki Roo Code Cloud. <learnMoreLink>Dowiedz się więcej</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Tendo problemas?",
"pasteCallbackUrl": "Copie a URL de redirect do seu navegador e cole aqui:",
"startOver": "Recomeçar",
"personalAccount": "Conta Pessoal",
"switchAccount": "Alternar Conta do Roo Code Cloud",
"cloudUrlPillLabel": "URL do Roo Code Cloud ",
"upsell": {
"autoApprovePowerUser": "Dando um pouco de independência ao Roo? Controle-o de qualquer lugar com o Roo Code Cloud. <learnMoreLink>Saiba mais</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Проблемы?",
"pasteCallbackUrl": "Скопируй URL перенаправления из браузера и вставь его сюда:",
"startOver": "Начать заново",
"personalAccount": "Личный аккаунт",
"switchAccount": "Переключить аккаунт Roo Code Cloud",
"cloudUrlPillLabel": "URL Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Предоставить Roo немного независимости? Управляйте им из любого места с помощью Roo Code Cloud. <learnMoreLink>Узнать больше</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Sorun yaşıyor musun?",
"pasteCallbackUrl": "Tarayıcından redirect URL'sini kopyala ve buraya yapıştır:",
"startOver": "Baştan başla",
"personalAccount": "Kişisel Hesap",
"switchAccount": "Roo Code Cloud Hesabını Değiştir",
"cloudUrlPillLabel": "Roo Code Cloud URL'si",
"upsell": {
"autoApprovePowerUser": "Roo'ya biraz bağımsızlık mı veriyorsunuz? Roo Code Cloud ile onu her yerden kontrol edin. <learnMoreLink>Daha fazla bilgi edinin</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "Gặp vấn đề?",
"pasteCallbackUrl": "Sao chép URL redirect từ trình duyệt và dán vào đây:",
"startOver": "Bắt đầu lại",
"personalAccount": "Tài Khoản Cá Nhân",
"switchAccount": "Chuyển Tài Khoản Roo Code Cloud",
"cloudUrlPillLabel": "URL Roo Code Cloud",
"upsell": {
"autoApprovePowerUser": "Trao cho Roo một chút độc lập? Kiểm soát nó từ mọi nơi với Roo Code Cloud. <learnMoreLink>Tìm hiểu thêm</learnMoreLink>.",

View file

@ -22,6 +22,8 @@
"havingTrouble": "遇到问题?",
"pasteCallbackUrl": "从浏览器复制重定向 URL 并粘贴到这里:",
"startOver": "重新开始",
"personalAccount": "个人账户",
"switchAccount": "切换 Roo Code Cloud 账户",
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "给 Roo 一些独立性?使用 Roo Code Cloud 从任何地方控制它。 <learnMoreLink>了解更多</learnMoreLink>。",

View file

@ -22,6 +22,8 @@
"havingTrouble": "遇到問題?",
"pasteCallbackUrl": "從瀏覽器複製重新導向 URL 並貼上到這裡:",
"startOver": "重新開始",
"personalAccount": "個人帳戶",
"switchAccount": "切換 Roo Code Cloud 帳戶",
"cloudUrlPillLabel": "Roo Code Cloud URL",
"upsell": {
"autoApprovePowerUser": "給 Roo 一點獨立性?使用 Roo Code Cloud 隨時隨地控制它。<learnMoreLink>了解更多</learnMoreLink>。",