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 && (