diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx
index b0f95a27b8d..1b4d60454aa 100644
--- a/ui/src/components/AgentActionButtons.tsx
+++ b/ui/src/components/AgentActionButtons.tsx
@@ -17,7 +17,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import { StatusBadge } from "./StatusBadge";
+import { AgentStatusBadge } from "./StatusBadge";
import { agentsApi } from "../api/agents";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
@@ -285,7 +285,7 @@ export function AgentActionButtons({
/>
{showStatus && (
-
+
)}
{children}
diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx
index b10f69e6504..26942704cc8 100644
--- a/ui/src/components/AgentProperties.tsx
+++ b/ui/src/components/AgentProperties.tsx
@@ -5,7 +5,7 @@ import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { queryKeys } from "../lib/queryKeys";
-import { StatusBadge } from "./StatusBadge";
+import { AgentStatusBadge } from "./StatusBadge";
import { Identity } from "./Identity";
import { formatDate, agentUrl } from "../lib/utils";
import { Separator } from "@/components/ui/separator";
@@ -41,7 +41,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
-
+
{roleLabels[agent.role] ?? agent.role}
diff --git a/ui/src/components/StatusBadge.tsx b/ui/src/components/StatusBadge.tsx
index 51a129326f0..f257cf9fcce 100644
--- a/ui/src/components/StatusBadge.tsx
+++ b/ui/src/components/StatusBadge.tsx
@@ -1,5 +1,13 @@
import { cn } from "../lib/utils";
-import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
+import {
+ statusBadge,
+ statusBadgeDefault,
+ agentStatusColor,
+ agentStatusColorDefault,
+ agentStatusBadge,
+ agentStatusCapsule,
+ agentStatusMotion,
+} from "../lib/status-colors";
export function StatusBadge({ status }: { status: string }) {
return (
@@ -13,3 +21,39 @@ export function StatusBadge({ status }: { status: string }) {
);
}
+
+/**
+ * Agent status chip — brand `.task-chip` (1px border, light/dark variants).
+ * Distinct from the shared {@link StatusBadge} so the agents section can carry
+ * the brand state colours without affecting run/issue/goal badges. `active`
+ * renders as "idle" (alias for dead code).
+ */
+export function AgentStatusBadge({ status }: { status: string }) {
+ const color = agentStatusColor[status] ?? agentStatusColorDefault;
+ const label = status === "active" ? "idle" : status;
+ return (
+
+ {label.replace(/_/g, " ")}
+
+ );
+}
+
+/**
+ * Agent status indicator — brand heartbeat capsule (vertical 8×16, r4). Running
+ * agents pulse, broken (error) agents blink; both honor `prefers-reduced-motion`.
+ */
+export function AgentStatusCapsule({ status }: { status: string }) {
+ const color = agentStatusColor[status] ?? agentStatusColorDefault;
+ const motion = agentStatusMotion[status] ?? "";
+ return (
+
+ );
+}
diff --git a/ui/src/index.css b/ui/src/index.css
index 5fb6eddfd7c..340fff5c398 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -404,6 +404,35 @@
}
}
+/* Agent heartbeat capsule motion (PAP-75).
+ `hb-pulse` marks a running agent (live); `hb-blink` marks a broken agent
+ (error). The blink alternates the fill between brand red and the page
+ background so the capsule reads as a faltering heartbeat. */
+@keyframes hb-pulse {
+ 0%, 100% { opacity: 0.4; transform: scale(0.85); }
+ 50% { opacity: 1; transform: scale(1); }
+}
+
+@keyframes hb-blink {
+ 0%, 49.9% { background-color: #DC2626; }
+ 50%, 100% { background-color: var(--background); }
+}
+
+.hb-pulse {
+ animation: hb-pulse 1.6s ease-in-out infinite;
+}
+
+.hb-blink {
+ animation: hb-blink 1.2s step-end infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hb-pulse,
+ .hb-blink {
+ animation: none;
+ }
+}
+
/* MDXEditor theme integration */
.paperclip-mdxeditor-scope,
.paperclip-mdxeditor {
diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts
index c1f673f83a5..3d9ab7209f8 100644
--- a/ui/src/lib/status-colors.ts
+++ b/ui/src/lib/status-colors.ts
@@ -83,20 +83,50 @@ export const statusBadge: Record = {
export const statusBadgeDefault = "bg-muted text-muted-foreground";
// ---------------------------------------------------------------------------
-// Agent status dot — solid background for small indicator dots
+// Agent status — brand state system (PAP-75)
+//
+// The agents section shows four states only. Each maps to a brand colour name;
+// the indicator is a brand heartbeat capsule (8×16, r4) and the row chip is a
+// brand `.task-chip` (1px border, light/dark variants). `active` is dead code
+// that folds into idle; `running` pulses and `error` blinks. `terminated` and
+// `pending_approval` are excluded from the list but may still render on detail
+// pages, where they fall through to the gray default.
// ---------------------------------------------------------------------------
-export const agentStatusDot: Record = {
- running: "bg-cyan-400 animate-pulse",
- active: "bg-green-400",
- paused: "bg-yellow-400",
- idle: "bg-yellow-400",
- pending_approval: "bg-amber-400",
- error: "bg-red-400",
- archived: "bg-neutral-400",
+export type AgentBadgeColor = "gray" | "blue" | "amber" | "red";
+
+/** Agent status → brand colour name. `active` aliases idle (never assigned). */
+export const agentStatusColor: Record = {
+ idle: "gray",
+ active: "gray",
+ running: "blue",
+ paused: "amber",
+ error: "red",
};
-export const agentStatusDotDefault = "bg-neutral-400";
+export const agentStatusColorDefault: AgentBadgeColor = "gray";
+
+/** Brand `.task-chip` styles (1px border) per colour name — light + dark. */
+export const agentStatusBadge: Record = {
+ gray: "bg-[#F5F3F0] text-[#52585D] border-[#A8AEB2] dark:bg-[#6e696024] dark:text-[#9A958A] dark:border-[#9e958a73]",
+ blue: "bg-[#DBEAFE] text-[#1D4ED8] border-[#2563EB] dark:bg-[#2563eb2e] dark:text-[#2563EB] dark:border-[#2563eb73]",
+ amber: "bg-[#FEF3C7] text-[#B45309] border-[#F59E0B] dark:bg-[#f59e0b24] dark:text-[#F59E0B] dark:border-[#f59e0b73]",
+ red: "bg-[#FEE2E2] text-[#991B1B] border-[#DC2626] dark:bg-[#dc26262e] dark:text-[#DC2626] dark:border-[#dc262673]",
+};
+
+/** Heartbeat-capsule fill (solid) per colour name. gray darkens in dark mode. */
+export const agentStatusCapsule: Record = {
+ gray: "bg-[#A8AEB2] dark:bg-[#6E6960]",
+ blue: "bg-[#2563EB]",
+ amber: "bg-[#F59E0B]",
+ red: "bg-[#DC2626]",
+};
+
+/** Per-status capsule motion (running pulses, error blinks). Honors reduced-motion. */
+export const agentStatusMotion: Record = {
+ running: "hb-pulse",
+ error: "hb-blink",
+};
// ---------------------------------------------------------------------------
// Priority colors
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index a88bc1668f9..8062a4d9edb 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -32,7 +32,6 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
import { StatusBadge } from "../components/StatusBadge";
-import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx
index 900c64c41bc..5a07a1b15b1 100644
--- a/ui/src/pages/Agents.tsx
+++ b/ui/src/pages/Agents.tsx
@@ -8,10 +8,9 @@ import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
-import { StatusBadge } from "../components/StatusBadge";
+import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
import { AgentActionButtons } from "../components/AgentActionButtons";
import { MembershipAction } from "../components/MembershipAction";
-import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -19,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
-import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
+import { Bot, Plus, List, GitBranch } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import {
resourceMembershipState,
@@ -33,8 +32,12 @@ const roleLabels = AGENT_ROLE_LABELS as Record;
type FilterTab = "all" | "active" | "paused" | "error";
-function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean {
- if (status === "terminated") return showTerminated;
+// Agents in these states never appear in the agents list — `terminated` is
+// hidden like an archived company, and `pending_approval` is a hiring gate that
+// lives in the task thread, not an agent run state (PAP-75).
+const HIDDEN_AGENT_STATUSES = new Set(["terminated", "pending_approval"]);
+
+function matchesFilter(status: string, tab: FilterTab): boolean {
if (tab === "all") return true;
if (tab === "active") return status === "active" || status === "running" || status === "idle";
if (tab === "paused") return status === "paused";
@@ -42,9 +45,9 @@ function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean):
return true;
}
-function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
+function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
return agents
- .filter((a) => matchesFilter(a.status, tab, showTerminated))
+ .filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab))
.sort((a, b) => a.name.localeCompare(b.name));
}
@@ -55,11 +58,17 @@ function getConfiguredModel(agent: Agent): string | null {
return model.length > 0 ? model : null;
}
-function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
+function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
return nodes
.reduce((acc, node) => {
- const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
- if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
+ const filteredReports = filterOrgTree(node.reports, tab);
+ // Hidden agents (terminated / pending_approval) never render as a row, but
+ // any visible reports are promoted so the tree doesn't lose live agents.
+ if (HIDDEN_AGENT_STATUSES.has(node.status)) {
+ acc.push(...filteredReports);
+ return acc;
+ }
+ if (matchesFilter(node.status, tab) || filteredReports.length > 0) {
acc.push({ ...node, reports: filteredReports });
}
return acc;
@@ -79,8 +88,6 @@ export function Agents() {
const [view, setView] = useState<"list" | "org">("org");
const forceListView = isMobile;
const effectiveView: "list" | "org" = forceListView ? "list" : view;
- const [showTerminated, setShowTerminated] = useState(false);
- const [filtersOpen, setFiltersOpen] = useState(false);
const { data: agents, isLoading, error } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -136,8 +143,8 @@ export function Agents() {
return ;
}
- const filtered = filterAgents(agents ?? [], tab, showTerminated);
- const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
+ const filtered = filterAgents(agents ?? [], tab);
+ const filteredOrg = filterOrgTree(orgTree ?? [], tab);
return (
@@ -155,36 +162,6 @@ export function Agents() {
/>
- {/* Filters */}
-
-
- {filtersOpen && (
-
-
-
- )}
-
{/* View toggle */}
{!forceListView && (
@@ -246,13 +223,7 @@ export function Agents() {
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
)}
- leading={
-
-
-
- }
+ leading={
}
trailing={
@@ -263,7 +234,7 @@ export function Agents() {
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
) : (
-
+
)}
@@ -287,7 +258,7 @@ export function Agents() {
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
-
+
{/* Row actions mirror the agent detail page; stop the click
@@ -403,8 +374,6 @@ function OrgTreeNode({
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === node.id;
- const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
-
return (
-
-
-
+
{node.name}
@@ -434,7 +401,7 @@ function OrgTreeNode({
liveCount={liveRunByAgent.get(node.id)!.liveCount}
/>
) : (
-
+
)}
@@ -462,7 +429,7 @@ function OrgTreeNode({
>
)}
-
+
-
-
- {(["running", "active", "paused", "error", "archived"] as const).map((label) => (
+
+
+ The agents section uses a brand heartbeat capsule (8×16) plus a brand
+ .task-chip. Four states only: idle (gray),
+ running (blue, pulses), paused (amber), error (red, blinks). Motion
+ honors prefers-reduced-motion.
+
+
+ {(["idle", "running", "paused", "error"] as const).map((label) => (
))}