Show agent environment metadata (#8671)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Agents page is where operators scan the current agent roster,
runtime status, model, adapter, and last heartbeat.
> - Environment selection now affects where agents execute, but the
roster did not expose each agent's effective execution environment.
> - Operators need that context when multiple environments are
configured, especially when sandbox-backed environments use different
providers.
> - This pull request adds environment/provider metadata to the Agents
page while keeping it hidden when the column would not add useful
information.
> - The benefit is better operational visibility without adding noise
for single-environment or environment-disabled instances.

## Linked Issues or Issue Description

No matching public GitHub issue was found after searching for related
Agents environment-column issues and PRs.

## Problem or motivation

Operators use the Agents page to scan active agents, but when multiple
execution environments are configured there was no row-level indication
of which environment each agent uses. That makes it harder to
distinguish host-local agents from sandbox-backed agents and to see the
sandbox provider at a glance.

## Proposed solution

Show each agent's effective environment in the Agents page metadata when
environments are enabled and multiple environments are configured.
Resolve the value from the agent override, instance default, or local
fallback, and include sandbox provider display names when available.

## Alternatives considered

Always showing the column would add noise for single-environment
instances. Adding filters and grouping was also considered, but this PR
keeps the scope to read-only metadata until the product has more usage
data.

## Roadmap alignment

This is a small UI visibility improvement related to the roadmap's
cloud/sandbox agents direction, without adding a new core workflow or
adapter capability.

## Additional context

The column is hidden when the experimental environments setting is
disabled or when only one environment is configured.

## What Changed

- Added environment metadata loading to the Agents page, including
environment list, capabilities, and instance settings.
- Resolved each agent's effective environment from the agent override,
instance default, or local fallback.
- Rendered environment name and sandbox provider detail in list and org
views when environments are enabled and multiple environments are
configured.
- Hid the environment column when environments are disabled or only one
environment is configured.
- Added focused UI tests for display, fallback, loading, hidden-column,
disabled-feature, and no filter/grouping behavior.
- Addressed Greptile review feedback by preserving custom local
environment names, reserving the column while environment data loads,
and separating the capabilities query key.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Agents.test.tsx` — 11 tests passed
- `pnpm --filter @paperclipai/ui build`
- Local PR-diff scan for secrets/private references before push
- GitHub PR workflow passed on head `bb770acc3`
- Commitperclip review check passed
- Greptile Review passed with confidence score 5/5 and no inline review
comments

## Risks

Low risk. The change is limited to the Agents page, query keys, and
tests. The main behavioral risk is extra metadata query traffic on the
Agents page when environments are enabled; those queries are skipped
when the experimental environments flag is disabled.

## Model Used

OpenAI Codex, GPT-5 coding agent with shell/tool use and code execution
in the local repository.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-06-26 15:31:42 -07:00 committed by GitHub
parent 765a75207a
commit 500a75f7ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 705 additions and 107 deletions

View File

@ -160,6 +160,7 @@ export const queryKeys = {
},
environments: {
list: (companyId: string) => ["environments", companyId] as const,
capabilities: (companyId: string) => ["environment-capabilities", companyId] as const,
},
projects: {
list: (companyId: string) => ["projects", companyId] as const,

View File

@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import type { Agent, Environment, EnvironmentCapabilities } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "../context/ToastContext";
import { Agents } from "./Agents";
@ -15,10 +15,19 @@ const mockAgentsApi = vi.hoisted(() => ({
org: vi.fn(),
}));
const mockEnvironmentsApi = vi.hoisted(() => ({
list: vi.fn(),
capabilities: vi.fn(),
}));
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockResourceMembershipsApi = vi.hoisted(() => ({
listMine: vi.fn(),
updateAgent: vi.fn(),
@ -55,10 +64,18 @@ vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/environments", () => ({
environmentsApi: mockEnvironmentsApi,
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: mockResourceMembershipsApi,
}));
@ -106,6 +123,92 @@ function makeAgent(overrides: Partial<Agent>): Agent {
};
}
function makeEnvironment(overrides: Partial<Environment>): Environment {
return {
id: "env-1",
name: "Daytona Sandbox",
description: null,
driver: "sandbox",
status: "active",
config: { provider: "daytona" },
envVars: {},
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
const environmentCapabilities: EnvironmentCapabilities = {
adapters: [],
drivers: {
local: "supported",
ssh: "supported",
sandbox: "supported",
plugin: "supported",
},
sandboxProviders: {
fake: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
displayName: "Fake",
source: "builtin",
},
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
displayName: "Daytona",
source: "plugin",
},
},
};
function makeInstanceSettings({
defaultEnvironmentId = null,
enableEnvironments = true,
}: {
defaultEnvironmentId?: string | null;
enableEnvironments?: boolean;
} = {}) {
return {
id: "instance-settings-1",
defaultEnvironmentId,
general: {
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "prompt",
backupRetention: {
dailyDays: 7,
weeklyWeeks: 4,
monthlyMonths: 1,
},
executionMode: "any",
},
experimental: {
enableEnvironments,
enableIsolatedWorkspaces: true,
enableStreamlinedLeftNavigation: false,
enableConferenceRoomChat: false,
enableTaskWatchdogs: true,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: false,
enableCloudSync: false,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
},
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
};
}
const invalidOrgChainHealth: AgentOrgChainHealth = {
status: "invalid_org_chain",
reason: "terminated_ancestor",
@ -141,6 +244,10 @@ async function flushReact() {
});
}
function findAgentRow(container: HTMLElement, agentName: string): HTMLElement | null {
return Array.from(container.querySelectorAll("a")).find((row) => row.textContent?.includes(agentName)) ?? null;
}
describe("Agents", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
@ -170,6 +277,11 @@ describe("Agents", () => {
reports: [],
},
]);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "env-daytona" }),
]);
mockEnvironmentsApi.capabilities.mockResolvedValue(environmentCapabilities);
mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings());
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
mockResourceMembershipsApi.listMine.mockResolvedValue({
projectMemberships: {},
@ -221,6 +333,324 @@ describe("Agents", () => {
expect(heartbeatCell?.textContent).not.toContain("\n");
});
it("shows effective environment and sandbox provider beside agents", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({
defaultEnvironmentId: "env-daytona",
}),
]);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({
id: "env-local",
name: "Local",
driver: "local",
config: {},
}),
makeEnvironment({ id: "env-daytona" }),
]);
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Daytona Sandbox");
expect(container.textContent).toContain("Daytona sandbox provider");
});
it("uses configured names for local-driver environments", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({
defaultEnvironmentId: "env-local",
}),
]);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({
id: "env-local",
name: "Dev Laptop",
driver: "local",
config: {},
}),
makeEnvironment({ id: "env-daytona" }),
]);
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Dev Laptop");
expect(container.textContent).toContain("Paperclip host");
});
it("reserves the environment column while environment metadata is loading", async () => {
let resolveEnvironments: (environments: Environment[]) => void = () => {};
mockAgentsApi.list.mockResolvedValue([
makeAgent({
defaultEnvironmentId: "env-daytona",
}),
]);
mockEnvironmentsApi.list.mockReturnValue(new Promise((resolve) => {
resolveEnvironments = resolve;
}));
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Loading environment");
await act(async () => {
resolveEnvironments([
makeEnvironment({
id: "env-local",
name: "Local",
driver: "local",
config: {},
}),
makeEnvironment({ id: "env-daytona" }),
]);
});
await flushReact();
expect(container.textContent).toContain("Daytona Sandbox");
expect(container.textContent).not.toContain("Loading environment");
});
it("hides the environment column when there is only one configured environment", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({
defaultEnvironmentId: "env-daytona",
}),
]);
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Daytona Sandbox");
expect(container.textContent).not.toContain("Daytona sandbox provider");
});
it("hides the environment column when environments are experimentally disabled", async () => {
mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableEnvironments: false }));
mockAgentsApi.list.mockResolvedValue([
makeAgent({
id: "agent-local",
name: "Local Agent",
urlKey: "local-agent",
defaultEnvironmentId: null,
}),
makeAgent({
id: "agent-sandbox",
name: "Sandbox Agent",
urlKey: "sandbox-agent",
defaultEnvironmentId: "env-daytona",
}),
]);
mockAgentsApi.org.mockResolvedValue([
{
id: "agent-local",
name: "Local Agent",
role: "engineer",
status: "active",
reports: [],
},
{
id: "agent-sandbox",
name: "Sandbox Agent",
role: "engineer",
status: "active",
reports: [],
},
]);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({
id: "env-local",
name: "Local",
driver: "local",
config: {},
}),
makeEnvironment({ id: "env-daytona" }),
]);
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Daytona Sandbox");
expect(container.textContent).not.toContain("Daytona sandbox provider");
expect(mockEnvironmentsApi.list).not.toHaveBeenCalled();
expect(mockEnvironmentsApi.capabilities).not.toHaveBeenCalled();
});
it("uses the instance default environment unless the agent overrides it", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({
id: "agent-fallback",
name: "Fallback Agent",
urlKey: "fallback-agent",
defaultEnvironmentId: null,
}),
makeAgent({
id: "agent-override",
name: "Override Agent",
urlKey: "override-agent",
defaultEnvironmentId: "env-override",
}),
]);
mockAgentsApi.org.mockResolvedValue([
{
id: "agent-fallback",
name: "Fallback Agent",
role: "engineer",
status: "active",
reports: [],
},
{
id: "agent-override",
name: "Override Agent",
role: "engineer",
status: "active",
reports: [],
},
]);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({
id: "env-default",
name: "Instance Default",
config: { provider: "daytona" },
}),
makeEnvironment({
id: "env-override",
name: "Agent Override",
config: { provider: "daytona" },
}),
]);
mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ defaultEnvironmentId: "env-default" }));
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const fallbackRow = findAgentRow(container, "Fallback Agent");
const overrideRow = findAgentRow(container, "Override Agent");
expect(fallbackRow?.textContent).toContain("Instance Default");
expect(overrideRow?.textContent).toContain("Agent Override");
expect(overrideRow?.textContent).not.toContain("Instance Default");
});
it("falls back to the raw sandbox provider config when capabilities omit a display name", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({
defaultEnvironmentId: "env-custom",
}),
]);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({
id: "env-local",
name: "Local",
driver: "local",
config: {},
}),
makeEnvironment({
id: "env-custom",
name: "Custom Sandbox",
config: { provider: "acme_sandbox" },
}),
]);
mockEnvironmentsApi.capabilities.mockResolvedValue({
...environmentCapabilities,
sandboxProviders: {},
});
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Custom Sandbox");
expect(container.textContent).toContain("acme_sandbox sandbox provider");
});
it("does not show environment filter or grouping controls yet", async () => {
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.querySelector('select[aria-label="Filter by environment"]')).toBeNull();
expect(container.querySelector('select[aria-label="Group agents"]')).toBeNull();
});
it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => {
root = createRoot(container);
await act(async () => {

View File

@ -2,7 +2,9 @@ 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 { environmentsApi } from "../api/environments";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@ -19,7 +21,7 @@ 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 { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import {
resourceMembershipState,
useResourceMembershipMutation,
@ -32,6 +34,24 @@ const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
type FilterTab = "all" | "active" | "paused" | "error";
interface EnvironmentDescriptor {
label: string;
detail: string;
title: string;
}
const localEnvironmentDescriptor: EnvironmentDescriptor = {
label: "Local",
detail: "Paperclip host",
title: "Local - Paperclip host",
};
const loadingEnvironmentDescriptor: EnvironmentDescriptor = {
label: "—",
detail: "Loading environment",
title: "Loading environment",
};
// 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).
@ -58,6 +78,61 @@ function getConfiguredModel(agent: Agent): string | null {
return model.length > 0 ? model : null;
}
function formatEnvironmentDriver(driver: Environment["driver"]): string {
if (driver === "ssh") return "SSH";
return driver.charAt(0).toUpperCase() + driver.slice(1);
}
function getSandboxProviderLabel(
environment: Environment,
capabilities?: EnvironmentCapabilities | null,
): string {
const provider = typeof environment.config.provider === "string"
? environment.config.provider.trim()
: "";
if (!provider) return "Sandbox";
return capabilities?.sandboxProviders?.[provider]?.displayName ?? provider;
}
function describeEnvironment(
environment: Environment,
capabilities?: EnvironmentCapabilities | null,
): EnvironmentDescriptor {
const detail = environment.driver === "sandbox"
? `${getSandboxProviderLabel(environment, capabilities)} sandbox provider`
: environment.driver === "local"
? "Paperclip host"
: formatEnvironmentDriver(environment.driver);
return {
label: environment.name,
detail,
title: `${environment.name} - ${detail}`,
};
}
function describeMissingEnvironment(environmentId: string): EnvironmentDescriptor {
return {
label: "Unknown environment",
detail: environmentId.slice(0, 8),
title: `Unknown environment - ${environmentId}`,
};
}
function resolveAgentEnvironment(
agent: Agent,
environmentsById: Map<string, Environment>,
instanceDefaultEnvironmentId: string | null,
capabilities?: EnvironmentCapabilities | null,
): EnvironmentDescriptor {
const environmentId = agent.defaultEnvironmentId ?? instanceDefaultEnvironmentId;
if (!environmentId) return localEnvironmentDescriptor;
const environment = environmentsById.get(environmentId);
return environment
? describeEnvironment(environment, capabilities)
: describeMissingEnvironment(environmentId);
}
function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
return nodes
.reduce<OrgNode[]>((acc, node) => {
@ -101,6 +176,25 @@ export function Agents() {
enabled: !!selectedCompanyId && effectiveView === "org",
});
const { data: instanceSettings } = useQuery({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
enabled: !!selectedCompanyId,
});
const environmentsEnabled = instanceSettings?.experimental.enableEnvironments === true;
const { data: environments } = useQuery({
queryKey: queryKeys.environments.list(selectedCompanyId!),
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && environmentsEnabled,
});
const { data: environmentCapabilities } = useQuery({
queryKey: queryKeys.environments.capabilities(selectedCompanyId!),
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
enabled: !!selectedCompanyId && environmentsEnabled,
});
const { data: runs } = useQuery({
queryKey: [...queryKeys.liveRuns(selectedCompanyId!), "agents-page"],
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
@ -131,6 +225,28 @@ export function Agents() {
return map;
}, [agents]);
const environmentsById = useMemo(() => {
const map = new Map<string, Environment>();
for (const environment of environments ?? []) map.set(environment.id, environment);
return map;
}, [environments]);
const environmentByAgentId = useMemo(() => {
const map = new Map<string, EnvironmentDescriptor>();
for (const agent of agents ?? []) {
map.set(
agent.id,
resolveAgentEnvironment(
agent,
environmentsById,
instanceSettings?.defaultEnvironmentId ?? null,
environmentCapabilities,
),
);
}
return map;
}, [agents, environmentsById, environmentCapabilities, instanceSettings?.defaultEnvironmentId]);
useEffect(() => {
setBreadcrumbs([{ label: "Agents" }]);
}, [setBreadcrumbs]);
@ -145,6 +261,119 @@ export function Agents() {
const filtered = filterAgents(agents ?? [], tab);
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
const environmentDataLoading = environmentsEnabled && environments === undefined;
const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1);
const resolveRenderedEnvironment = (agentId: string) => (
environmentDataLoading
? loadingEnvironmentDescriptor
: environmentByAgentId.get(agentId) ?? localEnvironmentDescriptor
);
const renderAgentRow = (agent: 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. 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}
environment={resolveRenderedEnvironment(agent.id)}
showEnvironment={showEnvironmentColumn}
/>
</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>
}
/>
);
};
return (
<div className="space-y-4">
@ -210,113 +439,13 @@ export function Agents() {
{/* 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>
}
/>
);
})}
{filtered.map(renderAgentRow)}
</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.
No agents match the selected status.
</p>
)}
@ -330,6 +459,9 @@ export function Agents() {
depth={0}
agentMap={agentMap}
liveRunByAgent={liveRunByAgent}
environmentByAgentId={environmentByAgentId}
environmentDataLoading={environmentDataLoading}
showEnvironment={showEnvironmentColumn}
tab={tab}
memberships={membershipsQuery.data}
membershipMutation={membershipMutation}
@ -340,7 +472,7 @@ export function Agents() {
{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.
No agents match the selected status.
</p>
)}
@ -358,6 +490,9 @@ function OrgTreeNode({
depth,
agentMap,
liveRunByAgent,
environmentByAgentId,
environmentDataLoading,
showEnvironment,
tab,
memberships,
membershipMutation,
@ -366,6 +501,9 @@ function OrgTreeNode({
depth: number;
agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
environmentByAgentId: Map<string, EnvironmentDescriptor>;
environmentDataLoading: boolean;
showEnvironment: boolean;
tab: FilterTab;
memberships: ReturnType<typeof useResourceMemberships>["data"];
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
@ -421,7 +559,15 @@ function OrgTreeNode({
)}
{agent && (
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns agent={agent} />
<AgentMetaColumns
agent={agent}
environment={
environmentDataLoading
? loadingEnvironmentDescriptor
: environmentByAgentId.get(agent.id) ?? localEnvironmentDescriptor
}
showEnvironment={showEnvironment}
/>
</div>
)}
<span className="w-20 flex justify-end">
@ -457,6 +603,9 @@ function OrgTreeNode({
depth={depth + 1}
agentMap={agentMap}
liveRunByAgent={liveRunByAgent}
environmentByAgentId={environmentByAgentId}
environmentDataLoading={environmentDataLoading}
showEnvironment={showEnvironment}
tab={tab}
memberships={memberships}
membershipMutation={membershipMutation}
@ -475,7 +624,15 @@ function OrgTreeNode({
* heartbeat is single-line (`whitespace-nowrap`) and wide enough for a full
* date like "Apr 30, 2026".
*/
function AgentMetaColumns({ agent }: { agent: Agent }) {
function AgentMetaColumns({
agent,
environment,
showEnvironment,
}: {
agent: Agent;
environment: EnvironmentDescriptor;
showEnvironment: boolean;
}) {
const model = getConfiguredModel(agent);
const adapterLabel = getAdapterLabel(agent.adapterType);
return (
@ -491,6 +648,16 @@ function AgentMetaColumns({ agent }: { agent: Agent }) {
{adapterLabel}
</div>
</div>
{showEnvironment && (
<div className="w-44 min-w-0 leading-tight">
<div className="truncate text-xs text-muted-foreground" title={environment.title}>
{environment.label}
</div>
<div className="truncate text-[11px] text-muted-foreground/70">
{environment.detail}
</div>
</div>
)}
<span className="w-24 whitespace-nowrap text-right text-xs text-muted-foreground">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>