import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, ChevronsUpDown, GripVertical, LogOut, Plus, RefreshCw, UserPlus, } from "lucide-react"; import { DndContext, MouseSensor, TouchSensor, closestCenter, type DragEndEvent, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { hidesCompanyPage, type Company } from "@paperclipai/shared"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { authApi } from "@/api/auth"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useCompany } from "@/context/CompanyContext"; import { useDialogActions } from "@/context/DialogContext"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { useCompanyOrder } from "@/hooks/useCompanyOrder"; import { useSignOut } from "@/hooks/useSignOut"; import { queryKeys } from "@/lib/queryKeys"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; import { TruncatedText } from "./TruncatedText"; import { FlyoutHeader, FlyoutOptions } from "./ui/flyout"; import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; showChevron?: boolean; } const TRIGGER_WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)"; const POPOVER_WORKSPACE_ICON_CLASS = "size-(--organization-popover-avatar-size) shrink-0 rounded-lg text-(length:--text-micro)"; const ORGANIZATION_ROW_CLASS = "h-(--organization-popover-company-row-height) min-w-0 gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) focus:bg-accent/50 focus:text-foreground"; const ORGANIZATION_ACTION_CLASS = "h-(--organization-popover-action-row-height) gap-(--organization-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--organization-popover-action-line-height) text-foreground focus:bg-accent/50 focus:text-foreground"; function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPopover?: boolean }) { return ( ); } function SortableCompanyItem({ company, isEditing, isSelected, onSelect, }: { company: Company; isEditing: boolean; isSelected: boolean; onSelect: (company: Company) => void; }) { const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging, } = useSortable({ id: company.id, disabled: !isEditing }); return ( { if (isEditing) { event.preventDefault(); return; } onSelect(company); }} className={cn( ORGANIZATION_ROW_CLASS, isEditing && "cursor-grab", isDragging && "opacity-80", )} > {company.name} {isEditing ? null : ( {company.issuePrefix} )} {isEditing ? ( ) : ( {isSelected ? : null} )} ); } export function SidebarCompanyMenu({ showChevron, ...props }: SidebarCompanyMenuProps = {}) { return ( ); } function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange, showChevron = true }: SidebarCompanyMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const [isEditingOrder, setIsEditingOrder] = useState(false); const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } = useCompany(); const { openOnboarding } = useDialogActions(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; const location = useLocation(); const navigate = useNavigate(); const open = controlledOpen ?? internalOpen; const setOpen = onOpenChange ?? setInternalOpen; const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 }, }), useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 6 }, }), ); const sidebarCompanies = useMemo( () => companies.filter((company) => company.status !== "archived"), [companies], ); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const { orderedCompanies, persistOrder } = useCompanyOrder({ companies: sidebarCompanies, userId: currentUserId, }); // Keep invitation links hidden until the operator policy is known. const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings(); const showInvitePeople = hiddenSettingsLoaded && !hidesCompanyPage(hiddenSettings, "company.members") && !hidesCompanyPage(hiddenSettings, "company.invites"); const switcherNoun = "organization"; const currentName = selectedCompany?.name ?? null; // Managed hosts forbid local company creation. Their extension owns that action. const companyCreationManaged = Boolean(useCloudInstance()); const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome }); function handleOpenChange(nextOpen: boolean) { if (!nextOpen) setIsEditingOrder(false); setOpen(nextOpen); } function closeNavigationChrome() { setOpen(false); setIsEditingOrder(false); 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() { if (companyCreationManaged) return; setOpen(false); if (isMobile) setSidebarOpen(false); // Skip the front-door "how would you like to get started?" choice and land // directly on "Name your organization" — this entry point is unambiguously // "create a new company" (PAP-431). openOnboarding({ initialStep: 1 }); } const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const ids = orderedCompanies.map((company) => company.id); const oldIndex = ids.indexOf(active.id as string); const newIndex = ids.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; persistOrder(arrayMove(ids, oldIndex, newIndex)); }, [orderedCompanies, persistOrder], ); return ( Organizations company.id)} strategy={verticalListSortingStrategy} > {orderedCompanies.map((company) => ( ))} {orderedCompanies.length === 0 ? ( // "No companies" is a claim about the account. After a failed // list request it is one we cannot make, and this menu is the // only place the customer can act on it — say what happened and // offer the way back. companyListUnavailable ? ( <> Couldn't load organizations { // Keep the menu open so the result of the retry is visible. event.preventDefault(); void retryCompanies(); }} > Try again ) : ( No organizations ) ) : null} {!companyCreationManaged && ( Create organization )} {showInvitePeople ? ( { if (isEditingOrder) { event.preventDefault(); return; } closeNavigationChrome(); }} > {currentName ? `Invite people to ${currentName}` : "Invite people"} ) : null} {session?.session ? ( signOutMutation.mutate()} disabled={isEditingOrder || signOutMutation.isPending} > {signOutMutation.isPending ? "Signing out..." : "Sign out"} ) : null} ); }