({
updateProfile: vi.fn(),
signOut: vi.fn(),
}));
+const mockNavigate = vi.hoisted(() => vi.fn());
+const mockOpenOnboarding = vi.hoisted(() => vi.fn());
+const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
+const mockLocation = vi.hoisted(() => ({ pathname: "/PAP/dashboard" }));
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
@@ -24,18 +28,51 @@ vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
{children}
),
+ useLocation: () => mockLocation,
+ useNavigate: () => mockNavigate,
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
+ companies: [
+ {
+ id: "company-1",
+ issuePrefix: "PAP",
+ name: "Acme Labs",
+ brandColor: "#3366ff",
+ status: "active",
+ },
+ {
+ id: "company-2",
+ issuePrefix: "STR",
+ name: "Strata",
+ brandColor: "#36a269",
+ status: "active",
+ },
+ ],
selectedCompany: {
id: "company-1",
+ issuePrefix: "PAP",
name: "Acme Labs",
brandColor: "#3366ff",
+ status: "active",
},
+ setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
+vi.mock("@/context/DialogContext", () => ({
+ useDialogActions: () => ({
+ openOnboarding: mockOpenOnboarding,
+ }),
+}));
+
+vi.mock("./CompanyPatternIcon", () => ({
+ CompanyPatternIcon: ({ companyName }: { companyName: string }) => (
+
{companyName.slice(0, 1)}
+ ),
+}));
+
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: false,
@@ -68,6 +105,7 @@ describe("SidebarCompanyMenu", () => {
},
});
mockAuthApi.signOut.mockResolvedValue(undefined);
+ mockLocation.pathname = "/PAP/dashboard";
});
afterEach(() => {
@@ -94,7 +132,7 @@ describe("SidebarCompanyMenu", () => {
expect(container.textContent).toContain("Acme Labs");
- const trigger = container.querySelector('button[aria-label="Open Acme Labs menu"]');
+ const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
expect(trigger).not.toBeNull();
await act(async () => {
@@ -103,6 +141,9 @@ describe("SidebarCompanyMenu", () => {
});
await flushReact();
+ expect(document.body.textContent).toContain("Switch workspace");
+ expect(document.body.textContent).toContain("Strata");
+ expect(document.body.textContent).toContain("Add company...");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
expect(document.body.textContent).toContain("Company settings");
expect(document.body.textContent).toContain("Sign out");
@@ -122,4 +163,47 @@ describe("SidebarCompanyMenu", () => {
root.unmount();
});
});
+
+ it("navigates to the selected workspace dashboard from company-prefixed routes", async () => {
+ mockLocation.pathname = "/PAP/issues";
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
+ expect(trigger).not.toBeNull();
+
+ await act(async () => {
+ trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
+ trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
+ .find((element) => element.textContent?.includes("Strata"));
+ expect(strataItem).toBeTruthy();
+
+ await act(async () => {
+ strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
+ expect(mockNavigate).toHaveBeenCalledWith("/STR/dashboard");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
});
diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx
index 53d9a58c..cfd39698 100644
--- a/ui/src/components/SidebarCompanyMenu.tsx
+++ b/ui/src/components/SidebarCompanyMenu.tsx
@@ -1,7 +1,8 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ChevronDown, LogOut, Settings, UserPlus } from "lucide-react";
-import { Link } from "@/lib/router";
+import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
+import type { Company } from "@paperclipai/shared";
+import { Link, useLocation, useNavigate } from "@/lib/router";
import { authApi } from "@/api/auth";
import { Button } from "@/components/ui/button";
import {
@@ -13,21 +14,39 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCompany } from "@/context/CompanyContext";
+import { useDialogActions } from "@/context/DialogContext";
import { queryKeys } from "@/lib/queryKeys";
+import { cn } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
+import { CompanyPatternIcon } from "./CompanyPatternIcon";
interface SidebarCompanyMenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
+function WorkspaceIcon({ company }: { company: Company }) {
+ return (
+
+ );
+}
+
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
const [internalOpen, setInternalOpen] = useState(false);
const queryClient = useQueryClient();
- const { selectedCompany } = useCompany();
+ const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
+ const { openOnboarding } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar();
+ const location = useLocation();
+ const navigate = useNavigate();
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
+ const sidebarCompanies = companies.filter((company) => company.status !== "archived");
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
@@ -48,33 +67,76 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
if (isMobile) setSidebarOpen(false);
}
+ function selectCompany(company: Company) {
+ const pathPrefix = location.pathname.split("/")[1]?.toUpperCase();
+ const isCompanyRoute = sidebarCompanies.some((sidebarCompany) => (
+ sidebarCompany.issuePrefix.toUpperCase() === pathPrefix
+ ));
+ const shouldLeaveCurrentRoute = company.id !== selectedCompany?.id
+ && (location.pathname.startsWith("/instance/") || isCompanyRoute);
+
+ setSelectedCompanyId(company.id);
+ setOpen(false);
+ if (isMobile) setSidebarOpen(false);
+ if (shouldLeaveCurrentRoute) {
+ navigate(`/${company.issuePrefix}/dashboard`);
+ }
+ }
+
+ function addCompany() {
+ setOpen(false);
+ if (isMobile) setSidebarOpen(false);
+ openOnboarding();
+ }
+
return (
-
-
- {selectedCompany?.name ?? "Company"}
+
+
+ Switch workspace
+
+ {sidebarCompanies.map((company) => {
+ const isSelected = company.id === selectedCompany?.id;
+ return (
+ selectCompany(company)}
+ className={cn(
+ "min-w-0 gap-2 py-2",
+ isSelected && "bg-accent text-accent-foreground",
+ )}
+ >
+
+ {company.name}
+ {isSelected ? : null}
+
+ );
+ })}
+ {sidebarCompanies.length === 0 ? (
+ No workspaces
+ ) : null}
+
+
+
+
+ Add company...
+