diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx
new file mode 100644
index 000000000..17ac15824
--- /dev/null
+++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx
@@ -0,0 +1,205 @@
+import { Theme } from "@radix-ui/themes";
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+type AuthState = {
+ cloudRegion: string | null;
+ orgProjectsMap: Record<
+ string,
+ { orgName: string; projects: { id: number; name: string }[] }
+ >;
+ currentOrgId: string | null;
+};
+
+let mockAuthState: AuthState = {
+ cloudRegion: "us",
+ orgProjectsMap: {},
+ currentOrgId: null,
+};
+
+const switchOrgMutate = vi.fn();
+const selectProjectMutate = vi.fn();
+const logoutMutate = vi.fn();
+const openSettings = vi.fn();
+const navigateToTaskInput = vi.fn();
+let switchOrgPending = false;
+
+vi.mock("@features/auth/hooks/authClient", () => ({
+ useOptionalAuthenticatedClient: () => null,
+}));
+
+vi.mock("@features/auth/hooks/authMutations", () => ({
+ useLogoutMutation: () => ({ mutate: logoutMutate }),
+ useSelectProjectMutation: () => ({ mutate: selectProjectMutate }),
+ useSwitchOrgMutation: () => ({
+ mutate: switchOrgMutate,
+ isPending: switchOrgPending,
+ }),
+}));
+
+vi.mock("@stores/navigationStore", () => ({
+ useNavigationStore: {
+ getState: () => ({ navigateToTaskInput }),
+ },
+}));
+
+vi.mock("@features/auth/hooks/authQueries", () => ({
+ useAuthStateValue: (selector: (state: AuthState) => unknown) =>
+ selector(mockAuthState),
+ useCurrentUser: () => ({
+ data: { email: "user@example.com", first_name: "Test", last_name: "User" },
+ }),
+}));
+
+vi.mock("@features/projects/hooks/useProjects", () => ({
+ useProjects: () => ({
+ groupedProjects: [],
+ currentProject: { id: 42, name: "Demo project" },
+ currentProjectId: 42,
+ }),
+}));
+
+vi.mock("@features/settings/stores/settingsDialogStore", () => ({
+ useSettingsDialogStore: (
+ selector: (state: { open: typeof openSettings }) => unknown,
+ ) => selector({ open: openSettings }),
+}));
+
+vi.mock("@renderer/trpc/client", () => ({
+ trpcClient: {
+ os: { openExternal: { mutate: vi.fn() } },
+ },
+}));
+
+import { ProjectSwitcher } from "./ProjectSwitcher";
+
+function renderInTheme() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ProjectSwitcher org switcher", () => {
+ beforeEach(() => {
+ switchOrgMutate.mockReset();
+ selectProjectMutate.mockReset();
+ logoutMutate.mockReset();
+ openSettings.mockReset();
+ navigateToTaskInput.mockReset();
+ switchOrgPending = false;
+ });
+
+ it("hides the Switch organization submenu when there is only one org", async () => {
+ mockAuthState = {
+ cloudRegion: "us",
+ currentOrgId: "org-1",
+ orgProjectsMap: {
+ "org-1": { orgName: "Solo Org", projects: [{ id: 42, name: "P1" }] },
+ },
+ };
+
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderInTheme();
+
+ await user.click(screen.getByText("Demo project"));
+
+ expect(screen.queryByText("Switch organization")).not.toBeInTheDocument();
+ });
+
+ it("shows the submenu with every org and marks the current one", async () => {
+ mockAuthState = {
+ cloudRegion: "us",
+ currentOrgId: "org-1",
+ orgProjectsMap: {
+ "org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
+ "org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
+ },
+ };
+
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderInTheme();
+
+ await user.click(screen.getByText("Demo project"));
+ await user.hover(await screen.findByText("Switch organization"));
+
+ const alpha = await screen.findByRole("menuitem", { name: /Alpha/ });
+ const beta = await screen.findByRole("menuitem", { name: /Beta/ });
+ expect(alpha).toBeInTheDocument();
+ expect(beta).toBeInTheDocument();
+
+ expect(alpha.querySelector(".text-accent-11")).not.toBeNull();
+ expect(beta.querySelector(".text-accent-11")).toBeNull();
+ });
+
+ it("fires switchOrg.mutate when picking a different org and navigates on success", async () => {
+ mockAuthState = {
+ cloudRegion: "us",
+ currentOrgId: "org-1",
+ orgProjectsMap: {
+ "org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
+ "org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
+ },
+ };
+
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderInTheme();
+
+ await user.click(screen.getByText("Demo project"));
+ await user.hover(await screen.findByText("Switch organization"));
+ fireEvent.click(await screen.findByRole("menuitem", { name: /Beta/ }));
+
+ expect(switchOrgMutate).toHaveBeenCalledTimes(1);
+ expect(switchOrgMutate).toHaveBeenCalledWith(
+ "org-2",
+ expect.objectContaining({ onSuccess: expect.any(Function) }),
+ );
+
+ const onSuccess = switchOrgMutate.mock.calls[0]?.[1]?.onSuccess;
+ onSuccess?.();
+ expect(navigateToTaskInput).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips switchOrg.mutate while a previous switch is pending", async () => {
+ switchOrgPending = true;
+ mockAuthState = {
+ cloudRegion: "us",
+ currentOrgId: "org-1",
+ orgProjectsMap: {
+ "org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
+ "org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
+ },
+ };
+
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderInTheme();
+
+ await user.click(screen.getByText("Demo project"));
+ await user.hover(await screen.findByText("Switch organization"));
+ fireEvent.click(await screen.findByRole("menuitem", { name: /Beta/ }));
+
+ expect(switchOrgMutate).not.toHaveBeenCalled();
+ });
+
+ it("does not call switchOrg when clicking the active org", async () => {
+ mockAuthState = {
+ cloudRegion: "us",
+ currentOrgId: "org-1",
+ orgProjectsMap: {
+ "org-1": { orgName: "Alpha", projects: [{ id: 1, name: "P1" }] },
+ "org-2": { orgName: "Beta", projects: [{ id: 2, name: "P2" }] },
+ },
+ };
+
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderInTheme();
+
+ await user.click(screen.getByText("Demo project"));
+ await user.hover(await screen.findByText("Switch organization"));
+ fireEvent.click(await screen.findByRole("menuitem", { name: /Alpha/ }));
+
+ expect(switchOrgMutate).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx
index 08d182b22..c9340619e 100644
--- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx
+++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx
@@ -2,6 +2,7 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"
import {
useLogoutMutation,
useSelectProjectMutation,
+ useSwitchOrgMutation,
} from "@features/auth/hooks/authMutations";
import {
useAuthStateValue,
@@ -12,6 +13,8 @@ import { useProjects } from "@features/projects/hooks/useProjects";
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
import {
ArrowSquareOut,
+ Building,
+ Buildings,
Check,
DiscordLogo,
FolderSimple,
@@ -52,25 +55,38 @@ import {
import { Box } from "@radix-ui/themes";
import { trpcClient } from "@renderer/trpc/client";
import { getCloudUrlFromRegion } from "@shared/utils/urls";
+import { useNavigationStore } from "@stores/navigationStore";
import { EXTERNAL_LINKS } from "@utils/links";
import { isMac } from "@utils/platform";
import { ChevronRightIcon } from "lucide-react";
-import { useState } from "react";
+import { useMemo, useState } from "react";
type ProjectInfo = { id: number; name: string };
type ProjectGroup = ReturnType["groupedProjects"][number];
+type OrgEntry = Pick;
export function ProjectSwitcher() {
const [popoverOpen, setPopoverOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
+ const orgProjectsMap = useAuthStateValue((state) => state.orgProjectsMap);
+ const currentOrgId = useAuthStateValue((state) => state.currentOrgId);
const client = useOptionalAuthenticatedClient();
const { data: currentUser } = useCurrentUser({ client });
const selectProjectMutation = useSelectProjectMutation();
+ const switchOrgMutation = useSwitchOrgMutation();
const logoutMutation = useLogoutMutation();
const { groupedProjects, currentProject, currentProjectId } = useProjects();
+ const orgs = useMemo(
+ () =>
+ Object.entries(orgProjectsMap)
+ .map(([orgId, { orgName }]) => ({ orgId, orgName }))
+ .sort((a, b) => a.orgName.localeCompare(b.orgName)),
+ [orgProjectsMap],
+ );
+
const handleProjectSelect = (projectId: number) => {
if (projectId !== currentProjectId) {
selectProjectMutation.mutate(projectId);
@@ -123,6 +139,18 @@ export function ProjectSwitcher() {
logoutMutation.mutate();
};
+ const handleSwitchOrg = (orgId: string) => {
+ setPopoverOpen(false);
+ if (orgId === currentOrgId || switchOrgMutation.isPending) {
+ return;
+ }
+ switchOrgMutation.mutate(orgId, {
+ onSuccess: () => {
+ useNavigationStore.getState().navigateToTaskInput();
+ },
+ });
+ };
+
return (
<>
@@ -185,6 +213,29 @@ export function ProjectSwitcher() {
Change project
+ {orgs.length > 1 && (
+
+
+
+ Switch organization
+
+
+ {orgs.map((org) => (
+ handleSwitchOrg(org.orgId)}
+ >
+
+ {org.orgName}
+ {org.orgId === currentOrgId && (
+
+ )}
+
+ ))}
+
+
+ )}
+
Create project