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:
Devin Foley 2026-08-25 14:09:54 -07:00 committed by GitHub
parent 397de98193
commit 9fc2f594ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 133 additions and 1 deletions

View File

@ -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();
});
});

View File

@ -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">