import * as PopoverPrimitive from "@radix-ui/react-popover"; import { IconApps, IconArrowUpRight, IconBrain, IconBrandJira, IconBrush, IconCalendar, IconCalendarTime, IconChartBar, IconCheck, IconChevronRight, IconClipboardList, IconCode, IconFileText, IconKey, IconLayoutBoard, IconListCheck, IconLoader2, IconLogout, IconMail, IconMessageCircle, IconMicrophone, IconNote, IconPhone, IconPhoto, IconPlus, IconPresentation, IconRoute, IconScreenShare, IconSelector, IconSettings, IconStack2, IconUser, IconUserCircle, IconUserPlus, IconUsers, IconUsersGroup, IconWorld, } from "@tabler/icons-react"; import { useState } from "react"; import { useNavigate } from "react-router"; import { agentNativePath } from "../api-path.js"; import { useT } from "../i18n.js"; import { useSession } from "../use-session.js"; import { useOrg, useSwitchOrg, useCreateOrg, useInviteMember, useAcceptInvitation, useJoinByDomain, } from "./hooks.js"; import { ORG_SWITCHER_MAX_APP_LINKS, useOrgSwitcherAppLinks, visibleOrgAppLinks, type OrgSwitcherAppLink, } from "./workspace-app-links.js"; export interface OrgSwitcherProps { className?: string; /** Hide entirely when the user only belongs to one org. Default: false. */ hideWhenSingle?: boolean; /** Keep the switcher's button height reserved while org state is loading. */ reserveSpace?: boolean; /** * Icon-only trigger for collapsed sidebar rails. The popover — and with it * the org list, pending invitations and "Join your team" — is identical; * dropping the switcher instead leaves a collapsed rail with no way to * reach another workspace. */ compact?: boolean; /** * Path to navigate to when the user clicks "Organization settings". * Defaults to the Organization tab inside Settings. Templates with an * established org surface can pass their own path; pass `null` to only open * the in-sidebar settings panel. */ settingsPath?: string | null; /** Path to navigate to when the user clicks "Profile". Defaults to the shared Account settings section. */ profilePath?: string | null; } function personalLabelFromEmail(email: string | null | undefined): string { if (!email) return "Personal"; const local = email.split("@")[0] ?? email; const cleaned = local.replace(/[._-]+/g, " ").trim(); if (!cleaned) return "Personal"; return cleaned .split(" ") .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } type Mode = "list" | "create" | "invite"; const POPOVER_CONTENT_CLASS = "z-50 min-w-[14rem] rounded-md border border-border bg-popover py-1 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"; const ITEM_CLASS = "flex w-full items-center gap-2 px-2.5 py-1.5 text-xs text-foreground hover:bg-accent focus-visible:bg-accent focus:outline-none disabled:opacity-50 disabled:pointer-events-none"; const SECTION_LABEL_CLASS = "px-2.5 pt-1 pb-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"; const APP_SUBMENU_CONTENT_CLASS = "z-50 w-72 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"; const SWITCHER_BUTTON_CLASS = "flex w-full items-center gap-2 rounded-md border-0 bg-accent/50 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent/70 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60 cursor-pointer"; const COMPACT_SWITCHER_BUTTON_CLASS = "flex items-center justify-center rounded-md border-0 bg-accent/50 p-1.5 text-muted-foreground hover:bg-accent/70 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60 cursor-pointer"; const DEFAULT_ORGANIZATION_SETTINGS_PATH = "/settings#organization"; const DEFAULT_PROFILE_PATH = "/settings#account"; const APP_ICON_MAP: Record = { Mail: IconMail, CalendarDays: IconCalendar, FileText: IconFileText, LayoutBoard: IconLayoutBoard, BarChart2: IconChartBar, GalleryHorizontal: IconPresentation, BrandJira: IconBrandJira, ClipboardList: IconClipboardList, Users: IconUsers, Code: IconCode, MessageCircle: IconMessageCircle, Route: IconRoute, ScreenShare: IconScreenShare, Brush: IconBrush, Brain: IconBrain, Phone: IconPhone, Note: IconNote, Microphone: IconMicrophone, CalendarTime: IconCalendarTime, Globe: IconWorld, Photo: IconPhoto, ListCheck: IconListCheck, }; function appMenuIcon(app: OrgSwitcherAppLink): typeof IconApps { if (app.icon) return APP_ICON_MAP[app.icon] ?? IconStack2; return app.isDispatch ? IconMessageCircle : IconStack2; } function organizationSettingsPath(path: string): string { if (path.includes("#")) return path; const pathname = path.split("?")[0]?.replace(/\/+$/, ""); return pathname === "/settings" ? `${path}#organization` : path; } function AppMenuLink({ app, onNavigate, }: { app: OrgSwitcherAppLink; onNavigate: () => void; }) { const Icon = appMenuIcon(app); const description = app.status === "pending" ? "Building" : app.description?.trim() || (app.isDispatch ? "Workspace hub" : `${app.name} workspace`); return ( {app.name} {description} ); } function AppsSubmenu({ apps, isLoading, dispatchHref, dispatchAllAppsHref, onNavigate, }: { apps: OrgSwitcherAppLink[]; isLoading: boolean; dispatchHref: string; dispatchAllAppsHref: string; onNavigate: () => void; }) { const { links, overflowCount } = visibleOrgAppLinks(apps); const visibleDispatchApp = links.find((app) => app.isDispatch); const dispatchApp = visibleDispatchApp ?? ({ id: "dispatch", name: "Dispatch", href: dispatchHref, isDispatch: true, status: "ready", } satisfies OrgSwitcherAppLink); const visibleNonDispatch = links .filter((app) => !app.isDispatch) .slice(0, visibleDispatchApp ? undefined : ORG_SWITCHER_MAX_APP_LINKS - 1); const shownCount = (dispatchApp ? 1 : 0) + visibleNonDispatch.length; const remainingCount = Math.max(overflowCount, apps.length - shownCount); return ( {visibleNonDispatch.length > 0 && (
)} {visibleNonDispatch.map((app) => ( ))} {remainingCount > 0 && ( <>
{`View ${remainingCount} more in Dispatch`} )} ); } function ReservedOrgSwitcherSpace({ className }: { className?: string }) { return