feat(ui): dashboard banner for paused imported agents with Resume all (#12142)
## 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
This commit is contained in:
parent
397de98193
commit
9fc2f594ae
|
|
@ -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>): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{pausedBanner?.kind === "imported" ? (
|
||||
<InlineBanner
|
||||
tone="warning"
|
||||
icon={PauseCircle}
|
||||
title={`${pausedImportedCount} imported agent${pausedImportedCount === 1 ? " is" : "s are"} paused and will not run.`}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => resumeImportedAgents.mutate()}
|
||||
disabled={resumeImportedAgents.isPending}
|
||||
data-testid="dashboard-resume-imported-agents"
|
||||
>
|
||||
{resumeImportedAgents.isPending ? "Resuming…" : "Resume all"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Agents from a company import arrive paused as a safety default. Resume them so assigned tasks can start.
|
||||
</InlineBanner>
|
||||
) : pausedBanner?.kind === "all-paused" ? (
|
||||
<InlineBanner
|
||||
tone="warning"
|
||||
icon={PauseCircle}
|
||||
title="All agents in this company are paused — nothing will run."
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/agents">Review agents</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Resume at least one agent to let assigned tasks start.
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
|
||||
{hasNoAgents && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-500/25 dark:bg-amber-950/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
|
|
|||
Loading…
Reference in New Issue