From c7b8b76d026a07419fc7e1bf7719b21e79bfaf26 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 16:03:28 -0700 Subject: [PATCH 1/4] add switch organization submenu --- .../components/ProjectSwitcher.test.tsx | 163 ++++++++++++++++++ .../sidebar/components/ProjectSwitcher.tsx | 38 ++++ 2 files changed, 201 insertions(+) create mode 100644 apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx 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..7eafc0fdd --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx @@ -0,0 +1,163 @@ +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(); + +vi.mock("@features/auth/hooks/authClient", () => ({ + useOptionalAuthenticatedClient: () => null, +})); + +vi.mock("@features/auth/hooks/authMutations", () => ({ + useLogoutMutation: () => ({ mutate: logoutMutate }), + useSelectProjectMutation: () => ({ mutate: selectProjectMutate }), + useSwitchOrgMutation: () => ({ mutate: switchOrgMutate }), +})); + +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(); + }); + + 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("svg")).not.toBeNull(); + expect(beta.querySelector("svg")).toBeNull(); + }); + + it("fires switchOrg.mutate when picking a different org and skips when picking 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")); + fireEvent.click(await screen.findByRole("menuitem", { name: /Beta/ })); + + expect(switchOrgMutate).toHaveBeenCalledExactlyOnceWith("org-2"); + }); + + 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..d76440471 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,7 @@ import { useProjects } from "@features/projects/hooks/useProjects"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { ArrowSquareOut, + Buildings, Check, DiscordLogo, FolderSimple, @@ -65,12 +67,19 @@ export function ProjectSwitcher() { 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 = Object.entries(orgProjectsMap) + .map(([id, { orgName }]) => ({ id, name: orgName })) + .sort((a, b) => a.name.localeCompare(b.name)); + const handleProjectSelect = (projectId: number) => { if (projectId !== currentProjectId) { selectProjectMutation.mutate(projectId); @@ -123,6 +132,13 @@ export function ProjectSwitcher() { logoutMutation.mutate(); }; + const handleSwitchOrg = (orgId: string) => { + setPopoverOpen(false); + if (orgId !== currentOrgId) { + switchOrgMutation.mutate(orgId); + } + }; + return ( <> @@ -185,6 +201,28 @@ export function ProjectSwitcher() { Change project + {orgs.length > 1 && ( + + + + Switch organization + + + {orgs.map((org) => ( + handleSwitchOrg(org.id)} + > + {org.name} + {org.id === currentOrgId && ( + + )} + + ))} + + + )} + Create project From 623cf8ab2aebd1ecdfa8074bce70552d7ff63eb5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 16:27:54 -0700 Subject: [PATCH 2/4] navigate after org switch and guard double click --- .../components/ProjectSwitcher.test.tsx | 48 +++++++++++++++++-- .../sidebar/components/ProjectSwitcher.tsx | 23 ++++++--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx index 7eafc0fdd..6b51a73ef 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx @@ -22,6 +22,8 @@ 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, @@ -30,7 +32,16 @@ vi.mock("@features/auth/hooks/authClient", () => ({ vi.mock("@features/auth/hooks/authMutations", () => ({ useLogoutMutation: () => ({ mutate: logoutMutate }), useSelectProjectMutation: () => ({ mutate: selectProjectMutate }), - useSwitchOrgMutation: () => ({ mutate: switchOrgMutate }), + useSwitchOrgMutation: () => ({ + mutate: switchOrgMutate, + isPending: switchOrgPending, + }), +})); + +vi.mock("@stores/navigationStore", () => ({ + useNavigationStore: { + getState: () => ({ navigateToTaskInput }), + }, })); vi.mock("@features/auth/hooks/authQueries", () => ({ @@ -77,6 +88,8 @@ describe("ProjectSwitcher org switcher", () => { selectProjectMutate.mockReset(); logoutMutate.mockReset(); openSettings.mockReset(); + navigateToTaskInput.mockReset(); + switchOrgPending = false; }); it("hides the Switch organization submenu when there is only one org", async () => { @@ -121,7 +134,36 @@ describe("ProjectSwitcher org switcher", () => { expect(beta.querySelector("svg")).toBeNull(); }); - it("fires switchOrg.mutate when picking a different org and skips when picking the current one", async () => { + 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", @@ -138,7 +180,7 @@ describe("ProjectSwitcher org switcher", () => { await user.hover(await screen.findByText("Switch organization")); fireEvent.click(await screen.findByRole("menuitem", { name: /Beta/ })); - expect(switchOrgMutate).toHaveBeenCalledExactlyOnceWith("org-2"); + expect(switchOrgMutate).not.toHaveBeenCalled(); }); it("does not call switchOrg when clicking the active org", async () => { diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index d76440471..d818ac2e6 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -54,12 +54,14 @@ 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 OrgEntry = { id: string; name: string }; type ProjectGroup = ReturnType["groupedProjects"][number]; export function ProjectSwitcher() { @@ -76,9 +78,13 @@ export function ProjectSwitcher() { const logoutMutation = useLogoutMutation(); const { groupedProjects, currentProject, currentProjectId } = useProjects(); - const orgs = Object.entries(orgProjectsMap) - .map(([id, { orgName }]) => ({ id, name: orgName })) - .sort((a, b) => a.name.localeCompare(b.name)); + const orgs = useMemo( + () => + Object.entries(orgProjectsMap) + .map(([id, { orgName }]) => ({ id, name: orgName })) + .sort((a, b) => a.name.localeCompare(b.name)), + [orgProjectsMap], + ); const handleProjectSelect = (projectId: number) => { if (projectId !== currentProjectId) { @@ -134,9 +140,14 @@ export function ProjectSwitcher() { const handleSwitchOrg = (orgId: string) => { setPopoverOpen(false); - if (orgId !== currentOrgId) { - switchOrgMutation.mutate(orgId); + if (orgId === currentOrgId || switchOrgMutation.isPending) { + return; } + switchOrgMutation.mutate(orgId, { + onSuccess: () => { + useNavigationStore.getState().navigateToTaskInput(); + }, + }); }; return ( From 4fdc257d131631f7cccda8756122d244136b5a56 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 22:01:12 -0700 Subject: [PATCH 3/4] icon --- .../renderer/features/sidebar/components/ProjectSwitcher.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index d818ac2e6..b287c7863 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -13,6 +13,7 @@ import { useProjects } from "@features/projects/hooks/useProjects"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { ArrowSquareOut, + Building, Buildings, Check, DiscordLogo, @@ -224,6 +225,7 @@ export function ProjectSwitcher() { key={org.id} onClick={() => handleSwitchOrg(org.id)} > + {org.name} {org.id === currentOrgId && ( From a3410414b69e41c02eb00b56f88870f285660df0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 26 May 2026 09:13:08 -0700 Subject: [PATCH 4/4] address Jonathan's review on org switcher submenu --- .../sidebar/components/ProjectSwitcher.test.tsx | 4 ++-- .../sidebar/components/ProjectSwitcher.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx index 6b51a73ef..17ac15824 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.test.tsx @@ -130,8 +130,8 @@ describe("ProjectSwitcher org switcher", () => { expect(alpha).toBeInTheDocument(); expect(beta).toBeInTheDocument(); - expect(alpha.querySelector("svg")).not.toBeNull(); - expect(beta.querySelector("svg")).toBeNull(); + 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 () => { diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index b287c7863..c9340619e 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -62,8 +62,8 @@ import { ChevronRightIcon } from "lucide-react"; import { useMemo, useState } from "react"; type ProjectInfo = { id: number; name: string }; -type OrgEntry = { id: string; name: string }; type ProjectGroup = ReturnType["groupedProjects"][number]; +type OrgEntry = Pick; export function ProjectSwitcher() { const [popoverOpen, setPopoverOpen] = useState(false); @@ -82,8 +82,8 @@ export function ProjectSwitcher() { const orgs = useMemo( () => Object.entries(orgProjectsMap) - .map(([id, { orgName }]) => ({ id, name: orgName })) - .sort((a, b) => a.name.localeCompare(b.name)), + .map(([orgId, { orgName }]) => ({ orgId, orgName })) + .sort((a, b) => a.orgName.localeCompare(b.orgName)), [orgProjectsMap], ); @@ -222,12 +222,12 @@ export function ProjectSwitcher() { {orgs.map((org) => ( handleSwitchOrg(org.id)} + key={org.orgId} + onClick={() => handleSwitchOrg(org.orgId)} > - {org.name} - {org.id === currentOrgId && ( + {org.orgName} + {org.orgId === currentOrgId && ( )}