diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index ee3ef2aac4e..0e68b05166e 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,92 @@ 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("moves agent creation into the section menu and links browse agents", async () => { + await renderSidebarAgents(); + + expect(container.querySelector('button[aria-label="New agent"]')).toBeNull(); + await openAgentsSectionMenu(); + + const newAgentItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("New agent")); + expect(newAgentItem).toBeTruthy(); + const browseLink = Array.from(document.body.querySelectorAll("a")) + .find((element) => element.textContent?.includes("Browse agents")); + expect(browseLink?.getAttribute("href")).toBe("/agents/all"); + + await act(async () => { + newAgentItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockOpenNewAgent).toHaveBeenCalledTimes(1); + }); + + 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 +332,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 +355,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 +385,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 6075bfe7c82..b3eefcb27b3 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,17 @@ 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 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 +40,46 @@ 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" }, +]; + +type AgentSortModeUpdatedDetail = { + storageKey: string; + sortMode: AgentSidebarSortMode; +}; + +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 +238,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 +348,38 @@ 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/lib/agent-order.ts b/ui/src/lib/agent-order.ts index 831624df4fa..2e1cac78103 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[]; }; +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 [];