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) => (
- - - - {label} + +
))}