From 500a75f7ceb7e5ba8ef2465936e7b7d9ff5e3164 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 26 Jun 2026 15:31:42 -0700 Subject: [PATCH] Show agent environment metadata (#8671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ui/src/lib/queryKeys.ts | 1 + ui/src/pages/Agents.test.tsx | 432 ++++++++++++++++++++++++++++++++++- ui/src/pages/Agents.tsx | 379 +++++++++++++++++++++--------- 3 files changed, 705 insertions(+), 107 deletions(-) diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 22c22abf2d..af61062adb 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index 495fba5c7c..7d36053dbb 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -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 { }; } +function makeEnvironment(overrides: Partial): 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 | 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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 () => { diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 4c4f3d023d..b0940605b0 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -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; 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, + 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((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(); + for (const environment of environments ?? []) map.set(environment.id, environment); + return map; + }, [environments]); + + const environmentByAgentId = useMemo(() => { + const map = new Map(); + 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 ( + + ) : ( + + )} + meta={ +
+ +
+ } + trailing={ +
+ + {liveRunByAgent.has(agent.id) ? ( + + ) : ( + + )} + +
+ {liveRunByAgent.has(agent.id) && ( + + )} + + + +
+ {/* Row actions mirror the agent detail page; stop the click + from bubbling to the row link so buttons don't navigate. */} +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+ 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", + })} + /> +
+ } + /> + ); + }; return (
@@ -210,113 +439,13 @@ export function Agents() { {/* List view */} {effectiveView === "list" && filtered.length > 0 && (
- {filtered.map((agent) => { - const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; - return ( - - ) : ( - - )} - meta={ -
- -
- } - trailing={ -
- - {liveRunByAgent.has(agent.id) ? ( - - ) : ( - - )} - -
- {liveRunByAgent.has(agent.id) && ( - - )} - - - -
- {/* Row actions mirror the agent detail page; stop the click - from bubbling to the row link so buttons don't navigate. */} -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - -
- 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", - })} - /> -
- } - /> - ); - })} + {filtered.map(renderAgentRow)}
)} {effectiveView === "list" && agents && agents.length > 0 && filtered.length === 0 && (

- No agents match the selected filter. + No agents match the selected status.

)} @@ -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 && (

- No agents match the selected filter. + No agents match the selected status.

)} @@ -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; liveRunByAgent: Map; + environmentByAgentId: Map; + environmentDataLoading: boolean; + showEnvironment: boolean; tab: FilterTab; memberships: ReturnType["data"]; membershipMutation: ReturnType; @@ -421,7 +559,15 @@ function OrgTreeNode({ )} {agent && (
- +
)} @@ -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}
+ {showEnvironment && ( +
+
+ {environment.label} +
+
+ {environment.detail} +
+
+ )} {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}