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:
parent
765a75207a
commit
500a75f7ce
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue