PAP-80: Agents section brand status capsules + chips (PAP-75)

Refactor the agents section status indicators to the approved PAP-75 brand
state system (agents only; task/issue chips are split to PAP-78/79).

- status-colors.ts: colour-named agentStatusColor (idle→gray, running→blue,
  paused→amber, error→red; active aliases idle) + brand .task-chip badge map,
  heartbeat-capsule fill map, and per-status motion map. Removes agentStatusDot.
- index.css: hb-pulse (running) + hb-blink (error) keyframes with a
  prefers-reduced-motion guard.
- StatusBadge.tsx: AgentStatusBadge (bordered brand chip, separate from the
  shared statusBadge) + AgentStatusCapsule (8×16 heartbeat capsule).
- Agents.tsx: dot → capsule and shared chip → AgentStatusBadge in both list and
  org views; exclude terminated + pending_approval from the list (org reports
  promoted); drop the now-defunct "Show terminated" filter.
- Audit: AgentActionButtons + AgentProperties render AgentStatusBadge; remove
  dead agentStatusDot import from AgentDetail; refresh the DesignGuide showcase.

Verified: ui typecheck clean; /agents + /design-guide visually checked in light
and dark mode (capsule fills, brand chip borders/translucency, running pulse,
error blink, reduced-motion fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 4.8 committed 2026-06-05 15:18:54 -07:00
1 parent 53b2142378
commit 1bc89aea68
8 files changed
+157 -85

No files matched your search

+2 -2
View File
@@ -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 && (
<span className="hidden sm:inline">
<StatusBadge status={agent.status} />
<AgentStatusBadge status={agent.status} />
</span>
)}
{children}
+2 -2
View File
@@ -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) {
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusBadge status={agent.status} />
<AgentStatusBadge status={agent.status} />
</PropertyRow>
<PropertyRow label="Role">
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
+45 -1
View File
@@ -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 }) {
</span>
);
}
/**
* 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 (
<span
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0",
agentStatusBadge[color]
)}
>
{label.replace(/_/g, " ")}
</span>
);
}
/**
* 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 (
<span
aria-hidden
className={cn("inline-block h-4 w-2 rounded-[4px] shrink-0", agentStatusCapsule[color], motion)}
/>
);
}
+29
View File
@@ -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 {
+40 -10
View File
@@ -83,20 +83,50 @@ export const statusBadge: Record<string, string> = {
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<string, string> = {
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<string, AgentBadgeColor> = {
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<AgentBadgeColor, string> = {
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<AgentBadgeColor, string> = {
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<string, string> = {
running: "hb-pulse",
error: "hb-blink",
};
// ---------------------------------------------------------------------------
// Priority colors
-1
View File
@@ -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";
+27 -60
View File
@@ -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<string, string>;
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<OrgNode[]>((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 <PageSkeleton variant="list" />;
}
const filtered = filterAgents(agents ?? [], tab, showTerminated);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
const filtered = filterAgents(agents ?? [], tab);
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
return (
<div className="space-y-4">
@@ -155,36 +162,6 @@ export function Agents() {
/>
</Tabs>
<div className="flex items-center gap-2">
{/* Filters */}
<div className="relative">
<button
className={cn(
"flex items-center gap-1.5 px-2 py-1.5 text-xs transition-colors border border-border",
filtersOpen || showTerminated ? "text-foreground bg-accent" : "text-muted-foreground hover:bg-accent/50"
)}
onClick={() => setFiltersOpen(!filtersOpen)}
>
<SlidersHorizontal className="h-3 w-3" />
Filters
{showTerminated && <span className="ml-0.5 px-1 bg-foreground/10 rounded text-[10px]">1</span>}
</button>
{filtersOpen && (
<div className="absolute right-0 top-full mt-1 z-50 w-48 border border-border bg-popover shadow-md p-1">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent/50 transition-colors"
onClick={() => setShowTerminated(!showTerminated)}
>
<span className={cn(
"flex items-center justify-center h-3.5 w-3.5 border border-border rounded-sm",
showTerminated && "bg-foreground"
)}>
{showTerminated && <span className="text-background text-[10px] leading-none">&#10003;</span>}
</span>
Show terminated
</button>
</div>
)}
</div>
{/* View toggle */}
{!forceListView && (
<div className="flex items-center border border-border">
@@ -246,13 +223,7 @@ export function Agents() {
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
)}
leading={
<span className="relative flex h-2.5 w-2.5">
<span
className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[agent.status] ?? agentStatusDotDefault}`}
/>
</span>
}
leading={<AgentStatusCapsule status={agent.status} />}
trailing={
<div className="flex items-center gap-3">
<span className="sm:hidden">
@@ -263,7 +234,7 @@ export function Agents() {
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
) : (
<StatusBadge status={agent.status} />
<AgentStatusBadge status={agent.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
@@ -287,7 +258,7 @@ export function Agents() {
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
<span className="w-20 flex justify-end">
<StatusBadge status={agent.status} />
<AgentStatusBadge status={agent.status} />
</span>
</div>
{/* 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 (
<div style={{ paddingLeft: depth * 24 }}>
<Link
@@ -415,9 +384,7 @@ function OrgTreeNode({
membershipState === "left" && "text-foreground/55",
)}
>
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
</span>
<AgentStatusCapsule status={node.status} />
<div className="flex-1 min-w-[7rem]">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
@@ -434,7 +401,7 @@ function OrgTreeNode({
liveCount={liveRunByAgent.get(node.id)!.liveCount}
/>
) : (
<StatusBadge status={node.status} />
<AgentStatusBadge status={node.status} />
)}
</span>
<div className="hidden sm:flex items-center gap-3">
@@ -462,7 +429,7 @@ function OrgTreeNode({
</>
)}
<span className="w-20 flex justify-end">
<StatusBadge status={node.status} />
<AgentStatusBadge status={node.status} />
</span>
</div>
<MembershipAction
+12 -9
View File
@@ -112,10 +112,9 @@ import {
AvatarGroup,
AvatarGroupCount,
} from "@/components/ui/avatar";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { PriorityIcon } from "@/components/PriorityIcon";
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
import { EntityRow } from "@/components/EntityRow";
import { EmptyState } from "@/components/EmptyState";
import { MetricCard } from "@/components/MetricCard";
@@ -551,14 +550,18 @@ export function DesignGuide() {
</div>
</SubSection>
<SubSection title="Agent status dots">
<div className="flex items-center gap-4 flex-wrap">
{(["running", "active", "paused", "error", "archived"] as const).map((label) => (
<SubSection title="Agent status (capsule + chip)">
<p className="text-xs text-muted-foreground mb-3 max-w-prose">
The agents section uses a brand heartbeat capsule (8×16) plus a brand
<code className="mx-1">.task-chip</code>. Four states only: idle (gray),
running (blue, pulses), paused (amber), error (red, blinks). Motion
honors <code>prefers-reduced-motion</code>.
</p>
<div className="flex items-center gap-6 flex-wrap">
{(["idle", "running", "paused", "error"] as const).map((label) => (
<div key={label} className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className={`inline-flex h-full w-full rounded-full ${agentStatusDot[label] ?? agentStatusDotDefault}`} />
</span>
<span className="text-xs text-muted-foreground">{label}</span>
<AgentStatusCapsule status={label} />
<AgentStatusBadge status={label} />
</div>
))}
</div>