paperclip/ui/src/pages/Agents.tsx
Dotta 7f893ac4ec
[codex] Harden execution reliability and heartbeat tooling (#3679)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Reliable execution depends on heartbeat routing, issue lifecycle
semantics, telemetry, and a fast enough local verification loop to keep
regressions visible
> - The remaining commits on this branch were mostly server/runtime
correctness fixes plus test and documentation follow-ups in that area
> - Those changes are logically separate from the UI-focused
issue-detail and workspace/navigation branches even when they touch
overlapping issue APIs
> - This pull request groups the execution reliability, heartbeat,
telemetry, and tooling changes into one standalone branch
> - The benefit is a focused review of the control-plane correctness
work, including the follow-up fix that restored the implicit
comment-reopen helpers after branch splitting

## What Changed

- Hardened issue/heartbeat execution behavior, including self-review
stage skipping, deferred mention wakes during active execution, stranded
execution recovery, active-run scoping, assignee resolution, and
blocked-to-todo wake resumption
- Reduced noisy polling/logging overhead by trimming issue run payloads,
compacting persisted run logs, silencing high-volume request logs, and
capping heartbeat-run queries in dashboard/inbox surfaces
- Expanded telemetry and status semantics with adapter/model fields on
task completion plus clearer status guidance in docs/onboarding material
- Updated test infrastructure and verification defaults with faster
route-test module isolation, cheaper default `pnpm test`, e2e isolation
from local state, and repo verification follow-ups
- Included docs/release housekeeping from the branch and added a small
follow-up commit restoring the implicit comment-reopen helpers that were
dropped during branch reconstruction

## Verification

- `pnpm vitest run
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/issue-telemetry-routes.test.ts`
- `pnpm vitest run server/src/__tests__/http-log-policy.test.ts
server/src/__tests__/heartbeat-run-log.test.ts
server/src/__tests__/health.test.ts`
- `server/src/__tests__/activity-service.test.ts`,
`server/src/__tests__/heartbeat-comment-wake-batching.test.ts`, and
`server/src/__tests__/heartbeat-process-recovery.test.ts` were attempted
on this host but the embedded Postgres harness reported
init-script/data-dir problems and skipped or failed to start, so they
are noted as environment-limited

## Risks

- Medium: this branch changes core issue/heartbeat routing and
reopen/wakeup behavior, so regressions would affect agent execution flow
rather than isolated UI polish
- Because it also updates verification infrastructure, reviewers should
pay attention to whether the new tests are asserting the right failure
modes and not just reshaping harness behavior

## Model Used

- OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact
deployed model ID is not exposed in this environment), reasoning
enabled, tool use and local code execution enabled

## 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)
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-14 13:34:52 -05:00

408 lines
16 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 { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
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 { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
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;
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, showTerminated: boolean): Agent[] {
return agents
.filter((a) => matchesFilter(a.status, tab, showTerminated))
.sort((a, b) => a.name.localeCompare(b.name));
}
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
return nodes
.reduce<OrgNode[]>((acc, node) => {
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
if (matchesFilter(node.status, tab, showTerminated) || 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 } = useDialog();
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 [showTerminated, setShowTerminated] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
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,
});
// 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, showTerminated);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
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">
{/* 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">
<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) => {
return (
<EntityRow
key={agent.id}
title={agent.name}
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className={agent.pausedAt && tab !== "paused" ? "opacity-50" : ""}
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>
}
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}
/>
) : (
<StatusBadge 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="text-xs text-muted-foreground font-mono w-14 text-right">
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
<span className="w-20 flex justify-end">
<StatusBadge status={agent.status} />
</span>
</div>
</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} />
))}
</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,
}: {
node: OrgNode;
depth: number;
agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
tab: FilterTab;
}) {
const agent = agentMap.get(node.id);
const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
return (
<div style={{ paddingLeft: depth * 24 }}>
<Link
to={agent ? agentUrl(agent) : `/agents/${node.id}`}
className={cn("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")}
>
<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>
<div className="flex-1 min-w-0">
<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}
/>
) : (
<StatusBadge 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 && (
<>
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
</>
)}
<span className="w-20 flex justify-end">
<StatusBadge status={node.status} />
</span>
</div>
</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} />
))}
</div>
)}
</div>
);
}
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>
);
}