71a8464fee
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The control plane owns agent lifecycle, issue assignment, routine dispatch, heartbeat wakeups, and recovery paths > - Terminated, paused, pending-approval, or otherwise invalid agents should not receive new work or new execution attempts > - The old behavior left eligibility checks spread across routes and services, so assignment and run paths could drift apart > - This pull request centralizes agent lifecycle eligibility and applies it consistently to assignment, invocation, routines, recovery, and UI affordances > - The benefit is safer autonomy: terminated agents stay paused, invalid org-chain agents are surfaced, and active agents keep receiving valid work ## Linked Issues or Issue Description Refs #5103 Related: #1864 Bug fix context: - What happened: agent assignment and heartbeat/run paths did not share one eligibility contract, so invalid lifecycle states could still be considered in some paths. - Expected behavior: terminated agents must never receive new assignments or heartbeat runs, and paused or otherwise invalid agents should be treated as non-invokable consistently. - Steps to reproduce: create or select an agent in an invalid lifecycle state, then attempt assignment, routine dispatch, or heartbeat/recovery wake paths. - Paperclip version/commit: fixed on top of `paperclipai/paperclip` `master` at the PR base. - Deployment mode: applies to the server control plane in local and authenticated deployments. ## What Changed - Added shared agent lifecycle eligibility helpers and exported the related shared types. - Centralized server-side assignability and invokability checks for issue assignment, agent routes, heartbeat dispatch, routines, recovery, and liveness logic. - Hardened issue assignment so invalid assignees are rejected instead of queued for work. - Hardened heartbeat/routine/recovery paths so terminated and otherwise invalid agents are not woken for new runs. - Updated board UI affordances to disable invalid agent actions and surface org-chain warnings where relevant. - Added targeted shared, server, and UI tests for the new eligibility behavior. ## Verification - `pnpm exec vitest run packages/shared/src/agent-eligibility.test.ts server/src/__tests__/agent-invokability.test.ts server/src/__tests__/heartbeat-archived-company-guard.test.ts server/src/__tests__/issue-liveness.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/routines-service.test.ts ui/src/lib/company-members.test.ts ui/src/pages/Agents.test.tsx` — 8 files, 144 tests passed. - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` — passed. - Checked the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Checked `ROADMAP.md`; this is a targeted control-plane safety fix and does not duplicate a planned core feature. - Searched GitHub for duplicate or related PRs/issues; closest related items are linked above. - CI and Greptile verification are pending on the opened PR and will be followed up before requesting merge. ## Risks Low to moderate risk. The intended behavioral shift is that invalid agents are refused earlier and more consistently, which could expose existing data with paused, pending, terminated, or broken org-chain assignees. The added tests cover the critical assignment, heartbeat, routine, recovery, shared helper, and UI paths. No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex via the Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. Reasoning mode and context window are managed by the adapter runtime and not exposed in this environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable: no design screenshots requested; UI behavior is covered by tests) - [x] I have updated relevant documentation to reflect my changes (not applicable: no user-facing command or schema docs changed) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending CI) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending Greptile) - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
|
import { Link, useNavigate, useLocation } from "@/lib/router";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { agentsApi, type OrgNode } from "../api/agents";
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useDialogActions } from "../context/DialogContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
|
|
import { AgentActionButtons } from "../components/AgentActionButtons";
|
|
import { MembershipAction } from "../components/MembershipAction";
|
|
import { EntityRow } from "../components/EntityRow";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
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 { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
|
|
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
|
import {
|
|
resourceMembershipState,
|
|
useResourceMembershipMutation,
|
|
useResourceMemberships,
|
|
} from "../hooks/useResourceMemberships";
|
|
|
|
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
|
|
|
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
|
|
|
type FilterTab = "all" | "active" | "paused" | "error";
|
|
|
|
// 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";
|
|
if (tab === "error") return status === "error";
|
|
return true;
|
|
}
|
|
|
|
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
|
|
return agents
|
|
.filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
function getConfiguredModel(agent: Agent): string | null {
|
|
const value = agent.adapterConfig?.model;
|
|
if (typeof value !== "string") return null;
|
|
const model = value.trim();
|
|
return model.length > 0 ? model : null;
|
|
}
|
|
|
|
function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
|
|
return nodes
|
|
.reduce<OrgNode[]>((acc, node) => {
|
|
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;
|
|
}, [])
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
export function Agents() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { openNewAgent } = useDialogActions();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { isMobile } = useSidebar();
|
|
const pathSegment = location.pathname.split("/").pop() ?? "all";
|
|
const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all";
|
|
const [view, setView] = useState<"list" | "org">("org");
|
|
const forceListView = isMobile;
|
|
const effectiveView: "list" | "org" = forceListView ? "list" : view;
|
|
|
|
const { data: agents, isLoading, error } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: orgTree } = useQuery({
|
|
queryKey: queryKeys.org(selectedCompanyId!),
|
|
queryFn: () => agentsApi.org(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId && effectiveView === "org",
|
|
});
|
|
|
|
const { data: runs } = useQuery({
|
|
queryKey: [...queryKeys.liveRuns(selectedCompanyId!), "agents-page"],
|
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
refetchInterval: 15_000,
|
|
});
|
|
const membershipsQuery = useResourceMemberships(selectedCompanyId);
|
|
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
|
|
|
|
// Map agentId -> first live run + live run count
|
|
const liveRunByAgent = useMemo(() => {
|
|
const map = new Map<string, { runId: string; liveCount: number }>();
|
|
for (const r of runs ?? []) {
|
|
if (r.status !== "running" && r.status !== "queued") continue;
|
|
const existing = map.get(r.agentId);
|
|
if (existing) {
|
|
existing.liveCount += 1;
|
|
continue;
|
|
}
|
|
map.set(r.agentId, { runId: r.id, liveCount: 1 });
|
|
}
|
|
return map;
|
|
}, [runs]);
|
|
|
|
const agentMap = useMemo(() => {
|
|
const map = new Map<string, Agent>();
|
|
for (const a of agents ?? []) map.set(a.id, a);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Agents" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
if (!selectedCompanyId) {
|
|
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <PageSkeleton variant="list" />;
|
|
}
|
|
|
|
const filtered = filterAgents(agents ?? [], tab);
|
|
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
|
|
<PageTabBar
|
|
items={[
|
|
{ value: "all", label: "All" },
|
|
{ value: "active", label: "Active" },
|
|
{ value: "paused", label: "Paused" },
|
|
{ value: "error", label: "Error" },
|
|
]}
|
|
value={tab}
|
|
onValueChange={(v) => navigate(`/agents/${v}`)}
|
|
/>
|
|
</Tabs>
|
|
<div className="flex items-center gap-2">
|
|
{/* View toggle */}
|
|
{!forceListView && (
|
|
<div className="flex items-center border border-border">
|
|
<button
|
|
className={cn(
|
|
"p-1.5 transition-colors",
|
|
effectiveView === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
|
)}
|
|
onClick={() => setView("list")}
|
|
>
|
|
<List className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
className={cn(
|
|
"p-1.5 transition-colors",
|
|
effectiveView === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
|
)}
|
|
onClick={() => setView("org")}
|
|
>
|
|
<GitBranch className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<Button size="sm" variant="outline" onClick={openNewAgent}>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
New Agent
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{filtered.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
|
|
)}
|
|
|
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
|
|
|
{agents && agents.length === 0 && (
|
|
<EmptyState
|
|
icon={Bot}
|
|
message="Create your first agent to get started."
|
|
action="New Agent"
|
|
onAction={openNewAgent}
|
|
/>
|
|
)}
|
|
|
|
{/* List view */}
|
|
{effectiveView === "list" && filtered.length > 0 && (
|
|
<div className="border border-border">
|
|
{filtered.map((agent) => {
|
|
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
|
return (
|
|
<EntityRow
|
|
key={agent.id}
|
|
title={agent.name}
|
|
// Fixed (truncating) title width so the `meta` group starts at a
|
|
// constant x on every row — that's what makes the model + timestamp
|
|
// columns line up vertically (PAP-86). Agent names vary in width, so
|
|
// a content-sized title (`min-w-[7rem]`) shifted meta's start per row.
|
|
titleClassName="w-56"
|
|
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
|
to={agentUrl(agent)}
|
|
className={cn(
|
|
"group",
|
|
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
|
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
|
)}
|
|
leading={hasInvalidOrgChain ? (
|
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
|
|
) : (
|
|
<AgentStatusCapsule status={agent.status} />
|
|
)}
|
|
meta={
|
|
<div className="hidden xl:flex items-center gap-3">
|
|
<AgentMetaColumns agent={agent} />
|
|
</div>
|
|
}
|
|
trailing={
|
|
<div className="flex items-center gap-3">
|
|
<span className="sm:hidden">
|
|
{liveRunByAgent.has(agent.id) ? (
|
|
<LiveRunIndicator
|
|
agentRef={agentRouteRef(agent)}
|
|
runId={liveRunByAgent.get(agent.id)!.runId}
|
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
|
/>
|
|
) : (
|
|
<AgentStatusBadge status={agent.status} />
|
|
)}
|
|
</span>
|
|
<div className="hidden sm:flex items-center gap-3">
|
|
{liveRunByAgent.has(agent.id) && (
|
|
<LiveRunIndicator
|
|
agentRef={agentRouteRef(agent)}
|
|
runId={liveRunByAgent.get(agent.id)!.runId}
|
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
|
/>
|
|
)}
|
|
<span className="w-20 flex justify-end">
|
|
<AgentStatusBadge status={agent.status} />
|
|
</span>
|
|
</div>
|
|
{/* Row actions mirror the agent detail page; stop the click
|
|
from bubbling to the row link so buttons don't navigate. */}
|
|
<div
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<AgentActionButtons
|
|
agent={agent}
|
|
companyId={selectedCompanyId}
|
|
runLabel="Run Heartbeat"
|
|
showStatus={false}
|
|
/>
|
|
</div>
|
|
<MembershipAction
|
|
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
|
|
pending={
|
|
membershipMutation.isPending &&
|
|
membershipMutation.variables?.resourceType === "agent" &&
|
|
membershipMutation.variables.resourceId === agent.id
|
|
}
|
|
pendingState={
|
|
membershipMutation.isPending &&
|
|
membershipMutation.variables?.resourceType === "agent" &&
|
|
membershipMutation.variables.resourceId === agent.id
|
|
? membershipMutation.variables.state
|
|
: null
|
|
}
|
|
resourceName={agent.name}
|
|
onJoin={() => membershipMutation.mutate({
|
|
resourceType: "agent",
|
|
resourceId: agent.id,
|
|
resourceName: agent.name,
|
|
state: "joined",
|
|
})}
|
|
onLeave={() => membershipMutation.mutate({
|
|
resourceType: "agent",
|
|
resourceId: agent.id,
|
|
resourceName: agent.name,
|
|
state: "left",
|
|
})}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
No agents match the selected filter.
|
|
</p>
|
|
)}
|
|
|
|
{/* Org chart view */}
|
|
{effectiveView === "org" && filteredOrg.length > 0 && (
|
|
<div className="border border-border py-1">
|
|
{filteredOrg.map((node) => (
|
|
<OrgTreeNode
|
|
key={node.id}
|
|
node={node}
|
|
depth={0}
|
|
agentMap={agentMap}
|
|
liveRunByAgent={liveRunByAgent}
|
|
tab={tab}
|
|
memberships={membershipsQuery.data}
|
|
membershipMutation={membershipMutation}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
No agents match the selected filter.
|
|
</p>
|
|
)}
|
|
|
|
{effectiveView === "org" && orgTree && orgTree.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
No organizational hierarchy defined.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OrgTreeNode({
|
|
node,
|
|
depth,
|
|
agentMap,
|
|
liveRunByAgent,
|
|
tab,
|
|
memberships,
|
|
membershipMutation,
|
|
}: {
|
|
node: OrgNode;
|
|
depth: number;
|
|
agentMap: Map<string, Agent>;
|
|
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
|
|
tab: FilterTab;
|
|
memberships: ReturnType<typeof useResourceMemberships>["data"];
|
|
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
|
|
}) {
|
|
const agent = agentMap.get(node.id);
|
|
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
|
|
const membershipState = resourceMembershipState(memberships, "agent", node.id);
|
|
const pending = membershipMutation.isPending &&
|
|
membershipMutation.variables?.resourceType === "agent" &&
|
|
membershipMutation.variables.resourceId === node.id;
|
|
|
|
return (
|
|
<div style={{ paddingLeft: depth * 24 }}>
|
|
<Link
|
|
to={agent ? agentUrl(agent) : `/agents/${node.id}`}
|
|
className={cn(
|
|
"group flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit",
|
|
agent?.pausedAt && tab !== "paused" && "opacity-50",
|
|
membershipState === "left" && "text-foreground/55",
|
|
)}
|
|
>
|
|
{hasInvalidOrgChain ? (
|
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
|
) : (
|
|
<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">
|
|
{roleLabels[node.role] ?? node.role}
|
|
{agent?.title ? ` - ${agent.title}` : ""}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<span className="sm:hidden">
|
|
{liveRunByAgent.has(node.id) ? (
|
|
<LiveRunIndicator
|
|
agentRef={agent ? agentRouteRef(agent) : node.id}
|
|
runId={liveRunByAgent.get(node.id)!.runId}
|
|
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
|
/>
|
|
) : (
|
|
<AgentStatusBadge status={node.status} />
|
|
)}
|
|
</span>
|
|
<div className="hidden sm:flex items-center gap-3">
|
|
{liveRunByAgent.has(node.id) && (
|
|
<LiveRunIndicator
|
|
agentRef={agent ? agentRouteRef(agent) : node.id}
|
|
runId={liveRunByAgent.get(node.id)!.runId}
|
|
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
|
/>
|
|
)}
|
|
{agent && (
|
|
<div className="hidden xl:flex items-center gap-3">
|
|
<AgentMetaColumns agent={agent} />
|
|
</div>
|
|
)}
|
|
<span className="w-20 flex justify-end">
|
|
<AgentStatusBadge status={node.status} />
|
|
</span>
|
|
</div>
|
|
<MembershipAction
|
|
state={membershipState}
|
|
pending={pending}
|
|
pendingState={pending ? membershipMutation.variables?.state : null}
|
|
resourceName={node.name}
|
|
onJoin={() => membershipMutation.mutate({
|
|
resourceType: "agent",
|
|
resourceId: node.id,
|
|
resourceName: node.name,
|
|
state: "joined",
|
|
})}
|
|
onLeave={() => membershipMutation.mutate({
|
|
resourceType: "agent",
|
|
resourceId: node.id,
|
|
resourceName: node.name,
|
|
state: "left",
|
|
})}
|
|
/>
|
|
</div>
|
|
</Link>
|
|
{node.reports && node.reports.length > 0 && (
|
|
<div className="border-l border-border/50 ml-4">
|
|
{node.reports.map((child) => (
|
|
<OrgTreeNode
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
agentMap={agentMap}
|
|
liveRunByAgent={liveRunByAgent}
|
|
tab={tab}
|
|
memberships={memberships}
|
|
membershipMutation={membershipMutation}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Provider/model + heartbeat columns shared by the list and org views. The
|
|
* model and adapter label share one fixed-width cell, each line truncating with
|
|
* an ellipsis so a long model id can never overlap the heartbeat column. The
|
|
* heartbeat is single-line (`whitespace-nowrap`) and wide enough for a full
|
|
* date like "Apr 30, 2026".
|
|
*/
|
|
function AgentMetaColumns({ agent }: { agent: Agent }) {
|
|
const model = getConfiguredModel(agent);
|
|
const adapterLabel = getAdapterLabel(agent.adapterType);
|
|
return (
|
|
<>
|
|
<div className="w-44 min-w-0 leading-tight">
|
|
<div
|
|
className="truncate font-mono text-xs text-muted-foreground"
|
|
title={model ?? undefined}
|
|
>
|
|
{model ?? "—"}
|
|
</div>
|
|
<div className="truncate font-mono text-[11px] text-muted-foreground/70" title={adapterLabel}>
|
|
{adapterLabel}
|
|
</div>
|
|
</div>
|
|
<span className="w-24 whitespace-nowrap text-right text-xs text-muted-foreground">
|
|
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
|
</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function LiveRunIndicator({
|
|
agentRef,
|
|
runId,
|
|
liveCount,
|
|
}: {
|
|
agentRef: string;
|
|
runId: string;
|
|
liveCount: number;
|
|
}) {
|
|
return (
|
|
<Link
|
|
to={`/agents/${agentRef}/runs/${runId}`}
|
|
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
|
</span>
|
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
|
Live{liveCount > 1 ? ` (${liveCount})` : ""}
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|