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 type { Company } from "@paperclipai/shared"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { authApi } from "@/api/auth"; import { cloudApi, type CloudStackSummary } from "@/api/cloud"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useCompany } from "@/context/CompanyContext"; import { useDialogActions } from "@/context/DialogContext"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useCompanyOrder } from "@/hooks/useCompanyOrder"; import { useSignOut } from "@/hooks/useSignOut"; import { navigateTopLevel } from "@/lib/browserNavigation"; import { cloudStackCreateUrl, cloudStackEnterUrl } from "@/lib/cloudLinks"; import { queryKeys } from "@/lib/queryKeys"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; } const WORKSPACE_ICON_CLASS = "size-5 shrink-0 rounded-md text-(length:--text-micro)"; const WORKSPACE_BADGE_CLASS = "shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-(length:--text-nano) text-muted-foreground"; function WorkspaceIcon({ company }: { company: Company }) { return ( ); } /** * Cloud stacks have no hot-linkable icon URL in the portfolio payload, so v1 * renders the same deterministic monogram treatment cloud's own portfolio page * uses — seeded by the display name, never fetched. */ function StackIcon({ displayName }: { displayName: string }) { return ; } /** * The switcher trigger on a Cloud instance. A Cloud tenant holds exactly one * company and the harness pushes the stack's uploaded workspace icon into * that company's branding, so the company logo is the stack logo here. * The stack rows keep the monogram treatment above. */ function CurrentStackIcon({ displayName, company, }: { displayName: string; company: Company | null; }) { return ( ); } function CloudStackItem({ stack, isSelected, onSelect, }: { stack: CloudStackSummary; isSelected: boolean; onSelect: (stack: CloudStackSummary) => void; }) { return ( onSelect(stack)} className={cn("min-w-0 gap-2 py-2", isSelected && "bg-accent text-accent-foreground")} > {stack.displayName} {/* Company badges are 3-4 character issue prefixes, but stack slugs are user-chosen and can be long enough to truncate the name to a single letter — the name is the primary identifier, so the badge yields. */} {stack.stackSlug} {isSelected ? : null} ); } 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( "min-w-0 gap-2 py-2", isEditing && "cursor-grab", isDragging && "opacity-80", isSelected && "bg-accent text-accent-foreground", )} > {company.name} {isEditing ? ( ) : ( <> {company.issuePrefix} {isSelected ? : null} )} ); } export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: 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, }); // In Paperclip Cloud the switcher lists the signed-in user's stacks // (organizations) instead of the instance's companies: a cloud instance holds // exactly one company, and switching means leaving this tenant host entirely. const cloud = useCloudInstance(); const isCloud = Boolean(cloud); const cloudBaseUrl = cloud?.cloudBaseUrl ?? null; const stacksQuery = useQuery({ queryKey: queryKeys.cloud.stacks, queryFn: () => cloudApi.listStacks(), enabled: isCloud, staleTime: 30_000, retry: false, }); const stacks = stacksQuery.data?.stacks ?? []; const currentStack = isCloud ? stacks.find((stack) => stack.isCurrent) ?? stacks.find((stack) => Boolean(cloud?.stackSlug) && stack.stackSlug === cloud?.stackSlug) ?? null : null; const createStackUrl = isCloud ? cloudStackCreateUrl(cloudBaseUrl) : null; const switcherNoun = isCloud ? "organization" : "company"; // The one name the chrome shows for "where am I": the stack in cloud, the // company when self-hosted. const currentName = isCloud ? currentStack?.displayName ?? cloud?.stackDisplayName ?? cloud?.stackSlug ?? null : selectedCompany?.name ?? null; 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`); } } /** * Switching stacks is a full top-level navigation, not client routing: the * cloud entry-code handoff authenticates the user for the target stack and * wakes it if it is asleep before landing on its own tenant host. */ function selectStack(stack: CloudStackSummary) { setOpen(false); if (isMobile) setSidebarOpen(false); if (stack.stackSlug === currentStack?.stackSlug) return; const target = cloudStackEnterUrl(cloudBaseUrl, stack.stackSlug); if (!target) return; navigateTopLevel(target); } function addCompany() { setOpen(false); if (isMobile) setSidebarOpen(false); // Cloud creates organizations in the cloud app; the in-app company wizard // is unreachable there (POST /companies is a 403 floor on managed stacks). if (isCloud) { if (createStackUrl) navigateTopLevel(createStackUrl); return; } // 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 (
{isCloud ? "Switch organization" : "Switch company"} {/* Stack order is owned by cloud's own portfolio in v1, so the drag-to-reorder affordance stays self-hosted-only. */} {isCloud ? null : ( )}
{isCloud ? ( <> {stacks.map((stack) => ( ))} {stacks.length === 0 ? ( {stacksQuery.isLoading ? "Loading organizations..." : stacksQuery.isError ? "Could not load organizations" : "No organizations"} ) : null} ) : ( <> 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 companies { // Keep the menu open so the result of the retry is visible. event.preventDefault(); void retryCompanies(); }} > Try again ) : ( No companies ) ) : null} )}
{/* A cloud instance without a configured cloud origin has nowhere to send the user, so the row (and its separator) drop out entirely. */} {isCloud && !createStackUrl ? null : ( <> Create new organization... )} { if (isEditingOrder) { event.preventDefault(); return; } closeNavigationChrome(); }} > {currentName ? `Invite people to ${currentName}` : "Invite people"} {session?.session ? ( <> signOutMutation.mutate()} disabled={isEditingOrder || signOutMutation.isPending} > {signOutMutation.isPending ? "Signing out..." : "Sign out"} ) : null}
); }