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) : "—"}