diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index ee3ef2aa..b96daff1 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -145,6 +145,34 @@ async function openAgentMenu(label = "Open actions for Alpha") { await flushReact(); } +async function openAgentsSectionMenu() { + const trigger = document.body.querySelector('button[aria-label="Agents 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(); +} + +function agentLinkLabels(container: HTMLElement) { + return Array.from(container.querySelectorAll('a[href^="/agents/"]')) + .map((anchor) => anchor.textContent?.trim()) + .filter(Boolean); +} + describe("SidebarAgents", () => { let container: HTMLDivElement; let root: ReturnType | null; @@ -165,6 +193,7 @@ describe("SidebarAgents", () => { user: { id: "user-1" }, }); mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + localStorage.clear(); }); afterEach(async () => { @@ -177,10 +206,11 @@ describe("SidebarAgents", () => { queryClient.clear(); container.remove(); document.body.innerHTML = ""; + localStorage.clear(); vi.clearAllMocks(); }); - it("shows edit and pause actions for an active sidebar agent", async () => { + async function renderSidebarAgents() { const currentRoot = createRoot(container); root = currentRoot; @@ -192,6 +222,97 @@ describe("SidebarAgents", () => { ); }); await flushReact(); + } + + it("keeps top mode in stored org-aware order", async () => { + localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"])); + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }), + makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }), + makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }), + ]); + + await renderSidebarAgents(); + + expect(agentLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]); + }); + + it("uses the heading for section menu and the plus button for agent creation", async () => { + await renderSidebarAgents(); + + const sectionMenuTrigger = container.querySelector('button[aria-label="Agents section actions"]'); + expect(sectionMenuTrigger?.textContent).toContain("Agents"); + expect(sectionMenuTrigger?.querySelector("svg")).toBeNull(); + + const newAgentButton = container.querySelector('button[aria-label="New agent"]'); + expect(newAgentButton).toBeTruthy(); + await act(async () => { + newAgentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockOpenNewAgent).toHaveBeenCalledTimes(1); + + await openAgentsSectionMenu(); + + const newAgentItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("New agent")); + expect(newAgentItem).toBeFalsy(); + const browseLink = Array.from(document.body.querySelectorAll("a")) + .find((element) => element.textContent?.includes("Browse agents")); + expect(browseLink?.getAttribute("href")).toBe("/agents/all"); + }); + + it("sorts alphabetically and persists the selected mode per company and user", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }), + makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }), + makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }), + ]); + + await renderSidebarAgents(); + await openAgentsSectionMenu(); + await chooseSortMode("Alphabetical"); + + expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]); + expect(localStorage.getItem("paperclip.agentSortMode:company-1:user-1")).toBe("alphabetical"); + }); + + it("sorts recent agents by heartbeat, updated time, and created time descending", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ + id: "agent-a", + name: "Alpha", + urlKey: "alpha", + lastHeartbeatAt: null, + updatedAt: new Date("2026-01-20T00:00:00Z"), + createdAt: new Date("2026-01-01T00:00:00Z"), + }), + makeAgent({ + id: "agent-b", + name: "Bravo", + urlKey: "bravo", + lastHeartbeatAt: new Date("2026-01-10T00:00:00Z"), + updatedAt: new Date("2026-01-02T00:00:00Z"), + createdAt: new Date("2026-01-02T00:00:00Z"), + }), + makeAgent({ + id: "agent-c", + name: "Charlie", + urlKey: "charlie", + lastHeartbeatAt: null, + updatedAt: new Date("2026-01-20T00:00:00Z"), + createdAt: new Date("2026-01-03T00:00:00Z"), + }), + ]); + + await renderSidebarAgents(); + await openAgentsSectionMenu(); + await chooseSortMode("Recent"); + + expect(agentLinkLabels(container)).toEqual(["Bravo", "Charlie", "Alpha"]); + }); + + it("shows edit and pause actions for an active sidebar agent", async () => { + await renderSidebarAgents(); await openAgentMenu(); const editLink = Array.from(document.body.querySelectorAll("a")) @@ -216,17 +337,8 @@ describe("SidebarAgents", () => { mockAgentsApi.list.mockResolvedValue([ makeAgent({ status: "paused", pauseReason: "manual", pausedAt: new Date("2026-01-02T00:00:00Z") }), ]); - const currentRoot = createRoot(container); - root = currentRoot; - await act(async () => { - currentRoot.render( - - - , - ); - }); - await flushReact(); + await renderSidebarAgents(); await openAgentMenu(); const resumeItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) @@ -248,17 +360,8 @@ describe("SidebarAgents", () => { makeAgent({ id: "agent-2", name: "Beta", urlKey: "beta" }), ]); mockAgentsApi.pause.mockImplementation(() => new Promise(() => {})); - const currentRoot = createRoot(container); - root = currentRoot; - await act(async () => { - currentRoot.render( - - - , - ); - }); - await flushReact(); + await renderSidebarAgents(); await openAgentMenu(); const pauseItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) @@ -287,17 +390,8 @@ describe("SidebarAgents", () => { pausedAt: new Date("2026-01-02T00:00:00Z"), }), ]); - const currentRoot = createRoot(container); - root = currentRoot; - await act(async () => { - currentRoot.render( - - - , - ); - }); - await flushReact(); + await renderSidebarAgents(); await openAgentMenu(); const budgetPausedItem = Array.from( diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 6075bfe7..0029a0d4 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,13 +1,13 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, NavLink, useLocation } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - ChevronRight, MoreHorizontal, PauseCircle, Pencil, PlayCircle, Plus, + Users, } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -20,14 +20,18 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { cn, agentRouteRef, agentUrl } from "../lib/utils"; import { useAgentOrder } from "../hooks/useAgentOrder"; +import { + AGENT_SORT_MODE_UPDATED_EVENT, + getAgentSortModeStorageKey, + readAgentSortMode, + type AgentSortModeUpdatedDetail, + type AgentSidebarSortMode, + writeAgentSortMode, +} from "../lib/agent-order"; import { AgentIcon } from "./AgentIconPicker"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; +import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection"; import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, @@ -37,6 +41,41 @@ import { } from "@/components/ui/dropdown-menu"; import type { Agent } from "@paperclipai/shared"; +const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [ + { value: "top", label: "Top" }, + { value: "alphabetical", label: "Alphabetical" }, + { value: "recent", label: "Recent" }, +]; + +function agentTimestamp(agent: Agent, field: "lastHeartbeatAt" | "updatedAt" | "createdAt"): number { + const raw = agent[field]; + if (!raw) return 0; + const time = new Date(raw).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function sortAgents(agents: Agent[], sortMode: AgentSidebarSortMode): Agent[] { + if (sortMode === "top") return agents; + const sorted = [...agents]; + if (sortMode === "alphabetical") { + sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" })); + return sorted; + } + sorted.sort((left, right) => { + const heartbeatDiff = agentTimestamp(right, "lastHeartbeatAt") - agentTimestamp(left, "lastHeartbeatAt"); + if (heartbeatDiff !== 0) return heartbeatDiff; + + const updatedDiff = agentTimestamp(right, "updatedAt") - agentTimestamp(left, "updatedAt"); + if (updatedDiff !== 0) return updatedDiff; + + const createdDiff = agentTimestamp(right, "createdAt") - agentTimestamp(left, "createdAt"); + return createdDiff !== 0 + ? createdDiff + : left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); + }); + return sorted; +} + function SidebarAgentItem({ activeAgentId, activeTab, @@ -195,16 +234,69 @@ export function SidebarAgents() { return filtered; }, [agents]); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const sortModeStorageKey = useMemo(() => { + if (!selectedCompanyId) return null; + return getAgentSortModeStorageKey(selectedCompanyId, currentUserId); + }, [currentUserId, selectedCompanyId]); + const [sortMode, setSortMode] = useState(() => { + if (!sortModeStorageKey) return "top"; + return readAgentSortMode(sortModeStorageKey); + }); const { orderedAgents } = useAgentOrder({ agents: visibleAgents, companyId: selectedCompanyId, userId: currentUserId, }); + const sortedAgents = useMemo( + () => sortAgents(orderedAgents, sortMode), + [orderedAgents, sortMode], + ); const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/); const activeAgentId = agentMatch?.[1] ?? null; const activeTab = agentMatch?.[2] ?? null; + useEffect(() => { + if (!sortModeStorageKey) { + setSortMode("top"); + return; + } + setSortMode(readAgentSortMode(sortModeStorageKey)); + }, [sortModeStorageKey]); + + useEffect(() => { + if (!sortModeStorageKey) return; + + const onStorage = (event: StorageEvent) => { + if (event.key !== sortModeStorageKey) return; + setSortMode(readAgentSortMode(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(AGENT_SORT_MODE_UPDATED_EVENT, onCustomEvent); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(AGENT_SORT_MODE_UPDATED_EVENT, onCustomEvent); + }; + }, [sortModeStorageKey]); + + const persistSortMode = useCallback( + (value: string) => { + const nextSortMode: AgentSidebarSortMode = + value === "alphabetical" || value === "recent" ? value : "top"; + setSortMode(nextSortMode); + if (sortModeStorageKey) { + writeAgentSortMode(sortModeStorageKey, nextSortMode); + } + }, + [sortModeStorageKey], + ); + const pauseResumeAgent = useMutation({ mutationFn: ({ agent, action }: { agent: Agent; action: "pause" | "resume" }) => action === "pause" @@ -252,53 +344,42 @@ export function SidebarAgents() { }); return ( - -
-
- - - - Agents - - - -
-
- - -
- {orderedAgents.map((agent: Agent) => { - const runCount = liveCountByAgent.get(agent.id) ?? 0; - return ( - pauseResumeAgent.mutate({ agent: targetAgent, action })} - runCount={runCount} - setSidebarOpen={setSidebarOpen} - /> - ); - })} -
-
-
+ + {sortedAgents.map((agent: Agent) => { + const runCount = liveCountByAgent.get(agent.id) ?? 0; + return ( + pauseResumeAgent.mutate({ agent: targetAgent, action })} + runCount={runCount} + setSidebarOpen={setSidebarOpen} + /> + ); + })} + ); } diff --git a/ui/src/components/SidebarProjects.test.tsx b/ui/src/components/SidebarProjects.test.tsx new file mode 100644 index 00000000..e9e46126 --- /dev/null +++ b/ui/src/components/SidebarProjects.test.tsx @@ -0,0 +1,299 @@ +// @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("uses the heading for section menu and the plus button for project creation", async () => { + await renderSidebarProjects(); + + const sectionMenuTrigger = container.querySelector('button[aria-label="Projects section actions"]'); + expect(sectionMenuTrigger?.textContent).toContain("Projects"); + expect(sectionMenuTrigger?.querySelector("svg")).toBeNull(); + + const newProjectButton = container.querySelector('button[aria-label="New project"]'); + expect(newProjectButton).toBeTruthy(); + await act(async () => { + newProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockOpenNewProject).toHaveBeenCalledTimes(1); + + await openProjectsMenu(container); + + const newProjectItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("New project")); + expect(newProjectItem).toBeFalsy(); + const browseLink = Array.from(document.body.querySelectorAll("a")) + .find((element) => element.textContent?.includes("Browse projects")); + expect(browseLink?.getAttribute("href")).toBe("/projects"); + }); + + 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 b48bc967..1895543c 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,27 @@ 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 ProjectSortModeUpdatedDetail, + 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 ProjectItemProps = { activeProjectRef: string | null; companyId: string | null; companyPrefix: string | null; @@ -48,7 +50,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 +143,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 +186,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 +204,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 +219,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 +273,44 @@ 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 00000000..8ca2a2e3 --- /dev/null +++ b/ui/src/components/SidebarSection.test.tsx @@ -0,0 +1,299 @@ +// @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"; +import { Plus } from "lucide-react"; + +const sidebarState = vi.hoisted(() => ({ + isMobile: false, +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + {children} + ), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => sidebarState, +})); + +// 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(() => { + sidebarState.isMobile = false; + 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( +
+ + Issues + + + Projects + +
, + ); + }); + 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?.parentElement?.textContent).toBe("Work"); + expect(projectsLabel?.parentElement?.textContent).toBe("Projects"); + expect(projectsLabel?.parentElement?.querySelector("svg")).toBeNull(); + expect(container.querySelector('button[aria-label="Collapse Projects"] svg')).toBeTruthy(); + }); + + it("keeps collapse on the caret and opens the menu from the heading", async () => { + const onOpenChange = vi.fn(); + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + Projects + , + ); + }); + await flushReact(); + + await openSectionMenu(container); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Browse projects"); + + const caret = container.querySelector('button[aria-label="Collapse Projects"]'); + await act(async () => { + caret?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("does not apply hover background classes to static section labels", async () => { + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + Issues + , + ); + }); + await flushReact(); + + const workLabel = Array.from(container.querySelectorAll("span")) + .find((element) => element.textContent === "Work"); + const staticLabelControl = workLabel?.parentElement; + + expect(staticLabelControl?.tagName).toBe("DIV"); + expect(staticLabelControl?.getAttribute("class")).not.toContain("hover:bg-accent/50"); + expect(staticLabelControl?.getAttribute("class")).not.toContain("focus-visible:bg-accent/50"); + }); + + it("keeps the header action outside the label menu hit area", async () => { + const onAction = vi.fn(); + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + Projects + , + ); + }); + await flushReact(); + + const sectionMenuTrigger = container.querySelector('button[aria-label="Projects section actions"]'); + const newProjectButton = container.querySelector('button[aria-label="New project"]'); + + expect(sectionMenuTrigger?.textContent).toContain("Projects"); + expect(sectionMenuTrigger?.querySelector("svg")).toBeNull(); + expect(sectionMenuTrigger?.getAttribute("class")).toContain("hover:bg-accent/50"); + expect(newProjectButton).toBeTruthy(); + + await act(async () => { + newProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(document.body.textContent).not.toContain("Browse projects"); + + await openSectionMenu(container); + expect(document.body.textContent).toContain("Browse projects"); + }); + + 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); + }); + + it("keeps section header controls visible on mobile", async () => { + sidebarState.isMobile = true; + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + Projects + , + ); + }); + await flushReact(); + + const projectsLabel = Array.from(container.querySelectorAll("span")) + .find((element) => element.textContent === "Projects"); + const caret = container.querySelector('button[aria-label="Expand Projects"] svg'); + const action = container.querySelector('button[aria-label="New project"]'); + + expect(caret?.getAttribute("class")).toContain("opacity-100"); + expect(caret?.getAttribute("class")).not.toContain("opacity-0"); + expect(projectsLabel?.parentElement?.textContent).toBe("Projects"); + expect(action?.getAttribute("class")).toContain("opacity-100"); + expect(action?.getAttribute("class")).not.toContain("opacity-0"); + }); +}); diff --git a/ui/src/components/SidebarSection.tsx b/ui/src/components/SidebarSection.tsx index 3dd68feb..aba5ff5a 100644 --- a/ui/src/components/SidebarSection.tsx +++ b/ui/src/components/SidebarSection.tsx @@ -1,17 +1,212 @@ -import type { ReactNode } from "react"; +import { useState, type ComponentType, type ReactNode } from "react"; +import { Link } from "@/lib/router"; +import { ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useSidebar } from "../context/SidebarContext"; +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; +}; + +type SidebarSectionHeaderAction = { + ariaLabel: string; + icon: SidebarSectionIcon; + onClick: () => void; +}; interface SidebarSectionProps { label: string; children: ReactNode; + collapsible?: { + open: boolean; + onOpenChange: (open: boolean) => void; + }; + menu?: SidebarSectionMenu; + headerAction?: SidebarSectionHeaderAction; } -export function SidebarSection({ label, children }: SidebarSectionProps) { +function SidebarSectionHeader({ + collapsible, + headerAction, + label, + menu, +}: Pick) { + const { isMobile } = useSidebar(); + 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 headerControlVisibilityClassName = isMobile + ? "opacity-100" + : "opacity-0 group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100"; + const caretClassName = cn( + "h-3 w-3 shrink-0 text-muted-foreground/60 transition-all", + headerControlVisibilityClassName, + collapsible?.open && "rotate-90", + menuOpen && "opacity-100", + ); + const actionClassName = cn( + "h-5 w-5 shrink-0 text-muted-foreground/60 transition-opacity hover:text-foreground data-[state=open]:opacity-100", + headerControlVisibilityClassName, + ); + const headerContent = {label}; + const HeaderActionIcon = headerAction?.icon; + + const headingControl = 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} + + + ) : ( +
{headerContent}
+ ); + return ( -
-
- {label} +
+
+ {collapsible ? ( + + + + ) : null} + {headingControl} + {headerAction && HeaderActionIcon ? ( + + ) : null}
-
{children}
+
+ ); +} + +export function SidebarSection({ + label, + children, + collapsible, + menu, + headerAction, +}: SidebarSectionProps) { + const content =
{children}
; + + if (collapsible) { + return ( + + + {content} + + ); + } + + return ( +
+ + {content}
); } diff --git a/ui/src/lib/agent-order.ts b/ui/src/lib/agent-order.ts index 831624df..317ba5ca 100644 --- a/ui/src/lib/agent-order.ts +++ b/ui/src/lib/agent-order.ts @@ -1,19 +1,32 @@ import type { Agent } from "@paperclipai/shared"; export const AGENT_ORDER_UPDATED_EVENT = "paperclip:agent-order-updated"; +export const AGENT_SORT_MODE_UPDATED_EVENT = "paperclip:agent-sort-mode-updated"; const AGENT_ORDER_STORAGE_PREFIX = "paperclip.agentOrder"; +const AGENT_SORT_MODE_STORAGE_PREFIX = "paperclip.agentSortMode"; const ANONYMOUS_USER_ID = "anonymous"; +export type AgentSidebarSortMode = "top" | "alphabetical" | "recent"; + type AgentOrderUpdatedDetail = { storageKey: string; orderedIds: string[]; }; +export type AgentSortModeUpdatedDetail = { + storageKey: string; + sortMode: AgentSidebarSortMode; +}; + 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): AgentSidebarSortMode { + 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 getAgentOrderStorageKey(companyId: string, userId: string | null return `${AGENT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`; } +export function getAgentSortModeStorageKey(companyId: string, userId: string | null | undefined): string { + return `${AGENT_SORT_MODE_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`; +} + export function readAgentOrder(storageKey: string): string[] { try { const raw = localStorage.getItem(storageKey); @@ -34,6 +51,14 @@ export function readAgentOrder(storageKey: string): string[] { } } +export function readAgentSortMode(storageKey: string): AgentSidebarSortMode { + try { + return normalizeSortMode(localStorage.getItem(storageKey)); + } catch { + return "top"; + } +} + export function writeAgentOrder(storageKey: string, orderedIds: string[]) { const normalized = normalizeIdList(orderedIds); try { @@ -50,6 +75,22 @@ export function writeAgentOrder(storageKey: string, orderedIds: string[]) { } } +export function writeAgentSortMode(storageKey: string, sortMode: AgentSidebarSortMode) { + 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(AGENT_SORT_MODE_UPDATED_EVENT, { + detail: { storageKey, sortMode: normalized }, + }), + ); + } +} + export function sortAgentsByDefaultSidebarOrder(agents: Agent[]): Agent[] { if (agents.length === 0) return []; diff --git a/ui/src/lib/project-order.ts b/ui/src/lib/project-order.ts index 9594a4c6..10665a79 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[]; }; +export 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;