diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx
index 12634662..d1471f2e 100644
--- a/ui/src/components/SidebarCompanyMenu.test.tsx
+++ b/ui/src/components/SidebarCompanyMenu.test.tsx
@@ -19,11 +19,19 @@ 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" }));
+const mockSidebarPreferencesApi = vi.hoisted(() => ({
+ getCompanyOrder: vi.fn(),
+ updateCompanyOrder: vi.fn(),
+}));
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
}));
+vi.mock("@/api/sidebarPreferences", () => ({
+ sidebarPreferencesApi: mockSidebarPreferencesApi,
+}));
+
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
{children}
@@ -112,6 +120,14 @@ describe("SidebarCompanyMenu", () => {
},
});
mockAuthApi.signOut.mockResolvedValue(undefined);
+ mockSidebarPreferencesApi.getCompanyOrder.mockResolvedValue({
+ orderedIds: ["company-1", "company-2", "company-3"],
+ updatedAt: null,
+ });
+ mockSidebarPreferencesApi.updateCompanyOrder.mockResolvedValue({
+ orderedIds: ["company-1", "company-2", "company-3"],
+ updatedAt: null,
+ });
mockLocation.pathname = "/PAP/dashboard";
});
@@ -149,6 +165,7 @@ describe("SidebarCompanyMenu", () => {
await flushReact();
expect(document.body.textContent).toContain("Switch workspace");
+ expect(document.body.textContent).toContain("Edit");
expect(document.body.textContent).toContain("Strata");
expect(document.body.textContent).toContain("ANA");
expect(document.body.textContent).toContain("Add company...");
@@ -172,6 +189,62 @@ describe("SidebarCompanyMenu", () => {
});
});
+ it("toggles company order editing without selecting a workspace", async () => {
+ 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 editButton = Array.from(document.body.querySelectorAll("button"))
+ .find((element) => element.textContent === "Edit");
+ expect(editButton).toBeTruthy();
+
+ await act(async () => {
+ editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ expect(document.body.textContent).toContain("Done");
+ expect(document.body.textContent).not.toContain("PAP");
+ expect(document.body.textContent).not.toContain("ANA");
+ expect(document.body.querySelector('button[aria-label="Reorder Strata"]')).toBeTruthy();
+
+ 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).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
it("navigates to the selected workspace dashboard from company-prefixed routes", async () => {
mockLocation.pathname = "/PAP/issues";
const root = createRoot(container);
diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx
index 9cc4dc56..ff6f336d 100644
--- a/ui/src/components/SidebarCompanyMenu.tsx
+++ b/ui/src/components/SidebarCompanyMenu.tsx
@@ -1,6 +1,25 @@
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
+import {
+ Check,
+ ChevronsUpDown,
+ GripVertical,
+ LogOut,
+ Plus,
+ Settings,
+ 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";
@@ -15,6 +34,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useCompany } from "@/context/CompanyContext";
import { useDialogActions } from "@/context/DialogContext";
+import { useCompanyOrder } from "@/hooks/useCompanyOrder";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
@@ -36,8 +56,81 @@ function WorkspaceIcon({ company }: { company: Company }) {
);
}
+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 queryClient = useQueryClient();
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialogActions();
@@ -46,6 +139,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
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],
@@ -55,6 +156,11 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
queryFn: () => authApi.getSession(),
retry: false,
});
+ const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
+ const { orderedCompanies, persistOrder } = useCompanyOrder({
+ companies: sidebarCompanies,
+ userId: currentUserId,
+ });
const signOutMutation = useMutation({
mutationFn: () => authApi.signOut(),
@@ -65,8 +171,14 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
},
});
+ function handleOpenChange(nextOpen: boolean) {
+ if (!nextOpen) setIsEditingOrder(false);
+ setOpen(nextOpen);
+ }
+
function closeNavigationChrome() {
setOpen(false);
+ setIsEditingOrder(false);
if (isMobile) setSidebarOpen(false);
}
@@ -92,8 +204,23 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
openOnboarding();
}
+ 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 (
-
+
-
- Switch workspace
-
+
+
+ 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}
-
- {company.issuePrefix}
-
- {isSelected ? : null}
-
- );
- })}
- {sidebarCompanies.length === 0 ? (
+
+ company.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {orderedCompanies.map((company) => (
+
+ ))}
+
+
+ {orderedCompanies.length === 0 ? (
No workspaces
) : null}
-
+
Add company...
-
-
+
+ {
+ if (isEditingOrder) {
+ event.preventDefault();
+ return;
+ }
+ closeNavigationChrome();
+ }}
+ >
{selectedCompany ? `Invite people to ${selectedCompany.name}` : "Invite people"}
-
-
+
+ {
+ if (isEditingOrder) {
+ event.preventDefault();
+ return;
+ }
+ closeNavigationChrome();
+ }}
+ >
Company settings
@@ -164,7 +326,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
signOutMutation.mutate()}
- disabled={signOutMutation.isPending}
+ disabled={isEditingOrder || signOutMutation.isPending}
>
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
diff --git a/ui/storybook/stories/navigation-layout.stories.tsx b/ui/storybook/stories/navigation-layout.stories.tsx
index 89e95105..2e87c862 100644
--- a/ui/storybook/stories/navigation-layout.stories.tsx
+++ b/ui/storybook/stories/navigation-layout.stories.tsx
@@ -11,7 +11,6 @@ import {
} from "lucide-react";
import { BreadcrumbBar } from "@/components/BreadcrumbBar";
import { CommandPalette } from "@/components/CommandPalette";
-import { CompanyRail } from "@/components/CompanyRail";
import { CompanySwitcher } from "@/components/CompanySwitcher";
import { KeyboardShortcutsCheatsheetContent } from "@/components/KeyboardShortcutsCheatsheet";
import { MobileBottomNav } from "@/components/MobileBottomNav";
@@ -75,7 +74,6 @@ function SidebarShell({ collapsed = false }: { collapsed?: boolean }) {
return (
-
@@ -249,12 +247,6 @@ function NavigationLayoutStories() {
-
-