mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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:
1 parent
53b2142378
commit
1bc89aea68
8 files changed
+157
-85
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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">✓</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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user