diff --git a/ui/src/components/SidebarProjects.test.tsx b/ui/src/components/SidebarProjects.test.tsx
new file mode 100644
index 00000000000..b4d80f7ff4f
--- /dev/null
+++ b/ui/src/components/SidebarProjects.test.tsx
@@ -0,0 +1,294 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import type { ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { Project } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SidebarProjects } from "./SidebarProjects";
+
+const mockProjectsApi = vi.hoisted(() => ({
+ list: vi.fn(),
+}));
+
+const mockAuthApi = vi.hoisted(() => ({
+ getSession: vi.fn(),
+}));
+
+const mockOpenNewProject = vi.hoisted(() => vi.fn());
+const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
+const mockPersistOrder = vi.hoisted(() => vi.fn());
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
+ {children}
+ ),
+ NavLink: ({
+ children,
+ className,
+ to,
+ ...props
+ }: {
+ children: ReactNode;
+ className?: string | ((state: { isActive: boolean }) => string);
+ to: string;
+ }) => (
+
+ {children}
+
+ ),
+ useLocation: () => ({ pathname: "/PAP/projects/bravo/issues", search: "", hash: "", state: null }),
+}));
+
+vi.mock("../context/CompanyContext", () => ({
+ useCompany: () => ({
+ selectedCompanyId: "company-1",
+ selectedCompany: { id: "company-1", issuePrefix: "PAP" },
+ }),
+}));
+
+vi.mock("../context/DialogContext", () => ({
+ useDialog: () => ({
+ openNewProject: mockOpenNewProject,
+ }),
+ useDialogActions: () => ({
+ openNewProject: mockOpenNewProject,
+ }),
+}));
+
+vi.mock("../context/SidebarContext", () => ({
+ useSidebar: () => ({
+ isMobile: false,
+ setSidebarOpen: mockSetSidebarOpen,
+ }),
+}));
+
+vi.mock("../api/projects", () => ({
+ projectsApi: mockProjectsApi,
+}));
+
+vi.mock("../api/auth", () => ({
+ authApi: mockAuthApi,
+}));
+
+vi.mock("../hooks/useProjectOrder", () => ({
+ useProjectOrder: ({ projects }: { projects: Project[] }) => {
+ const curatedOrder = ["project-b", "project-a", "project-c"];
+ return {
+ orderedProjects: [...projects].sort(
+ (left, right) => curatedOrder.indexOf(left.id) - curatedOrder.indexOf(right.id),
+ ),
+ persistOrder: mockPersistOrder,
+ };
+ },
+}));
+
+vi.mock("@/plugins/slots", () => ({
+ usePluginSlots: () => ({
+ slots: [{ id: "slot-1", pluginKey: "plugin-1" }],
+ }),
+ PluginSlotMount: ({ context }: { context: { projectId: string } }) => (
+
Plugin slot
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+if (!globalThis.PointerEvent) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (globalThis as any).PointerEvent = MouseEvent;
+}
+
+function makeProject(overrides: Partial): Project {
+ return {
+ id: "project-a",
+ companyId: "company-1",
+ urlKey: "alpha",
+ goalId: null,
+ goalIds: [],
+ goals: [],
+ name: "Alpha",
+ description: null,
+ status: "in_progress",
+ leadAgentId: null,
+ targetDate: null,
+ color: "#ef4444",
+ env: null,
+ pauseReason: null,
+ pausedAt: null,
+ executionWorkspacePolicy: null,
+ codebase: {
+ workspaceId: null,
+ repoUrl: null,
+ repoRef: null,
+ defaultRef: null,
+ repoName: null,
+ localFolder: null,
+ managedFolder: "/tmp/project-a",
+ effectiveLocalFolder: "/tmp/project-a",
+ origin: "local_folder",
+ },
+ workspaces: [],
+ primaryWorkspace: null,
+ managedByPlugin: null,
+ archivedAt: null,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ ...overrides,
+ };
+}
+
+async function flushReact() {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+}
+
+function projectLinkLabels(container: HTMLElement) {
+ return Array.from(container.querySelectorAll('a[href$="/issues"]'))
+ .map((anchor) => anchor.textContent?.replace("Plugin slot", "").trim())
+ .filter(Boolean);
+}
+
+async function openProjectsMenu(container: HTMLElement) {
+ const trigger = container.querySelector('button[aria-label="Projects section actions"]');
+ 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();
+}
+
+async function chooseSortMode(label: string) {
+ const item = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]'))
+ .find((element) => element.textContent?.includes(label));
+ expect(item).toBeTruthy();
+
+ await act(async () => {
+ item?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+}
+
+describe("SidebarProjects", () => {
+ let container: HTMLDivElement;
+ let root: ReturnType | null;
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = null;
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ localStorage.clear();
+ mockProjectsApi.list.mockResolvedValue([
+ makeProject({
+ id: "project-a",
+ urlKey: "alpha",
+ name: "Alpha",
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-05T00:00:00Z"),
+ }),
+ makeProject({
+ id: "project-b",
+ urlKey: "bravo",
+ name: "Bravo",
+ createdAt: new Date("2026-01-02T00:00:00Z"),
+ updatedAt: new Date("2026-01-10T00:00:00Z"),
+ }),
+ makeProject({
+ id: "project-c",
+ urlKey: "charlie",
+ name: "Charlie",
+ createdAt: new Date("2026-01-03T00:00:00Z"),
+ updatedAt: new Date("2026-01-12T00:00:00Z"),
+ }),
+ ]);
+ mockAuthApi.getSession.mockResolvedValue({
+ session: { id: "session-1", userId: "user-1" },
+ user: { id: "user-1" },
+ });
+ });
+
+ afterEach(async () => {
+ const currentRoot = root;
+ if (currentRoot) {
+ await act(async () => {
+ currentRoot.unmount();
+ });
+ }
+ queryClient.clear();
+ container.remove();
+ document.body.innerHTML = "";
+ localStorage.clear();
+ vi.clearAllMocks();
+ });
+
+ async function renderSidebarProjects() {
+ const currentRoot = createRoot(container);
+ root = currentRoot;
+
+ await act(async () => {
+ currentRoot.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ }
+
+ it("keeps top mode in curated order and renders plugin project slots", async () => {
+ await renderSidebarProjects();
+
+ expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
+ expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
+ });
+
+ it("moves project creation into the section menu and links browse projects", async () => {
+ await renderSidebarProjects();
+
+ expect(container.querySelector('button[aria-label="New project"]')).toBeNull();
+ await openProjectsMenu(container);
+
+ const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
+ .find((element) => element.textContent?.includes("New project"));
+ expect(newProjectItem).toBeTruthy();
+ const browseLink = Array.from(document.body.querySelectorAll("a"))
+ .find((element) => element.textContent?.includes("Browse projects"));
+ expect(browseLink?.getAttribute("href")).toBe("/projects");
+
+ await act(async () => {
+ newProjectItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(mockOpenNewProject).toHaveBeenCalledTimes(1);
+ });
+
+ it("sorts alphabetically and persists the selected mode per company and user", async () => {
+ await renderSidebarProjects();
+ await openProjectsMenu(container);
+ await chooseSortMode("Alphabetical");
+
+ expect(projectLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
+ expect(localStorage.getItem("paperclip.projectSortMode:company-1:user-1")).toBe("alphabetical");
+ });
+
+ it("sorts recent projects by updated time descending", async () => {
+ await renderSidebarProjects();
+ await openProjectsMenu(container);
+ await chooseSortMode("Recent");
+
+ expect(projectLinkLabels(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
+ });
+});
diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx
index b48bc96794b..e568f78af83 100644
--- a/ui/src/components/SidebarProjects.tsx
+++ b/ui/src/components/SidebarProjects.tsx
@@ -1,7 +1,7 @@
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
-import { ChevronRight, Plus } from "lucide-react";
+import { FolderOpen, Plus } from "lucide-react";
import {
DndContext,
MouseSensor,
@@ -22,25 +22,31 @@ import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
+import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
+import {
+ getProjectSortModeStorageKey,
+ PROJECT_SORT_MODE_UPDATED_EVENT,
+ readProjectSortMode,
+ type ProjectSidebarSortMode,
+ writeProjectSortMode,
+} from "../lib/project-order";
import type { Project } from "@paperclipai/shared";
type ProjectSidebarSlot = ReturnType["slots"][number];
-function SortableProjectItem({
- activeProjectRef,
- companyId,
- companyPrefix,
- isMobile,
- project,
- projectSidebarSlots,
- setSidebarOpen,
-}: {
+const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
+ { value: "top", label: "Top" },
+ { value: "alphabetical", label: "Alphabetical" },
+ { value: "recent", label: "Recent" },
+];
+
+type ProjectSortModeUpdatedDetail = {
+ storageKey: string;
+ sortMode: ProjectSidebarSortMode;
+};
+
+type ProjectItemProps = {
activeProjectRef: string | null;
companyId: string | null;
companyPrefix: string | null;
@@ -48,7 +54,92 @@ function SortableProjectItem({
project: Project;
projectSidebarSlots: ProjectSidebarSlot[];
setSidebarOpen: (open: boolean) => void;
-}) {
+ isDragging?: boolean;
+};
+
+function projectTimestamp(project: Project): number {
+ const updated = new Date(project.updatedAt).getTime();
+ if (Number.isFinite(updated)) return updated;
+ const created = new Date(project.createdAt).getTime();
+ return Number.isFinite(created) ? created : 0;
+}
+
+function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Project[] {
+ if (sortMode === "top") return projects;
+ const sorted = [...projects];
+ if (sortMode === "alphabetical") {
+ sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
+ return sorted;
+ }
+ sorted.sort((left, right) => {
+ const timeDiff = projectTimestamp(right) - projectTimestamp(left);
+ return timeDiff !== 0 ? timeDiff : left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
+ });
+ return sorted;
+}
+
+function ProjectItem({
+ activeProjectRef,
+ companyId,
+ companyPrefix,
+ isMobile,
+ project,
+ projectSidebarSlots,
+ setSidebarOpen,
+ isDragging = false,
+}: ProjectItemProps) {
+ const routeRef = projectRouteRef(project);
+
+ return (
+
+
{
+ if (isDragging) {
+ e.preventDefault();
+ return;
+ }
+ if (isMobile) setSidebarOpen(false);
+ }}
+ className={cn(
+ "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
+ activeProjectRef === routeRef || activeProjectRef === project.id
+ ? "bg-accent text-foreground"
+ : "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
+ )}
+ >
+
+ {project.name}
+ {project.pauseReason === "budget" ? : null}
+
+ {projectSidebarSlots.length > 0 && (
+
+ {projectSidebarSlots.map((slot) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function SortableProjectItem(props: ProjectItemProps) {
const {
attributes,
listeners,
@@ -56,9 +147,7 @@ function SortableProjectItem({
transform,
transition,
isDragging,
- } = useSortable({ id: project.id });
-
- const routeRef = projectRouteRef(project);
+ } = useSortable({ id: props.project.id });
return (
-
-
{
- if (isDragging) {
- e.preventDefault();
- return;
- }
- if (isMobile) setSidebarOpen(false);
- }}
- className={cn(
- "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
- activeProjectRef === routeRef || activeProjectRef === project.id
- ? "bg-accent text-foreground"
- : "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
- )}
- >
-
- {project.name}
- {project.pauseReason === "budget" ? : null}
-
- {projectSidebarSlots.length > 0 && (
-
- {projectSidebarSlots.map((slot) => (
-
- ))}
-
- )}
-
+
);
}
@@ -145,6 +190,14 @@ export function SidebarProjects() {
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
+ const sortModeStorageKey = useMemo(() => {
+ if (!selectedCompanyId) return null;
+ return getProjectSortModeStorageKey(selectedCompanyId, currentUserId);
+ }, [currentUserId, selectedCompanyId]);
+ const [sortMode, setSortMode] = useState(() => {
+ if (!sortModeStorageKey) return "top";
+ return readProjectSortMode(sortModeStorageKey);
+ });
const visibleProjects = useMemo(
() => (projects ?? []).filter((project: Project) => !project.archivedAt),
@@ -155,6 +208,11 @@ export function SidebarProjects() {
companyId: selectedCompanyId,
userId: currentUserId,
});
+ const sortedProjects = useMemo(
+ () => sortProjects(orderedProjects, sortMode),
+ [orderedProjects, sortMode],
+ );
+ const isTopMode = sortMode === "top";
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
@@ -165,8 +223,50 @@ export function SidebarProjects() {
}),
);
+ useEffect(() => {
+ if (!sortModeStorageKey) {
+ setSortMode("top");
+ return;
+ }
+ setSortMode(readProjectSortMode(sortModeStorageKey));
+ }, [sortModeStorageKey]);
+
+ useEffect(() => {
+ if (!sortModeStorageKey) return;
+
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== sortModeStorageKey) return;
+ setSortMode(readProjectSortMode(sortModeStorageKey));
+ };
+ const onCustomEvent = (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ if (!detail || detail.storageKey !== sortModeStorageKey) return;
+ setSortMode(detail.sortMode);
+ };
+
+ window.addEventListener("storage", onStorage);
+ window.addEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
+ };
+ }, [sortModeStorageKey]);
+
+ const persistSortMode = useCallback(
+ (value: string) => {
+ const nextSortMode: ProjectSidebarSortMode =
+ value === "alphabetical" || value === "recent" ? value : "top";
+ setSortMode(nextSortMode);
+ if (sortModeStorageKey) {
+ writeProjectSortMode(sortModeStorageKey, nextSortMode);
+ }
+ },
+ [sortModeStorageKey],
+ );
+
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
+ if (!isTopMode) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
@@ -177,38 +277,40 @@ export function SidebarProjects() {
persistOrder(arrayMove(ids, oldIndex, newIndex));
},
- [orderedProjects, persistOrder],
+ [isTopMode, orderedProjects, persistOrder],
+ );
+
+ const renderProject = (project: Project) => (
+
);
return (
-
-
-
-
-
-
- Projects
-
-
-
-
-
-
-
+
+ {isTopMode ? (
project.id)}
strategy={verticalListSortingStrategy}
>
-
+
{orderedProjects.map((project: Project) => (
-
-
+ ) : (
+
+ {sortedProjects.map((project: Project) => renderProject(project))}
+
+ )}
+
);
}
diff --git a/ui/src/components/SidebarSection.test.tsx b/ui/src/components/SidebarSection.test.tsx
new file mode 100644
index 00000000000..9cce745b9f3
--- /dev/null
+++ b/ui/src/components/SidebarSection.test.tsx
@@ -0,0 +1,149 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import type { ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SidebarSection } from "./SidebarSection";
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
+ {children}
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+if (!globalThis.PointerEvent) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (globalThis as any).PointerEvent = MouseEvent;
+}
+
+async function flushReact() {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+}
+
+async function openSectionMenu(container: HTMLElement) {
+ const trigger = container.querySelector('button[aria-label="Projects section actions"]');
+ 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();
+}
+
+describe("SidebarSection", () => {
+ let container: HTMLDivElement;
+ let root: ReturnType | null;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = null;
+ });
+
+ afterEach(async () => {
+ const currentRoot = root;
+ if (currentRoot) {
+ await act(async () => {
+ currentRoot.unmount();
+ });
+ }
+ container.remove();
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ });
+
+ it("keeps static and collapsible section labels on the same text column", async () => {
+ const currentRoot = createRoot(container);
+ root = currentRoot;
+
+ await act(async () => {
+ currentRoot.render(
+ ,
+ );
+ });
+ await flushReact();
+
+ const workLabel = Array.from(container.querySelectorAll("span"))
+ .find((element) => element.textContent === "Work");
+ const projectsLabel = Array.from(container.querySelectorAll("span"))
+ .find((element) => element.textContent === "Projects");
+
+ expect(workLabel?.previousElementSibling?.className).toContain("w-3");
+ expect(projectsLabel?.previousElementSibling?.tagName.toLowerCase()).toBe("svg");
+ expect(projectsLabel?.previousElementSibling?.getAttribute("class")).toContain("w-3");
+ });
+
+ it("renders configured menu actions and radio choices", async () => {
+ const onAction = vi.fn();
+ const onRadioValueChange = vi.fn();
+ const currentRoot = createRoot(container);
+ root = currentRoot;
+
+ await act(async () => {
+ currentRoot.render(
+
+ Projects
+ ,
+ );
+ });
+ await flushReact();
+
+ await openSectionMenu(container);
+
+ const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
+ .find((element) => element.textContent?.includes("New project"));
+ expect(newProjectItem).toBeTruthy();
+ const browseLink = Array.from(document.body.querySelectorAll("a"))
+ .find((element) => element.textContent?.includes("Browse projects"));
+ expect(browseLink?.getAttribute("href")).toBe("/projects");
+
+ const alphabeticalItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-radio-item"]'))
+ .find((element) => element.textContent?.includes("Alphabetical"));
+ expect(alphabeticalItem).toBeTruthy();
+
+ await act(async () => {
+ alphabeticalItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(onRadioValueChange).toHaveBeenCalledWith("alphabetical");
+
+ await openSectionMenu(container);
+ const reopenedNewProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
+ .find((element) => element.textContent?.includes("New project"));
+
+ await act(async () => {
+ reopenedNewProjectItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/ui/src/components/SidebarSection.tsx b/ui/src/components/SidebarSection.tsx
index 3dd68feb863..0eb45d5f8cc 100644
--- a/ui/src/components/SidebarSection.tsx
+++ b/ui/src/components/SidebarSection.tsx
@@ -1,17 +1,170 @@
-import type { ReactNode } from "react";
+import { useState, type ComponentType, type ReactNode } from "react";
+import { Link } from "@/lib/router";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+
+type SidebarSectionIcon = ComponentType<{ className?: string }>;
+
+export type SidebarSectionMenuAction =
+ | {
+ type: "item";
+ label: string;
+ icon?: SidebarSectionIcon;
+ href?: string;
+ onSelect?: () => void;
+ }
+ | { type: "separator" };
+
+export type SidebarSectionRadioChoice = {
+ label: string;
+ value: string;
+};
+
+type SidebarSectionMenu = {
+ actions?: SidebarSectionMenuAction[];
+ ariaLabel?: string;
+ radioChoices?: SidebarSectionRadioChoice[];
+ radioLabel?: string;
+ radioValue?: string;
+ onRadioValueChange?: (value: string) => void;
+};
interface SidebarSectionProps {
label: string;
children: ReactNode;
+ collapsible?: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ };
+ menu?: SidebarSectionMenu;
}
-export function SidebarSection({ label, children }: SidebarSectionProps) {
+function SidebarSectionHeader({
+ collapsible,
+ label,
+ menu,
+}: Pick) {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const hasMenu = Boolean(menu && ((menu.actions?.length ?? 0) > 0 || (menu.radioChoices?.length ?? 0) > 0));
+ const labelClassName = "text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60";
+ const caretClassName = cn(
+ "h-3 w-3 shrink-0 text-muted-foreground/60 opacity-0 transition-all group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100",
+ collapsible?.open && "rotate-90",
+ menuOpen && "opacity-100",
+ );
+
+ const labelContent = (
+ <>
+ {collapsible ? (
+
+ ) : (
+
+ )}
+ {label}
+ >
+ );
+
return (
+
+ {collapsible ? (
+
+ {labelContent}
+
+ ) : (
+
{labelContent}
+ )}
+
+ {hasMenu ? (
+
+
+
+
+
+ {menu?.actions?.map((action, index) => {
+ if (action.type === "separator") {
+ return ;
+ }
+ const Icon = action.icon;
+ const content = (
+ <>
+ {Icon ? : null}
+ {action.label}
+ >
+ );
+ if (action.href) {
+ return (
+
+ {content}
+
+ );
+ }
+ return (
+
+ {content}
+
+ );
+ })}
+ {menu?.radioChoices && menu.radioChoices.length > 0 ? (
+
+ {menu.radioChoices.map((choice) => (
+
+ {choice.label}
+
+ ))}
+
+ ) : null}
+
+
+ ) : null}
+
+ );
+}
+
+export function SidebarSection({ label, children, collapsible, menu }: SidebarSectionProps) {
+ const content = {children}
;
+
+ if (collapsible) {
+ return (
+
+
+ {content}
+
+ );
+ }
+
+ return (
-
- {label}
-
-
{children}
+
+ {content}
);
}
diff --git a/ui/src/lib/project-order.ts b/ui/src/lib/project-order.ts
index 9594a4c64cc..639ec691377 100644
--- a/ui/src/lib/project-order.ts
+++ b/ui/src/lib/project-order.ts
@@ -1,19 +1,32 @@
import type { Project } from "@paperclipai/shared";
export const PROJECT_ORDER_UPDATED_EVENT = "paperclip:project-order-updated";
+export const PROJECT_SORT_MODE_UPDATED_EVENT = "paperclip:project-sort-mode-updated";
const PROJECT_ORDER_STORAGE_PREFIX = "paperclip.projectOrder";
+const PROJECT_SORT_MODE_STORAGE_PREFIX = "paperclip.projectSortMode";
const ANONYMOUS_USER_ID = "anonymous";
+export type ProjectSidebarSortMode = "top" | "alphabetical" | "recent";
+
type ProjectOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
+type ProjectSortModeUpdatedDetail = {
+ storageKey: string;
+ sortMode: ProjectSidebarSortMode;
+};
+
function normalizeIdList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
+function normalizeSortMode(value: unknown): ProjectSidebarSortMode {
+ return value === "alphabetical" || value === "recent" || value === "top" ? value : "top";
+}
+
function resolveUserId(userId: string | null | undefined): string {
if (!userId) return ANONYMOUS_USER_ID;
const trimmed = userId.trim();
@@ -24,6 +37,10 @@ export function getProjectOrderStorageKey(companyId: string, userId: string | nu
return `${PROJECT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`;
}
+export function getProjectSortModeStorageKey(companyId: string, userId: string | null | undefined): string {
+ return `${PROJECT_SORT_MODE_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`;
+}
+
export function readProjectOrder(storageKey: string): string[] {
try {
const raw = localStorage.getItem(storageKey);
@@ -34,6 +51,14 @@ export function readProjectOrder(storageKey: string): string[] {
}
}
+export function readProjectSortMode(storageKey: string): ProjectSidebarSortMode {
+ try {
+ return normalizeSortMode(localStorage.getItem(storageKey));
+ } catch {
+ return "top";
+ }
+}
+
export function writeProjectOrder(storageKey: string, orderedIds: string[]) {
const normalized = normalizeIdList(orderedIds);
try {
@@ -50,6 +75,22 @@ export function writeProjectOrder(storageKey: string, orderedIds: string[]) {
}
}
+export function writeProjectSortMode(storageKey: string, sortMode: ProjectSidebarSortMode) {
+ const normalized = normalizeSortMode(sortMode);
+ try {
+ localStorage.setItem(storageKey, normalized);
+ } catch {
+ // Ignore storage write failures in restricted browser contexts.
+ }
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(
+ new CustomEvent(PROJECT_SORT_MODE_UPDATED_EVENT, {
+ detail: { storageKey, sortMode: normalized },
+ }),
+ );
+ }
+}
+
export function sortProjectsByStoredOrder(projects: Project[], orderedIds: string[]): Project[] {
if (projects.length === 0) return [];
if (orderedIds.length === 0) return projects;