From 9fc2f594ae0745de15e65abf95cba81c12bbb2c2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 14:09:54 -0700 Subject: [PATCH] feat(ui): dashboard banner for paused imported agents with Resume all (#12142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Company import parks every imported agent as a safety default > - The only surface that offered to activate them was the post-import checklist, which is UI-only and gone after a reload or an expired import job > - A company whose agents are all paused looks broken: tasks sit still and the dashboard gives no explanation or fix > - This pull request adds a dashboard banner for import-paused agents with a one-click Resume all, and a generic banner when every agent is paused > - The benefit is a durable, reload-proof place to understand and fix the parked state ## Linked Issues or Issue Description **What existing behavior does this improve?** The company dashboard for a company whose agents are paused, in particular after a company import. **Subsystem affected** Web UI — dashboard (`ui/src/pages/Dashboard.tsx`). **Current behavior** Imported agents arrive paused. The activation checklist on the import page is the only activation surface and is lost on reload. The dashboard shows paused counts in a metric card but no explanation and no action. Tasks assigned to the paused agents never start. **Proposed behavior** When any agent carries the `import` pause reason, the dashboard shows a warning banner ("N imported agents are paused and will not run") with a **Resume all** action. It resumes each parked agent sequentially, tolerates per-agent failures, and refreshes so the banner reflects whatever remains paused. When no import pauses exist but every agent in the company is paused, a generic all-paused banner links to the agents page. **Breaking changes** None. Depends on the `import` pause reason introduced in #12140 (this branch is stacked on it). ## What Changed - New exported helper `derivePausedAgentBanner(agents)` deciding between the imported banner, the all-paused banner, or none. - Dashboard renders the banners via the shared `InlineBanner`, with a sequential `agentsApi.resume` mutation for Resume all and query invalidation for the agent list and dashboard stats. ## Verification - `cd ui && npx vitest run src/pages/Dashboard.test.ts` — 4 tests pass (no agents, imported preference, all-paused fallback, mixed-state null). - `cd ui && pnpm run typecheck` — clean. - Manual: import a company package with paused agents, open its dashboard, click Resume all, and watch the banner clear as agents go idle. ## Risks - Low risk. Resume all reuses `POST /agents/:id/resume` with its existing guards, sequentially, matching the import page's activation checklist pattern. At current import sizes (tens of agents) this is fast; a server-side bulk endpoint is the follow-up if imports grow to hundreds of agents. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking and tool use, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/pages/Dashboard.test.ts | 45 +++++++++++++++++ ui/src/pages/Dashboard.tsx | 89 +++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/Dashboard.test.ts diff --git a/ui/src/pages/Dashboard.test.ts b/ui/src/pages/Dashboard.test.ts new file mode 100644 index 0000000000..bc053d2e7b --- /dev/null +++ b/ui/src/pages/Dashboard.test.ts @@ -0,0 +1,45 @@ +import type { Agent } from "@paperclipai/shared"; +import { describe, expect, it } from "vitest"; +import { derivePausedAgentBanner } from "./Dashboard"; + +function agent(overrides: Partial): Agent { + return { + id: "agent-1", + name: "Agent", + status: "idle", + pauseReason: null, + ...overrides, + } as unknown as Agent; +} + +describe("derivePausedAgentBanner", () => { + it("returns null with no agents loaded or an empty company", () => { + expect(derivePausedAgentBanner(undefined)).toBeNull(); + expect(derivePausedAgentBanner([])).toBeNull(); + }); + + it("prefers the imported banner and lists only import-paused agents", () => { + const banner = derivePausedAgentBanner([ + agent({ id: "a", status: "paused", pauseReason: "import" }), + agent({ id: "b", status: "paused", pauseReason: "manual" }), + agent({ id: "c", status: "idle" }), + ]); + expect(banner).toEqual({ kind: "imported", pausedImportedAgentIds: ["a"] }); + }); + + it("falls back to the all-paused banner when no import pauses exist", () => { + const banner = derivePausedAgentBanner([ + agent({ id: "a", status: "paused", pauseReason: "manual" }), + agent({ id: "b", status: "paused", pauseReason: "system" }), + ]); + expect(banner).toEqual({ kind: "all-paused" }); + }); + + it("shows nothing while at least one agent can run and none are import-paused", () => { + const banner = derivePausedAgentBanner([ + agent({ id: "a", status: "paused", pauseReason: "manual" }), + agent({ id: "b", status: "idle" }), + ]); + expect(banner).toBeNull(); + }); +}); diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index d207a4dbd2..75b24dbe89 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import { } from "../lib/onboarding-route"; import { claimOnboardingOffer } from "../lib/onboarding-auto-open"; import { Link } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { dashboardApi } from "../api/dashboard"; import { activityApi } from "../api/activity"; import { accessApi } from "../api/access"; @@ -33,6 +33,8 @@ import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel"; import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; import { PageSkeleton } from "../components/PageSkeleton"; import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { InlineBanner } from "../components/InlineBanner"; import type { Agent, Issue } from "@paperclipai/shared"; import { PluginSlotOutlet } from "@/plugins/slots"; import { SmokeLabDashboardCard } from "../components/SmokeLabDashboardCard"; @@ -44,6 +46,30 @@ function getRecentIssues(issues: Issue[]): Issue[] { .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } +export type PausedAgentBanner = + | { kind: "imported"; pausedImportedAgentIds: string[] } + | { kind: "all-paused" } + | null; + +/** + * Which paused-agents banner the dashboard should show. Import-paused agents + * get the specific banner with a bulk resume (they were parked by the import + * safety default and stay parked until someone acts); otherwise a company + * whose agents are ALL paused gets a generic explanation, because from the + * outside it is indistinguishable from a broken company. + */ +export function derivePausedAgentBanner(agents: Agent[] | undefined): PausedAgentBanner { + if (!agents || agents.length === 0) return null; + const importedPaused = agents.filter( + (agent) => agent.status === "paused" && agent.pauseReason === "import", + ); + if (importedPaused.length > 0) { + return { kind: "imported", pausedImportedAgentIds: importedPaused.map((agent) => agent.id) }; + } + if (agents.every((agent) => agent.status === "paused")) return { kind: "all-paused" }; + return null; +} + export function Dashboard() { const { selectedCompanyId, companies } = useCompany(); const { openOnboarding } = useDialogActions(); @@ -60,6 +86,31 @@ export function Dashboard() { enabled: !!selectedCompanyId, }); + // Bulk resume for agents parked by a company import. Sequential on purpose + // (mirrors the import page's activation checklist); a per-agent failure is + // tolerated so one bad agent never blocks the rest, and the refetch below + // re-renders the banner with whatever remains paused. + const queryClient = useQueryClient(); + const resumeImportedAgents = useMutation({ + mutationFn: async () => { + const targets = derivePausedAgentBanner(agents); + if (!targets || targets.kind !== "imported") return; + for (const agentId of targets.pausedImportedAgentIds) { + try { + await agentsApi.resume(agentId, selectedCompanyId ?? undefined); + } catch { + // Leave the agent paused; the banner re-renders with the remainder. + } + } + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }), + queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(selectedCompanyId!) }), + ]); + }, + }); + // A company with no agent cannot do anything — no runs, no tasks, nothing // to show. The banner below already says so and offers a link; this takes // the customer there instead of asking them to notice. @@ -258,11 +309,47 @@ export function Dashboard() { } const hasNoAgents = agents !== undefined && agents.length === 0; + const pausedBanner = derivePausedAgentBanner(agents); + const pausedImportedCount = + pausedBanner?.kind === "imported" ? pausedBanner.pausedImportedAgentIds.length : 0; return (
{error &&

{error.message}

} + {pausedBanner?.kind === "imported" ? ( + resumeImportedAgents.mutate()} + disabled={resumeImportedAgents.isPending} + data-testid="dashboard-resume-imported-agents" + > + {resumeImportedAgents.isPending ? "Resuming…" : "Resume all"} + + } + > + Agents from a company import arrive paused as a safety default. Resume them so assigned tasks can start. + + ) : pausedBanner?.kind === "all-paused" ? ( + + Review agents + + } + > + Resume at least one agent to let assigned tasks start. + + ) : null} + {hasNoAgents && (