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}
+ />
);
})}