feat(FN-1167): add agent org chart and reporting chain views
- Add an Org Chart view mode in AgentsView with persisted view selection, API-backed tree loading, and node selection into AgentDetailView - Add a Chain of Command section to AgentDetailView dashboard that loads reporting paths and lets users navigate to ancestor agents - Introduce dedicated styling for org chart cards/connectors, loading states, and chain-of-command pills with responsive behavior - Expand AgentsView and AgentDetailView test coverage for org chart rendering, loading/empty states, and chain-of-command interactions
This commit is contained in:
@@ -17,6 +17,7 @@ vi.mock("../../api", () => ({
|
||||
fetchAgentRunDetail: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
fetchAgentTasks: vi.fn(),
|
||||
fetchChainOfCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentLogViewer", () => ({
|
||||
@@ -27,7 +28,7 @@ vi.mock("../AgentLogViewer", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks } from "../../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand } from "../../api";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockUpdateAgent = vi.mocked(updateAgent);
|
||||
@@ -36,6 +37,7 @@ const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
|
||||
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
|
||||
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
|
||||
const mockFetchAgentTasks = vi.mocked(fetchAgentTasks);
|
||||
const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
|
||||
|
||||
describe("AgentDetailView", () => {
|
||||
const createMockAgent = (overrides: Partial<{
|
||||
@@ -89,6 +91,7 @@ describe("AgentDetailView", () => {
|
||||
]);
|
||||
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
|
||||
mockFetchAgentTasks.mockResolvedValue([]);
|
||||
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
@@ -393,6 +396,122 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chain of Command", () => {
|
||||
it("renders chain-of-command section and displays agents in order", async () => {
|
||||
mockFetchChainOfCommand.mockResolvedValue([
|
||||
{ id: "agent-root", name: "CEO Agent" } as AgentDetail,
|
||||
{ id: "agent-middle", name: "Director Agent" } as AgentDetail,
|
||||
{ id: "agent-001", name: "Test Agent" } as AgentDetail,
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Chain of Command")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const nodes = Array.from(document.querySelectorAll(".chain-of-command-node"));
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes.map((node) => node.textContent?.trim())).toEqual([
|
||||
"CEO Agent",
|
||||
"Director Agent",
|
||||
"Test Agent",
|
||||
]);
|
||||
expect(nodes[2].className).toContain("chain-of-command-node--current");
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to ancestor agent when chain node is clicked", async () => {
|
||||
const onChildClick = vi.fn();
|
||||
mockFetchChainOfCommand.mockResolvedValue([
|
||||
{ id: "agent-root", name: "CEO Agent" } as AgentDetail,
|
||||
{ id: "agent-middle", name: "Director Agent" } as AgentDetail,
|
||||
{ id: "agent-001", name: "Test Agent" } as AgentDetail,
|
||||
] as any);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
onChildClick={onChildClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("CEO Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "CEO Agent" }));
|
||||
expect(onChildClick).toHaveBeenCalledWith("agent-root");
|
||||
});
|
||||
|
||||
it("shows no reporting chain for empty or single-element chains", async () => {
|
||||
mockFetchChainOfCommand.mockResolvedValue([]);
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No reporting chain")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockFetchChainOfCommand.mockResolvedValue([{ id: "agent-001", name: "Test Agent" } as AgentDetail] as any);
|
||||
|
||||
rerender(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No reporting chain")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching chain of command", async () => {
|
||||
let resolveChain: ((agents: AgentDetail[]) => void) | undefined;
|
||||
mockFetchChainOfCommand.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveChain = resolve;
|
||||
}) as any,
|
||||
);
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading reporting chain...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
resolveChain?.([{ id: "agent-001", name: "Test Agent" } as AgentDetail]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Loading reporting chain...")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("displays agent ID in footer", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { AgentsView } from "../AgentsView";
|
||||
import * as apiModule from "../../api";
|
||||
import type { Agent, AgentState, AgentCapability } from "../../api";
|
||||
import type { Agent, AgentState, AgentCapability, OrgTreeNode } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
// Mock the API module
|
||||
@@ -14,14 +14,20 @@ vi.mock("../../api", () => ({
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
fetchOrgTree: vi.fn(),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentDetailView", () => ({
|
||||
AgentDetailView: ({ agentId }: { agentId: string }) => <div data-testid="agent-detail-view">Agent detail: {agentId}</div>,
|
||||
}));
|
||||
|
||||
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
|
||||
const mockCreateAgent = vi.mocked(apiModule.createAgent);
|
||||
const mockUpdateAgentState = vi.mocked(apiModule.updateAgentState);
|
||||
const mockDeleteAgent = vi.mocked(apiModule.deleteAgent);
|
||||
const mockStartAgentRun = vi.mocked(apiModule.startAgentRun);
|
||||
const mockFetchOrgTree = vi.mocked((apiModule as any).fetchOrgTree);
|
||||
const mockFetchAgentStats = vi.mocked((apiModule as any).fetchAgentStats);
|
||||
|
||||
describe("AgentsView", () => {
|
||||
@@ -84,6 +90,7 @@ describe("AgentsView", () => {
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
});
|
||||
mockFetchOrgTree.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
@@ -231,6 +238,151 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Org Chart view", () => {
|
||||
const orgTree: OrgTreeNode[] = [
|
||||
{
|
||||
agent: {
|
||||
id: "agent-root-1",
|
||||
name: "Chief Agent",
|
||||
role: "scheduler",
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
agent: {
|
||||
id: "agent-child-1",
|
||||
name: "Director One",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
agent: {
|
||||
id: "agent-grandchild-1",
|
||||
name: "Manager Alpha",
|
||||
role: "reviewer",
|
||||
state: "idle",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
agent: {
|
||||
id: "agent-child-2",
|
||||
name: "Director Two",
|
||||
role: "triage",
|
||||
state: "paused",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
agent: {
|
||||
id: "agent-root-2",
|
||||
name: "Independent Lead",
|
||||
role: "engineer",
|
||||
state: "error",
|
||||
lastError: "Agent stalled",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
it("renders org chart toggle with aria attributes and activates org view", async () => {
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
const orgButton = screen.getByRole("button", { name: "Org Chart view" });
|
||||
expect(orgButton.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
fireEvent.click(orgButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(orgButton.className).toContain("active");
|
||||
expect(orgButton.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchOrgTree).toHaveBeenCalledWith(projectId);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders org chart nodes and opens detail view when clicking a node", async () => {
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Chief Agent")).toBeTruthy();
|
||||
expect(screen.getByText("Director One")).toBeTruthy();
|
||||
expect(screen.getByText("Manager Alpha")).toBeTruthy();
|
||||
expect(screen.getByText("Independent Lead")).toBeTruthy();
|
||||
expect(screen.getAllByText(/Healthy|Idle|Paused|Unresponsive|Agent stalled/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Director One"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows org chart empty state when API returns no nodes", async () => {
|
||||
mockFetchOrgTree.mockResolvedValue([]);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No agents found")).toBeTruthy();
|
||||
expect(screen.getByText("Create an agent to get started")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while org chart request is in flight", async () => {
|
||||
let resolveOrgTree: ((value: OrgTreeNode[]) => void) | undefined;
|
||||
mockFetchOrgTree.mockImplementation(
|
||||
() =>
|
||||
new Promise<OrgTreeNode[]>((resolve) => {
|
||||
resolveOrgTree = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading org chart...")).toBeTruthy();
|
||||
});
|
||||
|
||||
resolveOrgTree?.([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Loading org chart...")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter agents by state", () => {
|
||||
it("renders the state filter with styled container", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
Reference in New Issue
Block a user