diff --git a/docs/pr-screenshots/pr-4616/sidebar-agent-actions.png b/docs/pr-screenshots/pr-4616/sidebar-agent-actions.png new file mode 100644 index 00000000..143c8812 Binary files /dev/null and b/docs/pr-screenshots/pr-4616/sidebar-agent-actions.png differ diff --git a/docs/pr-screenshots/pr-4616/sidebar-agent-row.png b/docs/pr-screenshots/pr-4616/sidebar-agent-row.png new file mode 100644 index 00000000..1da57eb6 Binary files /dev/null and b/docs/pr-screenshots/pr-4616/sidebar-agent-row.png differ diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx new file mode 100644 index 00000000..c75327fb --- /dev/null +++ b/ui/src/components/SidebarAgents.test.tsx @@ -0,0 +1,313 @@ +// @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 { Agent } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SidebarAgents } from "./SidebarAgents"; + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockHeartbeatsApi = vi.hoisted(() => ({ + liveRunsForCompany: vi.fn(), +})); + +const mockOpenNewAgent = vi.hoisted(() => vi.fn()); +const mockPushToast = vi.hoisted(() => vi.fn()); +const mockSetSidebarOpen = 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/dashboard", search: "", hash: "", state: null }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + }), +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => ({ + openNewAgent: mockOpenNewAgent, + }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: mockSetSidebarOpen, + }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ + pushToast: mockPushToast, + }), +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: mockHeartbeatsApi, +})); + +// 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 makeAgent(overrides: Partial): Agent { + return { + id: "agent-1", + companyId: "company-1", + name: "Alpha", + urlKey: "alpha", + role: "engineer", + title: null, + icon: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: 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)); + }); +} + +async function openAgentMenu(label = "Open actions for Alpha") { + const trigger = document.body.querySelector(`button[aria-label="${label}"]`); + 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("SidebarAgents", () => { + 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 } }, + }); + mockAgentsApi.list.mockResolvedValue([makeAgent({})]); + mockAgentsApi.pause.mockResolvedValue(makeAgent({ status: "paused" })); + mockAgentsApi.resume.mockResolvedValue(makeAgent({})); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1" }, + }); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + }); + + afterEach(async () => { + const currentRoot = root; + if (currentRoot) { + await act(async () => { + currentRoot.unmount(); + }); + } + queryClient.clear(); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("shows edit and pause actions for an active sidebar agent", async () => { + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + + , + ); + }); + await flushReact(); + await openAgentMenu(); + + const editLink = Array.from(document.body.querySelectorAll("a")) + .find((element) => element.textContent?.includes("Edit agent")); + expect(editLink?.getAttribute("href")).toBe("/agents/alpha/configuration"); + expect(document.body.textContent).toContain("Pause agent"); + + const pauseItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("Pause agent")); + expect(pauseItem).toBeTruthy(); + + await act(async () => { + pauseItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockAgentsApi.pause).toHaveBeenCalledWith("agent-1", "company-1"); + expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Agent paused" })); + }); + + it("shows resume for paused sidebar agents", async () => { + 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 openAgentMenu(); + + const resumeItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("Resume agent")); + expect(resumeItem).toBeTruthy(); + + await act(async () => { + resumeItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockAgentsApi.resume).toHaveBeenCalledWith("agent-1", "company-1"); + expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Agent resumed" })); + }); + + it("only shows updating state for the agent currently being changed", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "agent-1", name: "Alpha", urlKey: "alpha" }), + 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 openAgentMenu(); + + const pauseItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("Pause agent")); + expect(pauseItem).toBeTruthy(); + + await act(async () => { + pauseItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await openAgentMenu("Open actions for Beta"); + + const betaPauseItem = Array.from( + document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'), + ) + .find((element) => element.textContent?.includes("Pause agent")); + expect(betaPauseItem).toBeTruthy(); + expect(document.body.textContent).not.toContain("Updating..."); + }); + + it("does not offer sidebar resume for budget-paused agents", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ + status: "paused", + pauseReason: "budget", + pausedAt: new Date("2026-01-02T00:00:00Z"), + }), + ]); + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + + , + ); + }); + await flushReact(); + await openAgentMenu(); + + const budgetPausedItem = Array.from( + document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'), + ) + .find((element) => element.textContent?.includes("Budget paused")); + expect(budgetPausedItem).toBeTruthy(); + + await act(async () => { + budgetPausedItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockAgentsApi.resume).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 11084cd9..e2efa56e 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,10 +1,18 @@ import { useMemo, useState } from "react"; -import { NavLink, useLocation } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; -import { ChevronRight, Plus } from "lucide-react"; +import { Link, NavLink, useLocation } from "@/lib/router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ChevronRight, + MoreHorizontal, + PauseCircle, + Pencil, + PlayCircle, + Plus, +} from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; +import { useToastActions } from "../context/ToastContext"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { heartbeatsApi } from "../api/heartbeats"; @@ -14,17 +22,145 @@ import { cn, agentRouteRef, agentUrl } from "../lib/utils"; import { useAgentOrder } from "../hooks/useAgentOrder"; import { AgentIcon } from "./AgentIconPicker"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; +import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import type { Agent } from "@paperclipai/shared"; + +function SidebarAgentItem({ + activeAgentId, + activeTab, + agent, + disabled, + isMobile, + onPauseResume, + runCount, + setSidebarOpen, +}: { + activeAgentId: string | null; + activeTab: string | null; + agent: Agent; + disabled: boolean; + isMobile: boolean; + onPauseResume: (agent: Agent, action: "pause" | "resume") => void; + runCount: number; + setSidebarOpen: (open: boolean) => void; +}) { + const routeRef = agentRouteRef(agent); + const href = activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent); + const editHref = `${agentUrl(agent)}/configuration`; + const isActive = activeAgentId === routeRef; + const isPaused = agent.status === "paused"; + const isBudgetPaused = isPaused && agent.pauseReason === "budget"; + const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent"; + const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused; + const pauseResumeDisabledLabel = disabled + ? "Updating..." + : isBudgetPaused + ? "Budget paused" + : pauseResumeLabel; + + return ( +
+ { + if (isMobile) setSidebarOpen(false); + }} + className={cn( + "flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 text-[13px] font-medium transition-colors", + isActive + ? "bg-accent text-foreground" + : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" + )} + > + + {agent.name} + {(agent.pauseReason === "budget" || runCount > 0) && ( + + {agent.pauseReason === "budget" ? ( + + ) : null} + {runCount > 0 ? ( + + + + + ) : null} + {runCount > 0 ? ( + + {runCount} live + + ) : null} + + )} + + + + + + + + + { + if (isMobile) setSidebarOpen(false); + }} + > + + Edit agent + + + + { + if (pauseResumeDisabled) return; + onPauseResume(agent, isPaused ? "resume" : "pause"); + }} + disabled={pauseResumeDisabled} + title={isBudgetPaused ? "Agent was paused by budget limits" : undefined} + > + {isPaused ? : } + {pauseResumeDisabledLabel} + + + +
+ ); +} + export function SidebarAgents() { const [open, setOpen] = useState(true); + const [pendingAgentIds, setPendingAgentIds] = useState>(() => new Set()); + const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const { openNewAgent } = useDialog(); const { isMobile, setSidebarOpen } = useSidebar(); + const { pushToast } = useToastActions(); const location = useLocation(); const { data: agents } = useQuery({ @@ -69,6 +205,51 @@ export function SidebarAgents() { const activeAgentId = agentMatch?.[1] ?? null; const activeTab = agentMatch?.[2] ?? null; + const pauseResumeAgent = useMutation({ + mutationFn: ({ agent, action }: { agent: Agent; action: "pause" | "resume" }) => + action === "pause" + ? agentsApi.pause(agent.id, selectedCompanyId ?? undefined) + : agentsApi.resume(agent.id, selectedCompanyId ?? undefined), + onMutate: ({ agent }) => { + setPendingAgentIds((current) => { + const next = new Set(current); + next.add(agent.id); + return next; + }); + }, + onSuccess: async (_agent, { agent, action }) => { + if (selectedCompanyId) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(selectedCompanyId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(selectedCompanyId) }), + ]); + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentRouteRef(agent)) }), + ]); + pushToast({ + title: action === "pause" ? "Agent paused" : "Agent resumed", + body: agent.name, + tone: "success", + }); + }, + onError: (error, { agent, action }) => { + pushToast({ + title: action === "pause" ? "Could not pause agent" : "Could not resume agent", + body: error instanceof Error ? error.message : agent.name, + tone: "error", + }); + }, + onSettled: (_data, _error, { agent }) => { + setPendingAgentIds((current) => { + const next = new Set(current); + next.delete(agent.id); + return next; + }); + }, + }); return ( @@ -103,41 +284,17 @@ export function SidebarAgents() { {orderedAgents.map((agent: Agent) => { const runCount = liveCountByAgent.get(agent.id) ?? 0; return ( - { - if (isMobile) setSidebarOpen(false); - }} - className={cn( - "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", - activeAgentId === agentRouteRef(agent) - ? "bg-accent text-foreground" - : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" - )} - > - - {agent.name} - {(agent.pauseReason === "budget" || runCount > 0) && ( - - {agent.pauseReason === "budget" ? ( - - ) : null} - {runCount > 0 ? ( - - - - - ) : null} - {runCount > 0 ? ( - - {runCount} live - - ) : null} - - )} - + activeAgentId={activeAgentId} + activeTab={activeTab} + agent={agent} + disabled={pendingAgentIds.has(agent.id)} + isMobile={isMobile} + onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })} + runCount={runCount} + setSidebarOpen={setSidebarOpen} + /> ); })}